sync: update from private repo (bfcd4d5)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
c5be399fdd
commit
641fe0177d
@ -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', () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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, string>,
|
||||
): 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=<min>
|
||||
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<string, { gateway: Counters; direct: Counters }>();
|
||||
const totals = { gateway: emptyCounters(), direct: emptyCounters() };
|
||||
const orgMap = groupBy === 'org' ? repo.getUsageOrgMap() : new Map<string, string>();
|
||||
|
||||
// First pass: accumulate per (bucket, dimension) and per-dimension totals,
|
||||
// plus the admin per-user table (independent of groupBy).
|
||||
const buckets = new Map<string, Map<string, Counters>>();
|
||||
const totals = new Map<string, Counters>();
|
||||
const byUser = new Map<string, Counters>();
|
||||
|
||||
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<string, string> = {};
|
||||
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<string, Counters> = {};
|
||||
for (const k of keys) segments[k] = seg.get(k) ?? emptyCounters();
|
||||
return { bucket, segments };
|
||||
});
|
||||
|
||||
const totalsOut: Record<string, Counters> = {};
|
||||
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())
|
||||
|
||||
@ -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.
|
||||
|
||||
144
src/db/repository.llm-usage-hourly.test.ts
Normal file
144
src/db/repository.llm-usage-hourly.test.ts
Normal file
@ -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 });
|
||||
});
|
||||
});
|
||||
@ -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<string, string> {
|
||||
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<string, string>();
|
||||
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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -124,11 +124,12 @@ export async function start(opts: StartWorkerOptions = {}): Promise<void> {
|
||||
|
||||
// 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,
|
||||
|
||||
@ -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<string, UsageCounters>;
|
||||
}
|
||||
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<string, string>;
|
||||
series: UsageBucket[];
|
||||
totals: { gateway: UsageCounters; direct: UsageCounters };
|
||||
totals: Record<string, UsageCounters>;
|
||||
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<UsageDailyResponse> {
|
||||
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})`);
|
||||
|
||||
@ -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() {
|
||||
</div>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-xs font-mono text-slate-700 bg-surface/50 border border-hairline rounded p-3 max-h-[400px] overflow-y-auto">
|
||||
{detailQuery.data.content}
|
||||
{detailQuery.data.raw}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
|
||||
@ -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, unknown>) => string;
|
||||
|
||||
const GROUP_BYS: UsageGroupBy[] = ['source', 'model', 'route', 'user', 'org'];
|
||||
|
||||
export function UsagePage() {
|
||||
const { t } = useTranslation('usage');
|
||||
const [preset, setPreset] = useState<Preset>('last30');
|
||||
const [granularity, setGranularity] = useState<Gran>('day');
|
||||
const [groupBy, setGroupBy] = useState<UsageGroupBy>('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<string, number> = {};
|
||||
const acc: Record<string, number[]> = {};
|
||||
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 (
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
@ -130,7 +180,7 @@ export function UsagePage() {
|
||||
<p className="text-[13px] text-slate-500 dark:text-slate-400 mt-1">{t('subtitle')}</p>
|
||||
</header>
|
||||
|
||||
{/* Controls */}
|
||||
{/* Controls: range presets + custom + granularity */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex gap-1">
|
||||
{presets.map((p) => (
|
||||
@ -176,22 +226,41 @@ export function UsagePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group-by selector — the breakdown dimension for every chart below. */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<span className="text-xs text-slate-400 mr-1">{t('groupBy.label')}</span>
|
||||
{GROUP_BYS.map((gb) => (
|
||||
<button
|
||||
key={gb}
|
||||
onClick={() => setGroupBy(gb)}
|
||||
className={`px-2 py-1 text-xs rounded-md border transition-colors ${
|
||||
groupBy === gb
|
||||
? 'bg-accent text-white border-accent'
|
||||
: 'border-hairline text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{t(`groupBy.${gb}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{customInvalid && <div className="text-sm text-amber-600 dark:text-amber-400">{t('range.invalid')}</div>}
|
||||
{!customInvalid && isLoading && <div className="text-sm text-slate-400 italic">{t('loading')}</div>}
|
||||
{!customInvalid && error && <div className="text-sm text-red-600">{t('error')}</div>}
|
||||
|
||||
{!customInvalid && data && (
|
||||
<>
|
||||
<TotalsCards totals={data.totals} t={t} />
|
||||
<TotalsCards totals={data.totals} keys={keys} groupBy={groupBy} keyLabel={keyLabel} t={t} />
|
||||
|
||||
{series.length === 0 ? (
|
||||
{series.length === 0 || keys.length === 0 ? (
|
||||
<div className="border border-hairline rounded-lg p-8 text-center text-sm text-slate-400 italic">
|
||||
{t('empty')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<StackedBars series={dense} maxBucket={maxBucket} t={t} />
|
||||
<CumulativeLine series={dense} cumulative={cumulative} max={maxCumulative} t={t} />
|
||||
<SeriesLegend keys={keys} groupBy={groupBy} keyLabel={keyLabel} />
|
||||
<StackedBars series={dense} keys={keys} maxBucket={maxBucket} groupBy={groupBy} keyLabel={keyLabel} t={t} />
|
||||
<CumulativeLines series={dense} keys={keys} cumByKey={cumByKey} max={maxCumulative} grand={grandCumulative} groupBy={groupBy} t={t} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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<string, UsageCounters>;
|
||||
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) => (
|
||||
<div className="border border-hairline rounded-lg p-3">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
{dot && <span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: dot }} />}
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{label}</span>
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide truncate">{label}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<div>
|
||||
@ -231,44 +310,77 @@ function TotalsCards({ totals, t }: { totals: { gateway: UsageCounters; direct:
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// 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 (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{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)))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-3 flex-wrap text-[11px] text-slate-500">
|
||||
{keys.map((k, i) => (
|
||||
<span key={k} className="flex items-center gap-1">
|
||||
<span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: colorFor(k, i, groupBy) }} />
|
||||
<span className="truncate max-w-[160px]">{keyLabel(k)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="border border-hairline rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{t('chart.tokensTitle')}</span>
|
||||
<Legend t={t} />
|
||||
</div>
|
||||
<div className="flex items-end gap-1 h-44" role="img" aria-label={ariaLabel}>
|
||||
{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 (
|
||||
<div key={b.bucket} className="flex-1 min-w-0 flex flex-col items-center group relative">
|
||||
<div className="w-full flex flex-col justify-end" style={{ height: '100%' }}>
|
||||
<div className="w-full rounded-t-sm overflow-hidden flex flex-col" style={{ height: `${Math.max(hPct, sum > 0 ? 2 : 0)}%` }}>
|
||||
<div style={{ height: `${gPct}%`, background: GW }} />
|
||||
<div style={{ height: `${100 - gPct}%`, background: DR }} />
|
||||
{keys.map((k, i) => {
|
||||
const v = total(b.segments[k] ?? zeroCounters());
|
||||
const segPct = sum > 0 ? (v / sum) * 100 : 0;
|
||||
return segPct > 0 ? <div key={k} style={{ height: `${segPct}%`, background: colorFor(k, i, groupBy) }} /> : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Tooltip (decorative — chart summary is exposed via aria-label) */}
|
||||
<div aria-hidden="true" className="pointer-events-none absolute bottom-full mb-1 hidden group-hover:block z-10 whitespace-nowrap bg-slate-800 text-white text-[10px] rounded px-1.5 py-1 shadow">
|
||||
<div className="font-mono">{b.bucket}</div>
|
||||
<div><span style={{ color: GW }}>■</span> {fmtTokens(g)}</div>
|
||||
<div><span style={{ color: DR }}>■</span> {fmtTokens(d)}</div>
|
||||
<div className="font-mono mb-0.5">{b.bucket}</div>
|
||||
{keys.map((k, i) => {
|
||||
const v = total(b.segments[k] ?? zeroCounters());
|
||||
return v > 0 ? (
|
||||
<div key={k}><span style={{ color: colorFor(k, i, groupBy) }}>■</span> {keyLabel(k)}: {fmtTokens(v)}</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -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<string, number[]>;
|
||||
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 (
|
||||
<div className="border border-hairline rounded-lg p-4">
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{t('chart.cumulativeTitle')}</span>
|
||||
<span className="text-[11px] text-slate-500 font-mono">{t('chart.total', { value: fmtTokens(max) })}</span>
|
||||
<span className="text-[11px] text-slate-500 font-mono">{t('chart.total', { value: fmtTokens(grand) })}</span>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-36" role="img" aria-label={ariaLabel}>
|
||||
{/* A single bucket can't form a line — draw the point so it's visible. */}
|
||||
{n === 1 ? (
|
||||
<circle cx={coords[0].x} cy={coords[0].y} r={3} fill="var(--accent, #6366f1)" vectorEffect="non-scaling-stroke" />
|
||||
) : (
|
||||
<polyline points={points} fill="none" stroke="var(--accent, #6366f1)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||
)}
|
||||
{keys.map((k, ki) => {
|
||||
const vals = cumByKey[k] ?? [];
|
||||
const color = colorFor(k, ki, groupBy);
|
||||
if (n === 1) {
|
||||
return <circle key={k} cx={xAt(0)} cy={yAt(vals[0] ?? 0)} r={3} fill={color} vectorEffect="non-scaling-stroke" />;
|
||||
}
|
||||
const pts = vals.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(' ');
|
||||
return <polyline key={k} points={pts} fill="none" stroke={color} strokeWidth={2} vectorEffect="non-scaling-stroke" />;
|
||||
})}
|
||||
</svg>
|
||||
<div className="flex justify-between mt-1 text-[10px] text-slate-400 font-mono">
|
||||
<span>{series[0]?.bucket}</span>
|
||||
@ -316,15 +437,6 @@ function CumulativeLine({ series, cumulative, max, t }: { series: UsageBucket[];
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ t }: { t: TFn }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-[11px] text-slate-500">
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: GW }} />{t('chart.legendGateway')}</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: DR }} />{t('chart.legendDirect')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user