mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
feat(tui): show npub display names in the Npubs tab
Fetch configured npub names/roles from the auth proxy (`/npubs`) and use them in the Npubs tab, falling back to the truncated npub when a name is not set. The full npub stays on its own line, now with a role tag. - data: add NpubEntry + fetchNpubs() (handles wrapped/direct responses, returns [] when /npubs is unavailable) - app: fetch names only when the Npubs tab is visible; keep last good list - render: name-first column, role tag, named "Top Models per Npub" - add renderNpubs tests
This commit is contained in:
+11
-2
@@ -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, fetchNpubs, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type NpubEntry, type StatusInfo } from "./data.ts";
|
||||
import {
|
||||
applyScrollToContent,
|
||||
exitSearchMode,
|
||||
@@ -48,6 +48,7 @@ export async function runUsageTui(): Promise<void> {
|
||||
let balance: BalanceInfo | null = null;
|
||||
let status: StatusInfo | null = null;
|
||||
let clients: ClientInfo[] = [];
|
||||
let npubs: NpubEntry[] = [];
|
||||
let visibleTabs: Tab[] = getVisibleTabs(false);
|
||||
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let autoRefresh = true;
|
||||
@@ -144,6 +145,14 @@ export async function runUsageTui(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
render();
|
||||
} finally {
|
||||
@@ -170,7 +179,7 @@ export async function runUsageTui(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = renderTabContent(currentTab, stats, balance, status, width, clients);
|
||||
const content = renderTabContent(currentTab, stats, balance, status, width, npubs);
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { UsageTrackingEntry } from "../../daemon/types.ts";
|
||||
import {
|
||||
callAuth,
|
||||
callDaemon,
|
||||
getDaemonBaseUrl,
|
||||
isDaemonRunning,
|
||||
@@ -145,3 +146,29 @@ export async function fetchClients(): Promise<ClientInfo[]> {
|
||||
export function hasAnyNpubs(clients: ClientInfo[]): boolean {
|
||||
return clients.some((c) => !!c.ownerNpub);
|
||||
}
|
||||
|
||||
/** A configured npub as returned by the auth proxy (`/npubs`). */
|
||||
export interface NpubEntry {
|
||||
npub: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the configured npubs (with their display names and roles) from the
|
||||
* auth proxy. Returns an empty list when the endpoint is unavailable — e.g. a
|
||||
* local daemon that doesn't route `/npubs` — so the TUI degrades gracefully.
|
||||
*/
|
||||
export async function fetchNpubs(): Promise<NpubEntry[]> {
|
||||
try {
|
||||
const result = await callAuth("/npubs");
|
||||
if (result.error) return [];
|
||||
|
||||
// Handle both wrapped { output: { npubs } } and direct { npubs } responses.
|
||||
const direct = (result as { npubs?: NpubEntry[] }).npubs;
|
||||
const wrapped = (result.output as { npubs?: NpubEntry[] } | undefined)?.npubs;
|
||||
return direct ?? wrapped ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderNpubs } from "./render.ts";
|
||||
import { stripAnsi } from "./terminal.ts";
|
||||
import type { NpubEntry } from "./data.ts";
|
||||
import type { UsageStats } from "./types.ts";
|
||||
|
||||
const NPUB = "npub1abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopq";
|
||||
|
||||
function statsFor(npub: string): UsageStats {
|
||||
return {
|
||||
entries: [],
|
||||
totalEntries: 3,
|
||||
totalSatsCost: 100,
|
||||
recentSatsCost: 100,
|
||||
limit: 50,
|
||||
summary: {
|
||||
npubs: [
|
||||
{
|
||||
npub,
|
||||
requests: 3,
|
||||
promptTokens: 10,
|
||||
completionTokens: 20,
|
||||
totalTokens: 30,
|
||||
cost: 0,
|
||||
satsCost: 42,
|
||||
topModels: [
|
||||
{
|
||||
modelId: "gpt-5.4",
|
||||
requests: 3,
|
||||
satsCost: 42,
|
||||
totalTokens: 30,
|
||||
promptTokens: 10,
|
||||
completionTokens: 20,
|
||||
cost: 0,
|
||||
} as never,
|
||||
],
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
};
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
expect(out).toContain("Alice");
|
||||
expect(out).toContain("[admin]");
|
||||
// Full npub stays visible for copy-ability.
|
||||
expect(out).toContain(NPUB);
|
||||
// Name is used in the top-models section too.
|
||||
expect(out).toContain("Alice (3 reqs, 42.00 sats)");
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
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));
|
||||
expect(out).toContain("Npub Breakdown");
|
||||
});
|
||||
});
|
||||
+20
-11
@@ -3,7 +3,7 @@ import type { Tab } from "./types.ts";
|
||||
import {
|
||||
formatNumber,
|
||||
formatTime,
|
||||
type ClientInfo,
|
||||
type NpubEntry,
|
||||
} from "./data.ts";
|
||||
import { vimState } from "./state.ts";
|
||||
import { stripAnsi } from "./terminal.ts";
|
||||
@@ -503,20 +503,24 @@ export function renderClients(stats: UsageStats, width: number): string {
|
||||
return output;
|
||||
}
|
||||
|
||||
export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: number): string {
|
||||
export function renderNpubs(stats: UsageStats, npubs: NpubEntry[], 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 totalCost = stats.totalSatsCost;
|
||||
const maxCost = npubStats[0]!.satsCost;
|
||||
const lines: string[] = [];
|
||||
|
||||
const col1 = 24; // Npub (truncated)
|
||||
const col1 = 24; // Name (or truncated npub)
|
||||
const col2 = 12; // Requests
|
||||
const col3 = 24; // Cost
|
||||
const col4 = 12; // Tokens
|
||||
|
||||
const hNpub = "Npub".padEnd(col1);
|
||||
const hNpub = "Name".padEnd(col1);
|
||||
const hReqs = "Requests".padEnd(col2);
|
||||
const hCost = "Cost".padEnd(col3);
|
||||
const hTok = "Tokens".padEnd(col4);
|
||||
@@ -527,9 +531,11 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num
|
||||
for (const npub of npubStats) {
|
||||
const pct = totalCost > 0 ? ((npub.satsCost / totalCost) * 100).toFixed(1) : "0.0";
|
||||
const avgCostFormatted = formatCost(npub.requests > 0 ? npub.satsCost / npub.requests : 0);
|
||||
const shortNpub = truncateNpub(npub.npub);
|
||||
const entry = configured.get(npub.npub);
|
||||
const name = entry?.name?.trim();
|
||||
const label = name || truncateNpub(npub.npub);
|
||||
|
||||
const dNpub = shortNpub.padEnd(col1);
|
||||
const dNpub = label.slice(0, col1 - 1).padEnd(col1);
|
||||
const dReqs = formatReqs(npub.requests).padEnd(col2);
|
||||
const dCost = `${formatCost(npub.satsCost)} sats (${pct}%)`.padEnd(col3);
|
||||
const dTok = formatNumber(npub.totalTokens).padEnd(col4);
|
||||
@@ -541,8 +547,9 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num
|
||||
`${COLORS.green}${dCost}${COLORS.reset}` +
|
||||
`${COLORS.dim}${dTok}${dAvg}${COLORS.reset}`
|
||||
);
|
||||
// Full npub on its own line for copy-ability
|
||||
lines.push(` ${COLORS.dim}${npub.npub}${COLORS.reset}`);
|
||||
// Full npub (and role when known) on its own line for copy-ability.
|
||||
const roleSuffix = entry?.role ? ` [${entry.role}]` : "";
|
||||
lines.push(` ${COLORS.dim}${npub.npub}${roleSuffix}${COLORS.reset}`);
|
||||
lines.push(` ${renderBarChart("", npub.satsCost, maxCost, width - 6, COLORS.magenta, Number(pct), "npub-detail")}`);
|
||||
lines.push("");
|
||||
}
|
||||
@@ -555,7 +562,9 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num
|
||||
// Use pre-aggregated topModels from summary
|
||||
for (const topNpub of stats.summary.npubs.slice(0, 5)) {
|
||||
if (topNpub.topModels.length === 0) continue;
|
||||
npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
|
||||
const name = configured.get(topNpub.npub)?.name?.trim();
|
||||
const label = name || truncateNpub(topNpub.npub);
|
||||
npubModelLines.push(`${COLORS.bold}${label}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
|
||||
for (const m of topNpub.topModels) {
|
||||
npubModelLines.push(` ${(MODEL_COLORS[m.modelId] || MODEL_COLORS.default)}${m.modelId.padEnd(18)}${COLORS.reset} ${formatNumber(m.totalTokens).padEnd(8)} tokens ${formatCost(m.satsCost)} sats`);
|
||||
}
|
||||
@@ -615,7 +624,7 @@ 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 renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number, npubs: NpubEntry[] = []): string {
|
||||
switch (activeTab) {
|
||||
case "overview": return renderOverview(stats, balance, status, width);
|
||||
case "today": return renderToday(stats, width);
|
||||
@@ -623,7 +632,7 @@ 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, clients, width);
|
||||
case "npubs": return renderNpubs(stats, npubs, width);
|
||||
case "recent": return renderRecent(stats, width);
|
||||
default: return "Unknown tab";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user