From b7ab402644c90fd81178b51c4560b30b4251a803 Mon Sep 17 00:00:00 2001 From: unohee Date: Thu, 10 Sep 2026 20:55:51 +0900 Subject: [PATCH] feat(web): a usage window shorter than a day, and an axis fine enough to draw it (AGT-4296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard's shortest window was 24h, and a 24h total is dominated by whatever ran before the last deploy. Measured on vela the morning after the durable draft cache shipped: the draft stage read 34.1% prompt-cache over 24h and 66.8% over the last two hours. Only the second number described the code that was actually running — the first said the fix had not landed. `parseUsageSince` already accepted durations like `2h`; the selector just never offered one. What was missing was somewhere to draw it. `groupKeyOf` bottomed out at `day`, so a one-hour window rendered on the time axis is a single bar, which is not a series. So: a `by=hour` axis (`record.ts.slice(0, 13)`), 1h/2h/6h in the selector, and the axis chosen from the window — hour up to 48h, day beyond, because thirty days by hour is 720 bars, which is a texture rather than a reading. Hour keys stay UTC on the wire, like day keys, and are rendered on the reader's clock. Day keys are deliberately NOT converted: shifting one by the UTC offset relabels the day, and that alignment is AGT-4293's business. The card heading follows the axis that was actually drawn — left fixed it read 일별 over labels like `9. 10. 23시`, on first load for every reader, because the default window is 24h. Two test properties worth naming, both learned the hard way here: `process.env.TZ` is pinned in the client test file. Local-time rendering is invisible where UTC and local coincide, and CI runners are UTC — the mutant that skips the conversion entirely passed there while failing on a laptop in KST. Only the non-zero offset is load-bearing, not Seoul. `usage.css` now has a tripwire asserting every `var(--…)` it references is defined in `tokens.css`. Three undefined names shipped into that file during this work (`--surface-2`, `--text-1`, `--text-2`; the real names are `--surface2`, `--fg-primary`, `--fg-secondary`). An undefined custom property is not an error — the declaration is dropped and the rule renders with an inherited colour, so it looks plausible, and neither the build, nor oxlint, nor jsdom sees it. Cross-axis filters and the drill-down panel were split out to AGT-4297. Three review rounds all returned REVISE with the same two classes recurring — a fix trading one defect for another, and a fix re-breakable with a green suite — and every finding in the last round was in the drill-down UI while this half drew none. Per the commit gate, a repeating finding means the change is too large. Review: layer 2 (independent subagent), three rounds on the combined change; this half accumulated no findings across all three. Mutants: always-day axis kills 4 tests, no local-time conversion 2, fixed heading 2, hour key equal to day key 1, short windows removed 1. tsc --noEmit exit 0 · build exit 0 · full suite 5984 passed / 10 skipped, and the same under TZ=UTC · oxlint 0 warnings on the changed files. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- src/cli.ts | 2 +- src/support/usageLedger.test.ts | 17 ++++++ src/support/usageLedger.ts | 7 ++- tests/web/usage.test.ts | 94 +++++++++++++++++++++++++++++++-- tests/web/usageLayout.test.ts | 21 ++++++++ web/static/js/usage.mjs | 67 ++++++++++++++++++++--- web/static/usage.html | 3 ++ 8 files changed, 200 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 69592636..c71c07c1 100644 --- a/README.md +++ b/README.md @@ -615,7 +615,7 @@ openswarm start --foreground # run attached (logs stream to the terminal) openswarm status # pid, uptime, log path openswarm stop # stop the daemon openswarm dash # open the web dashboard (:3847) -openswarm cost --since 24h # LLM spend by model (--by stage|task|project|adapter|day, --json) +openswarm cost --since 24h # LLM spend by model (--by stage|task|project|adapter|day|hour, --json) ``` Every model API call is appended to `~/.openswarm/usage/.jsonl` with the diff --git a/src/cli.ts b/src/cli.ts index 97c5d456..0aace9dd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -718,7 +718,7 @@ program .command('cost') .description('Show LLM spend from the usage ledger (per API call, metered by the provider)') .option('--since ', 'Duration back from now (90m, 24h, 7d) or an ISO date', '24h') - .option('--by ', 'Group by model | stage | task | project | adapter | day', 'model') + .option('--by ', 'Group by model | stage | task | project | adapter | day | hour', 'model') .option('--json', 'Print the aggregate as JSON') .action(async (opts: { since: string; by: string; json?: boolean }) => { const { runCostCommand } = await import('./cli/costCommand.js'); diff --git a/src/support/usageLedger.test.ts b/src/support/usageLedger.test.ts index 4d000cb9..648be2c1 100644 --- a/src/support/usageLedger.test.ts +++ b/src/support/usageLedger.test.ts @@ -103,6 +103,23 @@ describe('usage ledger', () => { expect(aggregateUsage(records, 'day').rows.map((r) => r.key).sort()).toEqual(['2026-09-02', '2026-09-03']); }); + it('groups by UTC hour, so a window shorter than a day is still a series', () => { + // Rendered on the `day` axis a 1h window is one bar, which is not a series + // — the reason the shortest window on the dashboard used to be 24h. + const records = [ + record({ ts: '2026-09-02T03:59:59.999Z' }), + record({ ts: '2026-09-02T04:00:00.000Z' }), + record({ ts: '2026-09-02T04:30:00.000Z' }), + ]; + const rows = aggregateUsage(records, 'hour').rows; + + expect(rows.map((r) => r.key).sort()).toEqual(['2026-09-02T03', '2026-09-02T04']); + expect(rows.find((r) => r.key === '2026-09-02T04')?.calls).toBe(2); + // The same records still bucket into one day, so `hour` is an addition and + // not a redefinition of the coarser axis. + expect(aggregateUsage(records, 'day').rows).toHaveLength(1); + }); + it('parses durations back from now and ISO dates', () => { const now = Date.parse('2026-09-02T12:00:00.000Z'); expect(parseUsageSince('90m', now)).toBe(now - 90 * 60_000); diff --git a/src/support/usageLedger.ts b/src/support/usageLedger.ts index 47ff6095..14bb000b 100644 --- a/src/support/usageLedger.ts +++ b/src/support/usageLedger.ts @@ -144,7 +144,7 @@ function isUsageRecord(value: unknown): value is UsageRecord { && (typeof v.costUsd === 'number' || v.costUsd === null); } -export const USAGE_GROUP_KEYS = ['model', 'stage', 'task', 'project', 'adapter', 'day'] as const; +export const USAGE_GROUP_KEYS = ['model', 'stage', 'task', 'project', 'adapter', 'day', 'hour'] as const; export type UsageGroupKey = (typeof USAGE_GROUP_KEYS)[number]; export interface UsageAggregateRow { @@ -173,6 +173,11 @@ function groupKeyOf(record: UsageRecord, by: UsageGroupKey): string { case 'task': return record.taskId ?? '(unattributed)'; case 'project': return record.cwd ? basename(record.cwd) : '(unknown)'; case 'day': return record.ts.slice(0, 10); + // '2026-09-10T14'. UTC on the wire, like `day`; the client renders it in + // local time. A window shorter than a day drawn on the `day` axis is a + // single bar, which is not a series — which is why the dashboard's + // shortest window used to be 24h. (AGT-4296) + case 'hour': return record.ts.slice(0, 13); } } diff --git a/tests/web/usage.test.ts b/tests/web/usage.test.ts index 7182c37d..54830895 100644 --- a/tests/web/usage.test.ts +++ b/tests/web/usage.test.ts @@ -12,11 +12,12 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; // @ts-expect-error — browser ESM asset without type declarations import { attributedTasks, cacheRate, costPerCall, formatCost, formatPercent, formatTokens, - loadUsage, rateClass, renderDays, renderSummary, renderTable, share, + formatBucket, loadUsage, rateClass, renderDays, renderSummary, renderTable, share, + timeAxisFor, WINDOWS, rowShare, startUsageView, truncationNote, UNATTRIBUTED, windowFromSearch, } from '../../web/static/js/usage.mjs'; @@ -30,6 +31,17 @@ function row(key: string, over: Record = {}) { } /** Mount the real shell so the tests bind to the same ids the page ships. */ +// `UTC on the wire, local on screen` is invisible where the two coincide, and +// CI runners are UTC — a mutant that skips the conversion entirely passes +// there. Pinned to a non-zero offset so the assertion can fail everywhere. +// Only the offset is load-bearing; any non-UTC zone would do. (AGT-4296) +const ORIGINAL_TZ = process.env.TZ; +beforeAll(() => { process.env.TZ = 'Asia/Seoul'; }); +afterAll(() => { + if (ORIGINAL_TZ === undefined) delete process.env.TZ; + else process.env.TZ = ORIGINAL_TZ; +}); + function mountShell(): void { document.body.innerHTML = SHELL.replace(/^[\s\S]*?]*>/, '').replace(/<\/body>[\s\S]*$/, ''); } @@ -390,6 +402,7 @@ describe('loading', () => { const data = await loadUsage('7d', fetchImpl as never); const asked = fetchImpl.mock.calls.map(([url]) => new URL(url as string, 'http://x').searchParams.get('by')); + // 7d is past the hourly ceiling, so the time axis is still `day`. expect(asked.sort()).toEqual(['adapter', 'day', 'model', 'project', 'stage', 'task']); expect(fetchImpl.mock.calls.every(([url]) => (url as string).includes('since=7d'))).toBe(true); expect(data.model.rows[0].key).toBe('model-a'); @@ -401,7 +414,9 @@ describe('loading', () => { expect(document.querySelector('#table-model tbody td')?.textContent).toBe('model-a'); expect(document.querySelector('#table-stage tbody td')?.textContent).toBe('stage-a'); - expect(document.querySelector('#days .bar-day')?.textContent).toBe('day-a'); + // The default window is 24h, so the series comes off the hour axis. + expect(document.querySelector('#days .bar-day')?.textContent).toBe('hour-a'); + expect(document.querySelector('#days-title')?.textContent).toBe('시간별'); expect(document.querySelector('#status')?.textContent).toContain('기준'); }); @@ -622,3 +637,76 @@ describe('loading', () => { expect((fetchImpl.mock.calls[0][0] as string)).toContain('since=30d'); }); }); + +describe('windows and the time axis (AGT-4296)', () => { + beforeEach(mountShell); + + const ok = (by: string) => ({ + ok: true, + json: async () => ({ since: '2026-09-09T00:00:00.000Z', until: '2026-09-10T00:00:00.000Z', by, rows: [row(`${by}-a`)], total: row('total') }), + }); + + it('picks the finest axis that still draws a series, and stops before it is a texture', () => { + expect(timeAxisFor('1h')).toBe('hour'); + expect(timeAxisFor('2h')).toBe('hour'); + expect(timeAxisFor('48h')).toBe('hour'); + expect(timeAxisFor('49h')).toBe('day'); + expect(timeAxisFor('7d')).toBe('day'); + expect(timeAxisFor('90m')).toBe('hour'); + // A hand-typed ISO date can name a window of any length, so it takes the + // coarse axis rather than a guess. Same for a missing value. + expect(timeAxisFor('2026-09-01')).toBe('day'); + expect(timeAxisFor(undefined)).toBe('day'); + }); + + it('offers the short windows the selector was missing', () => { + expect(WINDOWS.slice(0, 3)).toEqual(['1h', '2h', '6h']); + const offered = [...document.querySelectorAll('#window option')].map(o => (o as HTMLOptionElement).value); + expect(offered).toEqual(WINDOWS); + // Every option is a window the URL parser will accept back. + expect(offered.every(v => windowFromSearch(`?since=${v}`) === v)).toBe(true); + }); + + it('asks for the hour axis on a short window and renders it as the series', async () => { + const fetchImpl = vi.fn(async (url: string) => ok(new URL(url, 'http://x').searchParams.get('by')!)); + const data = await loadUsage('2h', fetchImpl as never); + + const asked = fetchImpl.mock.calls.map(([url]) => new URL(url as string, 'http://x').searchParams.get('by')); + expect(asked).toContain('hour'); + expect(asked).not.toContain('day'); + expect(data.timeAxis).toBe('hour'); + expect(data.time).toBe(data.hour); + }); + + it("renders a UTC hour bucket on the reader's clock, and leaves a day alone", () => { + // A literal, not the implementation's own expression: recomputing the + // expected value with the code under test says "the code equals the code" + // and would not notice a wrong `timeZone`. 14:00Z is 23시 in the pinned zone. + expect(formatBucket('2026-09-10T14')).toBe('9. 10. 23시'); + // A day key must NOT be converted — shifting it by the offset relabels the + // day, which is AGT-4293's scope, not this change's. + expect(formatBucket('2026-09-10')).toBe('2026-09-10'); + expect(formatBucket('not-a-bucket')).toBe('not-a-bucket'); + expect(formatBucket(undefined)).toBe(''); + }); + + it('keeps the raw bucket key reachable after formatting it', () => { + document.body.innerHTML = '
'; + renderDays(document.querySelector('#d')!, { rows: [row('2026-09-10T14')], total: row('total') }); + const label = document.querySelector('#d .bar-day') as HTMLElement; + expect(label.title).toBe('2026-09-10T14'); + expect(label.textContent).toBe('9. 10. 23시'); + }); + + it('heads the card with the axis it actually drew', async () => { + const fetchImpl = vi.fn(async (url: string) => ok(new URL(url, 'http://x').searchParams.get('by')!)); + const view = startUsageView({ fetchImpl: fetchImpl as never, location: { search: '' } as never }); + (document.querySelector('#window') as HTMLSelectElement).value = '7d'; + await view.refresh(); + expect(document.querySelector('#days-title')?.textContent).toBe('일별'); + + (document.querySelector('#window') as HTMLSelectElement).value = '1h'; + await view.refresh(); + expect(document.querySelector('#days-title')?.textContent).toBe('시간별'); + }); +}); diff --git a/tests/web/usageLayout.test.ts b/tests/web/usageLayout.test.ts index a7224b96..faa576f9 100644 --- a/tests/web/usageLayout.test.ts +++ b/tests/web/usageLayout.test.ts @@ -27,7 +27,28 @@ const RAW = readFileSync(resolve(__dirname, '../../web/static/css/usage.css'), ' */ const CSS = RAW.replace(/\/\*[\s\S]*?\*\//g, ''); +const TOKENS_CSS = readFileSync(resolve(__dirname, '../../web/static/css/tokens.css'), 'utf8'); + describe('usage.css invariants', () => { + it('references only custom properties tokens.css actually defines', () => { + // Written after three `var(--…)` names that do not exist were shipped into + // this file (`--surface-2`, `--text-1`, `--text-2`; the real names are + // `--surface2`, `--fg-primary`, `--fg-secondary`). An undefined custom + // property is not an error — the declaration is dropped and the rule + // renders with an inherited colour, so it looks plausible. Neither the + // build, nor oxlint, nor jsdom sees it. + const defined = new Set( + [...TOKENS_CSS.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gm)].map(m => m[1]), + ); + const used = new Set([...CSS.matchAll(/var\((--[a-z0-9-]+)/g)].map(m => m[1])); + + expect([...used].filter(name => !defined.has(name))).toEqual([]); + // Guard the guard: if either file stopped being read, both sets would be + // empty and the assertion above would pass while checking nothing. + expect(used.size).toBeGreaterThan(10); + expect(defined.size).toBeGreaterThan(50); + }); + it('does not scope the rate colours to table cells', () => { // `td.rate-low` would leave #sum-cache uncoloured. expect(CSS).toMatch(/^\.rate-low\s/m); diff --git a/web/static/js/usage.mjs b/web/static/js/usage.mjs index f8f82bfb..5663e73a 100644 --- a/web/static/js/usage.mjs +++ b/web/static/js/usage.mjs @@ -11,7 +11,45 @@ // cache rate, cost per call, calls per task — are the ones that carry the // cost signal; the raw rows do not have them. -const AXES = ['day', 'model', 'stage', 'adapter', 'project', 'task']; +// AGT-4296: a window shorter than a day, and an axis fine enough to draw it +// on. 24h was the shortest window the selector offered, and a 24h total is +// dominated by whatever ran before the last deploy — the draft cache rate read +// 34.1% over 24h and 66.8% over the last two hours, and only the second one +// described the code actually running. + +/** Axes fetched for every window. The time axis is chosen per window. */ +const AXES = ['model', 'stage', 'adapter', 'project', 'task']; + +/** + * The finest time axis that still draws a series for this window. + * + * Beyond two days an hourly series is 720 bars, which is a texture rather than + * a reading. Anything the selector does not offer — a hand-typed ISO date, + * which can name a window of any length — falls back to the coarse axis rather + * than guessing. + */ +export function timeAxisFor(since) { + const m = /^(\d+)([mhd])$/.exec(String(since ?? '').trim()); + if (!m) return 'day'; + const hours = Number(m[1]) * { m: 1 / 60, h: 1, d: 24 }[m[2]]; + return hours <= 48 ? 'hour' : 'day'; +} + +/** + * A bucket key as a reader's clock shows it. + * + * `2026-09-10T14` is a UTC hour and carries no minutes, which `Date.parse` + * rejects on its own — so it is completed before parsing. A day key is left + * alone: converting it would shift it by the UTC offset and relabel the day, + * which is AGT-4293's business, not this change's. + */ +export function formatBucket(key) { + const text = String(key ?? ''); + if (!text.includes('T')) return text; + const at = new Date(`${text}:00:00Z`); + if (Number.isNaN(at.getTime())) return text; + return at.toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', hour12: false }); +} /** * cachedTokens / promptTokens, or null when there is nothing to divide. @@ -197,7 +235,7 @@ export function renderTable(table, aggregate, { limit = 25, sortBy = 'cost', lab }; } -/** Render the day axis as bars, oldest first — a series read left to right in time. */ +/** Render the time axis as bars, oldest first — a series read left to right in time. */ export function renderDays(container, aggregate) { container.replaceChildren(); const rows = [...(aggregate?.rows ?? [])].sort((a, b) => String(a.key).localeCompare(String(b.key))); @@ -225,7 +263,9 @@ export function renderDays(container, aggregate) { const day = document.createElement('span'); day.className = 'bar-day'; - day.textContent = row.key; + day.textContent = formatBucket(row.key); + // The raw key stays reachable: the label is now a rendering of it. + day.title = row.key; const track = document.createElement('div'); track.className = 'bar-track'; @@ -298,16 +338,24 @@ export function renderSummary(root, { model, task }) { * partial render for the rarer per-request failures is a separate change. */ export async function loadUsage(since, fetchImpl = globalThis.fetch) { - const results = await Promise.all(AXES.map(async (by) => { + const timeAxis = timeAxisFor(since); + const results = await Promise.all([timeAxis, ...AXES].map(async (by) => { const res = await fetchImpl(`/api/usage?since=${encodeURIComponent(since)}&by=${by}`); if (!res.ok) throw new Error(`/api/usage?by=${by} → ${res.status}`); return [by, await res.json()]; })); - return Object.fromEntries(results); + const data = Object.fromEntries(results); + // `time` is whichever axis was chosen, so the renderer need not know which. + data.time = data[timeAxis]; + data.timeAxis = timeAxis; + return data; } /** Windows the selector offers. A hand-typed `?since=` outside this set is ignored. */ -export const WINDOWS = ['24h', '7d', '30d']; +// A landed fix is invisible in a 24h total: the draft cache rate read 34.1% +// over 24h and 66.8% over the last two hours, and only the second described +// the deployed code. (AGT-4296) +export const WINDOWS = ['1h', '2h', '6h', '24h', '7d', '30d']; /** The window named by the URL, or null when it names nothing valid. */ export function windowFromSearch(search) { @@ -359,7 +407,12 @@ export function startUsageView({ root = document, fetchImpl = globalThis.fetch, const data = await loadUsage(since, fetchImpl); if (seq !== latest) return; // a newer window is already in flight renderSummary(root, data); - renderDays(root.querySelector('#days'), data.day); + renderDays(root.querySelector('#days'), data.time); + // The card is headed by whoever knows which axis was chosen. Left fixed + // it read 일별 over hourly labels like `9. 10. 23시`, on first load for + // every reader, because the default window is 24h. + const daysTitle = root.querySelector('#days-title'); + if (daysTitle) daysTitle.textContent = data.timeAxis === 'hour' ? '시간별' : '일별'; const LABELS = { model: '모델', stage: '스테이지', adapter: '어댑터', project: '프로젝트' }; for (const axis of ['model', 'stage', 'adapter', 'project']) { const table = root.querySelector(`#table-${axis}`); diff --git a/web/static/usage.html b/web/static/usage.html index 4bb0d3ba..a225c4d3 100644 --- a/web/static/usage.html +++ b/web/static/usage.html @@ -27,6 +27,9 @@