mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 20:38:22 +00:00
The auth proxy folds the last 7 chars of the owner's npub into client ids (`claude-code-a1b2c3d`) and the daemon never strips it, so the Recent tab's 13-char client cell clipped exactly the part that identifies the owner. Resolve each usage entry's client id back to `Name (client-id)` by joining GET /clients (clientId -> ownerNpub) with GET /npubs (npub -> name) and stripping the `-<npub tail>` suffix. Local mode has neither source, so its rendering is unchanged (raw id in the previous 14-col cell). - data: NPUB_SUFFIX_LENGTH, ClientNaming, buildClientNaming(), resolveClientLabel(); suffix lookups derive from /npubs and /clients - render: thread ClientNaming through renderTabContent, size the Recent client column to the labels, let BASE:PROVIDER absorb the remainder - tests: label resolution and both renderers
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
import { getVisibleTabs } from "./constants.ts";
|
|
import type { Tab } from "./types.ts";
|
|
import { buildClientNaming, emptyClientNaming, fetchBalance, fetchClients, fetchNpubs, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type ClientNaming, type NpubEntry, type StatusInfo } from "./data.ts";
|
|
import {
|
|
applyScrollToContent,
|
|
exitSearchMode,
|
|
nextSearchResult,
|
|
pageDown,
|
|
pageUp,
|
|
performSearch,
|
|
prevSearchResult,
|
|
scrollDown,
|
|
scrollToBottom,
|
|
scrollToTop,
|
|
scrollUp,
|
|
startSearch,
|
|
vimState,
|
|
} from "./state.ts";
|
|
import {
|
|
enterAlternateScreen,
|
|
eraseDown,
|
|
getHeight,
|
|
getWidth,
|
|
hideCursor,
|
|
leaveAlternateScreen,
|
|
moveCursor,
|
|
showCursor,
|
|
} from "./terminal.ts";
|
|
import { COLORS } from "./constants.ts";
|
|
import { renderHeader, renderSearchBar, renderSeparator, renderTabContent, renderTabs } from "./render.ts";
|
|
import type { TabId, UpdateInfo, UsageStats } from "./types.ts";
|
|
import { checkForUpdates } from "../../utils/update-checker.ts";
|
|
|
|
export async function runUsageTui(): Promise<void> {
|
|
const running = await isDaemonRunning();
|
|
if (!running) {
|
|
console.log(`${COLORS.red}Error: routstrd daemon is not running.${COLORS.reset}`);
|
|
console.log(`Run ${COLORS.green}routstrd start${COLORS.reset} first.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const stdin = process.stdin;
|
|
const stdout = process.stdout;
|
|
const isInteractive = Boolean(stdout.isTTY && stdin.isTTY);
|
|
|
|
let currentTab: TabId = "overview";
|
|
let stats: UsageStats | null = null;
|
|
let balance: BalanceInfo | null = null;
|
|
let status: StatusInfo | null = null;
|
|
let clients: ClientInfo[] = [];
|
|
let npubs: NpubEntry[] = [];
|
|
let naming: ClientNaming = emptyClientNaming();
|
|
let visibleTabs: Tab[] = getVisibleTabs(false);
|
|
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
|
let autoRefresh = true;
|
|
let cleanedUp = false;
|
|
let fetching = false;
|
|
|
|
// Update-check state — re-checked at most every 210 minutes to avoid
|
|
// spamming the npm registry. The first check happens shortly after
|
|
// startup so the TUI doesn't block on a network call.
|
|
const UPDATE_CHECK_INTERVAL_MS = 210 * 60 * 1000;
|
|
const UPDATE_CHECK_DELAY_MS = 3000;
|
|
let updateInfo: UpdateInfo | null = null;
|
|
let lastUpdateCheck = 0;
|
|
let updateCheckInProgress = false;
|
|
|
|
async function maybeCheckForUpdates(): Promise<void> {
|
|
if (updateCheckInProgress) return;
|
|
const now = Date.now();
|
|
if (updateInfo && now - lastUpdateCheck < UPDATE_CHECK_INTERVAL_MS) return;
|
|
|
|
updateCheckInProgress = true;
|
|
lastUpdateCheck = now;
|
|
try {
|
|
const result = await checkForUpdates();
|
|
const outdated = result.packages.filter((p) => p.hasUpdate);
|
|
updateInfo = {
|
|
hasUpdate: result.hasUpdate,
|
|
text: outdated.length > 0
|
|
? `Update available: ${outdated.map((p) => p.label).join(", ")} ${outdated.map((p) => p.latest).join(", ")}`
|
|
: "",
|
|
};
|
|
render();
|
|
} catch {
|
|
// Silently ignore — update checks are best-effort
|
|
} finally {
|
|
updateCheckInProgress = false;
|
|
}
|
|
}
|
|
|
|
if (isInteractive) {
|
|
stdout.write(enterAlternateScreen() + hideCursor());
|
|
stdin.setRawMode?.(true);
|
|
stdin.resume();
|
|
stdin.setEncoding("utf-8");
|
|
}
|
|
|
|
function cleanup(exitCode = 0) {
|
|
if (cleanedUp) return;
|
|
cleanedUp = true;
|
|
if (refreshInterval) clearInterval(refreshInterval);
|
|
|
|
if (isInteractive) {
|
|
stdin.setRawMode?.(false);
|
|
stdin.pause();
|
|
stdout.write(showCursor() + leaveAlternateScreen());
|
|
} else {
|
|
stdout.write(showCursor());
|
|
}
|
|
|
|
process.exit(exitCode);
|
|
}
|
|
|
|
process.on("SIGINT", () => cleanup(0));
|
|
process.on("SIGTERM", () => cleanup(0));
|
|
|
|
/**
|
|
* Background fetch — async, never blocks rendering. Updates the state
|
|
* variables only on success, then triggers a repaint. Overlapping calls
|
|
* are skipped via the `fetching` guard so we don't race stale results.
|
|
*/
|
|
async function fetchData(): Promise<void> {
|
|
if (fetching) return;
|
|
fetching = true;
|
|
try {
|
|
const running = await isDaemonRunning();
|
|
if (!running) {
|
|
stats = null;
|
|
} else {
|
|
// Fire all 4 fetches concurrently — cuts the blocked window significantly.
|
|
const [newStats, newBalance, newStatus, newClients] = await Promise.all([
|
|
fetchUsageSummary(),
|
|
fetchBalance(),
|
|
fetchStatus(),
|
|
fetchClients(),
|
|
]);
|
|
if (newStats) stats = newStats;
|
|
if (newBalance) balance = newBalance;
|
|
if (newStatus) status = newStatus;
|
|
if (newClients && newClients.length > 0) clients = newClients;
|
|
|
|
const npubsVisible = hasAnyNpubs(clients);
|
|
visibleTabs = getVisibleTabs(npubsVisible);
|
|
if (currentTab === "npubs" && !npubsVisible) {
|
|
currentTab = "clients";
|
|
vimState.scrollPos = 0;
|
|
}
|
|
|
|
// Names/roles live on the auth proxy, not the usage summary. Only
|
|
// fetch them when the Npubs tab is actually reachable, and keep the
|
|
// last good list if the endpoint is temporarily unreachable.
|
|
if (npubsVisible) {
|
|
const newNpubs = await fetchNpubs();
|
|
if (newNpubs.length > 0) npubs = newNpubs;
|
|
}
|
|
|
|
// Owner info (for "Name (client-id)" labels in the Recent tab) comes
|
|
// from /clients + /npubs; both are empty in local mode.
|
|
naming = buildClientNaming(clients, npubs);
|
|
}
|
|
render();
|
|
} finally {
|
|
fetching = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Synchronous paint — never awaits I/O. Reads the current state variables
|
|
* and writes to stdout. Safe to call from key handlers without blocking.
|
|
*/
|
|
function render(): void {
|
|
const width = getWidth();
|
|
const height = getHeight();
|
|
|
|
if (!stats) {
|
|
stdout.write(
|
|
moveCursor(1, 1) +
|
|
eraseDown() +
|
|
`${COLORS.red}Error: Could not fetch usage data.${COLORS.reset}\n` +
|
|
`Make sure routstrd is running.\n` +
|
|
`\nPress Q to quit.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const content = renderTabContent(currentTab, stats, balance, status, width, naming);
|
|
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 chrome = renderHeader(currentTab, width, visibleTabs, updateInfo ?? undefined) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
|
|
const chromeLines = chrome.split("\n").length - 1;
|
|
const footerSeparator = renderSeparator(width);
|
|
const footerLines = footerSeparator.split("\n").length - 1;
|
|
const contentViewportHeight = Math.max(1, height - chromeLines - footerLines - 1);
|
|
const visibleContent = applyScrollToContent(content, contentViewportHeight);
|
|
const footerBlock = (visibleContent ? "\n" : "") + footerSeparator + footer;
|
|
|
|
stdout.write(moveCursor(1, 1) + eraseDown() + chrome + visibleContent + footerBlock);
|
|
}
|
|
|
|
const handleKey = (key: string) => {
|
|
if (vimState.isSearching) {
|
|
if (key === "\x1b" || key === "\x1b[3~") {
|
|
exitSearchMode();
|
|
render();
|
|
return;
|
|
}
|
|
if (key === "\r" || key === "\n") {
|
|
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
|
|
exitSearchMode();
|
|
render();
|
|
return;
|
|
}
|
|
if (key === "\x7f" || key === "\x08") {
|
|
vimState.searchQuery = vimState.searchQuery.slice(0, -1);
|
|
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
|
|
render();
|
|
return;
|
|
}
|
|
if (key === "\x03") {
|
|
exitSearchMode();
|
|
render();
|
|
return;
|
|
}
|
|
if (key.length === 1 && key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) {
|
|
vimState.searchQuery += key;
|
|
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
|
|
render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (key === "q" || key === "Q" || key === "\u0003") return cleanup(0);
|
|
if (key === "r" || key === "R") {
|
|
void fetchData();
|
|
return;
|
|
}
|
|
if (key === "a" || key === "A") {
|
|
autoRefresh = !autoRefresh;
|
|
render();
|
|
return;
|
|
}
|
|
|
|
if (key === "j" || key === "\x1b[B" || key === "\x1bOB") {
|
|
scrollDown();
|
|
render();
|
|
return;
|
|
}
|
|
if (key === "k" || key === "\x1b[A" || key === "\x1bOA") {
|
|
scrollUp();
|
|
render();
|
|
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();
|
|
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();
|
|
return;
|
|
}
|
|
|
|
if (key === "g") {
|
|
if (vimState.lastKey === "g" && Date.now() - vimState.lastKeyTime < 300) {
|
|
scrollToBottom();
|
|
vimState.lastKey = "";
|
|
render();
|
|
return;
|
|
}
|
|
vimState.lastKey = "g";
|
|
vimState.lastKeyTime = Date.now();
|
|
scrollToTop();
|
|
render();
|
|
return;
|
|
}
|
|
|
|
if (key === "\x02") { pageUp(); render(); return; }
|
|
if (key === "\x06") { pageDown(); render(); return; }
|
|
if (key === "\x15") { scrollUp(10); render(); return; }
|
|
if (key === "\x04") { scrollDown(10); render(); return; }
|
|
if (key === "\x1b[H" || key === "\x1b[1~" || key === "\x1bOH") { scrollToTop(); render(); return; }
|
|
if (key === "\x1b[F" || key === "\x1b[4~" || key === "\x1bOF") { scrollToBottom(); render(); return; }
|
|
if (key === "/") { startSearch(false); render(); return; }
|
|
if (key === "?") { startSearch(true); render(); return; }
|
|
if (key === "n") {
|
|
if (vimState.searchReverse) prevSearchResult(stats?.entries.length || 0);
|
|
else nextSearchResult(stats?.entries.length || 0);
|
|
render();
|
|
return;
|
|
}
|
|
if (key === "N") {
|
|
if (vimState.searchReverse) nextSearchResult(stats?.entries.length || 0);
|
|
else prevSearchResult(stats?.entries.length || 0);
|
|
render();
|
|
return;
|
|
}
|
|
if (key === "\x1b") { scrollToTop(); render(); return; }
|
|
|
|
const tab = visibleTabs.find((t) => t.key === key);
|
|
if (tab) {
|
|
currentTab = tab.id;
|
|
vimState.scrollPos = 0;
|
|
render();
|
|
}
|
|
};
|
|
|
|
if (isInteractive) stdin.on("data", handleKey);
|
|
|
|
await fetchData();
|
|
|
|
// Kick off the first update check after a short delay so the initial
|
|
// data fetch isn't blocked by a network round-trip to npm.
|
|
setTimeout(() => void maybeCheckForUpdates(), UPDATE_CHECK_DELAY_MS);
|
|
|
|
refreshInterval = setInterval(() => {
|
|
if (autoRefresh) {
|
|
void fetchData();
|
|
}
|
|
void maybeCheckForUpdates();
|
|
}, 2000);
|
|
}
|