From 29d9fbfd7c6a4c18f33e35b8fd705641a051ab19 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Sun, 10 May 2026 22:09:29 +0200 Subject: [PATCH 1/2] refactor: modularize dashboard into SOLID modules with DI and testability Extract 1131-line monolith into 16 focused modules: - services/ (session-stats, daily-tokens, maintenance) with Repos DI - templates/ (pure render functions, separated CSS) - routes/ (RouteHandler interface, stats + page routes) - composition root with DashboardDeps pattern (matching plugin.ts) Add 67 new unit tests covering all extracted modules. dashboard.ts remains as backward-compatible shim. --- ROADMAP.md | 1 + src/dashboard.ts | 1145 +---------------- src/dashboard/index.ts | 58 + src/dashboard/routes/page-route.ts | 35 + src/dashboard/routes/route-handler.ts | 4 + src/dashboard/routes/stats-route.ts | 48 + src/dashboard/server.ts | 29 + .../services/daily-tokens-service.ts | 43 + src/dashboard/services/maintenance-service.ts | 86 ++ .../services/session-stats-service.ts | 134 ++ src/dashboard/services/types.ts | 38 + src/dashboard/templates/daily-chart.ts | 63 + src/dashboard/templates/formatters.ts | 44 + src/dashboard/templates/model-chart.ts | 103 ++ src/dashboard/templates/page-template.ts | 97 ++ src/dashboard/templates/session-card.ts | 81 ++ src/dashboard/templates/sessions-fragment.ts | 47 + src/dashboard/templates/stats-bar.ts | 13 + src/dashboard/templates/styles.ts | 297 +++++ src/dashboard/templates/tool-usage.ts | 52 + tests/unit/dashboard/daily-chart.test.ts | 34 + .../dashboard/daily-tokens-service.test.ts | 98 ++ tests/unit/dashboard/formatters.test.ts | 73 ++ .../dashboard/maintenance-service.test.ts | 157 +++ tests/unit/dashboard/model-chart.test.ts | 40 + tests/unit/dashboard/page-template.test.ts | 36 + tests/unit/dashboard/routes.test.ts | 174 +++ tests/unit/dashboard/session-card.test.ts | 107 ++ .../dashboard/session-stats-service.test.ts | 315 +++++ .../unit/dashboard/sessions-fragment.test.ts | 47 + tests/unit/dashboard/stats-bar.test.ts | 28 + tests/unit/dashboard/tool-usage.test.ts | 86 ++ 32 files changed, 2491 insertions(+), 1122 deletions(-) create mode 100644 src/dashboard/index.ts create mode 100644 src/dashboard/routes/page-route.ts create mode 100644 src/dashboard/routes/route-handler.ts create mode 100644 src/dashboard/routes/stats-route.ts create mode 100644 src/dashboard/server.ts create mode 100644 src/dashboard/services/daily-tokens-service.ts create mode 100644 src/dashboard/services/maintenance-service.ts create mode 100644 src/dashboard/services/session-stats-service.ts create mode 100644 src/dashboard/services/types.ts create mode 100644 src/dashboard/templates/daily-chart.ts create mode 100644 src/dashboard/templates/formatters.ts create mode 100644 src/dashboard/templates/model-chart.ts create mode 100644 src/dashboard/templates/page-template.ts create mode 100644 src/dashboard/templates/session-card.ts create mode 100644 src/dashboard/templates/sessions-fragment.ts create mode 100644 src/dashboard/templates/stats-bar.ts create mode 100644 src/dashboard/templates/styles.ts create mode 100644 src/dashboard/templates/tool-usage.ts create mode 100644 tests/unit/dashboard/daily-chart.test.ts create mode 100644 tests/unit/dashboard/daily-tokens-service.test.ts create mode 100644 tests/unit/dashboard/formatters.test.ts create mode 100644 tests/unit/dashboard/maintenance-service.test.ts create mode 100644 tests/unit/dashboard/model-chart.test.ts create mode 100644 tests/unit/dashboard/page-template.test.ts create mode 100644 tests/unit/dashboard/routes.test.ts create mode 100644 tests/unit/dashboard/session-card.test.ts create mode 100644 tests/unit/dashboard/session-stats-service.test.ts create mode 100644 tests/unit/dashboard/sessions-fragment.test.ts create mode 100644 tests/unit/dashboard/stats-bar.test.ts create mode 100644 tests/unit/dashboard/tool-usage.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index b8579da..10048b0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,6 +4,7 @@ ## Completed +- [x] Refactor dashboard into SOLID modules with DI, separated templates, services, and routes - [x] Refactor plugin hook processing into explicit interfaces and focused handlers (SOLID) - [x] Introduce Playwright end-to-end tests for dashboard rendering and stats endpoint - [x] Introduce Bun unit tests for plugin database logic and dashboard utility rendering diff --git a/src/dashboard.ts b/src/dashboard.ts index a877a42..fd261e4 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -1,1131 +1,32 @@ #!/usr/bin/env bun /** * OpenCode Usage Stats Dashboard - * Run: bun run ~/.config/opencode/plugins/usage-stats-dashboard.ts - * Open: http://localhost:3333 + * + * This file is a thin shim for backward compatibility (symlink target). + * All logic lives in src/dashboard/. */ -import { join } from "node:path"; -import type { DailyModelTokens, TokenSummary } from "./db/message/message-repo"; -import type { Repos } from "./db/repos"; -import type { DailyTokens } from "./db/shared-types"; -import { createSqliteRepos, gcOldData } from "./db/sqlite-repository"; -import type { ToolGroupSummary } from "./db/tool-call/tool-call-repo"; - -const DB_PATH = - process.env.OPENCODE_USAGE_STATS_DB || - join(process.env.HOME || "~", ".config", "opencode", "usage-stats.db"); -const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3333; - -// Track last aggregation time to avoid running too often -let lastAggregation = 0; -const MIN_AGGREGATION_INTERVAL_MS = 60_000; // 60 seconds - -// Track last GC time -let lastGC = 0; -const MIN_GC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours - -interface SessionStats { - session_id: string; - title: string | null; - directory: string | null; - first_seen: string; - last_seen: string; - input_tokens: number; - output_tokens: number; - reasoning_tokens: number; - cache_read_tokens: number; - cache_write_tokens: number; - cost: number; - agents: AgentStats[]; - modes: ModeStats[]; -} - -interface AgentStats { - agent_type: string; - call_count: number; - input_tokens: number; - output_tokens: number; - reasoning_tokens: number; - cache_read_tokens: number; - model_id: string | null; - provider_id: string | null; -} - -interface ModeStats { - agent: string; - message_count: number; - input_tokens: number; - output_tokens: number; - reasoning_tokens: number; - cache_read_tokens: number; - cost: number; - model_id: string | null; - provider_id: string | null; -} - -function getStats(repos: Repos): SessionStats[] { - const rootSessions = repos.sessions.getRootSessions(); - const childSessions = repos.sessions.getChildSessions(); - const agentCalls = repos.toolCalls.getAgentCalls(); - const modeRows = repos.messages.getModeStats(); - - // Map: parent_id -> child sessions - const childMap = new Map(); - for (const c of childSessions) { - if (!c.parent_id) continue; - if (!childMap.has(c.parent_id)) childMap.set(c.parent_id, []); - childMap.get(c.parent_id)?.push(c); - } - - // Map: parent_session_id -> { agent_type -> call_count } - const agentMap = new Map>(); - for (const a of agentCalls) { - if (!agentMap.has(a.session_id)) agentMap.set(a.session_id, new Map()); - agentMap.get(a.session_id)?.set(a.agent_type, a.call_count); - } - - // Map: session_id -> ModeStats[] - const modeMap = new Map(); - for (const m of modeRows) { - if (!modeMap.has(m.session_id)) modeMap.set(m.session_id, []); - modeMap.get(m.session_id)?.push({ - agent: m.agent, - message_count: m.message_count, - input_tokens: m.input_tokens, - output_tokens: m.output_tokens, - reasoning_tokens: m.reasoning_tokens, - cache_read_tokens: m.cache_read_tokens, - cost: m.cost, - model_id: m.model_id ?? null, - provider_id: m.provider_id ?? null, - }); - } - - return rootSessions.map((s) => { - const children = childMap.get(s.session_id) || []; - const agentCallCounts = agentMap.get(s.session_id) || new Map(); - - // Build agent details from child sessions - // Extract agent_type from child title pattern: "... (@agent-type subagent)" - const agentDetails: AgentStats[] = []; - const seenAgents = new Map(); - - for (const child of children) { - // Parse agent type from title like "PM says Ja (@product-manager subagent)" - const match = child.title?.match(/@(\S+)\s+subagent/); - const agentType = match?.[1] ?? "subagent"; - - if (seenAgents.has(agentType)) { - // Aggregate multiple calls of same agent type - const existing = seenAgents.get(agentType)!; - existing.call_count += 1; - existing.input_tokens += child.input_tokens; - existing.output_tokens += child.output_tokens; - existing.reasoning_tokens += child.reasoning_tokens; - existing.cache_read_tokens += child.cache_read_tokens; - } else { - const stats: AgentStats = { - agent_type: agentType, - call_count: 1, - input_tokens: child.input_tokens, - output_tokens: child.output_tokens, - reasoning_tokens: child.reasoning_tokens, - cache_read_tokens: child.cache_read_tokens, - model_id: child.model_id, - provider_id: child.provider_id, - }; - seenAgents.set(agentType, stats); - agentDetails.push(stats); - } - } - - // Override call_count from tool_calls if available (more accurate) - for (const agent of agentDetails) { - const count = agentCallCounts.get(agent.agent_type); - if (count) agent.call_count = count; - } - - // Add agents from tool_calls that have no child sessions yet (no token data) - for (const [agentType, count] of agentCallCounts) { - if (!seenAgents.has(agentType)) { - agentDetails.push({ - agent_type: agentType, - call_count: count, - input_tokens: 0, - output_tokens: 0, - reasoning_tokens: 0, - cache_read_tokens: 0, - model_id: null, - provider_id: null, - }); - } - } - - // Total = own tokens + all child tokens - const childIn = agentDetails.reduce((sum, a) => sum + a.input_tokens, 0); - const childOut = agentDetails.reduce((sum, a) => sum + a.output_tokens, 0); - const childReasoning = agentDetails.reduce( - (sum, a) => sum + a.reasoning_tokens, - 0, - ); - const childCache = agentDetails.reduce( - (sum, a) => sum + a.cache_read_tokens, - 0, - ); - - return { - session_id: s.session_id, - title: s.title, - directory: s.directory, - first_seen: s.first_seen, - last_seen: s.last_seen, - input_tokens: s.input_tokens + childIn, - output_tokens: s.output_tokens + childOut, - reasoning_tokens: s.reasoning_tokens + childReasoning, - cache_read_tokens: s.cache_read_tokens + childCache, - cache_write_tokens: s.cache_write_tokens, - cost: s.cost, - agents: agentDetails, - modes: modeMap.get(s.session_id) || [], - }; - }); -} - -function getTokenSummary(repos: Repos): TokenSummary { - return repos.messages.getTokenSummary(); -} - -function getDailyTokens(repos: Repos): DailyTokens[] { - const today = new Date().toISOString().slice(0, 10); - const todayRow = repos.messages.getTodayTokens(today); - - const historyRows = repos.dailyUsage.getHistoryUntil(today, 60); - - // Merge and fill gaps - const dataMap = new Map(); - for (const row of historyRows) dataMap.set(row.date, row.total); - dataMap.set(todayRow.date, todayRow.total); - - const result: DailyTokens[] = []; - for (let i = 59; i >= 0; i--) { - const d = new Date(); - d.setDate(d.getDate() - i); - const key = d.toISOString().slice(0, 10); - result.push({ date: key, total: dataMap.get(key) ?? 0 }); - } - - return result; -} - -function getDailyTokensByModel(repos: Repos): DailyModelTokens[] { - return repos.messages.getDailyTokensByModel(); -} - -function fmt(n: number): string { - return n.toLocaleString("de-DE"); -} - -export function fmtCompact(n: number): string { - if (n >= 1_000_000) { - const m = n / 1_000_000; - return m % 1 === 0 ? `${Math.round(m)}m` : `${m.toFixed(1)}m`; - } - if (n >= 1_000) { - const k = n / 1_000; - return k % 1 === 0 ? `${Math.round(k)}k` : `${k.toFixed(1)}k`; - } - return n.toString(); -} - -export function esc(s: string): string { - return s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -export function renderTokens( - input: number, - cache: number, - output: number, - reasoning: number, -): string { - const totalIn = input + cache; - const cachePercent = totalIn > 0 ? Math.round((cache / totalIn) * 100) : 0; - const cacheInfo = - cache > 0 - ? ` (${cachePercent}% cached)?` - : ""; - - let html = `${fmtCompact(totalIn)} in${cacheInfo}`; - html += ` / ${fmtCompact(output)} out`; - if (reasoning > 0) { - html += ` / ${fmtCompact(reasoning)} reasoning`; - } - return html; -} - -function renderSessionCard(s: SessionStats): string { - const title = s.title || s.directory?.split("/").pop() || s.session_id; - const time = s.last_seen?.replace("T", " ").slice(0, 16) ?? ""; - - const agentRows = s.agents - .map((a) => { - const agentTokens = renderTokens( - a.input_tokens, - a.cache_read_tokens, - a.output_tokens, - a.reasoning_tokens, - ); - const model = a.model_id - ? `${esc(a.model_id)}` - : ""; - return ` -
- ${esc(a.agent_type)} - ${a.call_count}x - ${model} - ${agentTokens} -
`; - }) - .join(""); - - const sessionTokens = renderTokens( - s.input_tokens, - s.cache_read_tokens, - s.output_tokens, - s.reasoning_tokens, - ); - - const modeRows = s.modes - .map((m) => { - const modeTokens = renderTokens( - m.input_tokens, - m.cache_read_tokens, - m.output_tokens, - m.reasoning_tokens, - ); - const label = m.agent.charAt(0).toUpperCase() + m.agent.slice(1); - const modelInfo = - m.provider_id || m.model_id - ? ` ${esc([m.provider_id, m.model_id].filter(Boolean).join(" / "))}` - : ""; - const costStr = - m.cost > 0 - ? ` $${m.cost.toFixed(4)}` - : ""; - return ` -
- ${esc(label)} - ${modelInfo} - ${m.message_count} msgs - ${modeTokens} - ${costStr} -
`; - }) - .join(""); - - return ` -
-
-
${esc(title)}
-
${time}
-
-
- ${s.directory ? `${esc(s.directory)}` : ""} - ${esc(s.session_id)} -
-
- Tokens: - ${sessionTokens} -
- ${agentRows ? `
Agents
${agentRows}
` : ""} - ${modeRows ? `
Mode
${modeRows}
` : ""} -
`; -} - -function renderStatsBar(summary: TokenSummary): string { - return ` -
- Overall - Today:${fmtCompact(summary.today)} - This Week:${fmtCompact(summary.thisWeek)} - This Month:${fmtCompact(summary.thisMonth)} - Last Month:${fmtCompact(summary.lastMonth)} -
`; -} - -function renderDailyChart(daily: DailyTokens[]): string { - // Build a map from DB data - const dataMap = new Map(); - for (const d of daily) dataMap.set(d.date, d.total); - - // Always render 60 days - const days: { date: string; total: number }[] = []; - for (let i = 59; i >= 0; i--) { - const d = new Date(); - d.setDate(d.getDate() - i); - const key = d.toISOString().slice(0, 10); - days.push({ date: key, total: dataMap.get(key) ?? 0 }); - } - - const max = Math.max(...days.map((d) => d.total)); - - const bars = days - .map((d) => { - const pct = - max > 0 && d.total > 0 - ? Math.max(1, Math.round((d.total / max) * 100)) - : 0; - // Format date as "Mon, 09 May" - const dateObj = new Date(`${d.date}T00:00:00`); - const weekday = dateObj.toLocaleDateString("en-US", { weekday: "short" }); - const day = String(dateObj.getDate()).padStart(2, "0"); - const month = dateObj.toLocaleDateString("en-US", { month: "short" }); - const tooltipDate = `${weekday}, ${day} ${month}`; - const tooltipTokens = fmt(d.total); - return ` -
- ${d.total > 0 ? `
${d.total >= 1000 ? `${Math.round(d.total / 1000)}k` : d.total}
` : ""} -
-
${tooltipDate}
${tooltipTokens} tokens
-
`; - }) - .join(""); - - // Compute 5-day rolling average - const avgPoints: { x: number; y: number }[] = []; - for (let i = 0; i < days.length; i++) { - const window = days.slice(Math.max(0, i - 4), i + 1); - const avg = window.reduce((s, d) => s + d.total, 0) / window.length; - const xPct = ((i + 0.5) / days.length) * 100; - const yPct = max > 0 ? 100 - (avg / max) * 100 : 100; - avgPoints.push({ x: xPct, y: yPct }); - } - const polyline = avgPoints.map((p) => `${p.x},${p.y}`).join(" "); - - return ` -
-
Daily Token Usage (last 60 days)
-
- ${bars} - - - -
-
- Daily tokens - 5-day avg -
-
`; -} - -const MODEL_COLORS = [ - "#58a6ff", - "#3fb950", - "#d2a8ff", - "#f0883e", - "#f85149", - "#79c0ff", - "#56d364", - "#e3b341", - "#bc8cff", - "#ff7b72", -]; - -function renderDailyModelChart(modelData: DailyModelTokens[]): string { - // Collect all unique models (sorted by total usage desc for consistent legend order) - const modelTotals = new Map(); - for (const d of modelData) { - modelTotals.set(d.model, (modelTotals.get(d.model) ?? 0) + d.total); - } - const models = [...modelTotals.entries()] - .sort((a, b) => b[1] - a[1]) - .map(([m]) => m); - - const colorMap = new Map(); - for (const [i, m] of models.entries()) { - colorMap.set(m, MODEL_COLORS[i % MODEL_COLORS.length]!); - } - - // Build map: date -> { model -> total } - const dataMap = new Map>(); - for (const d of modelData) { - if (!dataMap.has(d.date)) dataMap.set(d.date, new Map()); - dataMap.get(d.date)?.set(d.model, d.total); - } - - // 60 days - const days: { date: string; byModel: Map; total: number }[] = - []; - for (let i = 59; i >= 0; i--) { - const dt = new Date(); - dt.setDate(dt.getDate() - i); - const key = dt.toISOString().slice(0, 10); - const byModel = dataMap.get(key) ?? new Map(); - const total = [...byModel.values()].reduce((s, v) => s + v, 0); - days.push({ date: key, byModel, total }); - } - - const max = Math.max(...days.map((d) => d.total), 1); - - const bars = days - .map((d) => { - const dateObj = new Date(`${d.date}T00:00:00`); - const weekday = dateObj.toLocaleDateString("en-US", { weekday: "short" }); - const day = String(dateObj.getDate()).padStart(2, "0"); - const month = dateObj.toLocaleDateString("en-US", { month: "short" }); - const tooltipDate = `${weekday}, ${day} ${month}`; - - // Stacked segments (bottom to top = models array order) - const segments = models - .map((m) => { - const val = d.byModel.get(m) ?? 0; - if (val === 0) return ""; - const pct = (val / max) * 100; - const color = colorMap.get(m)!; - return `
`; - }) - .join(""); - - // Tooltip breakdown - const tooltipLines = models - .filter((m) => (d.byModel.get(m) ?? 0) > 0) - .map((m) => { - const color = colorMap.get(m)!; - return `\u25A0 ${esc(m)}: ${fmt(d.byModel.get(m)!)}`; - }) - .join("
"); - - return ` -
-
- ${segments} -
-
${tooltipDate}
${tooltipLines}
-
`; - }) - .join(""); - - const legend = models - .map((m) => { - const color = colorMap.get(m)!; - return `${esc(m)}`; - }) - .join(""); - - return ` -
-
Daily Token Usage by Model (last 60 days)
-
- ${bars} -
-
- ${legend} -
-
`; -} - -function getToolUsageSummary(repos: Repos): ToolGroupSummary[] { - return repos.toolCalls.getToolUsageSummary(); -} - -function renderToolUsage(groups: ToolGroupSummary[]): string { - if (groups.length === 0) return ""; - - const visibleGroups = groups.filter((g) => g.agent !== null); - - const groupsHtml = visibleGroups - .map((g) => { - const label = g.agent - ? g.agent.charAt(0).toUpperCase() + g.agent.slice(1) - : "Unknown"; - const modelInfo = - [g.provider_id, g.model_id].filter(Boolean).join(" / ") || "unknown"; - const totalCalls = g.tools.reduce( - (s, t) => s + t.thisMonth + t.lastMonth, - 0, - ); - const groupKey = `${g.agent ?? "__none__"}|${g.provider_id ?? "__none__"}|${g.model_id ?? "__none__"}`; - - const toolRows = g.tools - .map( - (t) => ` -
- ${esc(t.tool_name)} - Today:${fmt(t.today)} - This Week:${fmt(t.thisWeek)} - This Month:${fmt(t.thisMonth)} - Last Month:${fmt(t.lastMonth)} -
`, - ) - .join(""); - - return ` -
- - ${esc(label)} - ${esc(modelInfo)} - ${fmt(totalCalls)} calls - -
${toolRows}
-
`; - }) - .join(""); - - return ` -
-
Tool Usage
- ${groupsHtml} -
`; -} - -function renderSessionsFragment( - sessions: SessionStats[], - summary: TokenSummary, - daily: DailyTokens[], - dailyModel: DailyModelTokens[], - toolGroups: ToolGroupSummary[], -): string { - const bar = renderStatsBar(summary); - const chart = renderDailyChart(daily); - const modelChart = renderDailyModelChart(dailyModel); - const toolUsage = renderToolUsage(toolGroups); - - const leftPanel = ` -
- ${bar} -
- ${chart} - ${modelChart} - ${toolUsage} -
`; - - const sessionCards = - sessions.length === 0 - ? '
No sessions recorded yet.
' - : sessions.map(renderSessionCard).join(""); - - const rightPanel = ` -
-
Sessions
- ${sessionCards} -
`; - - return `
${leftPanel}${rightPanel}
`; -} - -function renderHTML( - sessions: SessionStats[], - summary: TokenSummary, - daily: DailyTokens[], - dailyModel: DailyModelTokens[], - toolGroups: ToolGroupSummary[], -): string { - return ` - - - - - OpenCode Usage Stats - - - -
-

