From cfb5d40f6ff36cee97656a962009463a04ca3d43 Mon Sep 17 00:00:00 2001 From: Christoph Gross <2333584+cgrossde@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:55:20 +0200 Subject: [PATCH] feat(stats): add daily cost chart tab to omp stats dashboard - New Costs tab with bar chart (All Models) and line chart (By Model) - Toggle between 14d / 30d / 90d time ranges, filtered client-side - Bar chart shows cost value on top of each bar - Line chart uses straight lines with always-visible data points - Summary cards: total 30d cost, avg/day, top model, trend vs prior period - Backend: getCostTimeSeries() query grouped by day x model x provider - All cost values displayed as whole dollar amounts (Math.round) --- packages/stats/src/aggregator.ts | 3 +- packages/stats/src/client/App.tsx | 11 +- .../stats/src/client/components/CostChart.tsx | 364 ++++++++++++++++++ .../src/client/components/CostSummary.tsx | 92 +++++ .../stats/src/client/components/Header.tsx | 4 +- packages/stats/src/client/types.ts | 13 + packages/stats/src/db.ts | 40 ++ packages/stats/src/types.ts | 22 ++ 8 files changed, 545 insertions(+), 4 deletions(-) create mode 100644 packages/stats/src/client/components/CostChart.tsx create mode 100644 packages/stats/src/client/components/CostSummary.tsx diff --git a/packages/stats/src/aggregator.ts b/packages/stats/src/aggregator.ts index 3cbb29227..946210275 100644 --- a/packages/stats/src/aggregator.ts +++ b/packages/stats/src/aggregator.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs"; import { getRecentErrors as dbGetRecentErrors, getRecentRequests as dbGetRecentRequests, + getCostTimeSeries, getFileOffset, getMessageById, getMessageCount, @@ -88,9 +89,9 @@ export async function getDashboardStats(): Promise { timeSeries: getTimeSeries(24), modelSeries: getModelTimeSeries(14), modelPerformanceSeries: getModelPerformanceSeries(14), + costSeries: getCostTimeSeries(90), }; } - export async function getRecentRequests(limit?: number): Promise { await initDb(); return dbGetRecentRequests(limit); diff --git a/packages/stats/src/client/App.tsx b/packages/stats/src/client/App.tsx index 569c1775c..92b2e0de6 100644 --- a/packages/stats/src/client/App.tsx +++ b/packages/stats/src/client/App.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useState } from "react"; import { getRecentErrors, getRecentRequests, getStats, sync } from "./api"; import { ChartsContainer } from "./components/ChartsContainer"; +import { CostChart } from "./components/CostChart"; +import { CostSummary } from "./components/CostSummary"; import { Header } from "./components/Header"; import { ModelsTable } from "./components/ModelsTable"; import { RequestDetail } from "./components/RequestDetail"; @@ -8,7 +10,7 @@ import { RequestList } from "./components/RequestList"; import { StatsGrid } from "./components/StatsGrid"; import type { DashboardStats, MessageStats } from "./types"; -type Tab = "overview" | "requests" | "errors" | "models"; +type Tab = "overview" | "requests" | "errors" | "models" | "costs"; export default function App() { const [stats, setStats] = useState(null); @@ -107,6 +109,13 @@ export default function App() { )} + {activeTab === "costs" && ( +
+ + +
+ )} + {selectedRequest !== null && ( setSelectedRequest(null)} /> )} diff --git a/packages/stats/src/client/components/CostChart.tsx b/packages/stats/src/client/components/CostChart.tsx new file mode 100644 index 000000000..3f8dee9bd --- /dev/null +++ b/packages/stats/src/client/components/CostChart.tsx @@ -0,0 +1,364 @@ +import { + BarElement, + CategoryScale, + Chart as ChartJS, + type ChartOptions, + Filler, + Legend, + LinearScale, + LineElement, + PointElement, + Title, + Tooltip, +} from "chart.js"; +import { format } from "date-fns"; +import { useMemo, useState } from "react"; +import { Bar, Line } from "react-chartjs-2"; +import type { CostTimeSeriesPoint } from "../types"; +import { useSystemTheme } from "../useSystemTheme"; + +ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler); + +const MODEL_COLORS = [ + "#a78bfa", // violet + "#22d3ee", // cyan + "#ec4899", // pink + "#4ade80", // green + "#fbbf24", // amber + "#f87171", // red + "#60a5fa", // blue +]; + +const CHART_THEMES = { + dark: { + legendLabel: "#94a3b8", + tooltipBackground: "#16161e", + tooltipTitle: "#f8fafc", + tooltipBody: "#94a3b8", + tooltipBorder: "rgba(255, 255, 255, 0.1)", + grid: "rgba(255, 255, 255, 0.06)", + tick: "#64748b", + barLabel: "rgba(248, 250, 252, 0.7)", + }, + light: { + legendLabel: "#475569", + tooltipBackground: "#ffffff", + tooltipTitle: "#0f172a", + tooltipBody: "#334155", + tooltipBorder: "rgba(15, 23, 42, 0.18)", + grid: "rgba(15, 23, 42, 0.08)", + tick: "#64748b", + barLabel: "rgba(15, 23, 42, 0.6)", + }, +} as const; + +const RANGE_OPTIONS = [14, 30, 90] as const; +type RangeDays = (typeof RANGE_OPTIONS)[number]; + +interface CostChartProps { + costSeries: CostTimeSeriesPoint[]; +} + +/** Inline Chart.js plugin — draws cost value centered above each bar. */ +function makeBarLabelPlugin(color: string): Parameters[0] { + return { + id: "costBarLabels", + afterDatasetsDraw(chart) { + const { ctx } = chart; + const dataset = chart.data.datasets[0]; + if (!dataset) return; + const meta = chart.getDatasetMeta(0); + ctx.save(); + ctx.font = "11px system-ui, sans-serif"; + ctx.fillStyle = color; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + for (const bar of meta.data) { + const value = (bar as unknown as { $context: { parsed: { y: number } } }).$context.parsed.y; + if (!value) continue; + const label = `$${Math.round(value)}`; + const { x, y } = bar.getProps(["x", "y"], true) as { x: number; y: number }; + ctx.fillText(label, x, y - 3); + } + ctx.restore(); + }, + }; +} + +export function CostChart({ costSeries }: CostChartProps) { + const [byModel, setByModel] = useState(false); + const [days, setDays] = useState(30); + const theme = useSystemTheme(); + const chartTheme = CHART_THEMES[theme]; + + const cutoff = Date.now() - days * 86400000; + const filtered = useMemo(() => costSeries.filter(p => p.timestamp >= cutoff), [costSeries, cutoff]); + + const chartData = useMemo( + () => (byModel ? buildByModelSeries(filtered) : buildAggregateSeries(filtered)), + [filtered, byModel], + ); + + const sharedPlugins = { + legend: { + display: byModel, + position: "top" as const, + align: "start" as const, + labels: { + color: chartTheme.legendLabel, + usePointStyle: true, + padding: 16, + font: { size: 12 }, + boxWidth: 8, + }, + }, + tooltip: { + backgroundColor: chartTheme.tooltipBackground, + titleColor: chartTheme.tooltipTitle, + bodyColor: chartTheme.tooltipBody, + borderColor: chartTheme.tooltipBorder, + borderWidth: 1, + padding: 12, + cornerRadius: 8, + callbacks: { + label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => { + const label = context.dataset.label ?? "Cost"; + const value = context.parsed.y ?? 0; + return `${label}: $${Math.round(value)}`; + }, + footer: (items: { parsed: { y: number | null } }[]) => { + if (!byModel || items.length < 2) return undefined; + const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0); + return `Total: $${Math.round(total)}`; + }, + }, + }, + }; + + const sharedScaleBase = { + grid: { color: chartTheme.grid, drawBorder: false }, + ticks: { color: chartTheme.tick, font: { size: 11 } }, + }; + + const yScale = { + ...sharedScaleBase, + ticks: { + ...sharedScaleBase.ticks, + callback: (value: number | string) => `$${Math.round(Number(value))}`, + }, + min: 0, + }; + + if (byModel) { + const lineData = { + labels: chartData.labels, + datasets: chartData.datasets.map((ds, index) => ({ + label: ds.label, + data: ds.data, + borderColor: MODEL_COLORS[index % MODEL_COLORS.length], + backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`, + fill: true, + tension: 0, + pointRadius: 3, + pointHoverRadius: 4, + borderWidth: 2, + })), + }; + + const lineOptions: ChartOptions<"line"> = { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + plugins: sharedPlugins, + scales: { x: sharedScaleBase, y: yScale }, + }; + + return ( + + + + ); + } + + const barData = { + labels: chartData.labels, + datasets: chartData.datasets.map((ds, index) => ({ + label: ds.label, + data: ds.data, + backgroundColor: MODEL_COLORS[index % MODEL_COLORS.length], + borderColor: MODEL_COLORS[index % MODEL_COLORS.length], + borderWidth: 0, + borderRadius: 3, + })), + }; + + const barLabelPlugin = makeBarLabelPlugin(chartTheme.barLabel); + + const barOptions: ChartOptions<"bar"> = { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + plugins: { ...sharedPlugins, costBarLabels: {} } as ChartOptions<"bar">["plugins"], + scales: { + x: { ...sharedScaleBase, stacked: true }, + y: { ...yScale, stacked: true }, + }, + layout: { padding: { top: 24 } }, + }; + + return ( + + + + ); +} + +interface ChartWrapperProps { + byModel: boolean; + days: RangeDays; + onByModelChange: (v: boolean) => void; + onDaysChange: (v: RangeDays) => void; + empty: boolean; + children: React.ReactNode; +} + +function ChartWrapper({ byModel, days, onByModelChange, onDaysChange, empty, children }: ChartWrapperProps) { + return ( +
+
+
+

Daily Cost

+

API spending over time

+
+
+
+ + +
+
+ {RANGE_OPTIONS.map(d => ( + + ))} +
+
+
+
+ {empty ? ( +
+ No cost data available +
+ ) : ( +
{children}
+ )} +
+
+ ); +} + +interface ChartSeries { + labels: string[]; + datasets: Array<{ label: string; data: number[] }>; +} + +function buildAggregateSeries(points: CostTimeSeriesPoint[]): ChartSeries { + if (points.length === 0) return { labels: [], datasets: [] }; + + const byDay = new Map(); + for (const point of points) { + byDay.set(point.timestamp, (byDay.get(point.timestamp) ?? 0) + point.cost); + } + + const sorted = [...byDay.entries()].sort((a, b) => a[0] - b[0]); + return { + labels: sorted.map(([ts]) => format(new Date(ts), "MMM d")), + datasets: [{ label: "Cost", data: sorted.map(([, cost]) => cost) }], + }; +} + +function buildByModelSeries(points: CostTimeSeriesPoint[], topN = 5): ChartSeries { + if (points.length === 0) return { labels: [], datasets: [] }; + + // Rank models by total cost + const totals = new Map(); + for (const point of points) { + const key = `${point.model}::${point.provider}`; + const existing = totals.get(key); + if (existing) { + existing.total += point.cost; + } else { + totals.set(key, { model: point.model, provider: point.provider, total: point.cost }); + } + } + + const sorted = [...totals.entries()].sort((a, b) => b[1].total - a[1].total); + const topEntries = sorted.slice(0, topN); + const topKeys = new Set(topEntries.map(([key]) => key)); + + // Disambiguate model labels when same model name appears from multiple providers + const modelCount = new Map(); + for (const [, { model }] of topEntries) { + modelCount.set(model, (modelCount.get(model) ?? 0) + 1); + } + const labelByKey = new Map(); + for (const [key, { model, provider }] of topEntries) { + labelByKey.set(key, (modelCount.get(model) ?? 0) > 1 ? `${model} (${provider})` : model); + } + + // Collect all day buckets + const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b); + + // Build per-day, per-series totals + const seriesNames = topEntries.map(([key]) => labelByKey.get(key) ?? key); + const hasOther = points.some(p => !topKeys.has(`${p.model}::${p.provider}`)); + if (hasOther) seriesNames.push("Other"); + + const dayMap = new Map>(); + for (const day of allDays) { + dayMap.set(day, {}); + } + for (const point of points) { + const key = `${point.model}::${point.provider}`; + const label = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other"; + const row = dayMap.get(point.timestamp)!; + row[label] = (row[label] ?? 0) + point.cost; + } + + return { + labels: allDays.map(ts => format(new Date(ts), "MMM d")), + datasets: seriesNames.map(name => ({ + label: name, + data: allDays.map(day => dayMap.get(day)?.[name] ?? 0), + })), + }; +} diff --git a/packages/stats/src/client/components/CostSummary.tsx b/packages/stats/src/client/components/CostSummary.tsx new file mode 100644 index 000000000..a9ace2888 --- /dev/null +++ b/packages/stats/src/client/components/CostSummary.tsx @@ -0,0 +1,92 @@ +import { useMemo } from "react"; +import type { CostTimeSeriesPoint } from "../types"; + +interface CostSummaryProps { + costSeries: CostTimeSeriesPoint[]; +} + +const SUMMARY_DAYS = 30; + +function formatCost(value: number): string { + return `$${Math.round(value)}`; +} + +export function CostSummary({ costSeries }: CostSummaryProps) { + const cutoff = Date.now() - SUMMARY_DAYS * 86400000; + const prevCutoff = cutoff - SUMMARY_DAYS * 86400000; + + const current = useMemo(() => costSeries.filter(p => p.timestamp >= cutoff), [costSeries, cutoff]); + const previous = useMemo( + () => costSeries.filter(p => p.timestamp >= prevCutoff && p.timestamp < cutoff), + [costSeries, prevCutoff, cutoff], + ); + + const totalCost = current.reduce((sum, p) => sum + p.cost, 0); + const prevTotalCost = previous.reduce((sum, p) => sum + p.cost, 0); + + const dayBuckets = new Set(current.map(p => p.timestamp)).size; + const avgDaily = dayBuckets > 0 ? totalCost / dayBuckets : 0; + + // Most expensive model over current period + const modelTotals = new Map(); + for (const point of current) { + modelTotals.set(point.model, (modelTotals.get(point.model) ?? 0) + point.cost); + } + let topModel = ""; + let topModelCost = 0; + for (const [model, cost] of modelTotals) { + if (cost > topModelCost) { + topModel = model; + topModelCost = cost; + } + } + + const trend = prevTotalCost > 0 ? ((totalCost - prevTotalCost) / prevTotalCost) * 100 : null; + + const cards = [ + { + label: "Total (30d)", + value: formatCost(totalCost), + positive: null as boolean | null, + }, + { + label: "Avg / day", + value: formatCost(avgDaily), + positive: null as boolean | null, + }, + { + label: "Top model", + value: topModel || "—", + sub: topModel ? formatCost(topModelCost) : undefined, + positive: null as boolean | null, + }, + { + label: "vs prev 30d", + value: trend !== null ? `${trend >= 0 ? "+" : ""}${Math.round(trend)}%` : "—", + sub: undefined as string | undefined, + positive: trend !== null ? trend <= 0 : null, + }, + ]; + + return ( +
+ {cards.map(card => ( +
+

{card.label}

+

+ {card.value} +

+ {card.sub &&

{card.sub}

} +
+ ))} +
+ ); +} diff --git a/packages/stats/src/client/components/Header.tsx b/packages/stats/src/client/components/Header.tsx index 1f974ae1d..b2d0deb53 100644 --- a/packages/stats/src/client/components/Header.tsx +++ b/packages/stats/src/client/components/Header.tsx @@ -1,6 +1,6 @@ import { Activity, RefreshCw } from "lucide-react"; -type Tab = "overview" | "requests" | "errors" | "models"; +type Tab = "overview" | "requests" | "errors" | "models" | "costs"; interface HeaderProps { activeTab: Tab; @@ -9,7 +9,7 @@ interface HeaderProps { syncing: boolean; } -const tabs: Tab[] = ["overview", "requests", "errors", "models"]; +const tabs: Tab[] = ["overview", "requests", "errors", "models", "costs"]; export function Header({ activeTab, onTabChange, onSync, syncing }: HeaderProps) { return ( diff --git a/packages/stats/src/client/types.ts b/packages/stats/src/client/types.ts index 1b2b2c449..bf8da9344 100644 --- a/packages/stats/src/client/types.ts +++ b/packages/stats/src/client/types.ts @@ -92,6 +92,18 @@ export interface ModelPerformancePoint { avgTokensPerSecond: number | null; } +export interface CostTimeSeriesPoint { + timestamp: number; + model: string; + provider: string; + cost: number; + costInput: number; + costOutput: number; + costCacheRead: number; + costCacheWrite: number; + requests: number; +} + export interface DashboardStats { overall: AggregatedStats; byModel: ModelStats[]; @@ -99,4 +111,5 @@ export interface DashboardStats { timeSeries: TimeSeriesPoint[]; modelSeries: ModelTimeSeriesPoint[]; modelPerformanceSeries: ModelPerformancePoint[]; + costSeries: CostTimeSeriesPoint[]; } diff --git a/packages/stats/src/db.ts b/packages/stats/src/db.ts index 2d1444833..30f2d0fe8 100644 --- a/packages/stats/src/db.ts +++ b/packages/stats/src/db.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs/promises"; import { getConfigRootDir, getStatsDbPath } from "@oh-my-pi/pi-utils"; import type { AggregatedStats, + CostTimeSeriesPoint, FolderStats, MessageStats, ModelPerformancePoint, @@ -480,3 +481,42 @@ export function getMessageById(id: number): MessageStats | null { const row = stmt.get(id); return row ? rowToMessageStats(row) : null; } + +/** + * Get daily cost time series data for the last N days, broken down by model. + */ +export function getCostTimeSeries(days = 90): CostTimeSeriesPoint[] { + if (!db) return []; + + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; + + const stmt = db.prepare(` + SELECT + (timestamp / 86400000) * 86400000 as bucket, + model, + provider, + SUM(cost_total) as cost, + SUM(cost_input) as cost_input, + SUM(cost_output) as cost_output, + SUM(cost_cache_read) as cost_cache_read, + SUM(cost_cache_write) as cost_cache_write, + COUNT(*) as requests + FROM messages + WHERE timestamp >= ? + GROUP BY bucket, model, provider + ORDER BY bucket ASC + `); + + const rows = stmt.all(cutoff) as any[]; + return rows.map(row => ({ + timestamp: row.bucket, + model: row.model, + provider: row.provider, + cost: row.cost, + costInput: row.cost_input, + costOutput: row.cost_output, + costCacheRead: row.cost_cache_read, + costCacheWrite: row.cost_cache_write, + requests: row.requests, + })); +} diff --git a/packages/stats/src/types.ts b/packages/stats/src/types.ts index 302d10890..4ddb5a052 100644 --- a/packages/stats/src/types.ts +++ b/packages/stats/src/types.ts @@ -140,6 +140,27 @@ export interface ModelPerformancePoint { avgTokensPerSecond: number | null; } +/** + * Cost time series data point (daily buckets). + */ +export interface CostTimeSeriesPoint { + /** Bucket timestamp (start of day) */ + timestamp: number; + /** Model name */ + model: string; + /** Provider name */ + provider: string; + /** Total cost for this bucket */ + cost: number; + /** Cost breakdown */ + costInput: number; + costOutput: number; + costCacheRead: number; + costCacheWrite: number; + /** Request count */ + requests: number; +} + /** * Overall dashboard stats. */ @@ -150,6 +171,7 @@ export interface DashboardStats { timeSeries: TimeSeriesPoint[]; modelSeries: ModelTimeSeriesPoint[]; modelPerformanceSeries: ModelPerformancePoint[]; + costSeries: CostTimeSeriesPoint[]; } /**