import type { DashboardUsageDaily, DashboardUsageByAgent, DashboardAgentRunTime, } from "@multica/core/types"; import { estimateCost, estimateCostBreakdown } from "../runtimes/utils"; // --------------------------------------------------------------------------- // Dashboard data aggregations // // The workspace dashboard returns the same per-(date, model) and // per-(agent, model) shapes the runtime page does, so cost math reuses // `estimateCost` / `estimateCostBreakdown` from the runtimes utils. What // the runtimes view does with `aggregateByDate` (works on RuntimeUsage, // which carries a `provider` field) we replicate here with a tighter // type — fewer optional fields, less conditional logic on the consumer // side. // --------------------------------------------------------------------------- export interface DailyCostStack { date: string; label: string; input: number; output: number; cacheWrite: number; total: number; } function formatDateLabel(d: string): string { // Anchor to local midnight so the formatted label matches the bucket the // server picked (which is already in workspace time). Pasting the raw // date as the body of `new Date()` would interpret it as UTC and shift // by the user's offset. const date = new Date(d + "T00:00:00"); return `${date.getMonth() + 1}/${date.getDate()}`; } // Per-(date, model) rows → 1 row per date with cost broken into the three // segments the stacked bar chart consumes. Stable sort by date asc so the // chart x-axis is left-to-right oldest-to-newest. export function aggregateDailyCost(usage: DashboardUsageDaily[]): DailyCostStack[] { const map = new Map(); for (const u of usage) { const b = estimateCostBreakdown(u); const entry = map.get(u.date) ?? { input: 0, output: 0, cacheWrite: 0 }; entry.input += b.input; entry.output += b.output; entry.cacheWrite += b.cacheWrite; map.set(u.date, entry); } const round = (n: number) => Math.round(n * 100) / 100; return [...map.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([date, s]) => { const input = round(s.input); const output = round(s.output); const cacheWrite = round(s.cacheWrite); return { date, label: formatDateLabel(date), input, output, cacheWrite, total: round(input + output + cacheWrite), }; }); } export interface DashboardTokenTotals { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; taskCount: number; } // Whole-window totals for the KPI tiles. taskCount sums DISTINCT task counts // per row — these are already collapsed server-side per (date, model), so // the value can over-count if the same task has tokens in two days; that's // acceptable for a KPI ("rough volume") and the per-agent run-time card // gives the precise figure. export function computeDailyTotals(usage: DashboardUsageDaily[]): DashboardTokenTotals { return usage.reduce( (acc, u) => ({ input: acc.input + u.input_tokens, output: acc.output + u.output_tokens, cacheRead: acc.cacheRead + u.cache_read_tokens, cacheWrite: acc.cacheWrite + u.cache_write_tokens, cost: acc.cost + estimateCost(u), taskCount: acc.taskCount + u.task_count, }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, taskCount: 0 }, ); } export interface AgentCostRow { agentId: string; tokens: number; cost: number; taskCount: number; } // Fold per-(agent, model) rows into one row per agent. Cost is the sum // across this agent's models, which is the figure the user cares about. // Sort by cost desc so the heaviest spender lands first. export function aggregateAgentTokens(rows: DashboardUsageByAgent[]): AgentCostRow[] { const map = new Map(); for (const r of rows) { const entry = map.get(r.agent_id) ?? { agentId: r.agent_id, tokens: 0, cost: 0, taskCount: 0, }; entry.tokens += r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens; entry.cost += estimateCost(r); entry.taskCount += r.task_count; map.set(r.agent_id, entry); } return [...map.values()].sort((a, b) => b.cost - a.cost); } export interface AgentDashboardRow { agentId: string; tokens: number; cost: number; seconds: number; taskCount: number; } // Merge per-agent token totals with per-agent run-time totals into one // row per agent. // // taskCount comes from `runTimeRows` when available — that rollup is a // true per-agent distinct count (`COUNT(*)` on (agent, terminal-task) in // SQL). The token rollup's per-(agent, model) counts double-count a task // when it spans multiple models, so we only fall back to it for agents // with no terminal run yet (in-flight tasks reported tokens but haven't // completed). Sorted by cost desc, then run time desc. export function mergeAgentDashboardRows( tokenRows: AgentCostRow[], runTimeRows: DashboardAgentRunTime[], ): AgentDashboardRow[] { const runTimeByAgent = new Map( runTimeRows.map((r) => [r.agent_id, r] as const), ); const merged = new Map(); for (const r of tokenRows) { const rt = runTimeByAgent.get(r.agentId); merged.set(r.agentId, { agentId: r.agentId, tokens: r.tokens, cost: r.cost, seconds: rt?.total_seconds ?? 0, taskCount: rt ? rt.task_count : r.taskCount, }); } // Agents with run-time rows but zero tokens still belong on the list // (a task that errored before producing usage). Their token columns // stay at 0. for (const r of runTimeRows) { if (merged.has(r.agent_id)) continue; merged.set(r.agent_id, { agentId: r.agent_id, tokens: 0, cost: 0, seconds: r.total_seconds, taskCount: r.task_count, }); } return [...merged.values()].sort((a, b) => { if (b.cost !== a.cost) return b.cost - a.cost; return b.seconds - a.seconds; }); } // Compact human duration: "1h 23m" / "12m 30s" / "45s" / "<1m". Used for // the dashboard run-time KPI and the per-agent run-time column. Keeps two // segments max — three segments adds visual noise without precision the // dashboard actually needs. export function formatDuration(seconds: number, lessThanMinuteLabel: string): string { if (seconds < 0 || !Number.isFinite(seconds)) return lessThanMinuteLabel; if (seconds < 60) { if (seconds < 1) return lessThanMinuteLabel; return `${Math.round(seconds)}s`; } const totalMinutes = Math.floor(seconds / 60); const hours = Math.floor(totalMinutes / 60); const mins = totalMinutes % 60; if (hours === 0) { const secs = Math.floor(seconds) % 60; return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; } if (hours >= 24) { const days = Math.floor(hours / 24); const h = hours % 24; return h > 0 ? `${days}d ${h}h` : `${days}d`; } return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; }