diff --git a/bin/token-usage-dashboard.js b/bin/token-usage-dashboard.js index ca1b22c..e1baa5c 100755 --- a/bin/token-usage-dashboard.js +++ b/bin/token-usage-dashboard.js @@ -47,12 +47,13 @@ if (flag('-v', '--version')) { } const host = value('-h', '--host') ?? process.env.HOST ?? '127.0.0.1'; -const wanted = Number(value('-p', '--port') ?? process.env.PORT ?? 4747); +const asked = value('-p', '--port') ?? process.env.PORT ?? '4747'; +const wanted = Number(asked); const dbPath = value('--db'); if (dbPath) process.env.AIUSAGE_DB = dbPath; if (!Number.isInteger(wanted) || wanted < 1 || wanted > 65535) { - console.error(`${value('-p', '--port')} is not a port. Pick a number between 1 and 65535.`); + console.error(`${asked} is not a port. Pick a number between 1 and 65535.`); process.exit(1); } diff --git a/src/lib/server/queries.ts b/src/lib/server/queries.ts index 00257f8..1d1af20 100644 --- a/src/lib/server/queries.ts +++ b/src/lib/server/queries.ts @@ -49,13 +49,11 @@ function where(filters: Filters, dated = true) { const SUMS = 'sum(input) input, sum(output) output, sum(cache_read) cache_read,' + ' sum(cache_write) cache_write, sum(cache_write_long) cache_write_long,' + - ' sum(reasoning) reasoning, count(*) events,' + - " count(distinct tool || ':' || session) sessions"; + ' sum(reasoning) reasoning, count(*) events'; /** Rows arrive split by model so each slice can be priced at its own rate. */ function fold(rows: Row[], overrides: Record): Map { const slices = new Map(); - const sessions = new Map>(); for (const row of rows) { const key = String(row.key ?? ''); @@ -63,7 +61,6 @@ function fold(rows: Row[], overrides: Record): Map if (!slice) { slice = { key, tokens: empty(), total: 0, reasoning: 0, cost: null, events: 0, sessions: 0 }; slices.set(key, slice); - sessions.set(key, new Set()); } const tokens = tokensOf(row); @@ -72,7 +69,6 @@ function fold(rows: Row[], overrides: Record): Map slice.total += totalOf(tokens); slice.reasoning += Number(row.reasoning) || 0; slice.events += Number(row.events) || 0; - slice.sessions += Number(row.sessions) || 0; slice.cost = plus(slice.cost, costOf(String(row.model ?? ''), tokens, overrides)); } @@ -96,23 +92,23 @@ function grouped(column: string, filters: Filters, overrides: Record b.total - a.total); -} + const slices = fold(rows, overrides); -function totals(filters: Filters, overrides: Record): Slice { - const clause = where(filters); - const rows = db() - .prepare(`select '' key, model, ${SUMS} from events ${clause.sql} group by model`) + // counted apart from the per-model rows, or a session that swapped model counts twice + const counts = db() + .prepare( + `select ${column} key, count(distinct tool || ':' || session) sessions` + + ` from events ${clause.sql} group by ${column}` + ) .all(...clause.params) as Row[]; - const slice = fold(rows, overrides).get('') ?? blank(''); + for (const row of counts) slices.get(String(row.key ?? ''))!.sessions = Number(row.sessions); - const distinct = db() - .prepare(`select count(distinct tool || ':' || session) sessions from events ${clause.sql}`) - .get(...clause.params) as Row; - slice.sessions = Number(distinct?.sessions) || 0; - return slice; + return [...slices.values()].sort((a, b) => b.total - a.total); } +const totals = (filters: Filters, overrides: Record): Slice => + grouped("''", filters, overrides)[0] ?? blank(''); + function series(filters: Filters, column: string, overrides: Record): DayPoint[] { const clause = where(filters); const rows = db() diff --git a/src/lib/server/refresh.ts b/src/lib/server/refresh.ts index 749a946..99ce468 100644 --- a/src/lib/server/refresh.ts +++ b/src/lib/server/refresh.ts @@ -3,20 +3,22 @@ import { refreshClaudeWindows } from './windows'; import { settings } from './settings'; import { usage } from './queries'; import { resolveRange } from '../range'; -import { describe, report } from './problems'; +import { describe, report, resolve } from './problems'; let timer: NodeJS.Timeout | null = null; export async function refresh(force = false): Promise { await Promise.all([ - scan().catch((error) => - report({ - id: 'scan', - level: 'error', - scope: 'Scan', - message: 'The scan stopped early', - hint: describe(error) - }) + scan().then( + () => resolve('scan'), + (error) => + report({ + id: 'scan', + level: 'error', + scope: 'Scan', + message: 'The scan stopped early', + hint: describe(error) + }) ), refreshClaudeWindows(force) ]); @@ -29,10 +31,16 @@ function warm(): void { usage({ from, to, range, tools: [], models: [], projects: [] }); } -export function boot(): void { - if (timer) return; +/** Starts the rescans, or swaps them onto whatever interval is saved now */ +export function schedule(): void { + if (timer) clearInterval(timer); const every = Math.max(15, settings().scanIntervalSec) * 1000; timer = setInterval(() => void refresh(), every); timer.unref?.(); +} + +export function boot(): void { + if (timer) return; + schedule(); void refresh(); } diff --git a/src/routes/api/settings/+server.ts b/src/routes/api/settings/+server.ts index 3367297..94675e0 100644 --- a/src/routes/api/settings/+server.ts +++ b/src/routes/api/settings/+server.ts @@ -1,6 +1,6 @@ import { saveSettings, settings, type Settings } from '#lib/server/settings.js'; import { clearUsageMemo } from '#lib/server/queries.js'; -import { refresh } from '#lib/server/refresh.js'; +import { refresh, schedule } from '#lib/server/refresh.js'; import type { RequestHandler } from './$types'; export const GET: RequestHandler = () => Response.json(settings()); @@ -9,6 +9,7 @@ export const POST: RequestHandler = async ({ request }) => { const patch = (await request.json()) as Partial; const next = saveSettings(patch); clearUsageMemo(); + if ('scanIntervalSec' in patch) schedule(); if (patch.claudeWindows || patch.prices) await refresh(); return Response.json(next); }; diff --git a/tests/queries.test.ts b/tests/queries.test.ts index 7b6551f..9344570 100644 --- a/tests/queries.test.ts +++ b/tests/queries.test.ts @@ -109,6 +109,16 @@ describe('usage', () => { expect(result.totals.sessions).toBe(5); }); + it('counts a session that swapped model once on every panel', () => { + add('swap-a', 'claude-code', 'claude-opus-5', '/work/swap', 1, 10, 'swap'); + add('swap-b', 'claude-code', 'claude-haiku-4-5', '/work/swap', 1, 10, 'swap'); + + const result = usage(filters({ projects: ['/work/swap'] })); + expect(result.byProject[0].sessions).toBe(1); + expect(result.byTool[0].sessions).toBe(1); + expect(result.totals.sessions).toBe(1); + }); + it('works out peak context, cache hits and the subagent share per session', () => { const at = new Date(now - DAY); const insert = handle.prepare(