diff --git a/lib/api/index.js b/lib/api/index.js index f00457d..f7e3e90 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -31,6 +31,7 @@ import { router as healthRouter } from './routes/health.js'; import { router as securityRouter } from './routes/security.js'; import { router as actionsRouter } from './routes/actions.js'; import { router as littleblueRouter } from './routes/littleblue.js'; +import { router as aiUsageRouter } from './routes/ai_usage.js'; export function mountApi(app) { const api = Router(); @@ -45,6 +46,7 @@ export function mountApi(app) { api.use('/security', securityRouter); api.use('/actions', actionsRouter); api.use('/little-blue', littleblueRouter); + api.use('/ai-usage', aiUsageRouter); api.use('/projects', projectsRouter); api.use('/projects/:project_id/tasks', tasksByProjectRouter); api.use('/tasks', tasksRouter); diff --git a/lib/api/routes/ai_usage.js b/lib/api/routes/ai_usage.js new file mode 100644 index 0000000..f7ab0af --- /dev/null +++ b/lib/api/routes/ai_usage.js @@ -0,0 +1,55 @@ +import { Router } from 'express'; +import { asyncWrap } from '../errors.js'; + +// Surfaces the Homelab Monitor (phuryn/claude-usage fork) on CT 300 as a compact +// summary for the Sacred Valley "AI Usage" card. The monitor owns the data +// (scans ~/.claude/**/*.jsonl + OpenClaw trajectories → usage.db); we just fetch +// + condense its API over the LAN. +const MON = () => process.env.AI_USAGE_URL || 'http://192.168.1.212:8080'; + +// Pure: condense the monitor's /api/data + /api/openclaw into the card summary. +export function summarizeUsage(data, openclaw) { + const rows = (data && data.daily_by_model) || []; + const days = [...new Set(rows.map(r => r.day))].sort(); + const today = days[days.length - 1] || null; + const weekDays = new Set(days.slice(-7)); + const acc = (pred) => rows.filter(pred).reduce((a, r) => ({ + input: a.input + (r.input || 0), + output: a.output + (r.output || 0), + cache: a.cache + (r.cache_read || 0) + (r.cache_creation || 0), + turns: a.turns + (r.turns || 0) + }), { input: 0, output: 0, cache: 0, turns: 0 }); + + const modelTokens = {}; + for (const r of rows) if (weekDays.has(r.day)) modelTokens[r.model] = (modelTokens[r.model] || 0) + (r.input || 0) + (r.output || 0); + const topModel = Object.entries(modelTokens).sort((a, b) => b[1] - a[1])[0]?.[0] || null; + + const byModel = (openclaw && openclaw.summary && openclaw.summary.by_model) || []; + const sorted = [...byModel].sort((a, b) => (b.count || 0) - (a.count || 0)); + const clean = (m) => m.replace(/^ollama\//, ''); + const local = { + runs: byModel.reduce((a, m) => a + (m.count || 0), 0), + models: sorted.map(m => ({ model: clean(m.model), count: m.count, p50_ms: m.p50_ms, p95_ms: m.p95_ms, error_rate: m.error_rate })), + top: sorted[0] ? { model: clean(sorted[0].model), p50_ms: sorted[0].p50_ms, p95_ms: sorted[0].p95_ms, error_rate: sorted[0].error_rate } : null + }; + + return { + ok: true, + generated_at: data?.generated_at || null, + today, + claude: { today: acc(r => r.day === today), week: acc(r => weekDays.has(r.day)), top_model: topModel }, + local + }; +} + +export const router = Router(); + +router.get('/', asyncWrap(async (_req, res) => { + const base = MON(); + const [data, openclaw] = await Promise.all([ + fetch(`${base}/api/data`, { signal: AbortSignal.timeout(5000) }).then(r => r.ok ? r.json() : null).catch(() => null), + fetch(`${base}/api/openclaw`, { signal: AbortSignal.timeout(5000) }).then(r => r.ok ? r.json() : null).catch(() => null) + ]); + if (!data) return res.json({ ok: false, error: 'monitor unreachable', url: base }); + res.json(summarizeUsage(data, openclaw)); +})); diff --git a/public/style.css b/public/style.css index 136d14a..297ddea 100644 --- a/public/style.css +++ b/public/style.css @@ -189,6 +189,12 @@ button.ghost:hover { color: var(--text); border-color: var(--accent-dim); } .rev-modal .md-preview { padding: 16px 20px; overflow-y: auto; flex: 1; } .rev-modal-foot { padding: 12px 16px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; } +/* AI Usage card */ +.aiu-sec { margin-bottom: 12px; } +.aiu-h { font-family: var(--font-display); font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--muted); margin-bottom: 6px; border-bottom: 1px solid var(--border); padding-bottom: 4px; } +.aiu-link { display: inline-block; margin-top: 4px; font-size: 11px; color: var(--accent); text-decoration: none; } +.aiu-link:hover { text-decoration: underline; } + /* modal */ .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; } .modal { diff --git a/public/views/cards/ai_usage.js b/public/views/cards/ai_usage.js new file mode 100644 index 0000000..9432bfa --- /dev/null +++ b/public/views/cards/ai_usage.js @@ -0,0 +1,41 @@ +// Sacred Valley card: AI Usage — Claude Code token usage + local (OpenClaw/Ollama) +// performance, summarised from the Homelab Monitor via /api/ai-usage. +import { el, mount } from '../../dom.js'; +import { api } from '../../api.js'; + +let body; +const fmt = (n) => { n = Number(n) || 0; if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'; return String(n); }; +const dur = (ms) => ms == null ? '—' : (ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : Math.round(ms) + 'ms'); +const mono = (t, color) => el('span', { style: { fontFamily: 'var(--font-mono)', ...(color ? { color } : {}) } }, t); + +async function load() { + if (!body) return; + try { + const u = await api.get('/api/ai-usage'); + if (!u.ok) { mount(body, el('span', { class: 'muted' }, 'Monitor unreachable (' + (u.url || '') + ')')); return; } + const c = u.claude, l = u.local; + mount(body, + el('div', { class: 'aiu-sec' }, + el('div', { class: 'aiu-h' }, 'Claude Code'), + el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'today'), mono(`${fmt(c.today.input)}↑ ${fmt(c.today.output)}↓`)), + el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'cache · turns'), mono(`${fmt(c.today.cache)} · ${c.today.turns}`)), + el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'week'), mono(`${fmt(c.week.input + c.week.output)} tok`)), + el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'top model'), mono((c.top_model || '—').replace(/^claude-/, ''), 'var(--accent)')) + ), + el('div', { class: 'aiu-sec' }, + el('div', { class: 'aiu-h' }, 'Local · OpenClaw'), + l.top + ? el('div', {}, + el('div', { class: 'sv-row' }, el('span', { class: 'k' }, l.top.model), mono(`${dur(l.top.p50_ms)} p50 · ${dur(l.top.p95_ms)} p95`)), + el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'runs · err'), mono(`${l.runs} · ${(l.top.error_rate * 100).toFixed(0)}%`))) + : el('span', { class: 'muted' }, 'No local runs yet') + ), + el('a', { class: 'aiu-link', href: 'http://192.168.1.212:8080/', target: '_blank', rel: 'noopener' }, 'Full dashboard ↗') + ); + } catch { mount(body, el('span', { class: 'muted' }, 'No usage data')); } +} + +export default { + id: 'ai-usage', title: 'AI Usage', size: 'm', + mount(e) { body = e; load(); }, start() {}, stop() { body = null; } +}; diff --git a/public/views/sacred_valley.js b/public/views/sacred_valley.js index 5ccfcd1..3bfa85d 100644 --- a/public/views/sacred_valley.js +++ b/public/views/sacred_valley.js @@ -12,8 +12,9 @@ import jobs from './cards/jobs.js'; import inbox from './cards/inbox.js'; import search from './cards/search.js'; import speedtest from './cards/speedtest.js'; +import aiUsage from './cards/ai_usage.js'; -const CARD_MODULES = [clock, weather, hostPerf, jobs, inbox, search, speedtest]; +const CARD_MODULES = [clock, weather, hostPerf, jobs, inbox, search, speedtest, aiUsage]; const BY_ID = new Map(CARD_MODULES.map(d => [d.id, d])); let active = []; // mounted cards needing stop() diff --git a/tests/api/ai_usage.test.js b/tests/api/ai_usage.test.js new file mode 100644 index 0000000..5301ef5 --- /dev/null +++ b/tests/api/ai_usage.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { summarizeUsage } from '../../lib/api/routes/ai_usage.js'; + +describe('summarizeUsage', () => { + it('sums today + week tokens, picks top model, condenses local perf', () => { + const data = { + generated_at: '2026-06-04T23:00:00Z', + daily_by_model: [ + { day: '2026-06-04', model: 'claude-opus-4-8', input: 100, output: 200, cache_read: 1000, cache_creation: 50, turns: 5 }, + { day: '2026-06-04', model: 'claude-sonnet-4-6', input: 10, output: 20, cache_read: 0, cache_creation: 0, turns: 2 }, + { day: '2026-06-03', model: 'claude-opus-4-8', input: 1, output: 1, cache_read: 0, cache_creation: 0, turns: 1 } + ] + }; + const openclaw = { summary: { by_model: [ + { model: 'ollama/qwen2.5-coder:7b', count: 165, errors: 0, error_rate: 0, p50_ms: 3540, p95_ms: 88749 }, + { model: 'ollama/llama3.1:8b', count: 50, errors: 2, error_rate: 0.04, p50_ms: 17443, p95_ms: 17991 } + ] } }; + + const s = summarizeUsage(data, openclaw); + expect(s.ok).toBe(true); + expect(s.today).toBe('2026-06-04'); + expect(s.claude.today).toEqual({ input: 110, output: 220, cache: 1050, turns: 7 }); + expect(s.claude.week.turns).toBe(8); // includes 06-03 + expect(s.claude.top_model).toBe('claude-opus-4-8'); // most tokens this week + expect(s.local.runs).toBe(215); + expect(s.local.top.model).toBe('qwen2.5-coder:7b'); // ollama/ stripped, highest count + expect(s.local.top.p95_ms).toBe(88749); + }); + + it('handles a missing openclaw payload', () => { + const s = summarizeUsage({ daily_by_model: [] }, null); + expect(s.ok).toBe(true); + expect(s.local.runs).toBe(0); + expect(s.local.top).toBeNull(); + }); +});