toknmtr-selfhost/src/routes/api/usage/+server.ts
megaproxy 71a60ab054 toknmtr — self-hostable Claude Code usage & analytics dashboard
Server (SvelteKit + SQLite Docker container) + per-machine agent that parses
Claude Code JSONL transcripts. docker-compose one-command deploy; raw transcript
view + search gated behind SHOW_TRANSCRIPTS (hidden by default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:33:55 +01:00

94 lines
3.2 KiB
TypeScript

import { json, error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { db } from '$lib/server/db';
import type { RequestHandler } from './$types';
/**
* Usage-gauge ingest. The agent PTY-drives `claude /usage` (see agent/usage.ts) and POSTs the
* three subscription bars here. Separate from /api/ingest because the shape and cadence differ
* (a slow, ~5-9s scrape done once per run, not per JSONL sweep).
*
* Auth: `Authorization: Bearer $API_TOKEN` (same token as /api/ingest).
*
* Accepted body — either a single gauge object or a batch:
* { host, ts_utc, session_pct?, week_all_pct?, week_sonnet_pct? }
* { host?, gauges: GaugeRow[] } // host is a fallback for rows that omit their own
*
* Rows upsert by (host, ts_utc) — re-posting the same scrape is idempotent.
*/
interface IncomingGauge {
host?: unknown;
ts_utc?: unknown;
session_pct?: unknown;
week_all_pct?: unknown;
week_sonnet_pct?: unknown;
}
interface UsageBody extends IncomingGauge {
gauges?: unknown;
}
function str(v: unknown): string | null {
return typeof v === 'string' && v.length > 0 ? v : null;
}
function num(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null;
}
// Lazily prepared on first request so importing this module (e.g. the build's
// analyse pass, which has no writable DB dir) never opens the database.
type Stmt = import('better-sqlite3').Statement<unknown[]>;
let prepared = false;
let upsertGaugeStmt!: Stmt;
let insertMany!: (rows: IncomingGauge[], fallbackHost: string | null) => number;
function ensurePrepared() {
if (prepared) return;
const conn = db();
upsertGaugeStmt = conn.prepare(`
INSERT INTO usage_gauges (host, ts_utc, session_pct, week_all_pct, week_sonnet_pct)
VALUES (@host, @ts_utc, @session_pct, @week_all_pct, @week_sonnet_pct)
ON CONFLICT(host, ts_utc) DO UPDATE SET
session_pct = COALESCE(excluded.session_pct, session_pct),
week_all_pct = COALESCE(excluded.week_all_pct, week_all_pct),
week_sonnet_pct = COALESCE(excluded.week_sonnet_pct, week_sonnet_pct)
`);
insertMany = conn.transaction((rows: IncomingGauge[], fallbackHost: string | null) => {
let count = 0;
for (const g of rows) {
const host = str(g.host) ?? fallbackHost;
const ts_utc = str(g.ts_utc);
if (!host || !ts_utc) continue; // incomplete PK — skip
upsertGaugeStmt.run({
host,
ts_utc,
session_pct: num(g.session_pct),
week_all_pct: num(g.week_all_pct),
week_sonnet_pct: num(g.week_sonnet_pct)
});
count++;
}
return count;
});
prepared = true;
}
export const POST: RequestHandler = async ({ request }) => {
const token = env.API_TOKEN;
if (!token) throw error(500, 'server missing API_TOKEN');
if (request.headers.get('authorization') !== `Bearer ${token}`) throw error(401, 'unauthorized');
const body = (await request.json().catch(() => null)) as UsageBody | null;
if (!body || typeof body !== 'object') throw error(400, 'invalid JSON body');
ensurePrepared();
const rows = Array.isArray(body.gauges) ? (body.gauges as IncomingGauge[]) : [body];
const count = insertMany(rows, str(body.host));
return json({ ok: true, gauges: count });
};
export const GET: RequestHandler = async () => json({ ok: true, service: 'toknmtr usage' });