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)
This commit is contained in:
Christoph Gross
2026-04-13 10:55:20 +02:00
committed by can1357
parent 7aac1a429e
commit cfb5d40f6f
8 changed files with 545 additions and 4 deletions
+2 -1
View File
@@ -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<DashboardStats> {
timeSeries: getTimeSeries(24),
modelSeries: getModelTimeSeries(14),
modelPerformanceSeries: getModelPerformanceSeries(14),
costSeries: getCostTimeSeries(90),
};
}
export async function getRecentRequests(limit?: number): Promise<MessageStats[]> {
await initDb();
return dbGetRecentRequests(limit);
+10 -1
View File
@@ -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<DashboardStats | null>(null);
@@ -107,6 +109,13 @@ export default function App() {
</div>
)}
{activeTab === "costs" && (
<div className="space-y-6 animate-fade-in">
<CostSummary costSeries={stats.costSeries} />
<CostChart costSeries={stats.costSeries} />
</div>
)}
{selectedRequest !== null && (
<RequestDetail id={selectedRequest} onClose={() => setSelectedRequest(null)} />
)}
@@ -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<typeof ChartJS.register>[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<RangeDays>(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 (
<ChartWrapper
byModel={byModel}
days={days}
onByModelChange={setByModel}
onDaysChange={setDays}
empty={chartData.labels.length === 0}
>
<Line data={lineData} options={lineOptions} />
</ChartWrapper>
);
}
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 (
<ChartWrapper
byModel={byModel}
days={days}
onByModelChange={setByModel}
onDaysChange={setDays}
empty={chartData.labels.length === 0}
>
<Bar data={barData} options={barOptions} plugins={[barLabelPlugin]} />
</ChartWrapper>
);
}
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 (
<div className="surface overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--border-subtle)] flex items-center justify-between gap-4 flex-wrap">
<div>
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Daily Cost</h3>
<p className="text-xs text-[var(--text-muted)] mt-1">API spending over time</p>
</div>
<div className="flex items-center gap-2">
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--border-subtle)]">
<button
type="button"
onClick={() => onByModelChange(false)}
className={`tab-btn text-xs ${!byModel ? "active" : ""}`}
>
All Models
</button>
<button
type="button"
onClick={() => onByModelChange(true)}
className={`tab-btn text-xs ${byModel ? "active" : ""}`}
>
By Model
</button>
</div>
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--radius-sm)] border-[var(--border-subtle)]">
{RANGE_OPTIONS.map(d => (
<button
key={d}
type="button"
onClick={() => onDaysChange(d)}
className={`tab-btn text-xs ${days === d ? "active" : ""}`}
>
{d}d
</button>
))}
</div>
</div>
</div>
<div className="p-5 min-h-[320px]">
{empty ? (
<div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
No cost data available
</div>
) : (
<div className="h-[280px]">{children}</div>
)}
</div>
</div>
);
}
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<number, number>();
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<string, { model: string; provider: string; total: number }>();
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<string, number>();
for (const [, { model }] of topEntries) {
modelCount.set(model, (modelCount.get(model) ?? 0) + 1);
}
const labelByKey = new Map<string, string>();
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<number, Record<string, number>>();
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),
})),
};
}
@@ -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<string, number>();
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 (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{cards.map(card => (
<div key={card.label} className="surface px-4 py-3">
<p className="text-xs text-[var(--text-muted)] mb-1">{card.label}</p>
<p
className={`text-lg font-semibold ${
card.positive === true
? "text-[var(--accent-green,#4ade80)]"
: card.positive === false
? "text-[var(--accent-pink)]"
: "text-[var(--text-primary)]"
}`}
>
{card.value}
</p>
{card.sub && <p className="text-xs text-[var(--text-muted)] mt-0.5">{card.sub}</p>}
</div>
))}
</div>
);
}
@@ -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 (
+13
View File
@@ -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[];
}
+40
View File
@@ -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,
}));
}
+22
View File
@@ -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[];
}
/**