diff --git a/src/tui/usage/app.ts b/src/tui/usage/app.ts index e2d5f63..99faa77 100644 --- a/src/tui/usage/app.ts +++ b/src/tui/usage/app.ts @@ -1,6 +1,6 @@ import { getVisibleTabs } from "./constants.ts"; import type { Tab } from "./types.ts"; -import { fetchBalance, fetchClients, fetchStatus, fetchUsage, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts"; +import { fetchBalance, fetchClients, fetchStatus, fetchUsage, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts"; import { applyScrollToContent, exitSearchMode, @@ -89,7 +89,7 @@ export async function runUsageTui(): Promise { const height = getHeight(); if (forceFetch || shouldUpdate) { - stats = await fetchUsage(10000); + stats = await fetchUsageSummary() ?? await fetchUsage(10000); balance = await fetchBalance(); status = await fetchStatus(); clients = await fetchClients(); diff --git a/src/tui/usage/data.ts b/src/tui/usage/data.ts index 5d342f2..53f3d32 100644 --- a/src/tui/usage/data.ts +++ b/src/tui/usage/data.ts @@ -1,6 +1,6 @@ import type { UsageTrackingEntry } from "../../daemon/types.ts"; import { callDaemon, isDaemonRunning } from "../../utils/daemon-client.ts"; -import type { ClientStats, DayStats, ModelStats, NpubStats, ProviderStats, UsageStats } from "./types.ts"; +import type { ClientStats, DayStats, ModelStats, NpubStats, ProviderStats, UsageStats, UsageSummary } from "./types.ts"; export { isDaemonRunning }; @@ -112,6 +112,31 @@ export async function fetchUsage(limit = 10000): Promise { } } +export async function fetchUsageSummary(): Promise { + try { + const running = await isDaemonRunning(); + if (!running) return null; + + const tz = new Date().getTimezoneOffset(); + const result = await callDaemon(`/usage/summary?tz=${tz}`); + if (result.error) return null; + + const summary = result.output as UsageSummary | undefined; + if (!summary || typeof summary.totals !== "object") return null; + + return { + entries: summary.recent, + totalEntries: summary.totals.requests, + totalSatsCost: summary.totals.satsCost, + recentSatsCost: summary.totals.satsCost, + limit: 50, + summary, + }; + } catch { + return null; + } +} + export function getTodayStart(): number { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index e22cfad..0a84823 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -240,7 +240,7 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s output = renderBox(statusLines, width, "System Status") + "\n" + output; } - const modelStats = getModelStats(stats.entries); + const modelStats = stats.summary?.models ?? getModelStats(stats.entries); if (modelStats.length > 0) { const maxCost = modelStats[0]!.satsCost; const totalCost = Math.max(totalVisibleCost, 1); @@ -259,7 +259,7 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s output += "\n" + renderBox(modelLines, width, "Top Models by Cost"); } - const clientStats = getClientStats(stats.entries); + const clientStats = stats.summary?.clients ?? getClientStats(stats.entries); if (clientStats.length > 0) { const maxCost = clientStats[0]!.satsCost; const totalCost = Math.max(totalVisibleCost, 1); @@ -282,19 +282,40 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s } export function renderToday(stats: UsageStats, width: number): string { - const hourly = getHourlyToday(stats.entries); - const todayStart = getTodayStart(); const currentHour = new Date().getHours(); - const todayStats = { date: formatDate(Date.now()), requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + const todayDateStr = formatDate(Date.now()); - for (const entry of stats.entries) { - if (entry.timestamp >= todayStart) { - todayStats.requests++; - todayStats.satsCost += entry.satsCost; - todayStats.promptTokens += entry.promptTokens; - todayStats.completionTokens += entry.completionTokens; - todayStats.totalTokens += entry.totalTokens; + let todayStats: { date: string; requests: number; satsCost: number; promptTokens: number; completionTokens: number; totalTokens: number }; + let recentDays: Array<{ date: string; requests: number; satsCost: number; totalTokens: number; promptTokens: number; completionTokens: number }>; + let hourlyMap: Map; + + if (stats.summary) { + // Use server-aggregated data + const { days, hoursToday } = stats.summary; + // days[0] is most-recent-first; check if it's today + const todayDayStat = days[0]?.date === todayDateStr ? days[0] : undefined; + todayStats = todayDayStat + ? { date: todayDayStat.date, requests: todayDayStat.requests, satsCost: todayDayStat.satsCost, promptTokens: todayDayStat.promptTokens, completionTokens: todayDayStat.completionTokens, totalTokens: todayDayStat.totalTokens } + : { date: todayDateStr, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + recentDays = days.slice(1, 7); + hourlyMap = new Map(hoursToday.map((h) => [h.hour, { requests: h.requests, satsCost: h.satsCost }])); + } else { + // Fallback: compute from raw entries + const todayStart = getTodayStart(); + todayStats = { date: todayDateStr, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + for (const entry of stats.entries) { + if (entry.timestamp >= todayStart) { + todayStats.requests++; + todayStats.satsCost += entry.satsCost; + todayStats.promptTokens += entry.promptTokens; + todayStats.completionTokens += entry.completionTokens; + todayStats.totalTokens += entry.totalTokens; + } } + recentDays = Array.from(getDayStats(stats.entries).values()).slice(1, 7); + hourlyMap = new Map( + Array.from(getHourlyToday(stats.entries).entries()).map(([h, s]) => [h, { requests: s.requests, satsCost: s.satsCost }]) + ); } const summaryLines = [ @@ -306,18 +327,17 @@ export function renderToday(stats: UsageStats, width: number): string { let output = renderBox(summaryLines, width, "Today"); - const days = Array.from(getDayStats(stats.entries).values()).slice(0, 7); - if (days.length > 1) { - const dayLines = days.slice(1).map((d) => `${d.date}: ${formatReqs(d.requests)} req, ${formatCost(d.satsCost)} sats, ${formatNumber(d.totalTokens)} tokens`); + if (recentDays.length > 0) { + const dayLines = recentDays.map((d) => `${d.date}: ${formatReqs(d.requests)} req, ${formatCost(d.satsCost)} sats, ${formatNumber(d.totalTokens)} tokens`); output += "\n" + renderBox(dayLines, width, "Recent Days"); } const hourLines: string[] = []; - const maxHourCost = Math.max(...Array.from(hourly.values()).map((h) => h.satsCost), 1); + const maxHourCost = Math.max(...Array.from(hourlyMap.values()).map((h) => h.satsCost), 1); const totalTodayCost = Math.max(todayStats.satsCost, 1); const hourLabels: string[] = []; for (let h = currentHour; h >= 0; h--) { - const hStat = hourly.get(h); + const hStat = hourlyMap.get(h); const reqs = hStat?.requests || 0; const cost = hStat?.satsCost || 0; hourLabels.push(`${h.toString().padStart(2, "0")}:00 (${formatReqs(reqs)} req, ${formatCost(cost)} sats) `); @@ -325,7 +345,7 @@ export function renderToday(stats: UsageStats, width: number): string { const maxHourLabel = Math.max(...hourLabels.map((l) => l.length)); startBarSection("hourly", maxHourLabel); for (let i = currentHour; i >= 0; i--) { - const hStat = hourly.get(i); + const hStat = hourlyMap.get(i); const reqs = hStat?.requests || 0; const cost = hStat?.satsCost || 0; hourLines.push(renderBarChart( @@ -346,7 +366,7 @@ export function renderToday(stats: UsageStats, width: number): string { } export function renderModels(stats: UsageStats, width: number): string { - const modelStats = getModelStats(stats.entries); + const modelStats = stats.summary?.models ?? getModelStats(stats.entries); if (modelStats.length === 0) return renderBox(["No model data available"], width, "Models"); // Use totalSatsCost (all-time) for percentage calculations to match header @@ -373,7 +393,7 @@ export function renderModels(stats: UsageStats, width: number): string { } export function renderProviders(stats: UsageStats, width: number): string { - const providerStats = getProviderStats(stats.entries); + const providerStats = stats.summary?.providers ?? getProviderStats(stats.entries); if (providerStats.length === 0) return renderBox(["No provider data available"], width, "Providers"); const lines: string[] = []; @@ -389,8 +409,8 @@ export function renderProviders(stats: UsageStats, width: number): string { } export function renderTokens(stats: UsageStats, width: number): string { - const totals = getTotals(stats.entries); - const modelStats = getModelStats(stats.entries); + const totals = stats.summary?.totals ?? getTotals(stats.entries); + const modelStats = stats.summary?.models ?? getModelStats(stats.entries); const summaryLines = [ `${COLORS.bold}Total Prompt Tokens:${COLORS.reset} ${formatNumber(totals.promptTokens)}`, `${COLORS.bold}Total Completion Tokens:${COLORS.reset} ${formatNumber(totals.completionTokens)}`, @@ -410,23 +430,25 @@ export function renderTokens(stats: UsageStats, width: number): string { output += "\n" + renderBox(tokenLines, width, "Tokens by Model"); } - const sizeBuckets = { - tiny: { min: 0, max: 1000, count: 0, cost: 0 }, - small: { min: 1000, max: 10000, count: 0, cost: 0 }, - medium: { min: 10000, max: 50000, count: 0, cost: 0 }, - large: { min: 50000, max: 100000, count: 0, cost: 0 }, - huge: { min: 100000, max: Infinity, count: 0, cost: 0 }, - }; - - for (const entry of stats.entries) { - for (const bucket of Object.values(sizeBuckets)) { - if (entry.totalTokens >= bucket.min && entry.totalTokens < bucket.max) { - bucket.count++; - bucket.cost += entry.satsCost; - break; + const sizeBuckets = stats.summary?.sizeBuckets ?? (() => { + const b = { + tiny: { min: 0, max: 1000, count: 0, cost: 0 }, + small: { min: 1000, max: 10000, count: 0, cost: 0 }, + medium: { min: 10000, max: 50000, count: 0, cost: 0 }, + large: { min: 50000, max: 100000, count: 0, cost: 0 }, + huge: { min: 100000, max: Infinity, count: 0, cost: 0 }, + }; + for (const entry of stats.entries) { + for (const bucket of Object.values(b)) { + if (entry.totalTokens >= bucket.min && entry.totalTokens < bucket.max) { + bucket.count++; + bucket.cost += entry.satsCost; + break; + } } } - } + return b; + })(); const sizeLines = Object.entries(sizeBuckets).map(([name, bucket]) => `${name.padEnd(6)}: ${formatReqs(bucket.count).padStart(5)} reqs, ${formatCost(bucket.cost)} sats`); output += "\n" + renderBox(sizeLines, width, "Request Size Distribution"); @@ -434,7 +456,7 @@ export function renderTokens(stats: UsageStats, width: number): string { } export function renderClients(stats: UsageStats, width: number): string { - const clientStats = getClientStats(stats.entries); + const clientStats = stats.summary?.clients ?? getClientStats(stats.entries); if (clientStats.length === 0) return renderBox(["No client data available (API key auth not used)"], width, "Client Breakdown"); // Use totalSatsCost (all-time) for percentage calculations to match header @@ -478,31 +500,43 @@ export function renderClients(stats: UsageStats, width: number): string { endBarSection("client-detail"); let output = renderBox(lines, width, "Client Breakdown"); - const clientModelMap = new Map>(); - - for (const entry of stats.entries) { - const client = entry.client || "unknown"; - const model = entry.modelId; - if (!clientModelMap.has(client)) clientModelMap.set(client, new Map()); - const modelMap = clientModelMap.get(client)!; - const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 }; - modelMap.set(model, { - requests: existing.requests + 1, - satsCost: existing.satsCost + entry.satsCost, - tokens: existing.tokens + entry.totalTokens, - }); - } - const clientModelLines: string[] = []; - for (const topClient of clientStats.slice(0, 3)) { - const modelMap = clientModelMap.get(topClient.client); - if (!modelMap) continue; - const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5); - clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${formatReqs(topClient.requests)} reqs, ${formatCost(topClient.satsCost)} sats)`); - for (const [model, data] of models) { - clientModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`); + + if (stats.summary) { + // Use pre-aggregated topModels from summary + for (const topClient of stats.summary.clients.slice(0, 3)) { + if (topClient.topModels.length === 0) continue; + clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${formatReqs(topClient.requests)} reqs, ${formatCost(topClient.satsCost)} sats)`); + for (const m of topClient.topModels) { + clientModelLines.push(` ${(MODEL_COLORS[m.modelId] || MODEL_COLORS.default)}${m.modelId.padEnd(18)}${COLORS.reset} ${formatNumber(m.totalTokens).padEnd(8)} tokens ${formatCost(m.satsCost)} sats`); + } + clientModelLines.push(""); + } + } else { + // Fallback: compute from raw entries + const clientModelMap = new Map>(); + for (const entry of stats.entries) { + const client = entry.client || "unknown"; + const model = entry.modelId; + if (!clientModelMap.has(client)) clientModelMap.set(client, new Map()); + const modelMap = clientModelMap.get(client)!; + const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 }; + modelMap.set(model, { + requests: existing.requests + 1, + satsCost: existing.satsCost + entry.satsCost, + tokens: existing.tokens + entry.totalTokens, + }); + } + for (const topClient of clientStats.slice(0, 3)) { + const modelMap = clientModelMap.get(topClient.client); + if (!modelMap) continue; + const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5); + clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${formatReqs(topClient.requests)} reqs, ${formatCost(topClient.satsCost)} sats)`); + for (const [model, data] of models) { + clientModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`); + } + clientModelLines.push(""); } - clientModelLines.push(""); } if (clientModelLines.length > 0) { @@ -512,7 +546,7 @@ export function renderClients(stats: UsageStats, width: number): string { } export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: number): string { - const npubStats = getNpubStats(stats.entries, clients); + const npubStats = stats.summary?.npubs ?? getNpubStats(stats.entries, clients); if (npubStats.length === 0) return renderBox(["No npub data available"], width, "Npub Breakdown"); const totalCost = stats.totalSatsCost; @@ -557,38 +591,48 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num endBarSection("npub-detail"); let output = renderBox(lines, width, "Npub Breakdown"); - - // Top models per npub (same pattern as clients tab) - const npubModelMap = new Map>(); - const clientNpubMap = new Map(); - for (const c of clients) { - if (c.ownerNpub) clientNpubMap.set(c.clientId, c.ownerNpub); - } - - for (const entry of stats.entries) { - const npub = clientNpubMap.get(entry.client || ""); - if (!npub) continue; - const model = entry.modelId; - if (!npubModelMap.has(npub)) npubModelMap.set(npub, new Map()); - const modelMap = npubModelMap.get(npub)!; - const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 }; - modelMap.set(model, { - requests: existing.requests + 1, - satsCost: existing.satsCost + entry.satsCost, - tokens: existing.tokens + entry.totalTokens, - }); - } - const npubModelLines: string[] = []; - for (const topNpub of npubStats.slice(0, 5)) { - const modelMap = npubModelMap.get(topNpub.npub); - if (!modelMap) continue; - const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5); - npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`); - for (const [model, data] of models) { - npubModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`); + + if (stats.summary) { + // 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)`); + 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`); + } + npubModelLines.push(""); + } + } else { + // Fallback: compute from raw entries + const npubModelMap = new Map>(); + const clientNpubMap = new Map(); + for (const c of clients) { + if (c.ownerNpub) clientNpubMap.set(c.clientId, c.ownerNpub); + } + for (const entry of stats.entries) { + const npub = clientNpubMap.get(entry.client || ""); + if (!npub) continue; + const model = entry.modelId; + if (!npubModelMap.has(npub)) npubModelMap.set(npub, new Map()); + const modelMap = npubModelMap.get(npub)!; + const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 }; + modelMap.set(model, { + requests: existing.requests + 1, + satsCost: existing.satsCost + entry.satsCost, + tokens: existing.tokens + entry.totalTokens, + }); + } + for (const topNpub of npubStats.slice(0, 5)) { + const modelMap = npubModelMap.get(topNpub.npub); + if (!modelMap) continue; + const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5); + npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`); + for (const [model, data] of models) { + npubModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`); + } + npubModelLines.push(""); } - npubModelLines.push(""); } if (npubModelLines.length > 0) { diff --git a/src/tui/usage/types.ts b/src/tui/usage/types.ts index 7e204bd..d49476d 100644 --- a/src/tui/usage/types.ts +++ b/src/tui/usage/types.ts @@ -1,4 +1,7 @@ import type { UsageTrackingEntry } from "../../daemon/types.ts"; +import type { UsageSummary } from "../../daemon/http/usage-summary.ts"; + +export type { UsageSummary }; export interface UsageStats { entries: UsageTrackingEntry[]; @@ -6,6 +9,8 @@ export interface UsageStats { totalSatsCost: number; recentSatsCost: number; limit: number; + /** Present when data was fetched via /usage/summary (server-aggregated path). */ + summary?: UsageSummary; } export interface DayStats {