mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
feat(tui): show owner names next to client ids in the Recent tab
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
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { getVisibleTabs } from "./constants.ts";
|
||||
import type { Tab } from "./types.ts";
|
||||
import { fetchBalance, fetchClients, fetchNpubs, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type NpubEntry, type StatusInfo } from "./data.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,
|
||||
@@ -49,6 +49,7 @@ export async function runUsageTui(): Promise<void> {
|
||||
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;
|
||||
@@ -153,6 +154,10 @@ export async function runUsageTui(): Promise<void> {
|
||||
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 {
|
||||
@@ -179,7 +184,7 @@ export async function runUsageTui(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = renderTabContent(currentTab, stats, balance, status, width, npubs);
|
||||
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;
|
||||
|
||||
@@ -172,3 +172,86 @@ export async function fetchNpubs(): Promise<NpubEntry[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of trailing npub characters the auth proxy folds into a client id. */
|
||||
export const NPUB_SUFFIX_LENGTH = 7;
|
||||
|
||||
/**
|
||||
* Lookup tables for turning a raw usage `client` id (e.g.
|
||||
* `claude-code-a1b2c3d`) back into a human label (e.g. `Alice (claude-code)`).
|
||||
*
|
||||
* All maps are empty in local mode, so {@link resolveClientLabel} falls back to
|
||||
* the raw id with no behaviour change there.
|
||||
*/
|
||||
export interface ClientNaming {
|
||||
/** Configured npubs (names + roles) from the auth proxy. */
|
||||
npubs: NpubEntry[];
|
||||
/** bare clientId -> owner npub, from `GET /clients`. */
|
||||
ownersByClientId: Map<string, string>;
|
||||
/** npub -> display name (null when unset). */
|
||||
npubNames: Map<string, string | null>;
|
||||
/** trailing npub suffix -> npub. */
|
||||
npubsBySuffix: Map<string, string>;
|
||||
}
|
||||
|
||||
export function buildClientNaming(clients: ClientInfo[], npubs: NpubEntry[]): ClientNaming {
|
||||
const ownersByClientId = new Map<string, string>();
|
||||
for (const c of clients) {
|
||||
if (c.ownerNpub) ownersByClientId.set(c.clientId, c.ownerNpub);
|
||||
}
|
||||
|
||||
const npubNames = new Map<string, string | null>();
|
||||
const npubsBySuffix = new Map<string, string>();
|
||||
const remember = (npub: string) => npubsBySuffix.set(npub.slice(-NPUB_SUFFIX_LENGTH), npub);
|
||||
|
||||
for (const n of npubs) {
|
||||
npubNames.set(n.npub, n.name ?? null);
|
||||
remember(n.npub);
|
||||
}
|
||||
// `GET /clients` also carries owner npubs, which covers entries whose owner
|
||||
// isn't present in the (possibly filtered) `/npubs` response.
|
||||
for (const owner of ownersByClientId.values()) remember(owner);
|
||||
|
||||
return { npubs, ownersByClientId, npubNames, npubsBySuffix };
|
||||
}
|
||||
|
||||
/** Configured display name for an npub, or null when unset/blank. */
|
||||
function npubDisplayName(naming: ClientNaming, npub: string): string | null {
|
||||
const name = naming.npubNames.get(npub)?.trim();
|
||||
return name && name.length > 0 ? name : null;
|
||||
}
|
||||
|
||||
/** `Alice (claude-code)` when the owner has a name, else the bare client id. */
|
||||
function formatClientLabel(naming: ClientNaming, bareId: string, ownerNpub: string): string {
|
||||
const name = npubDisplayName(naming, ownerNpub);
|
||||
return name ? `${name} (${bareId})` : bareId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a usage entry's `client` value as `Name (client-id)`, where `Name` is
|
||||
* the owner npub's display name and the `-<npub tail>` suffix is stripped.
|
||||
* Falls back to the raw id when no owner/name can be resolved (local mode).
|
||||
*/
|
||||
export function resolveClientLabel(clientId: string | undefined, naming: ClientNaming): string {
|
||||
const raw = clientId && clientId.length > 0 ? clientId : "unknown";
|
||||
|
||||
// Exact match: the raw id is a known bare clientId or its suffixed form.
|
||||
for (const [bareId, ownerNpub] of naming.ownersByClientId) {
|
||||
const suffixed = `${bareId}-${ownerNpub.slice(-NPUB_SUFFIX_LENGTH)}`;
|
||||
if (raw !== bareId && raw !== suffixed) continue;
|
||||
return formatClientLabel(naming, bareId, ownerNpub);
|
||||
}
|
||||
|
||||
// Fallback: strip a trailing `-<npub tail>` that matches a known npub.
|
||||
for (const [suffix, npub] of naming.npubsBySuffix) {
|
||||
const marker = `-${suffix}`;
|
||||
if (raw.length <= marker.length || !raw.endsWith(marker)) continue;
|
||||
return formatClientLabel(naming, raw.slice(0, -marker.length), npub);
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function emptyClientNaming(): ClientNaming {
|
||||
return buildClientNaming([], []);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderNpubs } from "./render.ts";
|
||||
import { renderNpubs, renderRecent } from "./render.ts";
|
||||
import { stripAnsi } from "./terminal.ts";
|
||||
import type { NpubEntry } from "./data.ts";
|
||||
import { buildClientNaming, resolveClientLabel, type ClientInfo, type NpubEntry } from "./data.ts";
|
||||
import type { UsageStats } from "./types.ts";
|
||||
|
||||
const NPUB = "npub1abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopq";
|
||||
@@ -42,8 +42,8 @@ function statsFor(npub: string): UsageStats {
|
||||
|
||||
describe("renderNpubs", () => {
|
||||
test("shows the configured display name and role", () => {
|
||||
const npubs: NpubEntry[] = [{ npub: NPUB, name: "Alice", role: "admin" }];
|
||||
const out = stripAnsi(renderNpubs(statsFor(NPUB), npubs, 120));
|
||||
const naming = buildClientNaming([], [{ npub: NPUB, name: "Alice", role: "admin" }]);
|
||||
const out = stripAnsi(renderNpubs(statsFor(NPUB), naming, 120));
|
||||
|
||||
expect(out).toContain("Alice");
|
||||
expect(out).toContain("[admin]");
|
||||
@@ -54,15 +54,90 @@ describe("renderNpubs", () => {
|
||||
});
|
||||
|
||||
test("falls back to a truncated npub when no name is configured", () => {
|
||||
const npubs: NpubEntry[] = [{ npub: NPUB, name: null, role: "user" }];
|
||||
const out = stripAnsi(renderNpubs(statsFor(NPUB), npubs, 120));
|
||||
const naming = buildClientNaming([], [{ npub: NPUB, name: null, role: "user" }]);
|
||||
const out = stripAnsi(renderNpubs(statsFor(NPUB), naming, 120));
|
||||
|
||||
expect(out).toContain(NPUB.slice(0, 10) + "…" + NPUB.slice(-6));
|
||||
expect(out).toContain("[user]");
|
||||
});
|
||||
|
||||
test("renders when no npub metadata is available", () => {
|
||||
const out = stripAnsi(renderNpubs(statsFor(NPUB), [], 120));
|
||||
const out = stripAnsi(renderNpubs(statsFor(NPUB), buildClientNaming([], []), 120));
|
||||
expect(out).toContain("Npub Breakdown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveClientLabel", () => {
|
||||
const client: ClientInfo = { clientId: "claude-code", name: "Claude Code", ownerNpub: NPUB };
|
||||
const withName = buildClientNaming([client], [{ npub: NPUB, name: "Alice", role: "admin" }]);
|
||||
|
||||
test("shows owner name and strips the npub suffix", () => {
|
||||
expect(resolveClientLabel(`claude-code-${NPUB.slice(-7)}`, withName)).toBe("Alice (claude-code)");
|
||||
});
|
||||
|
||||
test("falls back to the bare client id when the owner has no name", () => {
|
||||
const unnamed = buildClientNaming([client], [{ npub: NPUB, name: null, role: "user" }]);
|
||||
expect(resolveClientLabel(`claude-code-${NPUB.slice(-7)}`, unnamed)).toBe("claude-code");
|
||||
});
|
||||
|
||||
test("leaves unsuffixed ids and local mode untouched", () => {
|
||||
expect(resolveClientLabel("claude-code", withName)).toBe("Alice (claude-code)");
|
||||
expect(resolveClientLabel("pi-agent", buildClientNaming([], []))).toBe("pi-agent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderRecent", () => {
|
||||
test("renders Name (bare-id) with the owner suffix stripped", () => {
|
||||
const naming = buildClientNaming(
|
||||
[{ clientId: "claude-code", name: "Claude Code", ownerNpub: NPUB }],
|
||||
[{ npub: NPUB, name: "Alice", role: "admin" }],
|
||||
);
|
||||
const stats = {
|
||||
...statsFor(NPUB),
|
||||
entries: [
|
||||
{
|
||||
id: "1",
|
||||
timestamp: Date.now(),
|
||||
modelId: "gpt-5.4",
|
||||
baseUrl: "https://api.example.com",
|
||||
provider: "openai",
|
||||
requestId: "r1",
|
||||
cost: 0,
|
||||
satsCost: 1,
|
||||
promptTokens: 10,
|
||||
completionTokens: 20,
|
||||
totalTokens: 30,
|
||||
client: `claude-code-${NPUB.slice(-7)}`,
|
||||
},
|
||||
],
|
||||
} as unknown as UsageStats;
|
||||
|
||||
const out = stripAnsi(renderRecent(stats, 120, naming));
|
||||
expect(out).toContain("Alice (claude-code)");
|
||||
expect(out).not.toContain(NPUB.slice(-7));
|
||||
});
|
||||
|
||||
test("keeps the raw client id when there is no owner metadata", () => {
|
||||
const stats = {
|
||||
...statsFor(NPUB),
|
||||
entries: [
|
||||
{
|
||||
id: "1",
|
||||
timestamp: Date.now(),
|
||||
modelId: "gpt-5.4",
|
||||
baseUrl: "https://api.example.com",
|
||||
requestId: "r1",
|
||||
cost: 0,
|
||||
satsCost: 1,
|
||||
promptTokens: 10,
|
||||
completionTokens: 20,
|
||||
totalTokens: 30,
|
||||
client: "pi-agent",
|
||||
},
|
||||
],
|
||||
} as unknown as UsageStats;
|
||||
|
||||
const out = stripAnsi(renderRecent(stats, 120, buildClientNaming([], [])));
|
||||
expect(out).toContain("pi-agent");
|
||||
});
|
||||
});
|
||||
|
||||
+31
-15
@@ -3,7 +3,8 @@ import type { Tab } from "./types.ts";
|
||||
import {
|
||||
formatNumber,
|
||||
formatTime,
|
||||
type NpubEntry,
|
||||
resolveClientLabel,
|
||||
type ClientNaming,
|
||||
} from "./data.ts";
|
||||
import { vimState } from "./state.ts";
|
||||
import { stripAnsi } from "./terminal.ts";
|
||||
@@ -503,13 +504,12 @@ export function renderClients(stats: UsageStats, width: number): string {
|
||||
return output;
|
||||
}
|
||||
|
||||
export function renderNpubs(stats: UsageStats, npubs: NpubEntry[], width: number): string {
|
||||
export function renderNpubs(stats: UsageStats, naming: ClientNaming, width: number): string {
|
||||
const npubStats = stats.summary.npubs;
|
||||
if (npubStats.length === 0) return renderBox(["No npub data available"], width, "Npub Breakdown");
|
||||
|
||||
// Index configured npubs by their npub so usage rows can show names/roles.
|
||||
const configured = new Map<string, NpubEntry>();
|
||||
for (const entry of npubs) configured.set(entry.npub, entry);
|
||||
const configured = new Map(naming.npubs.map((entry) => [entry.npub, entry]));
|
||||
|
||||
const totalCost = stats.totalSatsCost;
|
||||
const maxCost = npubStats[0]!.satsCost;
|
||||
@@ -585,22 +585,38 @@ function truncateNpub(npub: string): string {
|
||||
return npub.slice(0, 10) + "…" + npub.slice(-6);
|
||||
}
|
||||
|
||||
export function renderRecent(stats: UsageStats, width: number): string {
|
||||
export function renderRecent(stats: UsageStats, width: number, naming: ClientNaming): string {
|
||||
const recentEntries = stats.entries.slice(0, 50);
|
||||
if (recentEntries.length === 0) return renderBox(["No recent entries"], width, "Recent Requests");
|
||||
|
||||
const clientCol = 14;
|
||||
const timeCol = 10;
|
||||
const modelCol = 18;
|
||||
const tokensCol = 18;
|
||||
const costCol = 18;
|
||||
const providerCol = Math.max(16, width - 4 - 10 - 18 - tokensCol - costCol - clientCol - 5);
|
||||
|
||||
// Remote mode: `Alice (claude-code)` — owner display name + the client id
|
||||
// with the `-<npub tail>` suffix stripped. Local mode (no owner/name data)
|
||||
// keeps the previous behaviour: the raw id in a fixed 14-col cell.
|
||||
const hasOwnerInfo = naming.npubs.length > 0 || naming.ownersByClientId.size > 0;
|
||||
const clientLabels = recentEntries.map((entry) =>
|
||||
hasOwnerInfo ? resolveClientLabel(entry.client, naming) : entry.client || "unknown"
|
||||
);
|
||||
const maxLabelLen = clientLabels.reduce((max, label) => Math.max(max, label.length), 6);
|
||||
const clientCol = hasOwnerInfo ? Math.min(32, Math.max(14, maxLabelLen)) : 14;
|
||||
|
||||
// Budget: time + model + tokens + cost + provider + client + 5 separators.
|
||||
const innerWidth = Math.max(0, width - 4);
|
||||
const providerCol = Math.max(12, innerWidth - timeCol - modelCol - tokensCol - costCol - 5 - clientCol);
|
||||
|
||||
const msatsToSats = (msats?: number) => typeof msats === "number" ? msats / 1000 : 0;
|
||||
const lines: string[] = [];
|
||||
lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"MODEL".padEnd(18)} ${"I/CR/CW/O".padEnd(tokensCol)} ${"I/O/T in sats".padEnd(costCol)} ${"BASE:PROVIDER".padEnd(providerCol)} ${"CLIENT".slice(0, clientCol)}${COLORS.reset}`);
|
||||
lines.push(`${COLORS.bold}${["TIME".padEnd(timeCol), "MODEL".padEnd(modelCol), "I/CR/CW/O".padEnd(tokensCol), "I/O/T in sats".padEnd(costCol), "BASE:PROVIDER".padEnd(providerCol), "CLIENT".padEnd(clientCol)].join(" ")}${COLORS.reset}`);
|
||||
lines.push(COLORS.dim + "─".repeat(width - 4) + COLORS.reset);
|
||||
|
||||
for (const entry of recentEntries) {
|
||||
for (let i = 0; i < recentEntries.length; i++) {
|
||||
const entry = recentEntries[i]!;
|
||||
const time = formatTime(entry.timestamp).slice(0, 8);
|
||||
const model = entry.modelId.slice(0, 18).padEnd(18);
|
||||
const model = entry.modelId.slice(0, modelCol).padEnd(modelCol);
|
||||
const tokens = [
|
||||
entry.promptTokens,
|
||||
entry.cacheReadInputTokens || 0,
|
||||
@@ -615,16 +631,16 @@ export function renderRecent(stats: UsageStats, width: number): string {
|
||||
].join("/");
|
||||
const baseUrl = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "");
|
||||
const provider = `${baseUrl}:${entry.provider || "unknown"}`.slice(0, providerCol).padEnd(providerCol);
|
||||
const clientName = (entry.client || "unknown").slice(0, clientCol - 1);
|
||||
const clientLabel = clientLabels[i]!.slice(0, clientCol).padEnd(clientCol);
|
||||
const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white;
|
||||
const modelColor = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
|
||||
lines.push(`${COLORS.dim}${time}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(tokensCol)} ${COLORS.green}${cost.padEnd(costCol)}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset} ${clientColor}${clientName}${COLORS.reset}`);
|
||||
lines.push(`${COLORS.dim}${time}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(tokensCol)} ${COLORS.green}${cost.padEnd(costCol)}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset} ${clientColor}${clientLabel}${COLORS.reset}`);
|
||||
}
|
||||
|
||||
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, npubs: NpubEntry[] = []): string {
|
||||
export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number, naming: ClientNaming): string {
|
||||
switch (activeTab) {
|
||||
case "overview": return renderOverview(stats, balance, status, width);
|
||||
case "today": return renderToday(stats, width);
|
||||
@@ -632,8 +648,8 @@ export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: B
|
||||
case "providers": return renderProviders(stats, width);
|
||||
case "tokens": return renderTokens(stats, width);
|
||||
case "clients": return renderClients(stats, width);
|
||||
case "npubs": return renderNpubs(stats, npubs, width);
|
||||
case "recent": return renderRecent(stats, width);
|
||||
case "npubs": return renderNpubs(stats, naming, width);
|
||||
case "recent": return renderRecent(stats, width, naming);
|
||||
default: return "Unknown tab";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user