routstrd 0.2.17 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,28 @@
1
1
  import type { Tab } from "./types.ts";
2
2
 
3
- export const TABS: Tab[] = [
3
+ export const ALL_TABS: Tab[] = [
4
4
  { id: "overview", name: "Overview", key: "1" },
5
5
  { id: "today", name: "Today", key: "2" },
6
6
  { id: "models", name: "Models", key: "3" },
7
7
  { id: "providers", name: "Providers", key: "4" },
8
8
  { id: "tokens", name: "Tokens", key: "5" },
9
9
  { id: "clients", name: "Clients", key: "6" },
10
- { id: "recent", name: "Recent", key: "7" },
10
+ { id: "npubs", name: "Npubs", key: "7" },
11
+ { id: "recent", name: "Recent", key: "8" },
11
12
  ];
12
13
 
14
+ /**
15
+ * Returns the visible tab list, hiding the Npubs tab when no clients
16
+ * have an ownerNpub. Keys are re-assigned sequentially (1..N).
17
+ */
18
+ export function getVisibleTabs(hasNpubs: boolean): Tab[] {
19
+ const tabs = ALL_TABS.filter((t) => t.id !== "npubs" || hasNpubs);
20
+ return tabs.map((t, i) => ({ ...t, key: String(i + 1) }));
21
+ }
22
+
23
+ /** Default tab list (no npub data yet). */
24
+ export const TABS: Tab[] = getVisibleTabs(false);
25
+
13
26
  export const COLORS = {
14
27
  reset: "\x1b[0m",
15
28
  bold: "\x1b[1m",
@@ -1,6 +1,8 @@
1
1
  import type { UsageTrackingEntry } from "../../daemon/types.ts";
2
2
  import { callDaemon, isDaemonRunning } from "../../utils/daemon-client.ts";
3
- import type { ClientStats, DayStats, ModelStats, ProviderStats, UsageStats } from "./types.ts";
3
+ import type { ClientStats, DayStats, ModelStats, NpubStats, ProviderStats, UsageStats } from "./types.ts";
4
+
5
+ export { isDaemonRunning };
4
6
 
5
7
  export interface BalanceKey {
6
8
  id: string;
@@ -223,6 +225,78 @@ export function getClientStats(entries: UsageTrackingEntry[]): ClientStats[] {
223
225
  return Array.from(clients.values()).sort((a, b) => b.satsCost - a.satsCost);
224
226
  }
225
227
 
228
+ // ─── Client / Npub helpers ────────────────────────────────────────────
229
+
230
+ export interface ClientInfo {
231
+ clientId: string;
232
+ name: string;
233
+ ownerNpub?: string;
234
+ }
235
+
236
+ export async function fetchClients(): Promise<ClientInfo[]> {
237
+ try {
238
+ const running = await isDaemonRunning();
239
+ if (!running) return [];
240
+
241
+ const result = await callDaemon("/clients");
242
+ if (result.error) return [];
243
+
244
+ const output = result.output as {
245
+ clients?: Array<{ id: string; name: string; ownerNpub?: string }>;
246
+ };
247
+
248
+ return (output?.clients || []).map((c) => ({
249
+ clientId: c.id,
250
+ name: c.name,
251
+ ownerNpub: c.ownerNpub,
252
+ }));
253
+ } catch {
254
+ return [];
255
+ }
256
+ }
257
+
258
+ export function hasAnyNpubs(clients: ClientInfo[]): boolean {
259
+ return clients.some((c) => !!c.ownerNpub);
260
+ }
261
+
262
+ export function getNpubStats(entries: UsageTrackingEntry[], clients: ClientInfo[]): NpubStats[] {
263
+ // Build client-id → ownerNpub lookup
264
+ const clientNpubMap = new Map<string, string>();
265
+ for (const c of clients) {
266
+ if (c.ownerNpub) {
267
+ clientNpubMap.set(c.clientId, c.ownerNpub);
268
+ }
269
+ }
270
+
271
+ // Aggregate usage per npub
272
+ const npubs = new Map<string, NpubStats>();
273
+ for (const entry of entries) {
274
+ const npub = clientNpubMap.get(entry.client || "");
275
+ if (!npub) continue; // skip entries whose client has no ownerNpub
276
+
277
+ const existing = npubs.get(npub) || {
278
+ npub,
279
+ requests: 0,
280
+ satsCost: 0,
281
+ promptTokens: 0,
282
+ completionTokens: 0,
283
+ totalTokens: 0,
284
+ };
285
+ npubs.set(npub, {
286
+ ...existing,
287
+ requests: existing.requests + 1,
288
+ satsCost: existing.satsCost + entry.satsCost,
289
+ promptTokens: existing.promptTokens + entry.promptTokens,
290
+ completionTokens: existing.completionTokens + entry.completionTokens,
291
+ totalTokens: existing.totalTokens + entry.totalTokens,
292
+ });
293
+ }
294
+
295
+ return Array.from(npubs.values()).sort((a, b) => b.satsCost - a.satsCost);
296
+ }
297
+
298
+ // ─── Totals ───────────────────────────────────────────────────────────
299
+
226
300
  export function getTotals(entries: UsageTrackingEntry[] | undefined) {
227
301
  if (!entries || !Array.isArray(entries)) {
228
302
  return { promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 };
@@ -1,4 +1,5 @@
1
- import { CLIENT_COLORS, COLORS, MODEL_COLORS, TABS } from "./constants.ts";
1
+ import { CLIENT_COLORS, COLORS, MODEL_COLORS } from "./constants.ts";
2
+ import type { Tab } from "./types.ts";
2
3
  import {
3
4
  formatDate,
4
5
  formatNumber,
@@ -7,9 +8,11 @@ import {
7
8
  getDayStats,
8
9
  getHourlyToday,
9
10
  getModelStats,
11
+ getNpubStats,
10
12
  getProviderStats,
11
13
  getTodayStart,
12
14
  getTotals,
15
+ type ClientInfo,
13
16
  } from "./data.ts";
14
17
  import { vimState } from "./state.ts";
15
18
  import { stripAnsi } from "./terminal.ts";
@@ -30,10 +33,11 @@ function formatReqs(value: number): string {
30
33
  return value.toString();
31
34
  }
32
35
 
33
- export function renderHeader(activeTab: TabId, width: number): string {
36
+ export function renderHeader(activeTab: TabId, width: number, visibleTabs: Tab[]): string {
34
37
  const title = `${COLORS.bold}${COLORS.cyan}ROUTSTRD USAGE MONITOR${COLORS.reset}`;
35
38
  const vimIndicator = `${COLORS.yellow}[vim]${COLORS.reset}`;
36
- const help = `${COLORS.dim}[Q] Quit [↑↓] Scroll [←→] Tabs [1-7] Tabs [R] Refresh${COLORS.reset}`;
39
+ const maxKey = visibleTabs.length;
40
+ const help = `${COLORS.dim}[Q] Quit [↑↓] Scroll [←→] Tabs [1-${maxKey}] Tabs [R] Refresh${COLORS.reset}`;
37
41
  const fill = width - title.length - help.length - vimIndicator.length - 6;
38
42
  return `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}\n`;
39
43
  }
@@ -49,8 +53,8 @@ export function renderSearchBar(): string {
49
53
  return `\n${searchLine}${placeholder}\n`;
50
54
  }
51
55
 
52
- export function renderTabs(activeTab: TabId): string {
53
- const tabStr = TABS.map((tab) => tab.id === activeTab
56
+ export function renderTabs(activeTab: TabId, visibleTabs: Tab[]): string {
57
+ const tabStr = visibleTabs.map((tab) => tab.id === activeTab
54
58
  ? `${COLORS.bgBlue} ${tab.key}:${tab.name} ${COLORS.reset}`
55
59
  : `${COLORS.dim}[${tab.key}]${COLORS.reset} ${tab.name}`).join(" ");
56
60
  return `${" ".repeat(2)}${tabStr}\n`;
@@ -507,31 +511,124 @@ export function renderClients(stats: UsageStats, width: number): string {
507
511
  return output;
508
512
  }
509
513
 
514
+ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: number): string {
515
+ const npubStats = getNpubStats(stats.entries, clients);
516
+ if (npubStats.length === 0) return renderBox(["No npub data available"], width, "Npub Breakdown");
517
+
518
+ const totalCost = stats.totalSatsCost;
519
+ const maxCost = npubStats[0]!.satsCost;
520
+ const lines: string[] = [];
521
+
522
+ const col1 = 24; // Npub (truncated)
523
+ const col2 = 12; // Requests
524
+ const col3 = 24; // Cost
525
+ const col4 = 12; // Tokens
526
+
527
+ const hNpub = "Npub".padEnd(col1);
528
+ const hReqs = "Requests".padEnd(col2);
529
+ const hCost = "Cost".padEnd(col3);
530
+ const hTok = "Tokens".padEnd(col4);
531
+ lines.push(`${COLORS.bold}${hNpub}${hReqs}${hCost}${hTok}Avg Cost${COLORS.reset}`);
532
+ lines.push(COLORS.dim + "─".repeat(Math.max(0, width - 4)) + COLORS.reset);
533
+
534
+ startBarSection("npub-detail", col1);
535
+ for (const npub of npubStats) {
536
+ const pct = totalCost > 0 ? ((npub.satsCost / totalCost) * 100).toFixed(1) : "0.0";
537
+ const avgCostFormatted = formatCost(npub.requests > 0 ? npub.satsCost / npub.requests : 0);
538
+ const shortNpub = truncateNpub(npub.npub);
539
+
540
+ const dNpub = shortNpub.padEnd(col1);
541
+ const dReqs = formatReqs(npub.requests).padEnd(col2);
542
+ const dCost = `${formatCost(npub.satsCost)} sats (${pct}%)`.padEnd(col3);
543
+ const dTok = formatNumber(npub.totalTokens).padEnd(col4);
544
+ const dAvg = `${avgCostFormatted} sats/req`;
545
+
546
+ lines.push(
547
+ `${COLORS.magenta}${COLORS.bold}${dNpub}${COLORS.reset}` +
548
+ `${dReqs}` +
549
+ `${COLORS.green}${dCost}${COLORS.reset}` +
550
+ `${COLORS.dim}${dTok}${dAvg}${COLORS.reset}`
551
+ );
552
+ // Full npub on its own line for copy-ability
553
+ lines.push(` ${COLORS.dim}${npub.npub}${COLORS.reset}`);
554
+ lines.push(` ${renderBarChart("", npub.satsCost, maxCost, width - 6, COLORS.magenta, Number(pct), "npub-detail")}`);
555
+ lines.push("");
556
+ }
557
+ endBarSection("npub-detail");
558
+
559
+ let output = renderBox(lines, width, "Npub Breakdown");
560
+
561
+ // Top models per npub (same pattern as clients tab)
562
+ const npubModelMap = new Map<string, Map<string, { requests: number; satsCost: number; tokens: number }>>();
563
+ const clientNpubMap = new Map<string, string>();
564
+ for (const c of clients) {
565
+ if (c.ownerNpub) clientNpubMap.set(c.clientId, c.ownerNpub);
566
+ }
567
+
568
+ for (const entry of stats.entries) {
569
+ const npub = clientNpubMap.get(entry.client || "");
570
+ if (!npub) continue;
571
+ const model = entry.modelId;
572
+ if (!npubModelMap.has(npub)) npubModelMap.set(npub, new Map());
573
+ const modelMap = npubModelMap.get(npub)!;
574
+ const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 };
575
+ modelMap.set(model, {
576
+ requests: existing.requests + 1,
577
+ satsCost: existing.satsCost + entry.satsCost,
578
+ tokens: existing.tokens + entry.totalTokens,
579
+ });
580
+ }
581
+
582
+ const npubModelLines: string[] = [];
583
+ for (const topNpub of npubStats.slice(0, 5)) {
584
+ const modelMap = npubModelMap.get(topNpub.npub);
585
+ if (!modelMap) continue;
586
+ const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5);
587
+ npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
588
+ for (const [model, data] of models) {
589
+ npubModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`);
590
+ }
591
+ npubModelLines.push("");
592
+ }
593
+
594
+ if (npubModelLines.length > 0) {
595
+ output += "\n" + renderBox(npubModelLines, width, "Top Models per Npub");
596
+ }
597
+
598
+ return output;
599
+ }
600
+
601
+ /** Truncate an npub for display: first 10 chars + … + last 6 chars. */
602
+ function truncateNpub(npub: string): string {
603
+ if (npub.length <= 24) return npub;
604
+ return npub.slice(0, 10) + "…" + npub.slice(-6);
605
+ }
606
+
510
607
  export function renderRecent(stats: UsageStats, width: number): string {
511
608
  const recentEntries = stats.entries.slice(0, 50);
512
609
  if (recentEntries.length === 0) return renderBox(["No recent entries"], width, "Recent Requests");
513
610
 
514
- const clientCol = 10;
611
+ const clientCol = 14;
515
612
  const lines: string[] = [];
516
- lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"CLIENT".padEnd(clientCol)} ${"MODEL".padEnd(18)} ${"TOKENS".padEnd(10)} ${"COST".padEnd(12)} ${"PROVIDER".slice(0, Math.max(0, width - 70))}${COLORS.reset}`);
613
+ lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"MODEL".padEnd(18)} ${"TOKENS".padEnd(10)} ${"COST".padEnd(12)} ${"PROVIDER".padEnd(16)} ${"CLIENT".slice(0, clientCol)}${COLORS.reset}`);
517
614
  lines.push(COLORS.dim + "─".repeat(width - 4) + COLORS.reset);
518
615
 
519
616
  for (const entry of recentEntries) {
520
617
  const time = formatTime(entry.timestamp).slice(0, 8);
521
- const clientName = (entry.client || "unknown").slice(0, clientCol - 1).padEnd(clientCol);
522
- const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white;
523
618
  const model = entry.modelId.slice(0, 18).padEnd(18);
524
619
  const tokens = `${formatNumber(entry.totalTokens).padEnd(6)} (${formatNumber(entry.promptTokens)}+${formatNumber(entry.completionTokens)})`;
525
620
  const cost = `${formatCost(entry.satsCost).padEnd(8)} sats`;
526
- const provider = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "").slice(0, Math.max(0, width - 70));
621
+ const provider = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "").slice(0, 16).padEnd(16);
622
+ const clientName = (entry.client || "unknown").slice(0, clientCol - 1);
623
+ const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white;
527
624
  const modelColor = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
528
- lines.push(`${COLORS.dim}${time}${COLORS.reset} ${clientColor}${clientName}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(10)} ${COLORS.green}${cost}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset}`);
625
+ lines.push(`${COLORS.dim}${time}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(10)} ${COLORS.green}${cost}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset} ${clientColor}${clientName}${COLORS.reset}`);
529
626
  }
530
627
 
531
628
  return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`);
532
629
  }
533
630
 
534
- export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number): string {
631
+ export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number, clients: ClientInfo[] = []): string {
535
632
  switch (activeTab) {
536
633
  case "overview": return renderOverview(stats, balance, status, width);
537
634
  case "today": return renderToday(stats, width);
@@ -539,6 +636,7 @@ export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: B
539
636
  case "providers": return renderProviders(stats, width);
540
637
  case "tokens": return renderTokens(stats, width);
541
638
  case "clients": return renderClients(stats, width);
639
+ case "npubs": return renderNpubs(stats, clients, width);
542
640
  case "recent": return renderRecent(stats, width);
543
641
  default: return "Unknown tab";
544
642
  }
@@ -44,7 +44,7 @@ export interface ClientStats {
44
44
  totalTokens: number;
45
45
  }
46
46
 
47
- export type TabId = "overview" | "today" | "models" | "providers" | "tokens" | "clients" | "recent";
47
+ export type TabId = "overview" | "today" | "models" | "providers" | "tokens" | "clients" | "npubs" | "recent";
48
48
 
49
49
  export interface Tab {
50
50
  id: TabId;
@@ -52,6 +52,15 @@ export interface Tab {
52
52
  key: string;
53
53
  }
54
54
 
55
+ export interface NpubStats {
56
+ npub: string;
57
+ requests: number;
58
+ satsCost: number;
59
+ promptTokens: number;
60
+ completionTokens: number;
61
+ totalTokens: number;
62
+ }
63
+
55
64
  export interface VimState {
56
65
  scrollPos: number;
57
66
  searchQuery: string;