ML-164: implementation
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Production Error Tools
|
* Production Error Tools & Browser
|
||||||
*
|
*
|
||||||
* Fetches and displays production error data from the /api/v1/errors JSON API.
|
* Fetches and displays production error data from the /api/v1/errors JSON API.
|
||||||
*
|
*
|
||||||
@@ -8,8 +8,11 @@
|
|||||||
* PI_SERVICE_FQDN_WEB — Production domain (e.g., https://musiclibrary.example.com)
|
* PI_SERVICE_FQDN_WEB — Production domain (e.g., https://musiclibrary.example.com)
|
||||||
*
|
*
|
||||||
* Tools:
|
* Tools:
|
||||||
* fetch_production_errors — List/filter errors with pagination
|
* fetch_production_errors — List/filter errors with pagination
|
||||||
* fetch_production_error — Single error detail with occurrences and stacktraces
|
* fetch_production_error — Single error detail with occurrences and stacktraces
|
||||||
|
*
|
||||||
|
* Commands:
|
||||||
|
* /prod-errors — Interactive TUI for browsing production errors
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
@@ -19,6 +22,12 @@ import {
|
|||||||
DEFAULT_MAX_BYTES,
|
DEFAULT_MAX_BYTES,
|
||||||
DEFAULT_MAX_LINES,
|
DEFAULT_MAX_LINES,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
import { BorderedLoader } from "@mariozechner/pi-coding-agent";
|
||||||
|
import {
|
||||||
|
matchesKey,
|
||||||
|
Key,
|
||||||
|
truncateToWidth,
|
||||||
|
} from "@mariozechner/pi-tui";
|
||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
|
|
||||||
// ── Types ───────────────────────────────────────────────────────────────────
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
@@ -295,6 +304,734 @@ async function fetchError(
|
|||||||
return data as ErrorDetailResponse;
|
return data as ErrorDetailResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── TUI Theme ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Minimal theme interface for color application in the ErrorBrowser. */
|
||||||
|
interface Theme {
|
||||||
|
fg(color: string, text: string): string;
|
||||||
|
bold(text: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TUI Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatRelativeTime(iso8601: string): string {
|
||||||
|
const then = new Date(iso8601).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
const diffSec = Math.floor((now - then) / 1000);
|
||||||
|
|
||||||
|
if (diffSec < 60) return "just now";
|
||||||
|
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
if (diffMin < 60) return `${diffMin}m ago`;
|
||||||
|
|
||||||
|
const diffHour = Math.floor(diffMin / 60);
|
||||||
|
if (diffHour < 24) return `${diffHour}h ago`;
|
||||||
|
|
||||||
|
const diffDay = Math.floor(diffHour / 24);
|
||||||
|
return `${diffDay}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateReason(reason: string, maxLen: number): string {
|
||||||
|
// Take only the first line
|
||||||
|
const firstLine = reason.split("\n")[0] ?? reason;
|
||||||
|
if (firstLine.length <= maxLen) return firstLine;
|
||||||
|
return firstLine.slice(0, maxLen - 1) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Error Browser Component ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ErrorBrowser {
|
||||||
|
// ── State ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private mode: "list" | "detail" | "loading" = "list";
|
||||||
|
private errors: ErrorListItem[];
|
||||||
|
private total: number;
|
||||||
|
private offset: number;
|
||||||
|
private readonly limit: number = 50;
|
||||||
|
private showResolved: boolean = true;
|
||||||
|
private showMuted: boolean = true;
|
||||||
|
private cursorIndex: number = 0;
|
||||||
|
private scrollOffset: number = 0;
|
||||||
|
private selectedError: ErrorDetail | null = null;
|
||||||
|
private detailScrollOffset: number = 0;
|
||||||
|
private readonly totalUnfiltered: number;
|
||||||
|
private readonly chromeHeight: number = 7;
|
||||||
|
private readonly errorEntryLines: number = 3;
|
||||||
|
|
||||||
|
// ── Callbacks ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
onClose?: () => void;
|
||||||
|
onCopy?: (text: string) => void;
|
||||||
|
|
||||||
|
// ── Caching ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private cachedWidth?: number;
|
||||||
|
private cachedLines?: string[];
|
||||||
|
|
||||||
|
// ── HTTP ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private currentAbortController?: AbortController;
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly token: string;
|
||||||
|
private readonly requestRender: () => void;
|
||||||
|
private readonly notify: (msg: string, level: "error" | "info" | "warning") => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
errors: ErrorListItem[],
|
||||||
|
total: number,
|
||||||
|
baseUrl: string,
|
||||||
|
token: string,
|
||||||
|
requestRender: () => void,
|
||||||
|
notify: (msg: string, level: "error" | "info" | "warning") => void,
|
||||||
|
) {
|
||||||
|
this.errors = errors;
|
||||||
|
this.total = total;
|
||||||
|
this.totalUnfiltered = total;
|
||||||
|
this.offset = 0;
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
this.token = token;
|
||||||
|
this.requestRender = requestRender;
|
||||||
|
this.notify = notify;
|
||||||
|
this.clampCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Viewport ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private get visibleHeight(): number {
|
||||||
|
return Math.max(10, process.stdout.rows - this.chromeHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigation helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private clampCursor(): void {
|
||||||
|
if (this.errors.length === 0) {
|
||||||
|
this.cursorIndex = -1;
|
||||||
|
} else {
|
||||||
|
this.cursorIndex = Math.max(0, Math.min(this.cursorIndex, this.errors.length - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampViewport(): void {
|
||||||
|
if (this.errors.length === 0) return;
|
||||||
|
const maxEntries = Math.floor(this.visibleHeight / this.errorEntryLines);
|
||||||
|
if (this.cursorIndex < this.scrollOffset) {
|
||||||
|
this.scrollOffset = this.cursorIndex;
|
||||||
|
} else if (this.cursorIndex >= this.scrollOffset + maxEntries) {
|
||||||
|
this.scrollOffset = this.cursorIndex - maxEntries + 1;
|
||||||
|
}
|
||||||
|
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.errors.length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private abortInFlight(): void {
|
||||||
|
if (this.currentAbortController) {
|
||||||
|
this.currentAbortController.abort();
|
||||||
|
this.currentAbortController = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildQueryParams(): Record<string, string> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (!this.showResolved) params.status = "unresolved";
|
||||||
|
if (!this.showMuted) params.muted = "false";
|
||||||
|
params.limit = String(this.limit);
|
||||||
|
params.offset = String(this.offset);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Async operations ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
toggleResolved(): void {
|
||||||
|
if (this.mode === "loading") return;
|
||||||
|
this.abortInFlight();
|
||||||
|
this.showResolved = !this.showResolved;
|
||||||
|
this.offset = 0;
|
||||||
|
this.cursorIndex = 0;
|
||||||
|
this.scrollOffset = 0;
|
||||||
|
this.mode = "loading";
|
||||||
|
this.invalidate();
|
||||||
|
this.requestRender();
|
||||||
|
|
||||||
|
const url = buildUrl(this.baseUrl, "/api/v1/errors", this.buildQueryParams());
|
||||||
|
const controller = new AbortController();
|
||||||
|
this.currentAbortController = controller;
|
||||||
|
|
||||||
|
fetchErrors(url, this.token, controller.signal)
|
||||||
|
.then((data) => {
|
||||||
|
this.errors = data.errors;
|
||||||
|
this.total = data.total;
|
||||||
|
this.offset = data.offset ?? 0;
|
||||||
|
this.cursorIndex = 0;
|
||||||
|
this.scrollOffset = 0;
|
||||||
|
this.clampCursor();
|
||||||
|
this.clampViewport();
|
||||||
|
this.mode = "list";
|
||||||
|
this.invalidate();
|
||||||
|
this.requestRender();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") return;
|
||||||
|
this.mode = "list";
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
this.notify(`Filter toggle failed: ${msg}`, "error");
|
||||||
|
this.invalidate();
|
||||||
|
this.requestRender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMuted(): void {
|
||||||
|
if (this.mode === "loading") return;
|
||||||
|
this.abortInFlight();
|
||||||
|
this.showMuted = !this.showMuted;
|
||||||
|
this.offset = 0;
|
||||||
|
this.cursorIndex = 0;
|
||||||
|
this.scrollOffset = 0;
|
||||||
|
this.mode = "loading";
|
||||||
|
this.invalidate();
|
||||||
|
this.requestRender();
|
||||||
|
|
||||||
|
const url = buildUrl(this.baseUrl, "/api/v1/errors", this.buildQueryParams());
|
||||||
|
const controller = new AbortController();
|
||||||
|
this.currentAbortController = controller;
|
||||||
|
|
||||||
|
fetchErrors(url, this.token, controller.signal)
|
||||||
|
.then((data) => {
|
||||||
|
this.errors = data.errors;
|
||||||
|
this.total = data.total;
|
||||||
|
this.offset = data.offset ?? 0;
|
||||||
|
this.cursorIndex = 0;
|
||||||
|
this.scrollOffset = 0;
|
||||||
|
this.clampCursor();
|
||||||
|
this.clampViewport();
|
||||||
|
this.mode = "list";
|
||||||
|
this.invalidate();
|
||||||
|
this.requestRender();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") return;
|
||||||
|
this.mode = "list";
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
this.notify(`Filter toggle failed: ${msg}`, "error");
|
||||||
|
this.invalidate();
|
||||||
|
this.requestRender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMore(): void {
|
||||||
|
if (this.mode === "loading") return;
|
||||||
|
if (this.offset + this.limit >= this.total) return; // end of results
|
||||||
|
|
||||||
|
this.abortInFlight();
|
||||||
|
this.offset = this.offset + this.limit;
|
||||||
|
this.mode = "loading";
|
||||||
|
this.invalidate();
|
||||||
|
this.requestRender();
|
||||||
|
|
||||||
|
const url = buildUrl(this.baseUrl, "/api/v1/errors", this.buildQueryParams());
|
||||||
|
const controller = new AbortController();
|
||||||
|
this.currentAbortController = controller;
|
||||||
|
|
||||||
|
fetchErrors(url, this.token, controller.signal)
|
||||||
|
.then((data) => {
|
||||||
|
// Append new errors (deduplicate by id)
|
||||||
|
const existingIds = new Set(this.errors.map((e) => e.id));
|
||||||
|
const newErrors = data.errors.filter((e) => !existingIds.has(e.id));
|
||||||
|
this.errors = [...this.errors, ...newErrors];
|
||||||
|
this.total = data.total;
|
||||||
|
// offset already incremented before fetch; keep it
|
||||||
|
this.clampCursor();
|
||||||
|
this.clampViewport();
|
||||||
|
this.mode = "list";
|
||||||
|
this.invalidate();
|
||||||
|
this.requestRender();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") return;
|
||||||
|
// Revert offset on failure
|
||||||
|
this.offset = this.offset - this.limit;
|
||||||
|
this.mode = "list";
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
this.notify(`Load more failed: ${msg}`, "error");
|
||||||
|
this.invalidate();
|
||||||
|
this.requestRender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchDetail(id: number): void {
|
||||||
|
if (this.mode === "loading") return;
|
||||||
|
|
||||||
|
this.abortInFlight();
|
||||||
|
this.mode = "loading";
|
||||||
|
this.detailScrollOffset = 0;
|
||||||
|
this.invalidate();
|
||||||
|
this.requestRender();
|
||||||
|
|
||||||
|
const url = buildUrl(this.baseUrl, `/api/v1/errors/${id}`);
|
||||||
|
const controller = new AbortController();
|
||||||
|
this.currentAbortController = controller;
|
||||||
|
|
||||||
|
fetchError(url, this.token, controller.signal)
|
||||||
|
.then((data) => {
|
||||||
|
this.selectedError = data.error;
|
||||||
|
this.detailScrollOffset = 0;
|
||||||
|
this.mode = "detail";
|
||||||
|
this.invalidate();
|
||||||
|
this.requestRender();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") return;
|
||||||
|
this.mode = "list";
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.includes("404")) {
|
||||||
|
this.notify(`Error #${id} not found`, "warning");
|
||||||
|
} else {
|
||||||
|
this.notify(`Failed to load error detail: ${msg}`, "error");
|
||||||
|
}
|
||||||
|
this.invalidate();
|
||||||
|
this.requestRender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Input handling ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
handleInput(data: string): void {
|
||||||
|
// Global keys (work in any mode)
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
if (this.mode === "detail") {
|
||||||
|
// Back to list
|
||||||
|
this.mode = "list";
|
||||||
|
this.selectedError = null;
|
||||||
|
this.detailScrollOffset = 0;
|
||||||
|
this.invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.onClose?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, "q")) {
|
||||||
|
if (this.mode !== "detail") {
|
||||||
|
this.onClose?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List mode keys ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (this.mode === "list" || this.mode === "loading") {
|
||||||
|
if (matchesKey(data, "r")) {
|
||||||
|
this.toggleResolved();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, "m")) {
|
||||||
|
this.toggleMuted();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, "l")) {
|
||||||
|
if (this.offset + this.limit >= this.total) return;
|
||||||
|
this.loadMore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.enter)) {
|
||||||
|
if (this.errors.length > 0 && this.cursorIndex >= 0) {
|
||||||
|
const error = this.errors[this.cursorIndex];
|
||||||
|
if (error) {
|
||||||
|
this.fetchDetail(error.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mode !== "list") return; // block navigation while loading
|
||||||
|
|
||||||
|
let moved = false;
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
|
||||||
|
this.cursorIndex--;
|
||||||
|
moved = true;
|
||||||
|
} else if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
|
||||||
|
this.cursorIndex++;
|
||||||
|
moved = true;
|
||||||
|
} else if (matchesKey(data, Key.pageUp)) {
|
||||||
|
const pageSize = Math.floor(this.visibleHeight / this.errorEntryLines);
|
||||||
|
this.cursorIndex -= pageSize;
|
||||||
|
moved = true;
|
||||||
|
} else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) {
|
||||||
|
const pageSize = Math.floor(this.visibleHeight / this.errorEntryLines);
|
||||||
|
this.cursorIndex += pageSize;
|
||||||
|
moved = true;
|
||||||
|
} else if (matchesKey(data, Key.ctrl("b"))) {
|
||||||
|
const pageSize = Math.floor(this.visibleHeight / this.errorEntryLines);
|
||||||
|
this.cursorIndex -= pageSize;
|
||||||
|
moved = true;
|
||||||
|
} else if (matchesKey(data, Key.home) || matchesKey(data, "g")) {
|
||||||
|
this.cursorIndex = 0;
|
||||||
|
moved = true;
|
||||||
|
} else if (matchesKey(data, Key.end) || matchesKey(data, "G")) {
|
||||||
|
this.cursorIndex = this.errors.length - 1;
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moved) {
|
||||||
|
this.clampCursor();
|
||||||
|
this.clampViewport();
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detail mode keys ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (this.mode === "detail") {
|
||||||
|
if (matchesKey(data, Key.enter)) {
|
||||||
|
// Copy current line to editor (prod-logs pattern)
|
||||||
|
if (this.selectedError) {
|
||||||
|
const detailLines = this.buildDetailLines();
|
||||||
|
const idx = this.detailScrollOffset;
|
||||||
|
if (idx < detailLines.length) {
|
||||||
|
this.onCopy?.(detailLines[idx]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let moved = false;
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
|
||||||
|
this.detailScrollOffset--;
|
||||||
|
moved = true;
|
||||||
|
} else if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
|
||||||
|
this.detailScrollOffset++;
|
||||||
|
moved = true;
|
||||||
|
} else if (matchesKey(data, Key.pageUp)) {
|
||||||
|
this.detailScrollOffset -= this.visibleHeight;
|
||||||
|
moved = true;
|
||||||
|
} else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) {
|
||||||
|
this.detailScrollOffset += this.visibleHeight;
|
||||||
|
moved = true;
|
||||||
|
} else if (matchesKey(data, Key.ctrl("b"))) {
|
||||||
|
this.detailScrollOffset -= this.visibleHeight;
|
||||||
|
moved = true;
|
||||||
|
} else if (matchesKey(data, Key.home) || matchesKey(data, "g")) {
|
||||||
|
this.detailScrollOffset = 0;
|
||||||
|
moved = true;
|
||||||
|
} else if (matchesKey(data, Key.end) || matchesKey(data, "G")) {
|
||||||
|
this.detailScrollOffset = Math.max(0, this.buildDetailLines().length - 1);
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moved) {
|
||||||
|
this.detailScrollOffset = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(this.detailScrollOffset, Math.max(0, this.buildDetailLines().length - 1)),
|
||||||
|
);
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
render(width: number, theme: Theme): string[] {
|
||||||
|
if (this.cachedLines && this.cachedWidth === width) {
|
||||||
|
return this.cachedLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: string[];
|
||||||
|
switch (this.mode) {
|
||||||
|
case "loading":
|
||||||
|
result = this.renderLoading(width, theme);
|
||||||
|
break;
|
||||||
|
case "detail":
|
||||||
|
result = this.renderDetail(width, theme);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
result = this.renderList(width, theme);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cachedWidth = width;
|
||||||
|
this.cachedLines = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderLoading(width: number, theme: Theme): string[] {
|
||||||
|
const border = theme.fg("accent", "─".repeat(width));
|
||||||
|
const rows = process.stdout.rows;
|
||||||
|
const result: string[] = [border];
|
||||||
|
const mid = Math.floor((rows - 1) / 2);
|
||||||
|
|
||||||
|
for (let i = 1; i < rows - 1; i++) {
|
||||||
|
if (i === mid) {
|
||||||
|
result.push(truncateToWidth(theme.fg("muted", " Loading…"), width, ""));
|
||||||
|
} else {
|
||||||
|
result.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(border);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderList(width: number, theme: Theme): string[] {
|
||||||
|
const border = theme.fg("accent", "─".repeat(width));
|
||||||
|
const result: string[] = [];
|
||||||
|
|
||||||
|
// Top border
|
||||||
|
result.push(border);
|
||||||
|
|
||||||
|
// Header with counts
|
||||||
|
const unresolvedCount = this.errors.filter(
|
||||||
|
(e) => e.status === "unresolved",
|
||||||
|
).length;
|
||||||
|
const headerText = ` Production Errors (${this.total} total${unresolvedCount > 0 ? `, ${unresolvedCount} unresolved` : ""})`;
|
||||||
|
result.push(
|
||||||
|
truncateToWidth(theme.fg("accent", theme.bold(headerText)), width, ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
result.push(truncateToWidth(theme.fg("dim", "─".repeat(width)), width, ""));
|
||||||
|
|
||||||
|
// Empty states
|
||||||
|
if (this.errors.length === 0) {
|
||||||
|
const msg =
|
||||||
|
this.totalUnfiltered > 0
|
||||||
|
? "No errors match the current filters"
|
||||||
|
: "No production errors found";
|
||||||
|
result.push(truncateToWidth(theme.fg("muted", ` ${msg}`), width, ""));
|
||||||
|
// Pad remaining viewport
|
||||||
|
while (result.length < this.visibleHeight + 3) {
|
||||||
|
result.push("");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Render errors within viewport
|
||||||
|
const maxEntries = Math.floor(this.visibleHeight / this.errorEntryLines);
|
||||||
|
const endIdx = Math.min(
|
||||||
|
this.scrollOffset + maxEntries,
|
||||||
|
this.errors.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
let linesRendered = 0;
|
||||||
|
for (let i = this.scrollOffset; i < endIdx; i++) {
|
||||||
|
const error = this.errors[i];
|
||||||
|
if (!error) continue;
|
||||||
|
|
||||||
|
const isCursor = i === this.cursorIndex;
|
||||||
|
const cursor = isCursor ? "▶" : " ";
|
||||||
|
|
||||||
|
// Status badge color
|
||||||
|
let statusColor = "muted";
|
||||||
|
if (error.status === "unresolved") statusColor = "error";
|
||||||
|
else if (error.status === "resolved") statusColor = "success";
|
||||||
|
|
||||||
|
const mutedLabel = error.muted ? " [MUTED]" : "";
|
||||||
|
const statusBadge = theme.fg(statusColor, `[${error.status.toUpperCase()}]`);
|
||||||
|
|
||||||
|
// Line 1: cursor + status badge + kind + truncated reason
|
||||||
|
const reasonText = truncateReason(error.reason, Math.max(30, width - 25));
|
||||||
|
const line1 = `${cursor} ${statusBadge}${mutedLabel} ${error.kind}: ${reasonText}`;
|
||||||
|
result.push(truncateToWidth(isCursor ? theme.fg("accent", line1) : line1, width, ""));
|
||||||
|
|
||||||
|
// Line 2: source info
|
||||||
|
const sourceInfo = error.source_function
|
||||||
|
? ` ${error.source_function} ${error.source_line || ""}`
|
||||||
|
: ` ${error.source_line || "(no source)"}`;
|
||||||
|
result.push(truncateToWidth(theme.fg("dim", sourceInfo), width, ""));
|
||||||
|
|
||||||
|
// Line 3: last seen relative time
|
||||||
|
const lastSeen = error.last_occurrence_at
|
||||||
|
? ` Last seen ${formatRelativeTime(error.last_occurrence_at)}`
|
||||||
|
: " No occurrences";
|
||||||
|
result.push(truncateToWidth(theme.fg("dim", lastSeen), width, ""));
|
||||||
|
|
||||||
|
linesRendered += this.errorEntryLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad remaining viewport if fewer lines than visible
|
||||||
|
const targetLines = 3 + this.visibleHeight;
|
||||||
|
while (result.length < targetLines) {
|
||||||
|
result.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Help lines
|
||||||
|
const resolvedLabel = this.showResolved ? "hide" : "show";
|
||||||
|
const mutedLabel = this.showMuted ? "hide" : "show";
|
||||||
|
const hasMore = this.offset + this.limit < this.total;
|
||||||
|
|
||||||
|
result.push(
|
||||||
|
truncateToWidth(
|
||||||
|
theme.fg(
|
||||||
|
"dim",
|
||||||
|
` ↑↓/jk navigate ↵ details r ${resolvedLabel} resolved m ${mutedLabel} muted`,
|
||||||
|
),
|
||||||
|
width,
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
result.push(
|
||||||
|
truncateToWidth(
|
||||||
|
theme.fg(
|
||||||
|
"dim",
|
||||||
|
hasMore
|
||||||
|
? ` l load more q quit`
|
||||||
|
: ` — end of results — q quit`,
|
||||||
|
),
|
||||||
|
width,
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bottom border
|
||||||
|
result.push(border);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDetailLines(): string[] {
|
||||||
|
const error = this.selectedError;
|
||||||
|
if (!error) return ["(no error selected)"];
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
const sep = "─".repeat(40);
|
||||||
|
|
||||||
|
lines.push(`Error #${error.id}: ${error.kind}`);
|
||||||
|
lines.push(sep);
|
||||||
|
lines.push(`Reason: ${error.reason}`);
|
||||||
|
lines.push(`Status: ${error.status} | Muted: ${error.muted}`);
|
||||||
|
lines.push(
|
||||||
|
`Source: ${error.source_line || "—"} — ${error.source_function || "—"}`,
|
||||||
|
);
|
||||||
|
lines.push(`Fingerprint: ${error.fingerprint || "—"}`);
|
||||||
|
lines.push(`First occurrence: ${error.first_occurrence_at ?? "N/A"}`);
|
||||||
|
lines.push(`Last occurrence: ${error.last_occurrence_at}`);
|
||||||
|
lines.push(`Total occurrences: ${error.occurrence_count}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
const occurrences = error.occurrences ?? [];
|
||||||
|
lines.push(`Occurrences (${occurrences.length}):`);
|
||||||
|
lines.push(sep);
|
||||||
|
|
||||||
|
if (occurrences.length === 0) {
|
||||||
|
lines.push(" No occurrences recorded");
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < occurrences.length; i++) {
|
||||||
|
const occ = occurrences[i]!;
|
||||||
|
lines.push(`#${i + 1} ${occ.inserted_at}`);
|
||||||
|
lines.push(` Reason: ${occ.reason}`);
|
||||||
|
|
||||||
|
if (occ.context && Object.keys(occ.context).length > 0) {
|
||||||
|
lines.push(` Context:`);
|
||||||
|
for (const [k, v] of Object.entries(occ.context)) {
|
||||||
|
lines.push(` ${k}: ${JSON.stringify(v)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (occ.breadcrumbs && occ.breadcrumbs.length > 0) {
|
||||||
|
lines.push(` Breadcrumbs:`);
|
||||||
|
for (const crumb of occ.breadcrumbs) {
|
||||||
|
lines.push(` • ${crumb}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (occ.stacktrace?.lines && occ.stacktrace.lines.length > 0) {
|
||||||
|
lines.push(` Stacktrace:`);
|
||||||
|
for (const sl of occ.stacktrace.lines) {
|
||||||
|
const app = sl.application || "—";
|
||||||
|
const loc = sl.file
|
||||||
|
? `${sl.file}${sl.line != null ? `:${sl.line}` : ""}`
|
||||||
|
: "(nofile)";
|
||||||
|
lines.push(
|
||||||
|
` ${app} / ${sl.module}.${sl.function}/${sl.arity} ${loc}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < occurrences.length - 1) {
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDetail(width: number, theme: Theme): string[] {
|
||||||
|
const border = theme.fg("accent", "─".repeat(width));
|
||||||
|
const result: string[] = [];
|
||||||
|
|
||||||
|
// Top border
|
||||||
|
result.push(border);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const error = this.selectedError;
|
||||||
|
const headerText = error
|
||||||
|
? ` Error #${error.id}: ${error.kind}`
|
||||||
|
: " Error Detail";
|
||||||
|
result.push(
|
||||||
|
truncateToWidth(theme.fg("accent", theme.bold(headerText)), width, ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
result.push(truncateToWidth(theme.fg("dim", "─".repeat(width)), width, ""));
|
||||||
|
|
||||||
|
// Detail content (scrolled)
|
||||||
|
const detailLines = this.buildDetailLines();
|
||||||
|
const endIdx = Math.min(
|
||||||
|
this.detailScrollOffset + this.visibleHeight,
|
||||||
|
detailLines.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = this.detailScrollOffset; i < endIdx; i++) {
|
||||||
|
result.push(truncateToWidth(` ${detailLines[i]}`, width, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad remaining viewport
|
||||||
|
const targetLines = 3 + this.visibleHeight;
|
||||||
|
while (result.length < targetLines) {
|
||||||
|
result.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Help line
|
||||||
|
const fromLine = this.detailScrollOffset + 1;
|
||||||
|
const toLine = Math.min(
|
||||||
|
this.detailScrollOffset + this.visibleHeight,
|
||||||
|
detailLines.length,
|
||||||
|
);
|
||||||
|
const pct =
|
||||||
|
detailLines.length > 0
|
||||||
|
? Math.round((this.detailScrollOffset / detailLines.length) * 100)
|
||||||
|
: 0;
|
||||||
|
result.push(
|
||||||
|
truncateToWidth(
|
||||||
|
theme.fg(
|
||||||
|
"dim",
|
||||||
|
` Lines ${fromLine}-${toLine} of ${detailLines.length} (${pct}%) ↑↓/jk scroll Enter copy Escape back`,
|
||||||
|
),
|
||||||
|
width,
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bottom border
|
||||||
|
result.push(border);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(): void {
|
||||||
|
this.cachedWidth = undefined;
|
||||||
|
this.cachedLines = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Extension ───────────────────────────────────────────────────────────────
|
// ── Extension ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function prodErrorsExtension(pi: ExtensionAPI) {
|
export default function prodErrorsExtension(pi: ExtensionAPI) {
|
||||||
@@ -552,4 +1289,108 @@ export default function prodErrorsExtension(pi: ExtensionAPI) {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── /prod-errors interactive command ──────────────────────────────────
|
||||||
|
|
||||||
|
pi.registerCommand("prod-errors", {
|
||||||
|
description:
|
||||||
|
"Browse production errors interactively via the /api/v1/errors JSON API",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
// ── Validate credentials ────────────────────────────────────────
|
||||||
|
|
||||||
|
const token = resolveVar("api_token");
|
||||||
|
const fqdn = resolveVar("service_fqdn_web");
|
||||||
|
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!token) missing.push("PI_API_TOKEN");
|
||||||
|
if (!fqdn) missing.push("PI_SERVICE_FQDN_WEB");
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Missing environment variables: ${missing.join(", ")}`,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch initial errors with loader UI ─────────────────────────
|
||||||
|
|
||||||
|
const errorsData = await ctx.ui.custom<ErrorListResponse | null>(
|
||||||
|
(tui, theme, _kb, done) => {
|
||||||
|
const loader = new BorderedLoader(
|
||||||
|
tui,
|
||||||
|
theme,
|
||||||
|
"Fetching production errors...",
|
||||||
|
);
|
||||||
|
loader.onAbort = () => done(null);
|
||||||
|
|
||||||
|
const url = buildUrl(fqdn!, "/api/v1/errors", {
|
||||||
|
limit: "50",
|
||||||
|
offset: "0",
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchErrors(url, token!, loader.signal)
|
||||||
|
.then(done)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("[prod-errors] Initial fetch failed:", err);
|
||||||
|
done(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return loader;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (errorsData === null) {
|
||||||
|
ctx.ui.notify("Cancelled or fetch failed — check logs for details", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorsData.errors.length === 0) {
|
||||||
|
ctx.ui.notify("No production errors found", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Interactive error browser ────────────────────────────────────
|
||||||
|
|
||||||
|
const baseUrl = fqdn!;
|
||||||
|
const apiToken = token!;
|
||||||
|
|
||||||
|
const copiedText = await ctx.ui.custom<string | null>(
|
||||||
|
(tui, theme, _kb, done) => {
|
||||||
|
const browser = new ErrorBrowser(
|
||||||
|
errorsData.errors,
|
||||||
|
errorsData.total,
|
||||||
|
baseUrl,
|
||||||
|
apiToken,
|
||||||
|
() => tui.requestRender(),
|
||||||
|
(msg, level) => ctx.ui.notify(msg, level),
|
||||||
|
);
|
||||||
|
|
||||||
|
browser.onClose = () => done(null);
|
||||||
|
browser.onCopy = (text: string) => done(text);
|
||||||
|
|
||||||
|
return {
|
||||||
|
render(width: number) {
|
||||||
|
return browser.render(width, theme);
|
||||||
|
},
|
||||||
|
invalidate() {
|
||||||
|
browser.invalidate();
|
||||||
|
},
|
||||||
|
handleInput(data: string) {
|
||||||
|
browser.handleInput(data);
|
||||||
|
tui.requestRender();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Place copied text in editor ──────────────────────────────────
|
||||||
|
|
||||||
|
if (copiedText !== null) {
|
||||||
|
ctx.ui.setEditorText(copiedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify("Error browser closed", "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "prod-errors",
|
"name": "prod-errors",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Fetch production errors via the JSON API for LLM analysis"
|
"description": "Fetch production errors via JSON API for LLM analysis, with interactive /prod-errors TUI"
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-2
@@ -1,13 +1,18 @@
|
|||||||
---
|
---
|
||||||
id: ML-164
|
id: ML-164
|
||||||
title: Create pi extension for interactive error browsing
|
title: Create pi extension for interactive error browsing
|
||||||
status: To Do
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-05-04 08:08'
|
created_date: '2026-05-04 08:08'
|
||||||
updated_date: '2026-05-04 10:03'
|
updated_date: '2026-05-04 12:44'
|
||||||
labels:
|
labels:
|
||||||
- pi
|
- pi
|
||||||
|
- ready
|
||||||
dependencies: []
|
dependencies: []
|
||||||
|
modified_files:
|
||||||
|
- .pi/extensions/prod-errors/index.ts
|
||||||
|
- .pi/extensions/prod-errors/package.json
|
||||||
|
- docs/production-infrastructure.md
|
||||||
parent_task_id: ML-161
|
parent_task_id: ML-161
|
||||||
priority: medium
|
priority: medium
|
||||||
ordinal: 6000
|
ordinal: 6000
|
||||||
@@ -282,3 +287,28 @@ No new changes beyond ML-162/ML-163: `PI_API_TOKEN` and `PI_SERVICE_FQDN_WEB` ar
|
|||||||
- `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui`: Already available.
|
- `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui`: Already available.
|
||||||
- No new npm dependencies.
|
- No new npm dependencies.
|
||||||
<!-- SECTION:PLAN:END -->
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### What was built
|
||||||
|
|
||||||
|
Added the `/prod-errors` interactive TUI command to the existing `.pi/extensions/prod-errors/` extension (which already contained the `fetch_production_errors` and `fetch_production_error` LLM tools from ML-163).
|
||||||
|
|
||||||
|
### Files changed
|
||||||
|
|
||||||
|
- `.pi/extensions/prod-errors/index.ts` — Added ErrorBrowser class (~540 lines), Theme interface, formatRelativeTime/truncateReason helpers, and `/prod-errors` command registration. Total file grew from ~360 to ~1396 lines.
|
||||||
|
- `.pi/extensions/prod-errors/package.json` — Updated description to mention TUI command.
|
||||||
|
- `docs/production-infrastructure.md` — Added `/prod-errors` to the pi coding agent tools table and added command description.
|
||||||
|
|
||||||
|
### Key design decisions
|
||||||
|
|
||||||
|
1. **Reuses existing HTTP functions** — `fetchErrors()` and `fetchError()` from ML-163 are reused directly by the ErrorBrowser class. The class builds URLs via the existing `buildUrl()` helper.
|
||||||
|
2. **Flat rendering (not Container/Text)** — Follows the prod-logs pattern of building arrays of styled strings, not the component-based approach using Container/Text/Box. This is simpler and directly compatible with the `ctx.ui.custom()` API.
|
||||||
|
3. **Self-contained class** — ErrorBrowser manages all state, navigation, rendering, and async operations internally. It receives `requestRender` and `notify` callbacks from the command handler for I/O.
|
||||||
|
4. **Occurrence count omission** — The list endpoint doesn't include `occurrence_count` (per ML-162 spec), so the list view shows relative timestamps instead.
|
||||||
|
5. **No collapsible stacktraces** — Deferred per implementation plan. Initial version shows all stacktraces inline with scrolling.
|
||||||
|
6. **Explicit load-more** — No auto-load on scroll-to-bottom. Uses `l` key for user-controlled pagination.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -238,13 +238,18 @@ runtime environment (not server-side config).
|
|||||||
| Extension | Tools | Env vars |
|
| Extension | Tools | Env vars |
|
||||||
|-----------|-------|----------|
|
|-----------|-------|----------|
|
||||||
| `prod-logs` | `fetch_production_logs` | `PI_COOLIFY_HOST`, `PI_COOLIFY_APP_UUID`, `PI_COOLIFY_TOKEN` |
|
| `prod-logs` | `fetch_production_logs` | `PI_COOLIFY_HOST`, `PI_COOLIFY_APP_UUID`, `PI_COOLIFY_TOKEN` |
|
||||||
| `prod-errors` | `fetch_production_errors`, `fetch_production_error` | `PI_API_TOKEN`, `PI_SERVICE_FQDN_WEB` |
|
| `prod-errors` | `fetch_production_errors`, `fetch_production_error`, `/prod-errors` | `PI_API_TOKEN`, `PI_SERVICE_FQDN_WEB` |
|
||||||
|
|
||||||
**`prod-logs` env vars:**
|
**`prod-logs` env vars:**
|
||||||
- `PI_COOLIFY_HOST` — Coolify server base URL (e.g., `https://coolify.example.com`)
|
- `PI_COOLIFY_HOST` — Coolify server base URL (e.g., `https://coolify.example.com`)
|
||||||
- `PI_COOLIFY_APP_UUID` — Application UUID in Coolify
|
- `PI_COOLIFY_APP_UUID` — Application UUID in Coolify
|
||||||
- `PI_COOLIFY_TOKEN` — Coolify API Bearer token
|
- `PI_COOLIFY_TOKEN` — Coolify API Bearer token
|
||||||
|
|
||||||
|
**`prod-errors` tools and command:**
|
||||||
|
- `fetch_production_errors` — List/filter errors via LLM tool
|
||||||
|
- `fetch_production_error` — Single error detail via LLM tool
|
||||||
|
- `/prod-errors` — Interactive TUI for browsing errors (list, detail, filter toggles)
|
||||||
|
|
||||||
**`prod-errors` env vars:**
|
**`prod-errors` env vars:**
|
||||||
- `PI_API_TOKEN` — Must match the `API_TOKEN` env var on the production server (used for Bearer auth on `/api/v1/*`)
|
- `PI_API_TOKEN` — Must match the `API_TOKEN` env var on the production server (used for Bearer auth on `/api/v1/*`)
|
||||||
- `PI_SERVICE_FQDN_WEB` — Production domain with protocol (e.g., `https://musiclibrary.claudio-ortolina.org`, no trailing slash)
|
- `PI_SERVICE_FQDN_WEB` — Production domain with protocol (e.g., `https://musiclibrary.claudio-ortolina.org`, no trailing slash)
|
||||||
|
|||||||
Reference in New Issue
Block a user