Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/daemon/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
131 changes: 112 additions & 19 deletions src/tui/usage/app.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<void> {
const running = await isDaemonRunning();
Expand All @@ -47,11 +48,15 @@ export async function runUsageTui(): Promise<void> {
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<typeof setInterval> | null = null;
let autoRefresh = true;
let cleanedUp = false;
let fetching = false;
let errorsFetching = false;

if (isInteractive) {
stdout.write(enterAlternateScreen() + hideCursor());
Expand Down Expand Up @@ -79,6 +84,29 @@ export async function runUsageTui(): Promise<void> {
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<void> {
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
Expand All @@ -92,7 +120,7 @@ export async function runUsageTui(): Promise<void> {
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(),
Expand All @@ -103,6 +131,10 @@ export async function runUsageTui(): Promise<void> {
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);
Expand Down Expand Up @@ -136,19 +168,78 @@ export async function runUsageTui(): Promise<void> {
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<void> {
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~") {
Expand Down Expand Up @@ -193,27 +284,31 @@ export async function runUsageTui(): Promise<void> {
}

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;
}

Expand Down Expand Up @@ -255,9 +350,7 @@ export async function runUsageTui(): Promise<void> {

const tab = visibleTabs.find((t) => t.key === key);
if (tab) {
currentTab = tab.id;
vimState.scrollPos = 0;
render();
activateTab(tab.id);
}
};

Expand Down
1 change: 1 addition & 0 deletions src/tui/usage/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];

/**
Expand Down
11 changes: 10 additions & 1 deletion src/tui/usage/data.ts
Original file line number Diff line number Diff line change
@@ -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 };

Expand Down Expand Up @@ -92,6 +92,15 @@ export async function fetchUsageSummary(): Promise<UsageStats | null> {
}
}

export async function fetchRecentErrors(): Promise<ErrorLogEntry[] | null> {
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");
Expand Down
40 changes: 38 additions & 2 deletions src/tui/usage/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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";
}
}
3 changes: 2 additions & 1 deletion src/tui/usage/types.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand Down
Loading