fix(prod-errors): security, dedup, and correctness fixes for TUI and tools

- buildUrl: detect localhost/loopback addresses and default to http://
  instead of silently upgrading everything to https://
- formatRelativeTime: guard against NaN for invalid ISO8601 inputs
- Fix 'q' key silently ignored in detail mode — now closes from any mode
- Extract shared retoggleFilter() to eliminate ~50 lines of duplication
  between toggleResolved() and toggleMuted()
- Extract shared formatDetailLines() used by both formatErrorDetail()
  (LLM tool) and buildDetailLines() (TUI) — removes ~60 duplicate lines
- Extract shared fetchApi<T>() to deduplicate HTTP error handling
  between fetchErrors() and fetchError()
- renderLoading: use visibleHeight instead of full terminal rows
- List empty-state padding: name chromeLines constant
- Fix double buildDetailLines() call in detail scroll clamping
This commit is contained in:
Claudio Ortolina
2026-05-04 14:12:15 +01:00
parent 93ac86f748
commit c24e4e8a59
+57 -142
View File
@@ -104,10 +104,16 @@ function buildUrl(
path: string, path: string,
params?: Record<string, string>, params?: Record<string, string>,
): string { ): string {
// Strip trailing slash and ensure protocol // Strip trailing slash and ensure protocol.
// Default to https:// unless the host looks like a local/dev address.
let url = base.replace(/\/+$/, ""); let url = base.replace(/\/+$/, "");
if (!/^https?:\/\//i.test(url)) { if (!/^https?:\/\//i.test(url)) {
url = `https://${url}`; const isLocal =
url.startsWith("localhost") ||
url.startsWith("127.") ||
url.startsWith("0.0.0.0") ||
url.startsWith("[::1]");
url = `${isLocal ? "http" : "https"}://${url}`;
} }
url = `${url}${path}`; url = `${url}${path}`;
@@ -140,7 +146,7 @@ function formatErrorListItem(error: ErrorListItem, index: number): string {
].join("\n"); ].join("\n");
} }
function formatErrorDetail(error: ErrorDetail): string { function formatDetailLines(error: ErrorDetail): string[] {
const lines: string[] = []; const lines: string[] = [];
const sep = "─".repeat(44); const sep = "─".repeat(44);
@@ -160,8 +166,11 @@ function formatErrorDetail(error: ErrorDetail): string {
lines.push(`Occurrences (${occurrences.length}):`); lines.push(`Occurrences (${occurrences.length}):`);
lines.push(sep); lines.push(sep);
if (occurrences.length === 0) {
lines.push(" No occurrences recorded");
} else {
for (let i = 0; i < occurrences.length; i++) { for (let i = 0; i < occurrences.length; i++) {
const occ = occurrences[i]; const occ = occurrences[i]!;
lines.push(`#${i + 1} ${occ.inserted_at}`); lines.push(`#${i + 1} ${occ.inserted_at}`);
lines.push(` Reason: ${occ.reason}`); lines.push(` Reason: ${occ.reason}`);
@@ -200,8 +209,13 @@ function formatErrorDetail(error: ErrorDetail): string {
lines.push(""); lines.push("");
} }
} }
}
return lines.join("\n"); return lines;
}
function formatErrorDetail(error: ErrorDetail): string {
return formatDetailLines(error).join("\n");
} }
function applyOutputTruncation(output: string): string { function applyOutputTruncation(output: string): string {
@@ -224,11 +238,12 @@ function applyOutputTruncation(output: string): string {
// ── HTTP helpers ──────────────────────────────────────────────────────────── // ── HTTP helpers ────────────────────────────────────────────────────────────
async function fetchErrors( async function fetchApi<T>(
url: string, url: string,
token: string, token: string,
validate: (data: unknown) => T,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<ErrorListResponse> { ): Promise<T> {
const response = await fetch(url, { const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
signal, signal,
@@ -252,6 +267,15 @@ async function fetchErrors(
); );
} }
return validate(data);
}
async function fetchErrors(
url: string,
token: string,
signal?: AbortSignal,
): Promise<ErrorListResponse> {
return fetchApi(url, token, (data) => {
const obj = data as Record<string, unknown>; const obj = data as Record<string, unknown>;
if (!obj.errors || !Array.isArray(obj.errors)) { if (!obj.errors || !Array.isArray(obj.errors)) {
throw new Error( throw new Error(
@@ -260,8 +284,8 @@ async function fetchErrors(
}.`, }.`,
); );
} }
return data as ErrorListResponse; return data as ErrorListResponse;
}, signal);
} }
async function fetchError( async function fetchError(
@@ -269,29 +293,7 @@ async function fetchError(
token: string, token: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<ErrorDetailResponse> { ): Promise<ErrorDetailResponse> {
const response = await fetch(url, { return fetchApi(url, token, (data) => {
headers: { Authorization: `Bearer ${token}` },
signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "(unable to read body)");
const truncated = body.length > 500 ? body.slice(0, 500) + "..." : body;
throw new Error(
`API returned ${response.status} ${response.statusText}\n${truncated}`,
);
}
let data: unknown;
try {
data = await response.json();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`Failed to parse API response: ${message}. Status: ${response.status}.`,
);
}
const obj = data as Record<string, unknown>; const obj = data as Record<string, unknown>;
if (!obj.error || typeof obj.error !== "object") { if (!obj.error || typeof obj.error !== "object") {
throw new Error( throw new Error(
@@ -300,8 +302,8 @@ async function fetchError(
}.`, }.`,
); );
} }
return data as ErrorDetailResponse; return data as ErrorDetailResponse;
}, signal);
} }
// ── TUI Theme ─────────────────────────────────────────────────────────────── // ── TUI Theme ───────────────────────────────────────────────────────────────
@@ -316,6 +318,8 @@ interface Theme {
function formatRelativeTime(iso8601: string): string { function formatRelativeTime(iso8601: string): string {
const then = new Date(iso8601).getTime(); const then = new Date(iso8601).getTime();
if (isNaN(then)) return "unknown";
const now = Date.now(); const now = Date.now();
const diffSec = Math.floor((now - then) / 1000); const diffSec = Math.floor((now - then) / 1000);
@@ -443,47 +447,23 @@ class ErrorBrowser {
// ── Async operations ─────────────────────────────────────────────────── // ── Async operations ───────────────────────────────────────────────────
toggleResolved(): void { toggleResolved(): void {
if (this.mode === "loading") return; this.retoggleFilter("resolved");
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 { toggleMuted(): void {
this.retoggleFilter("muted");
}
private retoggleFilter(filter: "resolved" | "muted"): void {
if (this.mode === "loading") return; if (this.mode === "loading") return;
this.abortInFlight(); this.abortInFlight();
if (filter === "resolved") {
this.showResolved = !this.showResolved;
} else {
this.showMuted = !this.showMuted; this.showMuted = !this.showMuted;
}
this.offset = 0; this.offset = 0;
this.cursorIndex = 0; this.cursorIndex = 0;
this.scrollOffset = 0; this.scrollOffset = 0;
@@ -512,7 +492,7 @@ class ErrorBrowser {
if (err instanceof Error && err.name === "AbortError") return; if (err instanceof Error && err.name === "AbortError") return;
this.mode = "list"; this.mode = "list";
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
this.notify(`Filter toggle failed: ${msg}`, "error"); this.notify(`Filter toggle (${filter}) failed: ${msg}`, "error");
this.invalidate(); this.invalidate();
this.requestRender(); this.requestRender();
}); });
@@ -611,11 +591,9 @@ class ErrorBrowser {
} }
if (matchesKey(data, "q")) { if (matchesKey(data, "q")) {
if (this.mode !== "detail") {
this.onClose?.(); this.onClose?.();
return; return;
} }
}
// ── List mode keys ──────────────────────────────────────────────── // ── List mode keys ────────────────────────────────────────────────
@@ -726,10 +704,8 @@ class ErrorBrowser {
} }
if (moved) { if (moved) {
this.detailScrollOffset = Math.max( const maxOffset = Math.max(0, this.buildDetailLines().length - 1);
0, this.detailScrollOffset = Math.max(0, Math.min(this.detailScrollOffset, maxOffset));
Math.min(this.detailScrollOffset, Math.max(0, this.buildDetailLines().length - 1)),
);
this.invalidate(); this.invalidate();
} }
@@ -764,11 +740,11 @@ class ErrorBrowser {
private renderLoading(width: number, theme: Theme): string[] { private renderLoading(width: number, theme: Theme): string[] {
const border = theme.fg("accent", "─".repeat(width)); const border = theme.fg("accent", "─".repeat(width));
const rows = process.stdout.rows; const viewportRows = this.visibleHeight + 3; // +3 for chrome lines
const mid = Math.floor(viewportRows / 2);
const result: string[] = [border]; const result: string[] = [border];
const mid = Math.floor((rows - 1) / 2);
for (let i = 1; i < rows - 1; i++) { for (let i = 1; i < viewportRows - 1; i++) {
if (i === mid) { if (i === mid) {
result.push(truncateToWidth(theme.fg("muted", " Loading…"), width, "")); result.push(truncateToWidth(theme.fg("muted", " Loading…"), width, ""));
} else { } else {
@@ -806,8 +782,9 @@ class ErrorBrowser {
? "No errors match the current filters" ? "No errors match the current filters"
: "No production errors found"; : "No production errors found";
result.push(truncateToWidth(theme.fg("muted", ` ${msg}`), width, "")); result.push(truncateToWidth(theme.fg("muted", ` ${msg}`), width, ""));
// Pad remaining viewport // Pad remaining viewport (3 chrome lines: border + header + divider)
while (result.length < this.visibleHeight + 3) { const chromeLines = 3;
while (result.length < this.visibleHeight + chromeLines) {
result.push(""); result.push("");
} }
} else { } else {
@@ -898,69 +875,7 @@ class ErrorBrowser {
private buildDetailLines(): string[] { private buildDetailLines(): string[] {
const error = this.selectedError; const error = this.selectedError;
if (!error) return ["(no error selected)"]; if (!error) return ["(no error selected)"];
return formatDetailLines(error);
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[] { private renderDetail(width: number, theme: Theme): string[] {