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:
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user