diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 96b4d59..3b97337 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -8,7 +8,7 @@ import { } from "@routstr/sdk"; import type { UsageTrackingDriver, SdkLogger } from "@routstr/sdk"; import type { RequestResponseLogSink } from "../request-response-log-sink"; -import { logger } from "../../utils/logger"; +import { getRecentErrors, logger } from "../../utils/logger"; import { loadDaemonConfig, saveDaemonConfig } from "../config-store"; import { CocodHttpError, @@ -1145,6 +1145,16 @@ export function createDaemonRequestHandler(deps: { return; } + if (req.method === "GET" && url.pathname === "/errors/recent") { + try { + const output = await getRecentErrors(parseLimit(url.searchParams.get("limit"), 50)); + sendJson(res, 200, { output }); + } catch (error) { + sendJson(res, 500, { error: toErrorMessage(error) }); + } + return; + } + if (req.method === "GET" && url.pathname === "/usage/summary") { try { const tz = Number.parseInt(url.searchParams.get("tz") || "0", 10) || 0; diff --git a/src/tui/usage/app.ts b/src/tui/usage/app.ts index 6eb9424..14013b2 100644 --- a/src/tui/usage/app.ts +++ b/src/tui/usage/app.ts @@ -1,6 +1,6 @@ import { getVisibleTabs } from "./constants.ts"; import type { Tab } from "./types.ts"; -import { fetchBalance, fetchClients, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts"; +import { fetchBalance, fetchClients, fetchRecentErrors, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts"; import { applyScrollToContent, exitSearchMode, @@ -25,10 +25,11 @@ import { leaveAlternateScreen, moveCursor, showCursor, + stripAnsi, } from "./terminal.ts"; import { COLORS } from "./constants.ts"; import { renderHeader, renderSearchBar, renderSeparator, renderTabContent, renderTabs } from "./render.ts"; -import type { TabId, UsageStats } from "./types.ts"; +import type { ErrorLogEntry, TabId, UsageStats } from "./types.ts"; export async function runUsageTui(): Promise { const running = await isDaemonRunning(); @@ -47,11 +48,15 @@ export async function runUsageTui(): Promise { let balance: BalanceInfo | null = null; let status: StatusInfo | null = null; let clients: ClientInfo[] = []; + let errors: ErrorLogEntry[] = []; + let selectedErrorIndex = 0; + let clipboardStatus = ""; let visibleTabs: Tab[] = getVisibleTabs(false); let refreshInterval: ReturnType | null = null; let autoRefresh = true; let cleanedUp = false; let fetching = false; + let errorsFetching = false; if (isInteractive) { stdout.write(enterAlternateScreen() + hideCursor()); @@ -79,6 +84,29 @@ export async function runUsageTui(): Promise { process.on("SIGINT", () => cleanup(0)); process.on("SIGTERM", () => cleanup(0)); + function updateErrors(newErrors: ErrorLogEntry[]): void { + const selectedError = errors[selectedErrorIndex]; + errors = newErrors; + const selectedIndex = selectedError + ? errors.findIndex((error) => error.timestamp === selectedError.timestamp && error.message === selectedError.message) + : -1; + selectedErrorIndex = selectedIndex >= 0 + ? selectedIndex + : Math.min(selectedErrorIndex, Math.max(0, errors.length - 1)); + } + + async function refreshErrors(): Promise { + if (errorsFetching) return; + errorsFetching = true; + try { + const newErrors = await fetchRecentErrors(); + if (newErrors !== null) updateErrors(newErrors); + render(); + } finally { + errorsFetching = false; + } + } + /** * Background fetch — async, never blocks rendering. Updates the state * variables only on success, then triggers a repaint. Overlapping calls @@ -92,7 +120,7 @@ export async function runUsageTui(): Promise { if (!running) { stats = null; } else { - // Fire all 4 fetches concurrently — cuts the blocked window significantly. + const errorsPromise = currentTab === "errors" ? fetchRecentErrors() : null; const [newStats, newBalance, newStatus, newClients] = await Promise.all([ fetchUsageSummary(), fetchBalance(), @@ -103,6 +131,10 @@ export async function runUsageTui(): Promise { if (newBalance) balance = newBalance; if (newStatus) status = newStatus; if (newClients && newClients.length > 0) clients = newClients; + if (errorsPromise) { + const newErrors = await errorsPromise; + if (newErrors !== null) updateErrors(newErrors); + } const npubsVisible = hasAnyNpubs(clients); visibleTabs = getVisibleTabs(npubsVisible); @@ -136,19 +168,78 @@ export async function runUsageTui(): Promise { return; } - const content = renderTabContent(currentTab, stats, balance, status, width, clients); - const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`; + const content = renderTabContent(currentTab, stats, balance, status, width, clients, errors, selectedErrorIndex); + const errorHelp = currentTab === "errors" ? ", [j/k or ↑/↓] select, [y] copy" : ""; + const footer = `${COLORS.dim}Press [q] to quit, [r] to refresh, [a] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"}${errorHelp} scroll:${vimState.scrollPos}${COLORS.reset}${clipboardStatus ? ` ${COLORS.green}${clipboardStatus}${COLORS.reset}` : ""}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`; const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar(); - const chromeLines = chrome.split("\n").length - 1; + const visualLines = (text: string) => text + .replace(/\n$/, "") + .split("\n") + .reduce((total, line) => total + Math.max(1, Math.ceil(stripAnsi(line).length / width)), 0); + const chromeLines = visualLines(chrome); const footerSeparator = renderSeparator(width); - const footerLines = footerSeparator.split("\n").length - 1; - const contentViewportHeight = Math.max(1, height - chromeLines - footerLines - 1); + const footerLines = visualLines(footerSeparator + footer); + const contentViewportHeight = Math.max(1, height - chromeLines - footerLines); + if (currentTab === "errors") { + const contentLines = content.split("\n"); + const selectedLines = contentLines + .map((line, index) => line.includes(COLORS.bgBlue) ? index : -1) + .filter((index) => index >= 0); + const first = selectedLines[0]; + const last = selectedLines[selectedLines.length - 1]; + if (first !== undefined && first < vimState.scrollPos) vimState.scrollPos = first; + if (last !== undefined && last >= vimState.scrollPos + contentViewportHeight) { + vimState.scrollPos = last - contentViewportHeight + 1; + } + } const visibleContent = applyScrollToContent(content, contentViewportHeight); const footerBlock = (visibleContent ? "\n" : "") + footerSeparator + footer; stdout.write(moveCursor(1, 1) + eraseDown() + chrome + visibleContent + footerBlock); } + async function copySelectedError(): Promise { + const error = errors[selectedErrorIndex]; + if (!error) return; + + const text = `[${error.timestamp}] [ERROR] ${error.message}`; + const commands = process.platform === "darwin" + ? [["pbcopy"]] + : [["wl-copy"], ["xclip", "-selection", "clipboard"]]; + + for (const command of commands) { + try { + const proc = Bun.spawn(command, { stdin: "pipe", stdout: "ignore", stderr: "ignore" }); + proc.stdin.write(text); + proc.stdin.end(); + if (await proc.exited === 0) { + clipboardStatus = "Copied error"; + render(); + return; + } + } catch { + // Try the next platform clipboard command. + } + } + + clipboardStatus = "Clipboard unavailable"; + render(); + } + + function activateTab(nextTab: TabId): void { + if (nextTab === "errors" && currentTab !== "errors") { + selectedErrorIndex = 0; + } + + currentTab = nextTab; + vimState.scrollPos = 0; + render(); + + if (currentTab === "errors") { + void refreshErrors(); + } + } + const handleKey = (key: string) => { if (vimState.isSearching) { if (key === "\x1b" || key === "\x1b[3~") { @@ -193,27 +284,31 @@ export async function runUsageTui(): Promise { } if (key === "j" || key === "\x1b[B" || key === "\x1bOB") { - scrollDown(); + if (currentTab === "errors") selectedErrorIndex = Math.min(Math.max(0, errors.length - 1), selectedErrorIndex + 1); + else scrollDown(); + clipboardStatus = ""; render(); return; } if (key === "k" || key === "\x1b[A" || key === "\x1bOA") { - scrollUp(); + if (currentTab === "errors") selectedErrorIndex = Math.max(0, selectedErrorIndex - 1); + else scrollUp(); + clipboardStatus = ""; render(); return; } + if (currentTab === "errors" && (key === "y" || key === "Y")) { + void copySelectedError(); + return; + } if (key === "l" || key === "\x1b[C" || key === "\x1bOC") { const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab); - currentTab = visibleTabs[(currentIdx + 1) % visibleTabs.length]!.id; - vimState.scrollPos = 0; - render(); + activateTab(visibleTabs[(currentIdx + 1) % visibleTabs.length]!.id); return; } if (key === "h" || key === "\x1b[D" || key === "\x1bOD") { const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab); - currentTab = visibleTabs[(currentIdx - 1 + visibleTabs.length) % visibleTabs.length]!.id; - vimState.scrollPos = 0; - render(); + activateTab(visibleTabs[(currentIdx - 1 + visibleTabs.length) % visibleTabs.length]!.id); return; } @@ -255,9 +350,7 @@ export async function runUsageTui(): Promise { const tab = visibleTabs.find((t) => t.key === key); if (tab) { - currentTab = tab.id; - vimState.scrollPos = 0; - render(); + activateTab(tab.id); } }; diff --git a/src/tui/usage/constants.ts b/src/tui/usage/constants.ts index 442c44e..309bc33 100644 --- a/src/tui/usage/constants.ts +++ b/src/tui/usage/constants.ts @@ -9,6 +9,7 @@ export const ALL_TABS: Tab[] = [ { id: "clients", name: "Clients", key: "6" }, { id: "npubs", name: "Npubs", key: "7" }, { id: "recent", name: "Recent", key: "8" }, + { id: "errors", name: "Errors", key: "9" }, ]; /** diff --git a/src/tui/usage/data.ts b/src/tui/usage/data.ts index e58412a..3bb1173 100644 --- a/src/tui/usage/data.ts +++ b/src/tui/usage/data.ts @@ -1,6 +1,6 @@ import type { UsageTrackingEntry } from "../../daemon/types.ts"; import { callDaemon, isDaemonRunning } from "../../utils/daemon-client.ts"; -import type { UsageStats, UsageSummary } from "./types.ts"; +import type { ErrorLogEntry, UsageStats, UsageSummary } from "./types.ts"; export { isDaemonRunning }; @@ -92,6 +92,15 @@ export async function fetchUsageSummary(): Promise { } } +export async function fetchRecentErrors(): Promise { + try { + const result = await callDaemon("/errors/recent?limit=50"); + return result.error ? null : (result.output as ErrorLogEntry[] || []); + } catch { + return null; + } +} + export function formatTime(timestamp: number): string { const d = new Date(timestamp); const p = (n: number) => String(n).padStart(2, "0"); diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index 727598e..02808da 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -8,7 +8,7 @@ import { import { vimState } from "./state.ts"; import { stripAnsi } from "./terminal.ts"; import type { BalanceInfo, StatusInfo } from "./data.ts"; -import type { TabId, UsageStats } from "./types.ts"; +import type { ErrorLogEntry, TabId, UsageStats } from "./types.ts"; /** Format a cost value: 0.12, 1.23, 12.34, 123.45, 1.23k, 1.23m */ function formatCost(value: number): string { @@ -600,7 +600,42 @@ export function renderRecent(stats: UsageStats, width: number): string { return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`); } -export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number, clients: ClientInfo[] = []): string { +export function renderErrors(errors: ErrorLogEntry[], width: number, selectedIndex = 0): string { + if (errors.length === 0) return renderBox(["No recent errors"], width, "Recent Errors"); + + const innerWidth = Math.max(1, width - 4); + const timeCol = innerWidth >= 30 ? 19 : 8; + const messageCol = Math.max(1, innerWidth - timeCol - 1); + const lines = [ + `${COLORS.bold}${"TIME".padEnd(timeCol)} ${"ERROR".slice(0, messageCol)}${COLORS.reset}`, + COLORS.dim + "─".repeat(innerWidth) + COLORS.reset, + ]; + + for (const [errorIndex, error] of errors.entries()) { + const fullTimestamp = error.timestamp.replace("T", " ").replace("Z", ""); + const timestamp = timeCol === 8 ? fullTimestamp.slice(11, 19) : fullTimestamp.slice(0, timeCol); + let firstLine = true; + + for (const rawLine of error.message.split("\n")) { + const message = rawLine.replace(/\t/g, " ").replace(/[\x00-\x08\x0B-\x1F\x7F]/g, ""); + const chunks = message.match(new RegExp(`.{1,${messageCol}}`, "g")) || [""]; + for (const chunk of chunks) { + const time = firstLine ? timestamp : ""; + const selected = errorIndex === selectedIndex; + const row = selected + ? `${COLORS.bgBlue}${COLORS.bright}${time.padEnd(timeCol)} ${chunk.padEnd(messageCol)}${COLORS.reset}` + : `${COLORS.dim}${time.padEnd(timeCol)}${COLORS.reset} ${COLORS.red}${chunk}${COLORS.reset}`; + lines.push(row); + firstLine = false; + } + } + } + + const title = width < 30 ? "Recent Errors" : `Recent Errors (${errors.length} shown)`; + return renderBox(lines, width, title); +} + +export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number, clients: ClientInfo[] = [], errors: ErrorLogEntry[] = [], selectedErrorIndex = 0): string { switch (activeTab) { case "overview": return renderOverview(stats, balance, status, width); case "today": return renderToday(stats, width); @@ -610,6 +645,7 @@ export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: B case "clients": return renderClients(stats, width); case "npubs": return renderNpubs(stats, clients, width); case "recent": return renderRecent(stats, width); + case "errors": return renderErrors(errors, width, selectedErrorIndex); default: return "Unknown tab"; } } diff --git a/src/tui/usage/types.ts b/src/tui/usage/types.ts index 2c0345d..acecc7a 100644 --- a/src/tui/usage/types.ts +++ b/src/tui/usage/types.ts @@ -1,6 +1,7 @@ import type { UsageTrackingEntry } from "../../daemon/types.ts"; import type { UsageSummary } from "../../daemon/http/usage-summary.ts"; +export type { ErrorLogEntry } from "../../utils/logger.ts"; export type { UsageSummary }; export interface UsageStats { @@ -12,7 +13,7 @@ export interface UsageStats { summary: UsageSummary; } -export type TabId = "overview" | "today" | "models" | "providers" | "tokens" | "clients" | "npubs" | "recent"; +export type TabId = "overview" | "today" | "models" | "providers" | "tokens" | "clients" | "npubs" | "recent" | "errors"; export interface Tab { id: TabId; diff --git a/src/utils/logger.test.ts b/src/utils/logger.test.ts new file mode 100644 index 0000000..bac946b --- /dev/null +++ b/src/utils/logger.test.ts @@ -0,0 +1,45 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; + +const testDir = await mkdtemp(join(tmpdir(), "routstrd-logger-")); +const previousRoutstrdDir = process.env.ROUTSTRD_DIR; +process.env.ROUTSTRD_DIR = testDir; +const { getRecentErrors } = await import("./logger.ts"); +if (previousRoutstrdDir === undefined) delete process.env.ROUTSTRD_DIR; +else process.env.ROUTSTRD_DIR = previousRoutstrdDir; + +afterAll(async () => { + await rm(testDir, { recursive: true, force: true }); +}); + +describe("getRecentErrors", () => { + it("returns newest errors first, preserves multiline messages, and applies the limit", async () => { + const logsDir = join(testDir, "logs"); + await mkdir(logsDir, { recursive: true }); + await writeFile(join(logsDir, "2026-07-10.log"), [ + "[2026-07-10T10:00:00.000Z] [ERROR] older error", + "older detail", + "[2026-07-10T11:00:00.000Z] [INFO] ignored", + "", + ].join("\n")); + await writeFile(join(logsDir, "2026-07-11.log"), [ + "[2026-07-11T10:00:00.000Z] [ERROR] newer error", + "newer detail", + "[2026-07-11T11:00:00.000Z] [ERROR] newest error", + "", + ].join("\n")); + + expect(await getRecentErrors(2)).toEqual([ + { timestamp: "2026-07-11T11:00:00.000Z", message: "newest error" }, + { timestamp: "2026-07-11T10:00:00.000Z", message: "newer error\nnewer detail" }, + ]); + + expect(await getRecentErrors(3)).toEqual([ + { timestamp: "2026-07-11T11:00:00.000Z", message: "newest error" }, + { timestamp: "2026-07-11T10:00:00.000Z", message: "newer error\nnewer detail" }, + { timestamp: "2026-07-10T10:00:00.000Z", message: "older error\nolder detail" }, + ]); + }); +}); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index cc317e0..912a872 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,4 +1,4 @@ -import { appendFile, mkdir } from "fs/promises"; +import { appendFile, mkdir, readFile, readdir } from "fs/promises"; import { existsSync } from "fs"; import { join } from "path"; @@ -19,6 +19,42 @@ async function ensureLogDir() { } } +export interface ErrorLogEntry { + timestamp: string; + message: string; +} + +export async function getRecentErrors(limit = 50): Promise { + if (!existsSync(LOGS_DIR)) return []; + + const files = (await readdir(LOGS_DIR)) + .filter((file) => /^\d{4}-\d{2}-\d{2}\.log$/.test(file)) + .sort() + .reverse(); + const errors: ErrorLogEntry[] = []; + + for (const file of files) { + const records: Array<{ timestamp: string; level: string; message: string }> = []; + for (const line of (await readFile(join(LOGS_DIR, file), "utf8")).split("\n")) { + const match = line.match(/^\[([^\]]+)] \[([^\]]+)] (.*)$/); + if (match) { + records.push({ timestamp: match[1]!, level: match[2]!, message: match[3]! }); + } else if (line && records.length > 0) { + records[records.length - 1]!.message += `\n${line}`; + } + } + + for (const record of records.reverse()) { + if (record.level === "ERROR") { + errors.push({ timestamp: record.timestamp, message: record.message }); + if (errors.length === limit) return errors; + } + } + } + + return errors; +} + async function writeLog(level: string, ...args: unknown[]) { await ensureLogDir(); const timestamp = new Date().toISOString();