OpenCode Usage Stats

-
-
- auto-refresh 5s - -
-
-
- ${renderSessionsFragment(sessions, summary, daily, dailyModel, toolGroups)} -
- - -`; -} - -async function isPortInUse(port: number): Promise { - try { - const response = await fetch(`http://localhost:${port}/`, { - signal: AbortSignal.timeout(500), - }); - await response.text(); - return true; - } catch { - return false; - } -} +export { + esc, + fmtCompact, + renderTokens, +} from "./dashboard/templates/formatters"; if (import.meta.main) { - const portBusy = await isPortInUse(PORT); - if (!portBusy) { - // Initial aggregation on dashboard startup - try { - const repos = createSqliteRepos(DB_PATH); - const today = new Date().toISOString().slice(0, 10); - const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) - .toISOString() - .slice(0, 10); - repos.dailyUsage.recompute(sevenDaysAgo, today); - lastAggregation = Date.now(); - - // Run GC on startup - gcOldData(repos, 90); - lastGC = Date.now(); - - repos.close(); - } catch (e) { - console.error("Initial aggregation/GC failed:", e); - } - - const readRepos = createSqliteRepos(DB_PATH, { readonly: true }); - - Bun.serve({ - port: PORT, - async fetch(req) { - const url = new URL(req.url); - - if (url.pathname === "/api/stats") { - // 1/100 chance to trigger aggregation (with 60s minimum interval) - if (Math.random() < 0.01) { - const now = Date.now(); - if (now - lastAggregation >= MIN_AGGREGATION_INTERVAL_MS) { - lastAggregation = now; - try { - const repos = createSqliteRepos(DB_PATH); - const sevenDaysAgo = new Date( - Date.now() - 7 * 24 * 60 * 60 * 1000, - ) - .toISOString() - .slice(0, 10); - const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000) - .toISOString() - .slice(0, 10); - repos.dailyUsage.recompute(sevenDaysAgo, yesterday); - repos.close(); - } catch (e) { - console.error("Background aggregation failed:", e); - } - } - } - - // 1/500 chance to trigger GC (with 24h minimum interval) - if (Math.random() < 0.002) { - const now = Date.now(); - if (now - lastGC >= MIN_GC_INTERVAL_MS) { - lastGC = now; - try { - const repos = createSqliteRepos(DB_PATH); - gcOldData(repos, 90); - repos.close(); - } catch (e) { - console.error("Background GC failed:", e); - } - } - } + const { createDashboard } = await import("./dashboard/index"); + const { createSqliteRepos, gcOldData } = await import( + "./db/sqlite-repository" + ); + const { join } = await import("node:path"); - try { - const sessions = getStats(readRepos); - const summary = getTokenSummary(readRepos); - const daily = getDailyTokens(readRepos); - const dailyModel = getDailyTokensByModel(readRepos); - const toolGroups = getToolUsageSummary(readRepos); - return new Response( - renderSessionsFragment( - sessions, - summary, - daily, - dailyModel, - toolGroups, - ), - { - headers: { "Content-Type": "text/html; charset=utf-8" }, - }, - ); - } catch (e) { - return new Response(`
DB error: ${e}
`, { - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); - } - } + const DB_PATH = + process.env.OPENCODE_USAGE_STATS_DB || + join(process.env.HOME || "~", ".config", "opencode", "usage-stats.db"); + const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3333; - try { - const sessions = getStats(readRepos); - const summary = getTokenSummary(readRepos); - const daily = getDailyTokens(readRepos); - const dailyModel = getDailyTokensByModel(readRepos); - const toolGroups = getToolUsageSummary(readRepos); - return new Response( - renderHTML(sessions, summary, daily, dailyModel, toolGroups), - { - headers: { "Content-Type": "text/html; charset=utf-8" }, - }, - ); - } catch (e) { - return new Response(`DB error: ${e}`, { status: 500 }); - } - }, - }); - console.log(`Dashboard running at http://localhost:${PORT}`); - } else { - console.log(`Dashboard already running on port ${PORT}, skipping.`); - } + const dashboard = createDashboard({ + createReadRepos: (p) => createSqliteRepos(p, { readonly: true }), + createWriteRepos: (p) => createSqliteRepos(p), + gcOldData, + }); + dashboard.start(PORT, DB_PATH); } diff --git a/src/dashboard/index.ts b/src/dashboard/index.ts new file mode 100644 index 0000000..29ac47c --- /dev/null +++ b/src/dashboard/index.ts @@ -0,0 +1,58 @@ +import { join } from "node:path"; +import type { Repos } from "../db/repos"; +import { createSqliteRepos, gcOldData } from "../db/sqlite-repository"; +import { createPageRoute } from "./routes/page-route"; +import { createStatsRoute } from "./routes/stats-route"; +import { isPortInUse, startServer } from "./server"; +import { createDailyTokensService } from "./services/daily-tokens-service"; +import { createMaintenanceService } from "./services/maintenance-service"; +import { createSessionStatsService } from "./services/session-stats-service"; + +export interface DashboardDeps { + createReadRepos: (dbPath: string) => Repos; + createWriteRepos: (dbPath: string) => Repos; + gcOldData: typeof gcOldData; +} + +export function createDashboard(deps: DashboardDeps) { + return { + async start(port: number, dbPath: string): Promise { + const portBusy = await isPortInUse(port); + if (portBusy) { + console.log(`Dashboard already running on port ${port}, skipping.`); + return; + } + + const maintenance = createMaintenanceService({ + createWriteRepos: () => deps.createWriteRepos(dbPath), + gcOldData: deps.gcOldData, + }); + maintenance.runInitial(); + + const readRepos = deps.createReadRepos(dbPath); + const sessionStats = createSessionStatsService(readRepos); + const dailyTokens = createDailyTokensService(readRepos); + + const routes = [ + createStatsRoute(sessionStats, dailyTokens, readRepos, maintenance), + createPageRoute(sessionStats, dailyTokens, readRepos), + ]; + + startServer(port, routes); + }, + }; +} + +const DB_PATH = + process.env.OPENCODE_USAGE_STATS_DB || + join(process.env.HOME || "~", ".config", "opencode", "usage-stats.db"); +const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3333; + +if (import.meta.main) { + const dashboard = createDashboard({ + createReadRepos: (p) => createSqliteRepos(p, { readonly: true }), + createWriteRepos: (p) => createSqliteRepos(p), + gcOldData, + }); + dashboard.start(PORT, DB_PATH); +} diff --git a/src/dashboard/routes/page-route.ts b/src/dashboard/routes/page-route.ts new file mode 100644 index 0000000..2da3fc5 --- /dev/null +++ b/src/dashboard/routes/page-route.ts @@ -0,0 +1,35 @@ +import type { Repos } from "../../db/repos"; +import type { DailyTokensService } from "../services/daily-tokens-service"; +import type { SessionStatsService } from "../services/session-stats-service"; +import { renderHTML } from "../templates/page-template"; +import type { RouteHandler } from "./route-handler"; + +export function createPageRoute( + sessionStats: SessionStatsService, + dailyTokens: DailyTokensService, + repos: Repos, +): RouteHandler { + return { + match(_url: URL): boolean { + return true; + }, + + handle(_req: Request, _url: URL): Response { + try { + const sessions = sessionStats.getSessionStats(); + const summary = dailyTokens.getTokenSummary(); + const daily = dailyTokens.getDailyTokens(); + const dailyModel = dailyTokens.getDailyTokensByModel(); + const toolGroups = repos.toolCalls.getToolUsageSummary(); + return new Response( + renderHTML(sessions, summary, daily, dailyModel, toolGroups), + { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }, + ); + } catch (e) { + return new Response(`DB error: ${e}`, { status: 500 }); + } + }, + }; +} diff --git a/src/dashboard/routes/route-handler.ts b/src/dashboard/routes/route-handler.ts new file mode 100644 index 0000000..f40a93b --- /dev/null +++ b/src/dashboard/routes/route-handler.ts @@ -0,0 +1,4 @@ +export interface RouteHandler { + match(url: URL): boolean; + handle(req: Request, url: URL): Response; +} diff --git a/src/dashboard/routes/stats-route.ts b/src/dashboard/routes/stats-route.ts new file mode 100644 index 0000000..b36fe48 --- /dev/null +++ b/src/dashboard/routes/stats-route.ts @@ -0,0 +1,48 @@ +import type { Repos } from "../../db/repos"; +import type { DailyTokensService } from "../services/daily-tokens-service"; +import type { MaintenanceService } from "../services/maintenance-service"; +import type { SessionStatsService } from "../services/session-stats-service"; +import { renderSessionsFragment } from "../templates/sessions-fragment"; +import type { RouteHandler } from "./route-handler"; + +export function createStatsRoute( + sessionStats: SessionStatsService, + dailyTokens: DailyTokensService, + repos: Repos, + maintenance: MaintenanceService, +): RouteHandler { + return { + match(url: URL): boolean { + return url.pathname === "/api/stats"; + }, + + handle(_req: Request, _url: URL): Response { + maintenance.maybeAggregate(); + maintenance.maybeGC(); + + try { + const sessions = sessionStats.getSessionStats(); + const summary = dailyTokens.getTokenSummary(); + const daily = dailyTokens.getDailyTokens(); + const dailyModel = dailyTokens.getDailyTokensByModel(); + const toolGroups = repos.toolCalls.getToolUsageSummary(); + return new Response( + renderSessionsFragment( + sessions, + summary, + daily, + dailyModel, + toolGroups, + ), + { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }, + ); + } catch (e) { + return new Response(`
DB error: ${e}
`, { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + }, + }; +} diff --git a/src/dashboard/server.ts b/src/dashboard/server.ts new file mode 100644 index 0000000..138c1b9 --- /dev/null +++ b/src/dashboard/server.ts @@ -0,0 +1,29 @@ +import type { RouteHandler } from "./routes/route-handler"; + +export async function isPortInUse(port: number): Promise { + try { + const response = await fetch(`http://localhost:${port}/`, { + signal: AbortSignal.timeout(500), + }); + await response.text(); + return true; + } catch { + return false; + } +} + +export function startServer(port: number, routes: RouteHandler[]): void { + Bun.serve({ + port, + fetch(req) { + const url = new URL(req.url); + for (const route of routes) { + if (route.match(url)) { + return route.handle(req, url); + } + } + return new Response("Not found", { status: 404 }); + }, + }); + console.log(`Dashboard running at http://localhost:${port}`); +} diff --git a/src/dashboard/services/daily-tokens-service.ts b/src/dashboard/services/daily-tokens-service.ts new file mode 100644 index 0000000..ef15b0b --- /dev/null +++ b/src/dashboard/services/daily-tokens-service.ts @@ -0,0 +1,43 @@ +import type { + DailyModelTokens, + TokenSummary, +} from "../../db/message/message-repo"; +import type { Repos } from "../../db/repos"; +import type { DailyTokens } from "../../db/shared-types"; + +export interface DailyTokensService { + getDailyTokens(): DailyTokens[]; + getDailyTokensByModel(): DailyModelTokens[]; + getTokenSummary(): TokenSummary; +} + +export function createDailyTokensService(repos: Repos): DailyTokensService { + return { + getDailyTokens(): DailyTokens[] { + const today = new Date().toISOString().slice(0, 10); + const todayRow = repos.messages.getTodayTokens(today); + const historyRows = repos.dailyUsage.getHistoryUntil(today, 60); + + const dataMap = new Map(); + for (const row of historyRows) dataMap.set(row.date, row.total); + dataMap.set(todayRow.date, todayRow.total); + + const result: DailyTokens[] = []; + for (let i = 59; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const key = d.toISOString().slice(0, 10); + result.push({ date: key, total: dataMap.get(key) ?? 0 }); + } + return result; + }, + + getDailyTokensByModel(): DailyModelTokens[] { + return repos.messages.getDailyTokensByModel(); + }, + + getTokenSummary(): TokenSummary { + return repos.messages.getTokenSummary(); + }, + }; +} diff --git a/src/dashboard/services/maintenance-service.ts b/src/dashboard/services/maintenance-service.ts new file mode 100644 index 0000000..1fea5af --- /dev/null +++ b/src/dashboard/services/maintenance-service.ts @@ -0,0 +1,86 @@ +import type { Repos } from "../../db/repos"; +import type { gcOldData as GcOldDataFn } from "../../db/sqlite-repository"; + +export interface MaintenanceService { + maybeAggregate(): void; + maybeGC(): void; + runInitial(): void; +} + +export interface MaintenanceDeps { + createWriteRepos: () => Repos; + gcOldData: typeof GcOldDataFn; + aggregationIntervalMs?: number; + gcIntervalMs?: number; + aggregationProbability?: number; + gcProbability?: number; + retentionDays?: number; +} + +export function createMaintenanceService( + deps: MaintenanceDeps, +): MaintenanceService { + const aggregationIntervalMs = deps.aggregationIntervalMs ?? 60_000; + const gcIntervalMs = deps.gcIntervalMs ?? 24 * 60 * 60 * 1000; + const aggregationProbability = deps.aggregationProbability ?? 0.01; + const gcProbability = deps.gcProbability ?? 0.002; + const retentionDays = deps.retentionDays ?? 90; + + let lastAggregation = 0; + let lastGC = 0; + + return { + runInitial(): void { + try { + const repos = deps.createWriteRepos(); + const today = new Date().toISOString().slice(0, 10); + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + repos.dailyUsage.recompute(sevenDaysAgo, today); + lastAggregation = Date.now(); + + deps.gcOldData(repos, retentionDays); + lastGC = Date.now(); + + repos.close(); + } catch (e) { + console.error("Initial aggregation/GC failed:", e); + } + }, + + maybeAggregate(): void { + if (Math.random() >= aggregationProbability) return; + const now = Date.now(); + if (now - lastAggregation < aggregationIntervalMs) return; + lastAggregation = now; + try { + const repos = deps.createWriteRepos(); + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + repos.dailyUsage.recompute(sevenDaysAgo, yesterday); + repos.close(); + } catch (e) { + console.error("Background aggregation failed:", e); + } + }, + + maybeGC(): void { + if (Math.random() >= gcProbability) return; + const now = Date.now(); + if (now - lastGC < gcIntervalMs) return; + lastGC = now; + try { + const repos = deps.createWriteRepos(); + deps.gcOldData(repos, retentionDays); + repos.close(); + } catch (e) { + console.error("Background GC failed:", e); + } + }, + }; +} diff --git a/src/dashboard/services/session-stats-service.ts b/src/dashboard/services/session-stats-service.ts new file mode 100644 index 0000000..661893a --- /dev/null +++ b/src/dashboard/services/session-stats-service.ts @@ -0,0 +1,134 @@ +import type { Repos } from "../../db/repos"; +import type { AgentStats, ModeStats, SessionStats } from "./types"; + +export interface SessionStatsService { + getSessionStats(): SessionStats[]; +} + +export function createSessionStatsService(repos: Repos): SessionStatsService { + return { + getSessionStats(): SessionStats[] { + const rootSessions = repos.sessions.getRootSessions(); + const childSessions = repos.sessions.getChildSessions(); + const agentCalls = repos.toolCalls.getAgentCalls(); + const modeRows = repos.messages.getModeStats(); + + const childMap = new Map(); + for (const c of childSessions) { + if (!c.parent_id) continue; + if (!childMap.has(c.parent_id)) childMap.set(c.parent_id, []); + childMap.get(c.parent_id)?.push(c); + } + + const agentMap = new Map>(); + for (const a of agentCalls) { + if (!agentMap.has(a.session_id)) agentMap.set(a.session_id, new Map()); + agentMap.get(a.session_id)?.set(a.agent_type, a.call_count); + } + + const modeMap = new Map(); + for (const m of modeRows) { + if (!modeMap.has(m.session_id)) modeMap.set(m.session_id, []); + modeMap.get(m.session_id)?.push({ + agent: m.agent, + message_count: m.message_count, + input_tokens: m.input_tokens, + output_tokens: m.output_tokens, + reasoning_tokens: m.reasoning_tokens, + cache_read_tokens: m.cache_read_tokens, + cost: m.cost, + model_id: m.model_id ?? null, + provider_id: m.provider_id ?? null, + }); + } + + return rootSessions.map((s) => { + const children = childMap.get(s.session_id) || []; + const agentCallCounts = agentMap.get(s.session_id) || new Map(); + + const agentDetails: AgentStats[] = []; + const seenAgents = new Map(); + + for (const child of children) { + const match = child.title?.match(/@(\S+)\s+subagent/); + const agentType = match?.[1] ?? "subagent"; + + if (seenAgents.has(agentType)) { + const existing = seenAgents.get(agentType)!; + existing.call_count += 1; + existing.input_tokens += child.input_tokens; + existing.output_tokens += child.output_tokens; + existing.reasoning_tokens += child.reasoning_tokens; + existing.cache_read_tokens += child.cache_read_tokens; + } else { + const stats: AgentStats = { + agent_type: agentType, + call_count: 1, + input_tokens: child.input_tokens, + output_tokens: child.output_tokens, + reasoning_tokens: child.reasoning_tokens, + cache_read_tokens: child.cache_read_tokens, + model_id: child.model_id, + provider_id: child.provider_id, + }; + seenAgents.set(agentType, stats); + agentDetails.push(stats); + } + } + + for (const agent of agentDetails) { + const count = agentCallCounts.get(agent.agent_type); + if (count) agent.call_count = count; + } + + for (const [agentType, count] of agentCallCounts) { + if (!seenAgents.has(agentType)) { + agentDetails.push({ + agent_type: agentType, + call_count: count, + input_tokens: 0, + output_tokens: 0, + reasoning_tokens: 0, + cache_read_tokens: 0, + model_id: null, + provider_id: null, + }); + } + } + + const childIn = agentDetails.reduce( + (sum, a) => sum + a.input_tokens, + 0, + ); + const childOut = agentDetails.reduce( + (sum, a) => sum + a.output_tokens, + 0, + ); + const childReasoning = agentDetails.reduce( + (sum, a) => sum + a.reasoning_tokens, + 0, + ); + const childCache = agentDetails.reduce( + (sum, a) => sum + a.cache_read_tokens, + 0, + ); + + return { + session_id: s.session_id, + title: s.title, + directory: s.directory, + first_seen: s.first_seen, + last_seen: s.last_seen, + input_tokens: s.input_tokens + childIn, + output_tokens: s.output_tokens + childOut, + reasoning_tokens: s.reasoning_tokens + childReasoning, + cache_read_tokens: s.cache_read_tokens + childCache, + cache_write_tokens: s.cache_write_tokens, + cost: s.cost, + agents: agentDetails, + modes: modeMap.get(s.session_id) || [], + }; + }); + }, + }; +} diff --git a/src/dashboard/services/types.ts b/src/dashboard/services/types.ts new file mode 100644 index 0000000..f4c8c67 --- /dev/null +++ b/src/dashboard/services/types.ts @@ -0,0 +1,38 @@ +export interface SessionStats { + session_id: string; + title: string | null; + directory: string | null; + first_seen: string; + last_seen: string; + input_tokens: number; + output_tokens: number; + reasoning_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; + cost: number; + agents: AgentStats[]; + modes: ModeStats[]; +} + +export interface AgentStats { + agent_type: string; + call_count: number; + input_tokens: number; + output_tokens: number; + reasoning_tokens: number; + cache_read_tokens: number; + model_id: string | null; + provider_id: string | null; +} + +export interface ModeStats { + agent: string; + message_count: number; + input_tokens: number; + output_tokens: number; + reasoning_tokens: number; + cache_read_tokens: number; + cost: number; + model_id: string | null; + provider_id: string | null; +} diff --git a/src/dashboard/templates/daily-chart.ts b/src/dashboard/templates/daily-chart.ts new file mode 100644 index 0000000..4f5b03c --- /dev/null +++ b/src/dashboard/templates/daily-chart.ts @@ -0,0 +1,63 @@ +import type { DailyTokens } from "../../db/shared-types"; +import { fmt } from "./formatters"; + +export function renderDailyChart(daily: DailyTokens[]): string { + const dataMap = new Map(); + for (const d of daily) dataMap.set(d.date, d.total); + + const days: { date: string; total: number }[] = []; + for (let i = 59; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const key = d.toISOString().slice(0, 10); + days.push({ date: key, total: dataMap.get(key) ?? 0 }); + } + + const max = Math.max(...days.map((d) => d.total)); + + const bars = days + .map((d) => { + const pct = + max > 0 && d.total > 0 + ? Math.max(1, Math.round((d.total / max) * 100)) + : 0; + const dateObj = new Date(`${d.date}T00:00:00`); + const weekday = dateObj.toLocaleDateString("en-US", { weekday: "short" }); + const day = String(dateObj.getDate()).padStart(2, "0"); + const month = dateObj.toLocaleDateString("en-US", { month: "short" }); + const tooltipDate = `${weekday}, ${day} ${month}`; + const tooltipTokens = fmt(d.total); + return ` +
+ ${d.total > 0 ? `
${d.total >= 1000 ? `${Math.round(d.total / 1000)}k` : d.total}
` : ""} +
+
${tooltipDate}
${tooltipTokens} tokens
+
`; + }) + .join(""); + + const avgPoints: { x: number; y: number }[] = []; + for (let i = 0; i < days.length; i++) { + const window = days.slice(Math.max(0, i - 4), i + 1); + const avg = window.reduce((s, d) => s + d.total, 0) / window.length; + const xPct = ((i + 0.5) / days.length) * 100; + const yPct = max > 0 ? 100 - (avg / max) * 100 : 100; + avgPoints.push({ x: xPct, y: yPct }); + } + const polyline = avgPoints.map((p) => `${p.x},${p.y}`).join(" "); + + return ` +
+
Daily Token Usage (last 60 days)
+
+ ${bars} + + + +
+
+ Daily tokens + 5-day avg +
+
`; +} diff --git a/src/dashboard/templates/formatters.ts b/src/dashboard/templates/formatters.ts new file mode 100644 index 0000000..da13b9e --- /dev/null +++ b/src/dashboard/templates/formatters.ts @@ -0,0 +1,44 @@ +export function fmtCompact(n: number): string { + if (n >= 1_000_000) { + const m = n / 1_000_000; + return m % 1 === 0 ? `${Math.round(m)}m` : `${m.toFixed(1)}m`; + } + if (n >= 1_000) { + const k = n / 1_000; + return k % 1 === 0 ? `${Math.round(k)}k` : `${k.toFixed(1)}k`; + } + return n.toString(); +} + +export function esc(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export function fmt(n: number): string { + return n.toLocaleString("de-DE"); +} + +export function renderTokens( + input: number, + cache: number, + output: number, + reasoning: number, +): string { + const totalIn = input + cache; + const cachePercent = totalIn > 0 ? Math.round((cache / totalIn) * 100) : 0; + const cacheInfo = + cache > 0 + ? ` (${cachePercent}% cached)?` + : ""; + + let html = `${fmtCompact(totalIn)} in${cacheInfo}`; + html += ` / ${fmtCompact(output)} out`; + if (reasoning > 0) { + html += ` / ${fmtCompact(reasoning)} reasoning`; + } + return html; +} diff --git a/src/dashboard/templates/model-chart.ts b/src/dashboard/templates/model-chart.ts new file mode 100644 index 0000000..ada0953 --- /dev/null +++ b/src/dashboard/templates/model-chart.ts @@ -0,0 +1,103 @@ +import type { DailyModelTokens } from "../../db/message/message-repo"; +import { esc, fmt } from "./formatters"; + +export const MODEL_COLORS = [ + "#58a6ff", + "#3fb950", + "#d2a8ff", + "#f0883e", + "#f85149", + "#79c0ff", + "#56d364", + "#e3b341", + "#bc8cff", + "#ff7b72", +]; + +export function renderDailyModelChart(modelData: DailyModelTokens[]): string { + const modelTotals = new Map(); + for (const d of modelData) { + modelTotals.set(d.model, (modelTotals.get(d.model) ?? 0) + d.total); + } + const models = [...modelTotals.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([m]) => m); + + const colorMap = new Map(); + for (const [i, m] of models.entries()) { + colorMap.set(m, MODEL_COLORS[i % MODEL_COLORS.length]!); + } + + const dataMap = new Map>(); + for (const d of modelData) { + if (!dataMap.has(d.date)) dataMap.set(d.date, new Map()); + dataMap.get(d.date)?.set(d.model, d.total); + } + + const days: { date: string; byModel: Map; total: number }[] = + []; + for (let i = 59; i >= 0; i--) { + const dt = new Date(); + dt.setDate(dt.getDate() - i); + const key = dt.toISOString().slice(0, 10); + const byModel = dataMap.get(key) ?? new Map(); + const total = [...byModel.values()].reduce((s, v) => s + v, 0); + days.push({ date: key, byModel, total }); + } + + const max = Math.max(...days.map((d) => d.total), 1); + + const bars = days + .map((d) => { + const dateObj = new Date(`${d.date}T00:00:00`); + const weekday = dateObj.toLocaleDateString("en-US", { weekday: "short" }); + const day = String(dateObj.getDate()).padStart(2, "0"); + const month = dateObj.toLocaleDateString("en-US", { month: "short" }); + const tooltipDate = `${weekday}, ${day} ${month}`; + + const segments = models + .map((m) => { + const val = d.byModel.get(m) ?? 0; + if (val === 0) return ""; + const pct = (val / max) * 100; + const color = colorMap.get(m)!; + return `
`; + }) + .join(""); + + const tooltipLines = models + .filter((m) => (d.byModel.get(m) ?? 0) > 0) + .map((m) => { + const color = colorMap.get(m)!; + return `\u25A0 ${esc(m)}: ${fmt(d.byModel.get(m)!)}`; + }) + .join("
"); + + return ` +
+
+ ${segments} +
+
${tooltipDate}
${tooltipLines}
+
`; + }) + .join(""); + + const legend = models + .map((m) => { + const color = colorMap.get(m)!; + return `${esc(m)}`; + }) + .join(""); + + return ` +
+
Daily Token Usage by Model (last 60 days)
+
+ ${bars} +
+
+ ${legend} +
+
`; +} diff --git a/src/dashboard/templates/page-template.ts b/src/dashboard/templates/page-template.ts new file mode 100644 index 0000000..7921eb7 --- /dev/null +++ b/src/dashboard/templates/page-template.ts @@ -0,0 +1,97 @@ +import type { + DailyModelTokens, + TokenSummary, +} from "../../db/message/message-repo"; +import type { DailyTokens } from "../../db/shared-types"; +import type { ToolGroupSummary } from "../../db/tool-call/tool-call-repo"; +import type { SessionStats } from "../services/types"; +import { renderSessionsFragment } from "./sessions-fragment"; +import { DASHBOARD_CSS } from "./styles"; + +export const CLIENT_SCRIPT = ` + function collectOpenToolGroups() { + const open = new Set(); + document.querySelectorAll('.tool-group[data-group-key]').forEach((el) => { + if (el.open) { + const key = el.getAttribute('data-group-key'); + if (key) open.add(key); + } + }); + return open; + } + + function restoreOpenToolGroups(openKeys) { + const groups = document.querySelectorAll('.tool-group[data-group-key]'); + let opened = 0; + groups.forEach((el) => { + const key = el.getAttribute('data-group-key'); + const shouldOpen = !!key && openKeys.has(key); + el.open = shouldOpen; + if (shouldOpen) opened += 1; + }); + + } + + async function refresh() { + const start = performance.now(); + const openToolGroups = collectOpenToolGroups(); + try { + const res = await fetch("/api/stats"); + const html = await res.text(); + document.getElementById("sessions").innerHTML = html; + restoreOpenToolGroups(openToolGroups); + const duration = Math.round(performance.now() - start); + updateRefreshTiming(duration); + } catch { + updateRefreshTiming(null); + } + } + function updateRefreshTiming(ms) { + const el = document.getElementById("refresh-timing"); + if (ms === null) { + el.textContent = "failed"; + el.className = "refresh-timing very-slow"; + return; + } + el.textContent = \`took \${ms}ms\`; + if (ms > 1000) { + el.className = "refresh-timing very-slow"; + } else if (ms > 500) { + el.className = "refresh-timing slow"; + } else { + el.className = "refresh-timing"; + } + } + setInterval(refresh, 5000);`; + +export function renderHTML( + sessions: SessionStats[], + summary: TokenSummary, + daily: DailyTokens[], + dailyModel: DailyModelTokens[], + toolGroups: ToolGroupSummary[], +): string { + return ` + + + + + OpenCode Usage Stats + + + +
+

OpenCode Usage Stats

+
+
+ auto-refresh 5s + +
+
+
+ ${renderSessionsFragment(sessions, summary, daily, dailyModel, toolGroups)} +
+ + +`; +} diff --git a/src/dashboard/templates/session-card.ts b/src/dashboard/templates/session-card.ts new file mode 100644 index 0000000..0415371 --- /dev/null +++ b/src/dashboard/templates/session-card.ts @@ -0,0 +1,81 @@ +import type { SessionStats } from "../services/types"; +import { esc, renderTokens } from "./formatters"; + +export function renderSessionCard(s: SessionStats): string { + const title = s.title || s.directory?.split("/").pop() || s.session_id; + const time = s.last_seen?.replace("T", " ").slice(0, 16) ?? ""; + + const agentRows = s.agents + .map((a) => { + const agentTokens = renderTokens( + a.input_tokens, + a.cache_read_tokens, + a.output_tokens, + a.reasoning_tokens, + ); + const model = a.model_id + ? `${esc(a.model_id)}` + : ""; + return ` +
+ ${esc(a.agent_type)} + ${a.call_count}x + ${model} + ${agentTokens} +
`; + }) + .join(""); + + const sessionTokens = renderTokens( + s.input_tokens, + s.cache_read_tokens, + s.output_tokens, + s.reasoning_tokens, + ); + + const modeRows = s.modes + .map((m) => { + const modeTokens = renderTokens( + m.input_tokens, + m.cache_read_tokens, + m.output_tokens, + m.reasoning_tokens, + ); + const label = m.agent.charAt(0).toUpperCase() + m.agent.slice(1); + const modelInfo = + m.provider_id || m.model_id + ? ` ${esc([m.provider_id, m.model_id].filter(Boolean).join(" / "))}` + : ""; + const costStr = + m.cost > 0 + ? ` $${m.cost.toFixed(4)}` + : ""; + return ` +
+ ${esc(label)} + ${modelInfo} + ${m.message_count} msgs + ${modeTokens} + ${costStr} +
`; + }) + .join(""); + + return ` +
+
+
${esc(title)}
+
${time}
+
+
+ ${s.directory ? `${esc(s.directory)}` : ""} + ${esc(s.session_id)} +
+
+ Tokens: + ${sessionTokens} +
+ ${agentRows ? `
Agents
${agentRows}
` : ""} + ${modeRows ? `
Mode
${modeRows}
` : ""} +
`; +} diff --git a/src/dashboard/templates/sessions-fragment.ts b/src/dashboard/templates/sessions-fragment.ts new file mode 100644 index 0000000..4b194b8 --- /dev/null +++ b/src/dashboard/templates/sessions-fragment.ts @@ -0,0 +1,47 @@ +import type { + DailyModelTokens, + TokenSummary, +} from "../../db/message/message-repo"; +import type { DailyTokens } from "../../db/shared-types"; +import type { ToolGroupSummary } from "../../db/tool-call/tool-call-repo"; +import type { SessionStats } from "../services/types"; +import { renderDailyChart } from "./daily-chart"; +import { renderDailyModelChart } from "./model-chart"; +import { renderSessionCard } from "./session-card"; +import { renderStatsBar } from "./stats-bar"; +import { renderToolUsage } from "./tool-usage"; + +export function renderSessionsFragment( + sessions: SessionStats[], + summary: TokenSummary, + daily: DailyTokens[], + dailyModel: DailyModelTokens[], + toolGroups: ToolGroupSummary[], +): string { + const bar = renderStatsBar(summary); + const chart = renderDailyChart(daily); + const modelChart = renderDailyModelChart(dailyModel); + const toolUsage = renderToolUsage(toolGroups); + + const leftPanel = ` +
+ ${bar} +
+ ${chart} + ${modelChart} + ${toolUsage} +
`; + + const sessionCards = + sessions.length === 0 + ? '
No sessions recorded yet.
' + : sessions.map(renderSessionCard).join(""); + + const rightPanel = ` +
+
Sessions
+ ${sessionCards} +
`; + + return `
${leftPanel}${rightPanel}
`; +} diff --git a/src/dashboard/templates/stats-bar.ts b/src/dashboard/templates/stats-bar.ts new file mode 100644 index 0000000..0021f20 --- /dev/null +++ b/src/dashboard/templates/stats-bar.ts @@ -0,0 +1,13 @@ +import type { TokenSummary } from "../../db/message/message-repo"; +import { fmtCompact } from "./formatters"; + +export function renderStatsBar(summary: TokenSummary): string { + return ` +
+ Overall + Today:${fmtCompact(summary.today)} + This Week:${fmtCompact(summary.thisWeek)} + This Month:${fmtCompact(summary.thisMonth)} + Last Month:${fmtCompact(summary.lastMonth)} +
`; +} diff --git a/src/dashboard/templates/styles.ts b/src/dashboard/templates/styles.ts new file mode 100644 index 0000000..6503670 --- /dev/null +++ b/src/dashboard/templates/styles.ts @@ -0,0 +1,297 @@ +export const DASHBOARD_CSS = ` + * { margin: 0; padding: 0; box-sizing: border-box; } + body { + font-family: "SF Mono", "Fira Code", "JetBrains Mono", monospace; + background: #0d1117; + color: #c9d1d9; + padding: 24px; + max-width: none; + margin: 0 auto; + } + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 32px; + padding-bottom: 16px; + border-bottom: 1px solid #21262d; + } + .header h1 { font-size: 18px; font-weight: 600; color: #f0f6fc; } + .refresh-badge { + font-size: 12px; color: #8b949e; + display: flex; align-items: center; gap: 10px; + } + .refresh-dot { + width: 6px; height: 6px; border-radius: 50%; + background: #238636; + animation: pulse 2s infinite; + } + @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } + .refresh-timing { + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-variant-numeric: tabular-nums; + background: #1f2937; + color: #6e7681; + border: 1px solid #30363d; + transition: all 0.3s; + } + .refresh-timing.slow { + background: #3a2f1a; + color: #d29922; + border-color: #5c4a1f; + } + .refresh-timing.very-slow { + background: #3a2416; + color: #f0883e; + border-color: #5c3d1f; + } + .session-card { + background: #161b22; + border: 1px solid #21262d; + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; + transition: border-color 0.2s; + } + .session-card:hover { border-color: #388bfd; } + .session-header { + display: flex; justify-content: space-between; + align-items: center; margin-bottom: 4px; + } + .session-title { font-size: 15px; font-weight: 600; color: #f0f6fc; } + .session-time { font-size: 12px; color: #484f58; } + .session-meta { + display: flex; gap: 8px; align-items: center; + margin-bottom: 8px; font-size: 11px; + word-break: break-all; + } + .session-id { color: #484f58; } + .session-dir { color: #8b949e; } + .session-dir::after { content: "|"; margin-left: 8px; color: #30363d; } + .session-tokens { + font-size: 13px; + display: flex; gap: 6px; align-items: center; flex-wrap: wrap; + } + .token-label { color: #8b949e; } + .token-in { color: #58a6ff; } + .token-out { color: #3fb950; } + .token-reasoning { color: #d2a8ff; } + .token-cache { color: #8b949e; font-size: 12px; } + .info-icon { + display: inline-flex; align-items: center; justify-content: center; + width: 14px; height: 14px; border-radius: 50%; + border: 1px solid #30363d; font-size: 10px; + color: #8b949e; cursor: help; margin-left: 3px; + vertical-align: middle; + } + .info-icon:hover { border-color: #58a6ff; color: #58a6ff; } + .token-sep { color: #30363d; } + .agents-section { + margin-top: 12px; padding-top: 10px; + border-top: 1px solid #21262d; + } + .agents-label { + font-size: 11px; color: #8b949e; + text-transform: uppercase; letter-spacing: 0.5px; + margin-bottom: 8px; + } + .agent-row { + display: flex; align-items: center; gap: 10px; + padding: 4px 0 4px 12px; font-size: 13px; + border-left: 2px solid #21262d; margin-bottom: 4px; + } + .agent-badge { + background: #1f2937; border: 1px solid #30363d; + border-radius: 4px; padding: 1px 8px; + font-size: 12px; color: #79c0ff; white-space: nowrap; + } + .agent-calls { color: #8b949e; font-size: 12px; min-width: 24px; } + .agent-model { color: #484f58; font-size: 11px; } + .tokens-detail { color: #8b949e; font-size: 12px; margin-left: auto; } + .tokens-detail .token-in { color: #58a6ff; } + .tokens-detail .token-out { color: #3fb950; } + .tokens-detail .token-reasoning { color: #d2a8ff; } + .tokens-detail .token-cache { color: #6e7681; } + .tokens-detail .token-sep { color: #30363d; } + .mode-row { + display: flex; align-items: center; gap: 10px; + padding: 4px 0 4px 12px; font-size: 13px; + border-left: 2px solid #21262d; margin-bottom: 4px; + } + .mode-badge { + background: #1f2937; border: 1px solid #30363d; + border-radius: 4px; padding: 1px 8px; + font-size: 12px; white-space: nowrap; + } + .mode-plan { color: #3fb950; border-color: #238636; } + .mode-build { color: #f0883e; border-color: #d47616; } + .mode-msgs { color: #8b949e; font-size: 12px; min-width: 50px; } + .mode-model { color: #484f58; font-size: 11px; } + .mode-cost { color: #f0883e; font-size: 12px; } + .empty { + text-align: center; color: #484f58; + padding: 48px; font-size: 14px; + } + .stats-bar { + display: flex; + align-items: center; + padding: 8px 0; margin-bottom: 0; + font-size: 12px; + white-space: nowrap; + } + .stats-pair { + width: 160px; + flex-shrink: 0; + } + .stats-label { color: #8b949e; } + .stats-value { color: #f0f6fc; font-weight: 600; margin-left: 4px; } + .stats-badge { width: 190px; flex-shrink: 0; } + .mode-stats-bar { + margin-bottom: 0; padding: 6px 0; + } + .mode-stats-bar:last-of-type { + margin-bottom: 0; + } + .section-divider { + border: none; border-top: 1px solid #21262d; + margin: 16px 0; + } + .mode-overall { color: #58a6ff; border-color: #1f6feb; } + .tool-usage-section { margin-bottom: 8px; } + .tool-group { + margin-bottom: 12px; + border: 1px solid #21262d; + border-radius: 8px; + background: #161b22; + } + .tool-group-header { + display: flex; align-items: center; gap: 8px; + padding: 8px 10px; + cursor: pointer; + list-style: none; + } + .tool-group-header::-webkit-details-marker { display: none; } + .tool-group-model { font-size: 11px; color: #484f58; } + .tool-group-total { margin-left: auto; font-size: 11px; color: #8b949e; } + .tool-group-body { padding: 0 10px 8px 10px; } + .tool-row { + display: grid; + grid-template-columns: 190px repeat(4, 160px); + align-items: center; + padding: 3px 0; font-size: 12px; + margin-bottom: 2px; + white-space: nowrap; + } + .tool-name { + justify-self: start; + background: #1f2937; border: 1px solid #30363d; + border-radius: 4px; padding: 1px 8px; + font-size: 12px; color: #8b949e; white-space: nowrap; + } + .tool-row .stats-pair { width: 160px; flex-shrink: 0; } + .daily-chart { + margin-bottom: 24px; padding-bottom: 16px; + border-bottom: 1px solid #21262d; + } + .chart-title { + font-size: 12px; color: #8b949e; text-transform: uppercase; + letter-spacing: 0.5px; margin-bottom: 12px; + } + .chart-container { + display: flex; align-items: flex-end; gap: 2px; + height: 80px; + position: relative; + } + .chart-avg-line { + position: absolute; + top: 0; left: 0; + width: 100%; height: 100%; + pointer-events: none; + } + .chart-col { + flex: 1; display: flex; flex-direction: column; + align-items: center; justify-content: flex-end; + height: 100%; min-width: 0; + position: relative; + } + .chart-value { + display: none; + font-size: 9px; color: #8b949e; margin-bottom: 4px; + white-space: nowrap; + } + .chart-col:hover .chart-value { display: block; } + .chart-bar { + width: 100%; min-height: 2px; + background: #238636; border-radius: 2px 2px 0 0; + transition: background 0.2s; + } + .chart-col:hover .chart-bar { background: #3fb950; } + .chart-tooltip { + display: none; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: #1c2128; + border: 1px solid #30363d; + border-radius: 6px; + padding: 6px 10px; + font-size: 11px; + color: #f0f6fc; + white-space: nowrap; + text-align: center; + z-index: 10; + pointer-events: none; + line-height: 1.5; + } + .chart-col:hover .chart-tooltip { display: block; } + .chart-legend { + display: flex; + column-gap: 20px; + row-gap: 4px; + justify-content: flex-end; + flex-wrap: wrap; + margin-top: 8px; font-size: 11px; color: #8b949e; + line-height: 1.2; + } + .legend-item { display: flex; align-items: center; gap: 6px; } + .legend-bar { + width: 12px; height: 8px; background: #238636; + border-radius: 2px; display: inline-block; + } + .legend-line { + width: 16px; height: 2px; background: #f0883e; + display: inline-block; border-radius: 1px; + } + .model-bar-stack { + width: 100%; display: flex; flex-direction: column-reverse; + border-radius: 2px 2px 0 0; overflow: hidden; + } + .model-bar-seg { + width: 100%; min-height: 0; + } + .two-col { + display: flex; gap: 24px; align-items: flex-start; + } + .left-panel { + flex: 1; min-width: 0; + position: sticky; top: 24px; align-self: flex-start; + background: rgba(255, 255, 255, 0.02); + border-radius: 8px; + padding: 16px 24px 16px 16px; + } + .right-panel { + flex: 1; min-width: 0; + border-left: 1px solid #21262d; + padding-left: 24px; + } + .right-panel-title { + font-size: 12px; color: #8b949e; text-transform: uppercase; + letter-spacing: 0.5px; margin-bottom: 12px; + } + @media (max-width: 1000px) { + .two-col { flex-direction: column; } + .left-panel { position: static; } + }`; diff --git a/src/dashboard/templates/tool-usage.ts b/src/dashboard/templates/tool-usage.ts new file mode 100644 index 0000000..4681b2d --- /dev/null +++ b/src/dashboard/templates/tool-usage.ts @@ -0,0 +1,52 @@ +import type { ToolGroupSummary } from "../../db/tool-call/tool-call-repo"; +import { esc, fmt } from "./formatters"; + +export function renderToolUsage(groups: ToolGroupSummary[]): string { + if (groups.length === 0) return ""; + + const visibleGroups = groups.filter((g) => g.agent !== null); + + const groupsHtml = visibleGroups + .map((g) => { + const label = g.agent + ? g.agent.charAt(0).toUpperCase() + g.agent.slice(1) + : "Unknown"; + const modelInfo = + [g.provider_id, g.model_id].filter(Boolean).join(" / ") || "unknown"; + const totalCalls = g.tools.reduce( + (s, t) => s + t.thisMonth + t.lastMonth, + 0, + ); + const groupKey = `${g.agent ?? "__none__"}|${g.provider_id ?? "__none__"}|${g.model_id ?? "__none__"}`; + + const toolRows = g.tools + .map( + (t) => ` +
+ ${esc(t.tool_name)} + Today:${fmt(t.today)} + This Week:${fmt(t.thisWeek)} + This Month:${fmt(t.thisMonth)} + Last Month:${fmt(t.lastMonth)} +
`, + ) + .join(""); + + return ` +
+ + ${esc(label)} + ${esc(modelInfo)} + ${fmt(totalCalls)} calls + +
${toolRows}
+
`; + }) + .join(""); + + return ` +
+
Tool Usage
+ ${groupsHtml} +
`; +} diff --git a/tests/unit/dashboard/daily-chart.test.ts b/tests/unit/dashboard/daily-chart.test.ts new file mode 100644 index 0000000..80db030 --- /dev/null +++ b/tests/unit/dashboard/daily-chart.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { renderDailyChart } from "../../../src/dashboard/templates/daily-chart"; + +describe("renderDailyChart", () => { + test("renders 60 chart columns", () => { + const html = renderDailyChart([]); + const count = (html.match(/class="chart-col"/g) || []).length; + expect(count).toBe(60); + }); + + test("renders title and legend", () => { + const html = renderDailyChart([]); + expect(html).toContain("Daily Token Usage (last 60 days)"); + expect(html).toContain("Daily tokens"); + expect(html).toContain("5-day avg"); + }); + + test("renders bars for provided data", () => { + const today = new Date().toISOString().slice(0, 10); + const html = renderDailyChart([{ date: today, total: 5000 }]); + expect(html).toContain("5k"); + }); + + test("handles all-zero data without errors", () => { + const html = renderDailyChart([{ date: "2025-01-01", total: 0 }]); + expect(html).toContain("chart-container"); + }); + + test("renders rolling average polyline", () => { + const html = renderDailyChart([]); + expect(html).toContain("polyline"); + expect(html).toContain("chart-avg-line"); + }); +}); diff --git a/tests/unit/dashboard/daily-tokens-service.test.ts b/tests/unit/dashboard/daily-tokens-service.test.ts new file mode 100644 index 0000000..489fa69 --- /dev/null +++ b/tests/unit/dashboard/daily-tokens-service.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { createDailyTokensService } from "../../../src/dashboard/services/daily-tokens-service"; +import type { Repos } from "../../../src/db/repos"; + +function makeStubRepos( + overrides: Partial<{ + todayTokens: { date: string; total: number }; + history: { date: string; total: number }[]; + tokenSummary: { + today: number; + thisWeek: number; + thisMonth: number; + lastMonth: number; + }; + dailyModel: { date: string; model: string; total: number }[]; + }> = {}, +): Repos { + return { + sessions: { + getRootSessions: () => [], + getChildSessions: () => [], + upsert: () => {}, + upsertFull: () => {}, + deleteOrphaned: () => 0, + }, + messages: { + getModeStats: () => [], + getTokenSummary: () => + overrides.tokenSummary ?? { + today: 0, + thisWeek: 0, + thisMonth: 0, + lastMonth: 0, + }, + getTodayTokens: () => + overrides.todayTokens ?? { + date: new Date().toISOString().slice(0, 10), + total: 0, + }, + getDailyTokensByModel: () => overrides.dailyModel ?? [], + upsert: () => {}, + deleteOlderThan: () => 0, + }, + toolCalls: { + getAgentCalls: () => [], + getToolUsageSummary: () => [], + insert: () => {}, + deleteOlderThan: () => 0, + }, + dailyUsage: { + recompute: () => {}, + getHistoryUntil: () => overrides.history ?? [], + }, + vacuum: () => {}, + close: () => {}, + }; +} + +describe("DailyTokensService", () => { + test("getDailyTokens returns 60 days with gap filling", () => { + const service = createDailyTokensService(makeStubRepos()); + const result = service.getDailyTokens(); + expect(result).toHaveLength(60); + expect(result[59]!.date).toBe(new Date().toISOString().slice(0, 10)); + }); + + test("getDailyTokens merges today tokens with history", () => { + const today = new Date().toISOString().slice(0, 10); + const service = createDailyTokensService( + makeStubRepos({ + todayTokens: { date: today, total: 500 }, + history: [{ date: today, total: 300 }], + }), + ); + const result = service.getDailyTokens(); + const todayEntry = result.find((d) => d.date === today); + expect(todayEntry!.total).toBe(500); + }); + + test("getTokenSummary delegates to repo", () => { + const service = createDailyTokensService( + makeStubRepos({ + tokenSummary: { today: 10, thisWeek: 20, thisMonth: 30, lastMonth: 40 }, + }), + ); + const summary = service.getTokenSummary(); + expect(summary.today).toBe(10); + expect(summary.lastMonth).toBe(40); + }); + + test("getDailyTokensByModel delegates to repo", () => { + const data = [{ date: "2025-01-01", model: "test", total: 100 }]; + const service = createDailyTokensService( + makeStubRepos({ dailyModel: data }), + ); + expect(service.getDailyTokensByModel()).toEqual(data); + }); +}); diff --git a/tests/unit/dashboard/formatters.test.ts b/tests/unit/dashboard/formatters.test.ts new file mode 100644 index 0000000..acc2fda --- /dev/null +++ b/tests/unit/dashboard/formatters.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { + esc, + fmt, + fmtCompact, + renderTokens, +} from "../../../src/dashboard/templates/formatters"; + +describe("formatters", () => { + describe("fmtCompact", () => { + test("formats numbers below 1000 as-is", () => { + expect(fmtCompact(0)).toBe("0"); + expect(fmtCompact(999)).toBe("999"); + }); + + test("formats thousands with k suffix", () => { + expect(fmtCompact(1_000)).toBe("1k"); + expect(fmtCompact(1_500)).toBe("1.5k"); + expect(fmtCompact(10_000)).toBe("10k"); + }); + + test("formats millions with m suffix", () => { + expect(fmtCompact(1_000_000)).toBe("1m"); + expect(fmtCompact(2_500_000)).toBe("2.5m"); + }); + }); + + describe("esc", () => { + test("escapes HTML special characters", () => { + expect(esc('&')).toBe( + "<script>alert("x")</script>&", + ); + }); + + test("returns plain strings unchanged", () => { + expect(esc("hello world")).toBe("hello world"); + }); + }); + + describe("fmt", () => { + test("formats numbers with locale separators", () => { + const result = fmt(1234); + expect(result).toContain("1"); + expect(result).toContain("234"); + }); + }); + + describe("renderTokens", () => { + test("includes cache and reasoning when present", () => { + const html = renderTokens(1000, 500, 250, 100); + expect(html).toContain("1.5k in"); + expect(html).toContain("33% cached"); + expect(html).toContain("250 out"); + expect(html).toContain("100 reasoning"); + }); + + test("omits reasoning segment when zero", () => { + const html = renderTokens(100, 0, 50, 0); + expect(html).not.toContain("reasoning"); + }); + + test("omits cache info when cache is zero", () => { + const html = renderTokens(100, 0, 50, 10); + expect(html).not.toContain("cached"); + }); + + test("handles all zeros", () => { + const html = renderTokens(0, 0, 0, 0); + expect(html).toContain("0 in"); + expect(html).toContain("0 out"); + }); + }); +}); diff --git a/tests/unit/dashboard/maintenance-service.test.ts b/tests/unit/dashboard/maintenance-service.test.ts new file mode 100644 index 0000000..6823dfe --- /dev/null +++ b/tests/unit/dashboard/maintenance-service.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from "bun:test"; +import { createMaintenanceService } from "../../../src/dashboard/services/maintenance-service"; +import type { Repos } from "../../../src/db/repos"; + +function makeStubRepos(): Repos { + return { + sessions: { + getRootSessions: () => [], + getChildSessions: () => [], + upsert: () => {}, + upsertFull: () => {}, + deleteOrphaned: () => 0, + }, + messages: { + getModeStats: () => [], + getTokenSummary: () => ({ + today: 0, + thisWeek: 0, + thisMonth: 0, + lastMonth: 0, + }), + getTodayTokens: () => ({ date: "2025-01-01", total: 0 }), + getDailyTokensByModel: () => [], + upsert: () => {}, + deleteOlderThan: () => 0, + }, + toolCalls: { + getAgentCalls: () => [], + getToolUsageSummary: () => [], + insert: () => {}, + deleteOlderThan: () => 0, + }, + dailyUsage: { + recompute: () => {}, + getHistoryUntil: () => [], + }, + vacuum: () => {}, + close: () => {}, + }; +} + +describe("MaintenanceService", () => { + test("runInitial calls recompute and gcOldData", () => { + let recomputeCalled = false; + let gcCalled = false; + let closeCalled = false; + + const repos = makeStubRepos(); + repos.dailyUsage.recompute = () => { + recomputeCalled = true; + }; + repos.close = () => { + closeCalled = true; + }; + + const service = createMaintenanceService({ + createWriteRepos: () => repos, + gcOldData: () => { + gcCalled = true; + return { messages: 0, toolCalls: 0, sessions: 0 }; + }, + }); + + service.runInitial(); + expect(recomputeCalled).toBe(true); + expect(gcCalled).toBe(true); + expect(closeCalled).toBe(true); + }); + + test("maybeAggregate with probability=0 never runs", () => { + let called = false; + const repos = makeStubRepos(); + repos.dailyUsage.recompute = () => { + called = true; + }; + + const service = createMaintenanceService({ + createWriteRepos: () => repos, + gcOldData: () => ({ messages: 0, toolCalls: 0, sessions: 0 }), + aggregationProbability: 0, + }); + + for (let i = 0; i < 100; i++) service.maybeAggregate(); + expect(called).toBe(false); + }); + + test("maybeAggregate with probability=1 runs and respects interval", () => { + let callCount = 0; + const repos = makeStubRepos(); + repos.dailyUsage.recompute = () => { + callCount++; + }; + + const service = createMaintenanceService({ + createWriteRepos: () => repos, + gcOldData: () => ({ messages: 0, toolCalls: 0, sessions: 0 }), + aggregationProbability: 1, + aggregationIntervalMs: 999_999_999, + }); + + service.maybeAggregate(); + service.maybeAggregate(); + expect(callCount).toBe(1); + }); + + test("maybeGC with probability=0 never runs", () => { + let called = false; + const service = createMaintenanceService({ + createWriteRepos: makeStubRepos, + gcOldData: () => { + called = true; + return { messages: 0, toolCalls: 0, sessions: 0 }; + }, + gcProbability: 0, + }); + + for (let i = 0; i < 100; i++) service.maybeGC(); + expect(called).toBe(false); + }); + + test("swallows errors from runInitial without throwing", () => { + const service = createMaintenanceService({ + createWriteRepos: () => { + throw new Error("DB locked"); + }, + gcOldData: () => ({ messages: 0, toolCalls: 0, sessions: 0 }), + }); + + expect(() => service.runInitial()).not.toThrow(); + }); + + test("swallows errors from maybeAggregate without throwing", () => { + const service = createMaintenanceService({ + createWriteRepos: () => { + throw new Error("DB locked"); + }, + gcOldData: () => ({ messages: 0, toolCalls: 0, sessions: 0 }), + aggregationProbability: 1, + aggregationIntervalMs: 0, + }); + + expect(() => service.maybeAggregate()).not.toThrow(); + }); + + test("swallows errors from maybeGC without throwing", () => { + const service = createMaintenanceService({ + createWriteRepos: () => { + throw new Error("DB locked"); + }, + gcOldData: () => ({ messages: 0, toolCalls: 0, sessions: 0 }), + gcProbability: 1, + gcIntervalMs: 0, + }); + + expect(() => service.maybeGC()).not.toThrow(); + }); +}); diff --git a/tests/unit/dashboard/model-chart.test.ts b/tests/unit/dashboard/model-chart.test.ts new file mode 100644 index 0000000..0a6d4ac --- /dev/null +++ b/tests/unit/dashboard/model-chart.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { + MODEL_COLORS, + renderDailyModelChart, +} from "../../../src/dashboard/templates/model-chart"; + +describe("renderDailyModelChart", () => { + test("renders title", () => { + const html = renderDailyModelChart([]); + expect(html).toContain("Daily Token Usage by Model"); + }); + + test("renders stacked segments for models", () => { + const today = new Date().toISOString().slice(0, 10); + const html = renderDailyModelChart([ + { date: today, model: "claude-sonnet", total: 3000 }, + { date: today, model: "gpt-4o", total: 2000 }, + ]); + expect(html).toContain("claude-sonnet"); + expect(html).toContain("gpt-4o"); + expect(html).toContain("model-bar-seg"); + }); + + test("assigns colors from MODEL_COLORS", () => { + const today = new Date().toISOString().slice(0, 10); + const html = renderDailyModelChart([ + { date: today, model: "model-a", total: 100 }, + ]); + expect(html).toContain(MODEL_COLORS[0]!); + }); + + test("renders legend entries", () => { + const today = new Date().toISOString().slice(0, 10); + const html = renderDailyModelChart([ + { date: today, model: "test-model", total: 100 }, + ]); + expect(html).toContain("legend-item"); + expect(html).toContain("test-model"); + }); +}); diff --git a/tests/unit/dashboard/page-template.test.ts b/tests/unit/dashboard/page-template.test.ts new file mode 100644 index 0000000..f0d86cf --- /dev/null +++ b/tests/unit/dashboard/page-template.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { renderHTML } from "../../../src/dashboard/templates/page-template"; + +describe("renderHTML", () => { + const summary = { today: 0, thisWeek: 0, thisMonth: 0, lastMonth: 0 }; + + test("returns full HTML document with doctype", () => { + const html = renderHTML([], summary, [], [], []); + expect(html).toMatch(/^/); + expect(html).toContain(""); + }); + + test("includes page title", () => { + const html = renderHTML([], summary, [], [], []); + expect(html).toContain("OpenCode Usage Stats"); + }); + + test("includes CSS styles", () => { + const html = renderHTML([], summary, [], [], []); + expect(html).toContain("