diff --git a/src/bridge/skills-api.test.ts b/src/bridge/skills-api.test.ts index ca99f33..7ac9727 100644 --- a/src/bridge/skills-api.test.ts +++ b/src/bridge/skills-api.test.ts @@ -92,6 +92,9 @@ describe('GET /api/skills/:name', () => { expect(res.body.name).toBe('sys-skill'); expect(res.body.source).toBe('system'); expect(res.body.content).toContain('body'); + // raw is the full file (incl. frontmatter) the editor must edit/save. + expect(res.body.raw).toContain('name: sys-skill'); + expect(res.body.raw).toContain('body'); expect(res.body.files).toContain('SKILL.md'); expect(res.body).toHaveProperty('maxSeverity'); }); @@ -256,6 +259,45 @@ describe('PUT /api/skills/:name (update)', () => { .send({ content: 'x' }); expect(res.status).toBe(400); }); + + // Regression: editing the body-only `content` (frontmatter dropped) used to + // overwrite SKILL.md without frontmatter, making the skill vanish from the + // catalog. The PUT now rejects frontmatter-less content and leaves the file + // intact. + it('rejects content without valid frontmatter and leaves the skill intact', async () => { + addUserSkill('user-1', 'keepme'); + const filePath = join(userRoot, 'user-1', 'skills', 'keepme', 'SKILL.md'); + const before = readFileSync(filePath, 'utf-8'); + const res = await request(makeApp(makeCatalog(), { id: 'user-1' })) + .put('/api/skills/keepme?scope=user') + .send({ content: '# keepme\njust the body, no frontmatter' }); + expect(res.status).toBe(400); + // File untouched → still has frontmatter → still loadable. + expect(readFileSync(filePath, 'utf-8')).toBe(before); + const list = await request(makeApp(makeCatalog(), { id: 'user-1' })).get('/api/skills?scope=user'); + expect(list.body.skills.map((s: { name: string }) => s.name)).toContain('keepme'); + }); + + it('rejects a frontmatter name that does not match the skill (no rename via edit)', async () => { + addUserSkill('user-1', 'orig'); + const res = await request(makeApp(makeCatalog(), { id: 'user-1' })) + .put('/api/skills/orig?scope=user') + .send({ content: SKILL_MD('renamed') }); + expect(res.status).toBe(400); + expect(existsSync(join(userRoot, 'user-1', 'skills', 'orig', 'SKILL.md'))).toBe(true); + }); + + it('saves full content (frontmatter preserved) round-trip', async () => { + addUserSkill('user-1', 'rt'); + const newFull = SKILL_MD('rt') + '\nmore body'; + const res = await request(makeApp(makeCatalog(), { id: 'user-1' })) + .put('/api/skills/rt?scope=user') + .send({ content: newFull }); + expect(res.status).toBe(200); + const saved = readFileSync(join(userRoot, 'user-1', 'skills', 'rt', 'SKILL.md'), 'utf-8'); + expect(saved).toContain('name: rt'); // frontmatter kept + expect(saved).toContain('more body'); // body updated + }); }); describe('DELETE /api/skills/:name', () => { diff --git a/src/bridge/skills-api.ts b/src/bridge/skills-api.ts index e00d3e7..94f5c71 100644 --- a/src/bridge/skills-api.ts +++ b/src/bridge/skills-api.ts @@ -5,6 +5,7 @@ import { randomBytes } from 'crypto'; import type { SkillCatalog, SkillEntry } from '../engine/skills.js'; import { VALID_SKILL_NAME } from '../engine/skills.js'; import { scanSkillContent, scanSkillDirectory, maxSeverity } from '../engine/skills-scanner.js'; +import matter from 'gray-matter'; import { logger } from '../logger.js'; import { handleInstallFromUrl } from './skills-git-install.js'; @@ -175,6 +176,10 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v source: entry.source, hasDir: entry.dirPath !== null, content, + // Full file incl. frontmatter — what the editor must load and save back. + // Editing/saving the body-only `content` would drop the frontmatter and + // make the skill unreadable (it vanishes from the catalog). + raw, files, findings, maxSeverity: maxSeverity(findings), @@ -320,6 +325,30 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v return; } + // Guard against destroying the skill: the catalog identifies a skill by + // its frontmatter `name`. Saving content without a valid `name` (e.g. a + // body-only edit that dropped the frontmatter) would make it unreadable + // and vanish from the list. Require valid frontmatter matching the skill. + let fmName = ''; + try { + const parsed = matter(content); + fmName = typeof parsed.data?.name === 'string' ? parsed.data.name : ''; + } catch { + fmName = ''; + } + if (!fmName || !VALID_SKILL_NAME.test(fmName)) { + res.status(400).json({ + error: 'Content must begin with YAML frontmatter containing a valid "name" (otherwise the skill becomes unreadable). Edit the full SKILL.md, including the --- frontmatter --- block.', + }); + return; + } + if (fmName !== name) { + res.status(400).json({ + error: `Frontmatter name "${fmName}" must match the skill name "${name}". Renaming via edit is not supported.`, + }); + return; + } + // Scan new content const findings = scanSkillContent(content); const severity = maxSeverity(findings); diff --git a/src/bridge/usage-api.test.ts b/src/bridge/usage-api.test.ts index 14deaaa..ec4dce7 100644 --- a/src/bridge/usage-api.test.ts +++ b/src/bridge/usage-api.test.ts @@ -1,14 +1,17 @@ /** - * Usage dashboard API (GET /api/usage/daily) tests. + * Usage dashboard API v2 (GET /api/usage/daily) tests. * * Coverage: * - admin sees all users + byUser breakdown; non-admin scoped to own rows * - no-auth (authActive=false) sees everyone (scope 'all') - * - day / week / month bucketing collapses model/route correctly + * - default groupBy=source keeps gateway/direct totals; series carries segments + * - day / week / month bucketing + * - groupBy model / route / user / org produces the right series keys + * - tzOffset re-buckets UTC hours into the viewer's local calendar day * - inclusive range, default range, from>to → 400, range-too-large → 400 * - invalid dates fall back to defaults (not 500) * - * Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md + * Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md */ import { describe, it, expect, beforeEach } from 'vitest'; import express from 'express'; @@ -26,10 +29,16 @@ function makeApp(repo: Repository, opts: { authActive: boolean; user?: { id: str return app; } -function seed(repo: Repository, rows: Array<{ day: string; userId: string; source: 'gateway' | 'direct'; model?: string; route?: string; tin: number; tout: number; req?: number }>) { +/** Seed at hour grain. `day` is expanded to noon UTC so it maps to the same + * local day under tzOffset=0 (the default the legacy assertions rely on). */ +function seed( + repo: Repository, + rows: Array<{ day?: string; hour?: string; userId: string; source: 'gateway' | 'direct'; model?: string; route?: string; tin: number; tout: number; req?: number }>, +) { for (const r of rows) { - repo.incrementLlmUsage({ - day: r.day, userId: r.userId, source: r.source, + repo.incrementLlmUsageHourly({ + hour: r.hour ?? `${r.day}T12`, + userId: r.userId, source: r.source, model: r.model ?? 'm', route: r.route ?? 'r', tokensIn: r.tin, tokensOut: r.tout, requests: r.req ?? 1, }); @@ -59,6 +68,17 @@ describe('GET /api/usage/daily', () => { expect(res.body.totals.direct).toMatchObject({ tokensIn: 10, tokensOut: 5, requests: 1 }); }); + it('default groupBy is source with gateway→direct keys', async () => { + const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } }); + const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30'); + expect(res.body.groupBy).toBe('source'); + expect(res.body.keys).toEqual(['gateway', 'direct']); + // series carries per-key segments now + const b = res.body.series.find((x: { bucket: string }) => x.bucket === '2026-06-10'); + expect(b.segments.gateway).toMatchObject({ tokensIn: 100, tokensOut: 40 }); + expect(b.segments.direct).toMatchObject({ tokensIn: 10, tokensOut: 5 }); + }); + it('admin sees all users with a byUser breakdown', async () => { const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } }); const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30'); @@ -133,3 +153,128 @@ describe('GET /api/usage/daily', () => { expect(res.body.to).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); }); + +describe('GET /api/usage/daily — v2 group-by axes', () => { + let repo: Repository; + beforeEach(() => { + repo = new Repository(':memory:'); + seed(repo, [ + { day: '2026-06-11', userId: 'u1', source: 'gateway', model: 'big', route: 'pool-a', tin: 100, tout: 40 }, + { day: '2026-06-11', userId: 'u1', source: 'gateway', model: 'small', route: 'pool-b', tin: 20, tout: 10 }, + { day: '2026-06-11', userId: 'u2', source: 'direct', model: 'big', route: 'pool-a', tin: 5, tout: 5 }, + ]); + }); + + it('groupBy=model splits series by model, ordered by total desc', async () => { + const app = makeApp(repo, { authActive: false }); + const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=model'); + expect(res.body.groupBy).toBe('model'); + expect(res.body.keys).toEqual(['big', 'small']); // big 150 > small 30 + expect(res.body.totals.big).toMatchObject({ tokensIn: 105, tokensOut: 45 }); + expect(res.body.totals.small).toMatchObject({ tokensIn: 20, tokensOut: 10 }); + }); + + it('groupBy=route splits series by backend route', async () => { + const app = makeApp(repo, { authActive: false }); + const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=route'); + expect(res.body.keys.sort()).toEqual(['pool-a', 'pool-b']); + expect(res.body.totals['pool-a']).toMatchObject({ tokensIn: 105, tokensOut: 45 }); + }); + + it('groupBy=user keys by id and resolves display labels', async () => { + const alice = repo.createUser({ email: 'a@x', name: 'Alice', role: 'user', status: 'active' }); + seed(repo, [{ day: '2026-06-11', userId: alice.id, source: 'direct', tin: 1, tout: 1 }]); + const app = makeApp(repo, { authActive: false }); + const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=user'); + expect(res.body.keys).toContain(alice.id); + expect(res.body.labels[alice.id]).toBe('Alice'); + }); + + it('folds the tail beyond 12 series into a single "other" bucket', async () => { + const r2 = new Repository(':memory:'); + // 14 distinct models with descending totals m00 (highest) .. m13 (lowest). + for (let i = 0; i < 14; i++) { + seed(r2, [{ day: '2026-06-11', userId: 'u1', source: 'direct', model: `m${String(i).padStart(2, '0')}`, tin: 100 - i, tout: 0 }]); + } + const app = makeApp(r2, { authActive: false }); + const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=model'); + expect(res.body.keys).toHaveLength(12); // 11 kept + 'other' + expect(res.body.keys[res.body.keys.length - 1]).toBe('other'); + // folded tail = m11+m12+m13 inputs = (100-11)+(100-12)+(100-13) = 264 + expect(res.body.totals.other).toMatchObject({ tokensIn: 264 }); + // no key is duplicated + expect(new Set(res.body.keys).size).toBe(res.body.keys.length); + }); + + it('merges a real series literally named "other" with the folded tail (no dup key)', async () => { + const r2 = new Repository(':memory:'); + // A high-volume model called 'other' that survives in the kept top-11… + seed(r2, [{ day: '2026-06-11', userId: 'u1', source: 'direct', model: 'other', tin: 1000, tout: 0 }]); + // …plus 13 smaller models so folding still happens. + for (let i = 0; i < 13; i++) { + seed(r2, [{ day: '2026-06-11', userId: 'u1', source: 'direct', model: `m${String(i).padStart(2, '0')}`, tin: 50 - i, tout: 0 }]); + } + const app = makeApp(r2, { authActive: false }); + const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=model'); + // 'other' appears exactly once + expect(res.body.keys.filter((k: string) => k === 'other')).toHaveLength(1); + expect(new Set(res.body.keys).size).toBe(res.body.keys.length); + // its total is the real 1000 PLUS the folded tail, not overwritten + expect(res.body.totals.other.tokensIn).toBeGreaterThan(1000); + }); + + it('groupBy=org maps users to their org and buckets the orgless under no-org', async () => { + const db = repo.getDb(); + db.prepare("INSERT INTO users (id, email, name, role, status, created_at) VALUES ('u1','a@x','A','user','active',datetime('now'))").run(); + db.prepare("INSERT INTO user_gitea_orgs (user_id, org_id, org_name) VALUES ('u1','g1','Acme')").run(); + const app = makeApp(repo, { authActive: false }); + const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=org'); + // u1 → Acme, u2 has no org → no-org + expect(res.body.keys.sort()).toEqual(['Acme', 'no-org']); + expect(res.body.totals['Acme']).toMatchObject({ tokensIn: 120, tokensOut: 50 }); + expect(res.body.totals['no-org']).toMatchObject({ tokensIn: 5, tokensOut: 5 }); + }); +}); + +describe('GET /api/usage/daily — tzOffset local re-bucketing', () => { + let repo: Repository; + beforeEach(() => { + repo = new Repository(':memory:'); + // UTC 2026-06-10T20 → JST (+540) is 2026-06-11 05:00 → local day 06-11. + seed(repo, [{ hour: '2026-06-10T20', userId: 'u1', source: 'direct', tin: 9, tout: 1 }]); + }); + + it('UTC offset (tzOffset=0) lands the row on the UTC day', async () => { + const app = makeApp(repo, { authActive: false }); + const res = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-10&tzOffset=0'); + expect(res.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-10']); + expect(res.body.totals.direct).toMatchObject({ tokensIn: 9 }); + }); + + it('JST offset (tzOffset=540) shifts the row onto the local next day', async () => { + const app = makeApp(repo, { authActive: false }); + const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&tzOffset=540'); + expect(res.body.tzOffset).toBe(540); + expect(res.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-11']); + expect(res.body.totals.direct).toMatchObject({ tokensIn: 9 }); + }); + + it('the same UTC row is absent from the UTC day once viewed in JST', async () => { + const app = makeApp(repo, { authActive: false }); + const res = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-10&tzOffset=540'); + // local day for the row is 06-11, so the 06-10 window is empty + expect(res.body.series).toHaveLength(0); + }); + + it('a negative offset (US Eastern, -300) shifts an early-UTC row to the previous local day', async () => { + const r2 = new Repository(':memory:'); + // UTC 2026-06-11T02 − 5h = 2026-06-10T21 → local day 06-10. + seed(r2, [{ hour: '2026-06-11T02', userId: 'u1', source: 'direct', tin: 7, tout: 0 }]); + const app = makeApp(r2, { authActive: false }); + const onUtcDay = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&tzOffset=-300'); + expect(onUtcDay.body.series).toHaveLength(0); // not on 06-11 local + const onLocalDay = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-10&tzOffset=-300'); + expect(onLocalDay.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-10']); + expect(onLocalDay.body.totals.direct).toMatchObject({ tokensIn: 7 }); + }); +}); diff --git a/src/bridge/usage-api.ts b/src/bridge/usage-api.ts index c1cfd21..1d71c2c 100644 --- a/src/bridge/usage-api.ts +++ b/src/bridge/usage-api.ts @@ -1,18 +1,25 @@ import { Router, Request, Response } from 'express'; -import type { Repository, LlmUsageDailyAgg } from '../db/repository.js'; +import type { Repository, LlmUsageHourlyRow } from '../db/repository.js'; import { logger } from '../logger.js'; /** - * Per-user LLM usage dashboard API. Reads the llm_usage_daily ledger - * (gateway + direct, recorded at the OpenAICompatClient completion - * boundary) and shapes a time series for the Usage tab. + * Per-user LLM usage dashboard API (v2). Reads the hour-grain llm_usage_hourly + * ledger (gateway + direct, recorded at the OpenAICompatClient completion + * boundary) and shapes a multi-series time series for the Usage tab. * - * Visibility: admin (and the no-auth single-user local mode) see every - * user's usage; a non-admin authenticated user sees only their own rows. - * This is a separate lens from the gateway per-key billing view — the two - * are never summed. + * Two axes the caller controls: + * - groupBy: which dimension becomes the chart's series — source | model | + * route | user | org. Defaults to 'source' (gateway vs direct). + * - tzOffset: viewer's local offset in minutes (-getTimezoneOffset(), so JST + * is +540). UTC hours are re-bucketed into the viewer's local calendar + * period so "today" matches the wall clock, not UTC. * - * Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md + * Visibility: admin (and the no-auth single-user local mode) see every user's + * usage; a non-admin authenticated user sees only their own rows. This is a + * separate lens from the gateway per-key billing view — the two are never + * summed. + * + * Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md */ const DAY_RE = /^\d{4}-\d{2}-\d{2}$/; @@ -23,8 +30,14 @@ function isValidDay(s: unknown): s is string { const d = new Date(`${s}T00:00:00.000Z`); return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s; } + const MAX_RANGE_DAYS = 800; // ~2y guard so a hand-crafted range can't scan unbounded +const MAX_TZ_OFFSET = 14 * 60; // clamp to the real-world UTC-14..+14 envelope +const MAX_SERIES = 12; // cap distinct series; the rest fold into an 'other' bucket + type Granularity = 'day' | 'week' | 'month'; +type GroupBy = 'source' | 'model' | 'route' | 'user' | 'org'; +const GROUP_BYS: readonly GroupBy[] = ['source', 'model', 'route', 'user', 'org']; interface Counters { tokensIn: number; @@ -36,31 +49,36 @@ function emptyCounters(): Counters { return { tokensIn: 0, tokensOut: 0, requests: 0 }; } -function addInto(target: Counters, row: LlmUsageDailyAgg): void { +function addInto(target: Counters, row: { tokensIn: number; tokensOut: number; requests: number }): void { target.tokensIn += row.tokensIn; target.tokensOut += row.tokensOut; target.requests += row.requests; } -function utcToday(): string { - return new Date().toISOString().slice(0, 10); +function localTotal(c: Counters): number { + return c.tokensIn + c.tokensOut; } -/** day - n days, as 'YYYY-MM-DD' (UTC). */ +/** Viewer's local 'today' as 'YYYY-MM-DD' given their tz offset (minutes). */ +function localToday(tzOffsetMin: number): string { + return new Date(Date.now() + tzOffsetMin * 60_000).toISOString().slice(0, 10); +} + +/** day + deltaDays, as 'YYYY-MM-DD' (calendar arithmetic via UTC midnight). */ function shiftDay(day: string, deltaDays: number): string { const d = new Date(`${day}T00:00:00.000Z`); d.setUTCDate(d.getUTCDate() + deltaDays); return d.toISOString().slice(0, 10); } -/** Inclusive day count between two 'YYYY-MM-DD' (UTC). */ +/** Inclusive day count between two 'YYYY-MM-DD'. */ function dayDiff(from: string, to: string): number { const a = Date.parse(`${from}T00:00:00.000Z`); const b = Date.parse(`${to}T00:00:00.000Z`); return Math.round((b - a) / 86_400_000); } -/** ISO-8601 week key 'YYYY-Www' for a 'YYYY-MM-DD' day (UTC). */ +/** ISO-8601 week key 'YYYY-Www' for a 'YYYY-MM-DD' day. */ function isoWeekKey(day: string): string { const d = new Date(`${day}T00:00:00.000Z`); // ISO week: Thursday of the current week decides the year. @@ -80,10 +98,25 @@ function bucketKey(day: string, granularity: Granularity): string { } /** - * Human-friendly label for a usage owner id. Real users resolve to their - * name (or email) so the admin breakdown isn't a wall of opaque ids; the - * 'local' / 'system' sentinels are returned verbatim so the UI can localize - * them. Falls back to the raw id when no user row exists. + * Local calendar day of a UTC hour 'YYYY-MM-DDTHH' for a viewer at tzOffsetMin. + * Shifting the UTC instant by the offset and slicing the date component lands + * on the viewer's wall-clock day (e.g. UTC 2026-06-10T15 + 540min → 2026-06-11). + */ +function localDayOf(utcHour: string, tzOffsetMin: number): string { + const ms = Date.parse(`${utcHour}:00:00.000Z`) + tzOffsetMin * 60_000; + return new Date(ms).toISOString().slice(0, 10); +} + +function parseTzOffset(raw: unknown): number { + const n = typeof raw === 'string' ? parseInt(raw, 10) : NaN; + if (!Number.isFinite(n)) return 0; + return Math.max(-MAX_TZ_OFFSET, Math.min(MAX_TZ_OFFSET, n)); +} + +/** + * Human-friendly label for a usage owner id. Real users resolve to their name + * (or email); the 'local' / 'system' sentinels are returned verbatim so the UI + * can localize them. Falls back to the raw id when no user row exists. */ function resolveDisplayName(repo: Repository, userId: string): string { if (userId === 'local' || userId === 'system') return userId; @@ -91,13 +124,35 @@ function resolveDisplayName(repo: Repository, userId: string): string { return u?.name || u?.email || userId; } +/** Series key (and its human label) for a row under the chosen groupBy. */ +function dimensionOf( + row: LlmUsageHourlyRow, + groupBy: GroupBy, + orgMap: Map, +): string { + switch (groupBy) { + case 'model': + return row.model || 'unknown'; + case 'route': + return row.route || 'unknown'; + case 'user': + return row.userId; + case 'org': + return orgMap.get(row.userId) ?? 'no-org'; + case 'source': + default: + return row.source === 'gateway' ? 'gateway' : 'direct'; + } +} + export function createUsageRouter(repo: Repository, opts: { authActive: boolean }): Router { const router = Router(); - // GET /daily?from=YYYY-MM-DD&to=YYYY-MM-DD&granularity=day|week|month + // GET /daily?from&to&granularity=day|week|month&groupBy=source|model|route|user|org&tzOffset= router.get('/daily', (req: Request, res: Response) => { try { - const to = isValidDay(req.query['to']) ? req.query['to'] : utcToday(); + const tzOffset = parseTzOffset(req.query['tzOffset']); + const to = isValidDay(req.query['to']) ? req.query['to'] : localToday(tzOffset); const from = isValidDay(req.query['from']) ? req.query['from'] : shiftDay(to, -29); if (from > to) { res.status(400).json({ error: 'from must be on or before to' }); @@ -108,33 +163,49 @@ export function createUsageRouter(repo: Repository, opts: { authActive: boolean return; } const gq = req.query['granularity']; - const granularity: Granularity = - gq === 'week' || gq === 'month' ? gq : 'day'; + const granularity: Granularity = gq === 'week' || gq === 'month' ? gq : 'day'; + const gbq = req.query['groupBy']; + const groupBy: GroupBy = + typeof gbq === 'string' && (GROUP_BYS as readonly string[]).includes(gbq) + ? (gbq as GroupBy) + : 'source'; - // Visibility: a non-admin authenticated user is scoped to their own - // rows. Admin and the no-auth local mode see everyone. + // Visibility: a non-admin authenticated user is scoped to their own rows. + // Admin and the no-auth local mode see everyone. const user = req.user as Express.User | undefined; const isAdmin = !opts.authActive || user?.role === 'admin'; const scopeUserId = isAdmin ? undefined : (user?.id ?? 'local'); - const rows = repo.queryLlmUsageDaily({ from, to, userId: scopeUserId }); + // Widen the UTC scan by ±1 day so every hour that maps into a local day + // within [from, to] is fetched, then filter precisely against localDay. + const fromHour = `${shiftDay(from, -1)}T00`; + const toHour = `${shiftDay(to, 1)}T23`; + const rows = repo.queryLlmUsageHourly({ fromHour, toHour, userId: scopeUserId }); - // Bucket by (bucketKey, source). Buckets are sparse — only days with - // usage appear; the client fills gaps for the chart. - const buckets = new Map(); - const totals = { gateway: emptyCounters(), direct: emptyCounters() }; + const orgMap = groupBy === 'org' ? repo.getUsageOrgMap() : new Map(); + + // First pass: accumulate per (bucket, dimension) and per-dimension totals, + // plus the admin per-user table (independent of groupBy). + const buckets = new Map>(); + const totals = new Map(); const byUser = new Map(); for (const row of rows) { - const key = bucketKey(row.day, granularity); - let b = buckets.get(key); - if (!b) { - b = { gateway: emptyCounters(), direct: emptyCounters() }; - buckets.set(key, b); - } - const sourceKey = row.source === 'gateway' ? 'gateway' : 'direct'; - addInto(b[sourceKey], row); - addInto(totals[sourceKey], row); + const localDay = localDayOf(row.hour, tzOffset); + if (localDay < from || localDay > to) continue; // precise local-range filter + const bk = bucketKey(localDay, granularity); + const dim = dimensionOf(row, groupBy, orgMap); + + let seg = buckets.get(bk); + if (!seg) { seg = new Map(); buckets.set(bk, seg); } + let c = seg.get(dim); + if (!c) { c = emptyCounters(); seg.set(dim, c); } + addInto(c, row); + + let t = totals.get(dim); + if (!t) { t = emptyCounters(); totals.set(dim, t); } + addInto(t, row); + if (isAdmin) { let u = byUser.get(row.userId); if (!u) { u = emptyCounters(); byUser.set(row.userId, u); } @@ -142,17 +213,78 @@ export function createUsageRouter(repo: Repository, opts: { authActive: boolean } } + // Order series keys: source keeps a fixed gateway→direct order; every + // other axis orders by total tokens desc. Beyond MAX_SERIES, fold the + // tail into a visible 'other' bucket so the legend/palette stay sane. + let keys: string[]; + if (groupBy === 'source') { + keys = ['gateway', 'direct'].filter((k) => totals.has(k)); + } else { + const ordered = Array.from(totals.keys()).sort( + (a, b) => localTotal(totals.get(b)!) - localTotal(totals.get(a)!), + ); + keys = ordered; + if (ordered.length > MAX_SERIES) { + const keep = ordered.slice(0, MAX_SERIES - 1); + const fold = new Set(ordered.slice(MAX_SERIES - 1)); + // Re-key folded dimensions into 'other' across totals + every bucket. + // A real dimension literally named 'other' may survive in `keep`; in + // that case MERGE the folded tail into it (never overwrite) and avoid + // a duplicate key, so the legend/totals stay consistent. + const otherTotal = totals.get('other') ?? emptyCounters(); + for (const k of fold) { addInto(otherTotal, totals.get(k)!); totals.delete(k); } + totals.set('other', otherTotal); + for (const seg of buckets.values()) { + const otherSeg = emptyCounters(); + let touched = false; + for (const k of fold) { + const c = seg.get(k); + if (c) { addInto(otherSeg, c); seg.delete(k); touched = true; } + } + if (touched) { + const existing = seg.get('other'); + if (existing) addInto(existing, otherSeg); + else seg.set('other', otherSeg); + } + } + keys = keep.includes('other') ? keep : [...keep, 'other']; + logger.info( + `[usage-api] groupBy=${groupBy} folded ${fold.size} series into 'other' (cap=${MAX_SERIES})`, + ); + } + } + + // labels: for the 'user' axis, opaque ids resolve to display names; every + // other axis is already human-readable (UI localizes source/org/other). + // The synthetic 'other' fold bucket is left unlabelled so the UI can + // localize it (otherwise resolveDisplayName would echo the raw 'other'). + const labels: Record = {}; + if (groupBy === 'user') { + for (const k of keys) if (k !== 'other') labels[k] = resolveDisplayName(repo, k); + } + const series = Array.from(buckets.entries()) .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) - .map(([bucket, c]) => ({ bucket, gateway: c.gateway, direct: c.direct })); + .map(([bucket, seg]) => { + const segments: Record = {}; + for (const k of keys) segments[k] = seg.get(k) ?? emptyCounters(); + return { bucket, segments }; + }); + + const totalsOut: Record = {}; + for (const k of keys) totalsOut[k] = totals.get(k) ?? emptyCounters(); res.json({ from, to, granularity, + groupBy, + tzOffset, scope: isAdmin ? 'all' : 'self', + keys, + labels, series, - totals, + totals: totalsOut, ...(isAdmin ? { byUser: Array.from(byUser.entries()) diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 6ed1d9c..ebaedb6 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -103,6 +103,7 @@ export function runMigrations(db: Database.Database): void { migrateGatewayVirtualKeys(db); migratePushNotificationsTables(db); migrateLlmUsageDaily(db); + migrateLlmUsageHourly(db); } /** @@ -130,6 +131,37 @@ function migrateLlmUsageDaily(db: Database.Database): void { `); } +/** + * Usage dashboard v2: hour-grain ledger (supersedes llm_usage_daily as the + * write target). Idempotent; mirrors schema.sql + Repository.initSchema. + * Backfills the daily archive into the hourly table once (hour = day||'T00') + * via INSERT OR IGNORE so re-running the migration never double-counts. + * Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md. + */ +function migrateLlmUsageHourly(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS llm_usage_hourly ( + hour TEXT NOT NULL, + user_id TEXT NOT NULL, + source TEXT NOT NULL, + model TEXT NOT NULL, + route TEXT NOT NULL, + tokens_in INTEGER NOT NULL DEFAULT 0, + tokens_out INTEGER NOT NULL DEFAULT 0, + requests INTEGER NOT NULL DEFAULT 0, + last_updated_at TEXT NOT NULL, + PRIMARY KEY (hour, user_id, source, model, route) + ); + CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_hour + ON llm_usage_hourly (user_id, hour); + INSERT OR IGNORE INTO llm_usage_hourly + (hour, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at) + SELECT day || 'T00', user_id, source, model, route, + tokens_in, tokens_out, requests, last_updated_at + FROM llm_usage_daily; + `); +} + /** * Idempotent column addition helper. Checks PRAGMA table_info and runs the * callback only when the column is missing. diff --git a/src/db/repository.llm-usage-hourly.test.ts b/src/db/repository.llm-usage-hourly.test.ts new file mode 100644 index 0000000..4e75981 --- /dev/null +++ b/src/db/repository.llm-usage-hourly.test.ts @@ -0,0 +1,144 @@ +/** + * Usage dashboard v2: hour-grain ledger (llm_usage_hourly) repository tests. + * + * Coverage: + * - incrementLlmUsageHourly UPSERTs on first call, accumulates on second + * - requests defaults to +1; a usage-less call still bumps requests + * - negative deltas clamp to zero + * - hour defaults to the UTC hour of `at`; boundary splits buckets + * - distinct (model)/(route)/(source) stay separate rows (uncollapsed grain) + * - queryLlmUsageHourly returns raw rows, inclusive range, userId scope + * - getUsageOrgMap unions gitea + local orgs, collapses multi-org via MIN + * + * Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md + */ +import { describe, expect, it, beforeEach } from 'vitest'; +import { Repository } from './repository.js'; +import { runMigrations } from './migrate.js'; + +function makeRepo(): Repository { + return new Repository(':memory:'); +} + +describe('llm_usage_hourly repository', () => { + let repo: Repository; + beforeEach(() => { + repo = makeRepo(); + }); + + it('UPSERTs first call and accumulates on the same grain', () => { + const grain = { hour: '2026-06-11T08', userId: 'u1', source: 'direct' as const, model: 'm', route: 'r' }; + repo.incrementLlmUsageHourly({ ...grain, tokensIn: 100, tokensOut: 40 }); + repo.incrementLlmUsageHourly({ ...grain, tokensIn: 10, tokensOut: 5 }); + const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23' }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + hour: '2026-06-11T08', userId: 'u1', source: 'direct', model: 'm', route: 'r', + tokensIn: 110, tokensOut: 45, requests: 2, + }); + }); + + it('a usage-less call still bumps requests', () => { + repo.incrementLlmUsageHourly({ hour: '2026-06-11T08', userId: 'u1', source: 'gateway', model: 'm', route: 'r' }); + const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23' }); + expect(rows[0]).toMatchObject({ tokensIn: 0, tokensOut: 0, requests: 1 }); + }); + + it('clamps negative deltas to zero', () => { + repo.incrementLlmUsageHourly({ hour: '2026-06-11T08', userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: -5, tokensOut: -9, requests: -3 }); + const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23' }); + expect(rows[0]).toMatchObject({ tokensIn: 0, tokensOut: 0, requests: 0 }); + }); + + it('derives the UTC hour from `at`; hour boundary splits buckets', () => { + const grain = { userId: 'u1', source: 'direct' as const, model: 'm', route: 'r', tokensIn: 1, tokensOut: 0 }; + repo.incrementLlmUsageHourly({ ...grain, at: '2026-06-11T08:59:59.000Z' }); + repo.incrementLlmUsageHourly({ ...grain, at: '2026-06-11T09:00:01.000Z' }); + const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23' }); + expect(rows.map((r) => r.hour)).toEqual(['2026-06-11T08', '2026-06-11T09']); + }); + + it('keeps model/route/source as distinct uncollapsed rows', () => { + const base = { hour: '2026-06-11T08', userId: 'u1', tokensIn: 1, tokensOut: 1 }; + repo.incrementLlmUsageHourly({ ...base, source: 'direct', model: 'big', route: 'host-a' }); + repo.incrementLlmUsageHourly({ ...base, source: 'direct', model: 'small', route: 'host-a' }); + repo.incrementLlmUsageHourly({ ...base, source: 'gateway', model: 'big', route: 'host-a' }); + const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23' }); + expect(rows).toHaveLength(3); // grain not collapsed + }); + + it('queryLlmUsageHourly honours an inclusive hour range', () => { + for (const hour of ['2026-06-11T06', '2026-06-11T07', '2026-06-11T08', '2026-06-11T09']) { + repo.incrementLlmUsageHourly({ hour, userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: 1, tokensOut: 0 }); + } + const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T07', toHour: '2026-06-11T08' }); + expect(rows.map((r) => r.hour)).toEqual(['2026-06-11T07', '2026-06-11T08']); + }); + + it('userId filter scopes the query to one user', () => { + repo.incrementLlmUsageHourly({ hour: '2026-06-11T08', userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: 1, tokensOut: 0 }); + repo.incrementLlmUsageHourly({ hour: '2026-06-11T08', userId: 'u2', source: 'direct', model: 'm', route: 'r', tokensIn: 9, tokensOut: 0 }); + const mine = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23', userId: 'u1' }); + expect(mine).toHaveLength(1); + expect(mine[0]).toMatchObject({ userId: 'u1', tokensIn: 1 }); + }); +}); + +describe('getUsageOrgMap', () => { + let repo: Repository; + beforeEach(() => { + repo = makeRepo(); + }); + + it('maps users from the gitea org cache', () => { + const db = repo.getDb(); + db.prepare("INSERT INTO users (id, email, name, role, status, created_at) VALUES ('u1','a@x','A','user','active',datetime('now'))").run(); + db.prepare('INSERT INTO user_gitea_orgs (user_id, org_id, org_name) VALUES (?, ?, ?)').run('u1', 'g1', 'Acme'); + const map = repo.getUsageOrgMap(); + expect(map.get('u1')).toBe('Acme'); + expect(map.has('u2')).toBe(false); + }); + + it('maps users from local orgs and collapses multi-org via MIN(name)', () => { + const db = repo.getDb(); + db.prepare("INSERT INTO users (id, email, name, role, status, created_at) VALUES ('u1','a@x','A','user','active',datetime('now'))").run(); + db.prepare("INSERT INTO local_orgs (id, name) VALUES ('o1','Zeta'), ('o2','Alpha')").run(); + db.prepare("INSERT INTO local_org_members (org_id, user_id) VALUES ('o1','u1'), ('o2','u1')").run(); + const map = repo.getUsageOrgMap(); + expect(map.get('u1')).toBe('Alpha'); // MIN over {Zeta, Alpha} + }); + + it('unions gitea and local sources for the same user (MIN wins)', () => { + const db = repo.getDb(); + db.prepare("INSERT INTO users (id, email, name, role, status, created_at) VALUES ('u1','a@x','A','user','active',datetime('now'))").run(); + db.prepare("INSERT INTO user_gitea_orgs (user_id, org_id, org_name) VALUES ('u1','g1','Beta')").run(); + db.prepare("INSERT INTO local_orgs (id, name) VALUES ('o1','Alpha')").run(); + db.prepare("INSERT INTO local_org_members (org_id, user_id) VALUES ('o1','u1')").run(); + const map = repo.getUsageOrgMap(); + expect(map.get('u1')).toBe('Alpha'); // MIN over {Beta, Alpha} + }); +}); + +describe('daily → hourly backfill (migrate)', () => { + it('backfills daily rows into the hourly table at hour T00, idempotently', () => { + const repo = new Repository(':memory:'); + const db = repo.getDb(); + // Simulate a pre-v2 production DB: a row only in the daily archive. + db.prepare( + `INSERT INTO llm_usage_daily + (day, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at) + VALUES ('2026-06-10','u1','direct','m','r',100,40,3,'2026-06-10T23:00:00Z')`, + ).run(); + + runMigrations(db); + const after1 = repo.queryLlmUsageHourly({ fromHour: '2026-06-10T00', toHour: '2026-06-10T23' }); + expect(after1).toHaveLength(1); + expect(after1[0]).toMatchObject({ hour: '2026-06-10T00', userId: 'u1', tokensIn: 100, tokensOut: 40, requests: 3 }); + + // Re-running the migration must not double-count (INSERT OR IGNORE). + runMigrations(db); + const after2 = repo.queryLlmUsageHourly({ fromHour: '2026-06-10T00', toHour: '2026-06-10T23' }); + expect(after2).toHaveLength(1); + expect(after2[0]).toMatchObject({ tokensIn: 100, tokensOut: 40, requests: 3 }); + }); +}); diff --git a/src/db/repository.ts b/src/db/repository.ts index cb1b71d..5b93e84 100644 --- a/src/db/repository.ts +++ b/src/db/repository.ts @@ -367,6 +367,40 @@ export interface LlmUsageDailyAgg { requests: number; } +/** Hour-grain UPSERT input for the v2 usage ledger. */ +export interface LlmUsageHourlyIncrement { + /** UTC hour bucket 'YYYY-MM-DDTHH'. Defaults to the current hour (UTC). */ + hour?: string; + /** Owner id, or 'local' (no-auth) / 'system' (ownerless) sentinel. */ + userId: string; + source: 'gateway' | 'direct'; + /** Real model name (chunk.model), routing-key fallback, or 'unknown'. */ + model: string; + /** Backend server name (gateway backendId / direct host), or 'unknown'. */ + route: string; + tokensIn?: number; + tokensOut?: number; + requests?: number; + /** ISO timestamp for last_updated_at + hour default. Defaults to now. */ + at?: string; +} + +/** + * Raw hour-grain ledger row (no axis collapsed) for the usage API. The API + * re-buckets `hour` into the viewer's local calendar period and groups by + * whichever of source/model/route/user/org the request asked for. + */ +export interface LlmUsageHourlyRow { + hour: string; + userId: string; + source: string; + model: string; + route: string; + tokensIn: number; + tokensOut: number; + requests: number; +} + /** * Coerce an optional limit (tokens_budget / rate_limit_rpm) to either * a positive integer or null. Anything else (undefined, null, 0, @@ -1189,6 +1223,28 @@ export class Repository { CREATE INDEX IF NOT EXISTS idx_llm_usage_daily_user_day ON llm_usage_daily (user_id, day); `); + + // Usage dashboard v2: hour-grain ledger (supersedes llm_usage_daily as the + // write target; daily kept as frozen archive). Mirrors schema.sql + + // migrate.ts (dual-path). Backfill of the daily archive lives in migrate.ts + // so it runs once on upgrade, not on every fresh initSchema. + // Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md + this.db.exec(` + CREATE TABLE IF NOT EXISTS llm_usage_hourly ( + hour TEXT NOT NULL, + user_id TEXT NOT NULL, + source TEXT NOT NULL, + model TEXT NOT NULL, + route TEXT NOT NULL, + tokens_in INTEGER NOT NULL DEFAULT 0, + tokens_out INTEGER NOT NULL DEFAULT 0, + requests INTEGER NOT NULL DEFAULT 0, + last_updated_at TEXT NOT NULL, + PRIMARY KEY (hour, user_id, source, model, route) + ); + CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_hour + ON llm_usage_hourly (user_id, hour); + `); } private ensureColumn(tableName: string, columnName: string, definition: string): void { @@ -3646,6 +3702,105 @@ export class Repository { })); } + /** + * v2 write path: UPSERT per-(hour, user, source, model, route) counters. + * `hour` defaults to the current UTC hour 'YYYY-MM-DDTHH'. Same contract as + * incrementLlmUsage (deltas clamped at zero; usage-less completions still + * bump `requests`). Supersedes incrementLlmUsage as the recorder target. + */ + incrementLlmUsageHourly(params: LlmUsageHourlyIncrement): void { + const tIn = Math.max(0, Math.floor(params.tokensIn ?? 0)); + const tOut = Math.max(0, Math.floor(params.tokensOut ?? 0)); + const reqs = Math.max(0, Math.floor(params.requests ?? 1)); + const ts = params.at ?? new Date().toISOString(); + const hour = params.hour ?? ts.slice(0, 13); + this.db + .prepare( + `INSERT INTO llm_usage_hourly + (hour, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (hour, user_id, source, model, route) DO UPDATE SET + tokens_in = tokens_in + excluded.tokens_in, + tokens_out = tokens_out + excluded.tokens_out, + requests = requests + excluded.requests, + last_updated_at = excluded.last_updated_at`, + ) + .run(hour, params.userId, params.source, params.model, params.route, tIn, tOut, reqs, ts); + } + + /** + * Raw hour-grain rows for the usage dashboard v2, no axis collapsed so the + * API can group by any of source/model/route/user/org and re-bucket into the + * viewer's local timezone. `userId` scopes a non-admin to their own rows. + * Inclusive `fromHour`/`toHour` are 'YYYY-MM-DDTHH' (UTC) — callers widen the + * UTC window by ±1 day before filtering precisely against local days. + */ + queryLlmUsageHourly(opts: { + fromHour: string; + toHour: string; + userId?: string; + }): LlmUsageHourlyRow[] { + const where = ['hour >= ?', 'hour <= ?']; + const args: unknown[] = [opts.fromHour, opts.toHour]; + if (opts.userId !== undefined) { + where.push('user_id = ?'); + args.push(opts.userId); + } + const rows = this.db + .prepare( + `SELECT hour, user_id, source, model, route, tokens_in, tokens_out, requests + FROM llm_usage_hourly + WHERE ${where.join(' AND ')} + ORDER BY hour ASC`, + ) + .all(...args) as Array<{ + hour: string; + user_id: string; + source: string; + model: string; + route: string; + tokens_in: number; + tokens_out: number; + requests: number; + }>; + return rows.map((r) => ({ + hour: r.hour, + userId: r.user_id, + source: r.source, + model: r.model, + route: r.route, + tokensIn: r.tokens_in, + tokensOut: r.tokens_out, + requests: r.requests, + })); + } + + /** + * Map every user to a single org label for the usage dashboard's "by org" + * breakdown. Unions Gitea org cache (user_gitea_orgs) and local orgs + * (local_org_members → local_orgs.name); a multi-org user collapses to the + * alphabetically-first org name (MIN) so the breakdown is deterministic. + * Users with no org (and the 'local'/'system' sentinels) are simply absent — + * the API buckets them under 'no-org'. + */ + getUsageOrgMap(): Map { + const rows = this.db + .prepare( + `SELECT user_id, MIN(org_name) AS org_name FROM ( + SELECT user_id, org_name FROM user_gitea_orgs + UNION ALL + SELECT m.user_id AS user_id, o.name AS org_name + FROM local_org_members m + JOIN local_orgs o ON o.id = m.org_id + ) + GROUP BY user_id`, + ) + .all() as Array<{ user_id: string; org_name: string }>; + const map = new Map(); + for (const r of rows) map.set(r.user_id, r.org_name); + return map; + } + /** Return the underlying Database instance (needed by migrate.ts and session store) */ getDb(): Database.Database { return this.db; diff --git a/src/db/schema.sql b/src/db/schema.sql index 5d33be9..27a3287 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -613,6 +613,28 @@ CREATE TABLE IF NOT EXISTS llm_usage_daily ( CREATE INDEX IF NOT EXISTS idx_llm_usage_daily_user_day ON llm_usage_daily (user_id, day); +-- Usage dashboard v2: hour-grain ledger so the dashboard can re-bucket UTC +-- usage into the viewer's local calendar day/week/month, and group by any of +-- source/model/route/user/org. This supersedes llm_usage_daily as the write +-- target; the daily table is kept as a frozen pre-migration archive and is +-- backfilled into here once (hour = day || 'T00'). user_id keeps the same +-- 'local'/'system' sentinel convention so ON CONFLICT never hits NULL!=NULL. +-- Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md +CREATE TABLE IF NOT EXISTS llm_usage_hourly ( + hour TEXT NOT NULL, -- 'YYYY-MM-DDTHH' (UTC) + user_id TEXT NOT NULL, -- owner id / 'local' / 'system' + source TEXT NOT NULL, -- 'gateway' | 'direct' + model TEXT NOT NULL, -- real model name (chunk.model) + route TEXT NOT NULL, -- backend server (gateway backendId / direct host) + tokens_in INTEGER NOT NULL DEFAULT 0, + tokens_out INTEGER NOT NULL DEFAULT 0, + requests INTEGER NOT NULL DEFAULT 0, + last_updated_at TEXT NOT NULL, + PRIMARY KEY (hour, user_id, source, model, route) +); +CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_hour + ON llm_usage_hourly (user_id, hour); + -- ── Browser Notifications V2: Web Push subscriptions + per-user prefs ─── -- Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md -- endpoint is globally UNIQUE so logging into a different user in the same diff --git a/src/llm/usage-recorder.ts b/src/llm/usage-recorder.ts index 80105e8..eb9037a 100644 --- a/src/llm/usage-recorder.ts +++ b/src/llm/usage-recorder.ts @@ -33,7 +33,7 @@ let recorder: LlmUsageRecorder | null = null; /** * Install (or clear with null) the process-global usage recorder. Called - * once during bootstrap with a thin wrapper over Repository.incrementLlmUsage. + * once during bootstrap with a thin wrapper over Repository.incrementLlmUsageHourly. */ export function setLlmUsageRecorder(fn: LlmUsageRecorder | null): void { recorder = fn; diff --git a/src/worker-bootstrap.ts b/src/worker-bootstrap.ts index d0568ca..fb06a07 100644 --- a/src/worker-bootstrap.ts +++ b/src/worker-bootstrap.ts @@ -124,11 +124,12 @@ export async function start(opts: StartWorkerOptions = {}): Promise { // Install the process-global LLM usage recorder so every OpenAICompatClient // completion (agent loop, title, classify, reflection — gateway + direct) - // lands in the per-user daily ledger. Best-effort: the recorder helper - // swallows write errors so a DB hiccup never kills an agent stream. - // Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md + // lands in the per-user hour-grain ledger. Hour grain lets the dashboard + // re-bucket usage into the viewer's local calendar day. Best-effort: the + // recorder helper swallows write errors so a DB hiccup never kills a stream. + // Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md setLlmUsageRecorder((event) => { - repo.incrementLlmUsage({ + repo.incrementLlmUsageHourly({ userId: event.userId, source: event.source, model: event.model, diff --git a/ui/src/api.ts b/ui/src/api.ts index a397422..733b1ba 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -1111,7 +1111,10 @@ export interface SkillSummary { } export interface SkillDetail extends SkillSummary { + /** Body only (frontmatter stripped) — for read-only preview. */ content: string; + /** Full SKILL.md incl. frontmatter — what the editor must edit/save. */ + raw: string; files: string[]; findings: Array<{ severity: 'medium' | 'high'; pattern: string; match: string; line: number; file?: string }>; maxSeverity: 'high' | 'medium' | 'none'; @@ -1285,17 +1288,19 @@ export async function postTestNotification(): Promise<{ ok: boolean }> { } // ============================================================ -// LLM usage dashboard (per-user, gateway + direct). -// Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md +// LLM usage dashboard v2 (per-user, multi-axis group-by + local timezone). +// Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md export interface UsageCounters { tokensIn: number; tokensOut: number; requests: number; } +export type UsageGroupBy = 'source' | 'model' | 'route' | 'user' | 'org'; + +/** One time bucket: a per-series-key map of counters. Keys come from `keys`. */ export interface UsageBucket { bucket: string; // 'YYYY-MM-DD' | 'YYYY-Www' | 'YYYY-MM' - gateway: UsageCounters; - direct: UsageCounters; + segments: Record; } export interface UsageByUser extends UsageCounters { userId: string; @@ -1306,9 +1311,15 @@ export interface UsageDailyResponse { from: string; to: string; granularity: 'day' | 'week' | 'month'; + groupBy: UsageGroupBy; + tzOffset: number; scope: 'all' | 'self'; + /** Ordered series keys (legend/palette order). */ + keys: string[]; + /** Human labels for keys (only populated for groupBy=user; else key=label). */ + labels: Record; series: UsageBucket[]; - totals: { gateway: UsageCounters; direct: UsageCounters }; + totals: Record; byUser?: UsageByUser[]; // admin / local mode only } @@ -1316,11 +1327,15 @@ export async function getUsageDaily(params: { from?: string; to?: string; granularity?: 'day' | 'week' | 'month'; + groupBy?: UsageGroupBy; + tzOffset?: number; }): Promise { const qs = new URLSearchParams(); if (params.from) qs.set('from', params.from); if (params.to) qs.set('to', params.to); if (params.granularity) qs.set('granularity', params.granularity); + if (params.groupBy) qs.set('groupBy', params.groupBy); + if (params.tzOffset !== undefined) qs.set('tzOffset', String(params.tzOffset)); const q = qs.toString(); const res = await fetch(`${BASE}/usage/daily${q ? `?${q}` : ''}`); if (!res.ok) throw new Error(`Failed to load usage (${res.status})`); diff --git a/ui/src/components/settings/SkillsForm.tsx b/ui/src/components/settings/SkillsForm.tsx index 3b922f4..67b9e83 100644 --- a/ui/src/components/settings/SkillsForm.tsx +++ b/ui/src/components/settings/SkillsForm.tsx @@ -146,7 +146,9 @@ export function SkillsForm() { const handleStartEdit = () => { if (detailQuery.data) { - setEditContent(detailQuery.data.content); + // Edit the FULL file (frontmatter + body). Loading body-only `content` + // and saving it back drops the frontmatter and deletes the skill. + setEditContent(detailQuery.data.raw); setEditMode(true); setError(null); } @@ -370,7 +372,7 @@ export function SkillsForm() { ) : (
-                  {detailQuery.data.content}
+                  {detailQuery.data.raw}
                 
)} diff --git a/ui/src/components/usage/UsagePage.tsx b/ui/src/components/usage/UsagePage.tsx index 05ca229..e7f4800 100644 --- a/ui/src/components/usage/UsagePage.tsx +++ b/ui/src/components/usage/UsagePage.tsx @@ -1,13 +1,22 @@ import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; -import { getUsageDaily, type UsageBucket, type UsageCounters } from '../../api'; +import { getUsageDaily, type UsageBucket, type UsageCounters, type UsageGroupBy } from '../../api'; type Preset = 'last7' | 'last30' | 'last90' | 'ytd' | 'custom'; type Gran = 'day' | 'week' | 'month'; -function utcToday(): string { - return new Date().toISOString().slice(0, 10); +/** Viewer's local calendar today as 'YYYY-MM-DD' (the server re-buckets UTC + * hours into this local frame via tzOffset). */ +function localToday(): string { + const d = new Date(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${d.getFullYear()}-${mm}-${dd}`; +} +/** Minutes east of UTC for the viewer (JST → +540). */ +function localTzOffset(): number { + return -new Date().getTimezoneOffset(); } function shiftDay(day: string, delta: number): string { const x = new Date(`${day}T00:00:00.000Z`); @@ -15,7 +24,7 @@ function shiftDay(day: string, delta: number): string { return x.toISOString().slice(0, 10); } function yearStart(): string { - return `${new Date().getUTCFullYear()}-01-01`; + return `${new Date().getFullYear()}-01-01`; } function fmtTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`; @@ -28,6 +37,12 @@ function total(c: UsageCounters): number { function zeroCounters(): UsageCounters { return { tokensIn: 0, tokensOut: 0, requests: 0 }; } +/** Total tokens across all series segments in a bucket. */ +function bucketTotal(b: UsageBucket): number { + let s = 0; + for (const k of Object.keys(b.segments)) s += total(b.segments[k]); + return s; +} // Mirror the server's bucket keys (usage-api.ts) so we can fill empty buckets // and keep the chart's time axis linear instead of index-based. @@ -57,7 +72,7 @@ function denseBuckets(series: UsageBucket[], from: string, to: string, g: Gran): const key = bucketKey(day, g); if (!seen.has(key)) { seen.add(key); - out.push(byKey.get(key) ?? { bucket: key, gateway: zeroCounters(), direct: zeroCounters() }); + out.push(byKey.get(key) ?? { bucket: key, segments: {} }); } day = shiftDay(day, 1); } @@ -65,7 +80,7 @@ function denseBuckets(series: UsageBucket[], from: string, to: string, g: Gran): } function rangeFor(preset: Preset, customFrom: string, customTo: string): { from: string; to: string } { - const to = utcToday(); + const to = localToday(); switch (preset) { case 'last7': return { from: shiftDay(to, -6), to }; case 'last90': return { from: shiftDay(to, -89), to }; @@ -76,27 +91,43 @@ function rangeFor(preset: Preset, customFrom: string, customTo: string): { from: } } -const GW = '#6366f1'; // indigo-500 — gateway -const DR = '#22c55e'; // green-500 — direct +const GW = '#6366f1'; // indigo-500 — gateway (source axis) +const DR = '#22c55e'; // green-500 — direct (source axis) +const OTHER_COLOR = '#94a3b8'; // slate-400 — folded 'other' bucket +// Distinct palette for dynamic axes (model / route / user / org). +const PALETTE = [ + '#6366f1', '#22c55e', '#f59e0b', '#ec4899', '#06b6d4', '#a855f7', + '#ef4444', '#14b8a6', '#eab308', '#3b82f6', '#f97316', '#8b5cf6', +]; + +function colorFor(key: string, index: number, groupBy: UsageGroupBy): string { + if (key === 'other') return OTHER_COLOR; + if (groupBy === 'source') return key === 'gateway' ? GW : DR; + return PALETTE[index % PALETTE.length]; +} /** i18next t with interpolation support (widened from the bare key signature). */ type TFn = (key: string, opts?: Record) => string; +const GROUP_BYS: UsageGroupBy[] = ['source', 'model', 'route', 'user', 'org']; + export function UsagePage() { const { t } = useTranslation('usage'); const [preset, setPreset] = useState('last30'); const [granularity, setGranularity] = useState('day'); + const [groupBy, setGroupBy] = useState('source'); const [customFrom, setCustomFrom] = useState(''); const [customTo, setCustomTo] = useState(''); const { from, to } = rangeFor(preset, customFrom, customTo); + const tzOffset = localTzOffset(); // Client-side guard: don't fire a request the server would 400 on; show an // inline message instead of a generic error. const customInvalid = preset === 'custom' && !!customFrom && !!customTo && customFrom > customTo; const { data, isLoading, error } = useQuery({ - queryKey: ['usage-daily', from, to, granularity], - queryFn: () => getUsageDaily({ from, to, granularity }), + queryKey: ['usage-daily', from, to, granularity, groupBy, tzOffset], + queryFn: () => getUsageDaily({ from, to, granularity, groupBy, tzOffset }), enabled: !customInvalid, }); @@ -104,23 +135,42 @@ export function UsagePage() { const grans: Gran[] = ['day', 'week', 'month']; const series: UsageBucket[] = data?.series ?? []; + const keys = data?.keys ?? []; // Gap-free buckets so the bar/line x-axis is time-linear, not index-based. const dense = useMemo( () => (data ? denseBuckets(series, data.from, data.to, granularity) : []), [series, data, granularity], ); const maxBucket = useMemo( - () => dense.reduce((m, b) => Math.max(m, total(b.gateway) + total(b.direct)), 0), + () => dense.reduce((m, b) => Math.max(m, bucketTotal(b)), 0), [dense], ); - const cumulative = useMemo(() => { - let run = 0; - return dense.map((b) => { - run += total(b.gateway) + total(b.direct); - return run; - }); - }, [dense]); - const maxCumulative = cumulative.length ? cumulative[cumulative.length - 1] : 0; + // Per-series running cumulative (one independent line each). + // cumByKey[key][i] = that series' cumulative tokens through bucket i. The + // shared y-axis is the largest single-series final cumulative. + const { cumByKey, maxCumulative, grandCumulative } = useMemo(() => { + const run: Record = {}; + const acc: Record = {}; + for (const k of keys) { run[k] = 0; acc[k] = []; } + for (const b of dense) { + for (const k of keys) { + run[k] += total(b.segments[k] ?? zeroCounters()); + acc[k].push(run[k]); + } + } + const maxLine = keys.reduce((m, k) => Math.max(m, run[k] ?? 0), 0); + const grand = keys.reduce((s, k) => s + (run[k] ?? 0), 0); + return { cumByKey: acc, maxCumulative: maxLine, grandCumulative: grand }; + }, [dense, keys]); + + // Display label for a series key (resolve user ids, localize sentinels). + const keyLabel = (key: string): string => { + if (data?.labels?.[key]) return data.labels[key]; + if (groupBy === 'source') return t(key === 'gateway' ? 'chart.legendGateway' : 'chart.legendDirect'); + if (key === 'no-org') return t('axis.noOrg'); + if (key === 'other') return t('axis.other'); + return key; + }; return (
@@ -130,7 +180,7 @@ export function UsagePage() {

{t('subtitle')}

- {/* Controls */} + {/* Controls: range presets + custom + granularity */}
{presets.map((p) => ( @@ -176,22 +226,41 @@ export function UsagePage() {
+ {/* Group-by selector — the breakdown dimension for every chart below. */} +
+ {t('groupBy.label')} + {GROUP_BYS.map((gb) => ( + + ))} +
+ {customInvalid &&
{t('range.invalid')}
} {!customInvalid && isLoading &&
{t('loading')}
} {!customInvalid && error &&
{t('error')}
} {!customInvalid && data && ( <> - + - {series.length === 0 ? ( + {series.length === 0 || keys.length === 0 ? (
{t('empty')}
) : ( <> - - + + + )} @@ -203,17 +272,27 @@ export function UsagePage() { ); } -function TotalsCards({ totals, t }: { totals: { gateway: UsageCounters; direct: UsageCounters }; t: TFn }) { - const combined: UsageCounters = { - tokensIn: totals.gateway.tokensIn + totals.direct.tokensIn, - tokensOut: totals.gateway.tokensOut + totals.direct.tokensOut, - requests: totals.gateway.requests + totals.direct.requests, - }; +function TotalsCards({ + totals, keys, groupBy, keyLabel, t, +}: { + totals: Record; + keys: string[]; + groupBy: UsageGroupBy; + keyLabel: (k: string) => string; + t: TFn; +}) { + const combined: UsageCounters = { tokensIn: 0, tokensOut: 0, requests: 0 }; + for (const k of keys) { + const c = totals[k] ?? zeroCounters(); + combined.tokensIn += c.tokensIn; + combined.tokensOut += c.tokensOut; + combined.requests += c.requests; + } const card = (label: string, c: UsageCounters, dot?: string) => (
{dot && } - {label} + {label}
@@ -231,44 +310,77 @@ function TotalsCards({ totals, t }: { totals: { gateway: UsageCounters; direct:
); + // Combined card always; for the source axis keep the familiar gateway/direct + // pair. For dynamic axes show the top few series so the cards don't explode. + const detailKeys = groupBy === 'source' ? keys : keys.slice(0, 2); return (
{card(t('totals.combined'), combined)} - {card(t('totals.gateway'), totals.gateway, GW)} - {card(t('totals.direct'), totals.direct, DR)} + {detailKeys.map((k, i) => card(keyLabel(k), totals[k] ?? zeroCounters(), colorFor(k, i, groupBy)))}
); } -function StackedBars({ series, maxBucket, t }: { series: UsageBucket[]; maxBucket: number; t: TFn }) { - const grand = series.reduce((s, b) => s + total(b.gateway) + total(b.direct), 0); +function SeriesLegend({ + keys, groupBy, keyLabel, +}: { + keys: string[]; + groupBy: UsageGroupBy; + keyLabel: (k: string) => string; +}) { + return ( +
+ {keys.map((k, i) => ( + + + {keyLabel(k)} + + ))} +
+ ); +} + +function StackedBars({ + series, keys, maxBucket, groupBy, keyLabel, t, +}: { + series: UsageBucket[]; + keys: string[]; + maxBucket: number; + groupBy: UsageGroupBy; + keyLabel: (k: string) => string; + t: TFn; +}) { + const grand = series.reduce((s, b) => s + bucketTotal(b), 0); const ariaLabel = t('chart.barsAria', { title: t('chart.tokensTitle'), total: fmtTokens(grand), count: series.length }); return (
{t('chart.tokensTitle')} -
{series.map((b) => { - const g = total(b.gateway); - const d = total(b.direct); - const sum = g + d; + const sum = bucketTotal(b); const hPct = maxBucket > 0 ? (sum / maxBucket) * 100 : 0; - const gPct = sum > 0 ? (g / sum) * 100 : 0; return (
0 ? 2 : 0)}%` }}> -
-
+ {keys.map((k, i) => { + const v = total(b.segments[k] ?? zeroCounters()); + const segPct = sum > 0 ? (v / sum) * 100 : 0; + return segPct > 0 ?
: null; + })}
{/* Tooltip (decorative — chart summary is exposed via aria-label) */}
); @@ -282,31 +394,40 @@ function StackedBars({ series, maxBucket, t }: { series: UsageBucket[]; maxBucke ); } -function CumulativeLine({ series, cumulative, max, t }: { series: UsageBucket[]; cumulative: number[]; max: number; t: TFn }) { +function CumulativeLines({ + series, keys, cumByKey, max, grand, groupBy, t, +}: { + series: UsageBucket[]; + keys: string[]; + cumByKey: Record; + max: number; + grand: number; + groupBy: UsageGroupBy; + t: TFn; +}) { const W = 600; const H = 140; const pad = 4; - const n = cumulative.length; - const coords = cumulative.map((v, i) => { - const x = n <= 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad); - const y = max > 0 ? H - pad - (v / max) * (H - 2 * pad) : H - pad; - return { x, y }; - }); - const points = coords.map((c) => `${c.x.toFixed(1)},${c.y.toFixed(1)}`).join(' '); - const ariaLabel = t('chart.lineAria', { total: fmtTokens(max) }); + const n = series.length; + const xAt = (i: number) => (n <= 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad)); + const yAt = (v: number) => (max > 0 ? H - pad - (v / max) * (H - 2 * pad) : H - pad); + const ariaLabel = t('chart.lineAria', { total: fmtTokens(grand) }); return (
{t('chart.cumulativeTitle')} - {t('chart.total', { value: fmtTokens(max) })} + {t('chart.total', { value: fmtTokens(grand) })}
- {/* A single bucket can't form a line — draw the point so it's visible. */} - {n === 1 ? ( - - ) : ( - - )} + {keys.map((k, ki) => { + const vals = cumByKey[k] ?? []; + const color = colorFor(k, ki, groupBy); + if (n === 1) { + return ; + } + const pts = vals.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(' '); + return ; + })}
{series[0]?.bucket} @@ -316,15 +437,6 @@ function CumulativeLine({ series, cumulative, max, t }: { series: UsageBucket[]; ); } -function Legend({ t }: { t: TFn }) { - return ( -
- {t('chart.legendGateway')} - {t('chart.legendDirect')} -
- ); -} - function ByUserTable({ rows, t }: { rows: Array<{ userId: string; displayName: string } & UsageCounters>; t: TFn }) { // Localize the sentinels; real users show their resolved name with the id as // a secondary line. diff --git a/ui/src/i18n/locales/en/usage.json b/ui/src/i18n/locales/en/usage.json index 46e97e8..826ca15 100644 --- a/ui/src/i18n/locales/en/usage.json +++ b/ui/src/i18n/locales/en/usage.json @@ -1,6 +1,6 @@ { "title": "LLM Usage", - "subtitle": "Combined token usage across AAO Gateway and direct calls, aggregated per UTC day. This is a separate view from the gateway per-key billing panel.", + "subtitle": "Combined token usage across AAO Gateway and direct calls, bucketed by your local calendar day. Break it down by source, model, backend, user, or organization. This is a separate view from the gateway per-key billing panel.", "range": { "last7": "Last 7 days", "last30": "Last 30 days", @@ -15,6 +15,18 @@ "week": "Week", "month": "Month" }, + "groupBy": { + "label": "Break down by", + "source": "Route (Gateway/Direct)", + "model": "Model", + "route": "Backend", + "user": "User", + "org": "Organization" + }, + "axis": { + "noOrg": "No organization", + "other": "Other" + }, "totals": { "input": "Input tokens", "output": "Output tokens", @@ -24,7 +36,7 @@ "combined": "Combined" }, "chart": { - "tokensTitle": "Tokens by period (gateway vs direct)", + "tokensTitle": "Tokens by period", "cumulativeTitle": "Cumulative tokens", "legendGateway": "Gateway", "legendDirect": "Direct", diff --git a/ui/src/i18n/locales/ja/usage.json b/ui/src/i18n/locales/ja/usage.json index 73817fb..cf8f3aa 100644 --- a/ui/src/i18n/locales/ja/usage.json +++ b/ui/src/i18n/locales/ja/usage.json @@ -1,6 +1,6 @@ { "title": "LLM 使用量", - "subtitle": "AAO Gateway 経由と Direct を合算したトークン使用量を UTC 日次で集計します。ゲートウェイのキー別課金パネルとは別集計です。", + "subtitle": "AAO Gateway 経由と Direct を合算したトークン使用量を、お使いの環境のローカル日付で集計します。経路・モデル・バックエンド・ユーザー・組織で内訳を切り替えられます。ゲートウェイのキー別課金パネルとは別集計です。", "range": { "last7": "直近7日", "last30": "直近30日", @@ -15,6 +15,18 @@ "week": "週", "month": "月" }, + "groupBy": { + "label": "内訳", + "source": "経路(Gateway / Direct)", + "model": "モデル", + "route": "バックエンド", + "user": "ユーザー", + "org": "組織" + }, + "axis": { + "noOrg": "組織なし", + "other": "その他" + }, "totals": { "input": "入力トークン", "output": "出力トークン", @@ -24,7 +36,7 @@ "combined": "合計" }, "chart": { - "tokensTitle": "期間別トークン(Gateway / Direct)", + "tokensTitle": "期間別トークン", "cumulativeTitle": "累積トークン", "legendGateway": "Gateway", "legendDirect": "Direct",