From 00bb61b054ab55cf5b28ddc15d7f6e833f349de7 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 17 May 2026 02:15:37 +0200 Subject: [PATCH] feat(stats): added time-range-aware bucketing to model series charts - Passed `bucketMs` to `getModelTimeSeries` and `getModelPerformanceSeries` so SQL bucketing matches the selected range. - Added `range-meta.ts` to centralise bucket sizes, counts, tick formats, and labels per time range. - Updated `ChartsContainer` and `ModelsTable` to use range-aware x-axis formatting and trend labels. --- packages/stats/src/aggregator.ts | 37 ++++++++-- packages/stats/src/client/App.tsx | 3 +- .../src/client/components/ChartsContainer.tsx | 12 ++-- .../src/client/components/ModelsTable.tsx | 29 +++++--- .../stats/src/client/components/range-meta.ts | 72 +++++++++++++++++++ packages/stats/src/db.ts | 29 ++++++-- 6 files changed, 154 insertions(+), 28 deletions(-) create mode 100644 packages/stats/src/client/components/range-meta.ts diff --git a/packages/stats/src/aggregator.ts b/packages/stats/src/aggregator.ts index 610303eee..a58125cce 100644 --- a/packages/stats/src/aggregator.ts +++ b/packages/stats/src/aggregator.ts @@ -266,7 +266,9 @@ interface TimeRangeConfig { timeSeriesHours: number; timeSeriesBucketMs: number; modelSeriesDays: number; + modelSeriesBucketMs: number; modelPerformanceDays: number; + modelPerformanceBucketMs: number; costSeriesDays: number; cutoff: number | null; } @@ -278,42 +280,54 @@ const TIME_RANGE_TO_CONFIG: Record> = timeSeriesHours: 1, timeSeriesBucketMs: HOUR_MS, modelSeriesDays: 1, + modelSeriesBucketMs: HOUR_MS, modelPerformanceDays: 1, + modelPerformanceBucketMs: HOUR_MS, costSeriesDays: 1, }, "24h": { timeSeriesHours: 24, timeSeriesBucketMs: HOUR_MS, modelSeriesDays: 1, + modelSeriesBucketMs: HOUR_MS, modelPerformanceDays: 1, + modelPerformanceBucketMs: HOUR_MS, costSeriesDays: 1, }, "7d": { timeSeriesHours: 24 * 7, timeSeriesBucketMs: DAY_MS, modelSeriesDays: 7, + modelSeriesBucketMs: DAY_MS, modelPerformanceDays: 7, + modelPerformanceBucketMs: DAY_MS, costSeriesDays: 7, }, "30d": { timeSeriesHours: 24 * 30, timeSeriesBucketMs: DAY_MS, modelSeriesDays: 30, + modelSeriesBucketMs: DAY_MS, modelPerformanceDays: 30, + modelPerformanceBucketMs: DAY_MS, costSeriesDays: 30, }, "90d": { timeSeriesHours: 24 * 90, timeSeriesBucketMs: DAY_MS, modelSeriesDays: 90, + modelSeriesBucketMs: DAY_MS, modelPerformanceDays: 90, + modelPerformanceBucketMs: DAY_MS, costSeriesDays: 90, }, all: { timeSeriesHours: 24 * 3650, timeSeriesBucketMs: DAY_MS, modelSeriesDays: 3650, + modelSeriesBucketMs: DAY_MS, modelPerformanceDays: 3650, + modelPerformanceBucketMs: DAY_MS, costSeriesDays: 3650, }, }; @@ -338,16 +352,24 @@ function getTimeRangeConfig(range?: string | null): TimeRangeConfig { */ export async function getDashboardStats(range?: string | null): Promise { await initDb(); - const { timeSeriesHours, timeSeriesBucketMs, modelSeriesDays, modelPerformanceDays, costSeriesDays, cutoff } = - getTimeRangeConfig(range); + const { + timeSeriesHours, + timeSeriesBucketMs, + modelSeriesDays, + modelSeriesBucketMs, + modelPerformanceDays, + modelPerformanceBucketMs, + costSeriesDays, + cutoff, + } = getTimeRangeConfig(range); return { overall: getOverallStats(cutoff ?? undefined), byModel: getStatsByModel(cutoff ?? undefined), byFolder: getStatsByFolder(cutoff ?? undefined), timeSeries: getTimeSeries(timeSeriesHours, cutoff, timeSeriesBucketMs), - modelSeries: getModelTimeSeries(modelSeriesDays, cutoff), - modelPerformanceSeries: getModelPerformanceSeries(modelPerformanceDays, cutoff), + modelSeries: getModelTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs), + modelPerformanceSeries: getModelPerformanceSeries(modelPerformanceDays, cutoff, modelPerformanceBucketMs), costSeries: getCostTimeSeries(costSeriesDays, cutoff), }; } @@ -366,12 +388,13 @@ export async function getModelDashboardStats( range?: string | null, ): Promise> { await initDb(); - const { modelSeriesDays, modelPerformanceDays, cutoff } = getTimeRangeConfig(range); + const { modelSeriesDays, modelSeriesBucketMs, modelPerformanceDays, modelPerformanceBucketMs, cutoff } = + getTimeRangeConfig(range); return { byModel: getStatsByModel(cutoff ?? undefined), - modelSeries: getModelTimeSeries(modelSeriesDays, cutoff), - modelPerformanceSeries: getModelPerformanceSeries(modelPerformanceDays, cutoff), + modelSeries: getModelTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs), + modelPerformanceSeries: getModelPerformanceSeries(modelPerformanceDays, cutoff, modelPerformanceBucketMs), }; } diff --git a/packages/stats/src/client/App.tsx b/packages/stats/src/client/App.tsx index 6c99ab615..bf1fb9ac4 100644 --- a/packages/stats/src/client/App.tsx +++ b/packages/stats/src/client/App.tsx @@ -155,10 +155,11 @@ export default function App() {
{modelStats ? ( <> - + ) : ( diff --git a/packages/stats/src/client/components/ChartsContainer.tsx b/packages/stats/src/client/components/ChartsContainer.tsx index 74b0c80a9..d63fb5a67 100644 --- a/packages/stats/src/client/components/ChartsContainer.tsx +++ b/packages/stats/src/client/components/ChartsContainer.tsx @@ -9,11 +9,11 @@ import { Title, Tooltip, } from "chart.js"; -import { format } from "date-fns"; import { useMemo } from "react"; import { Line } from "react-chartjs-2"; -import type { ModelTimeSeriesPoint } from "../types"; +import type { ModelTimeSeriesPoint, TimeRange } from "../types"; import { useSystemTheme } from "../useSystemTheme"; +import { formatRangeTick, rangeMeta } from "./range-meta"; ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler); @@ -49,14 +49,16 @@ const CHART_THEMES = { } as const; interface ChartsContainerProps { modelSeries: ModelTimeSeriesPoint[]; + timeRange: TimeRange; } -export function ChartsContainer({ modelSeries }: ChartsContainerProps) { +export function ChartsContainer({ modelSeries, timeRange }: ChartsContainerProps) { const chartData = useMemo(() => buildModelPreferenceSeries(modelSeries), [modelSeries]); const theme = useSystemTheme(); const chartTheme = CHART_THEMES[theme]; + const meta = rangeMeta(timeRange); const data = { - labels: chartData.data.map(d => format(new Date(d.timestamp), "MMM d")), + labels: chartData.data.map(d => formatRangeTick(d.timestamp, timeRange)), datasets: chartData.series.map((seriesName, index) => ({ label: seriesName, data: chartData.data.map(d => d[seriesName] ?? 0), @@ -137,7 +139,7 @@ export function ChartsContainer({ modelSeries }: ChartsContainerProps) {

Model Preference

-

Share of requests over the last 14 days

+

Share of requests over {meta.windowLabel}

{chartData.data.length === 0 ? ( diff --git a/packages/stats/src/client/components/ModelsTable.tsx b/packages/stats/src/client/components/ModelsTable.tsx index 4f0fa629f..be83010e6 100644 --- a/packages/stats/src/client/components/ModelsTable.tsx +++ b/packages/stats/src/client/components/ModelsTable.tsx @@ -11,7 +11,7 @@ import { import { format } from "date-fns"; import { useMemo, useState } from "react"; import { Line } from "react-chartjs-2"; -import type { ModelPerformancePoint, ModelStats } from "../types"; +import type { ModelPerformancePoint, ModelStats, TimeRange } from "../types"; import { useSystemTheme } from "../useSystemTheme"; import { DetailChartEmpty, @@ -29,6 +29,7 @@ import { type TableChartTheme, TrendEmpty, } from "./models-table-shared"; +import { rangeMeta } from "./range-meta"; ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); @@ -37,6 +38,7 @@ const GRID_TEMPLATE = "2fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 140px 40px"; interface ModelsTableProps { models: ModelStats[]; performanceSeries: ModelPerformancePoint[]; + timeRange: TimeRange; } type ModelPerformanceSeries = { @@ -49,10 +51,14 @@ type ModelPerformanceSeries = { }>; }; -export function ModelsTable({ models, performanceSeries }: ModelsTableProps) { +export function ModelsTable({ models, performanceSeries, timeRange }: ModelsTableProps) { const [expandedKey, setExpandedKey] = useState(null); + const meta = rangeMeta(timeRange); - const performanceSeriesByKey = useMemo(() => buildModelPerformanceLookup(performanceSeries), [performanceSeries]); + const performanceSeriesByKey = useMemo( + () => buildModelPerformanceLookup(performanceSeries, meta.bucketCount, meta.bucketMs), + [performanceSeries, meta.bucketCount, meta.bucketMs], + ); const theme = useSystemTheme(); const chartTheme = TABLE_CHART_THEMES[theme]; const sortedModels = [...models].sort( @@ -70,7 +76,7 @@ export function ModelsTable({ models, performanceSeries }: ModelsTableProps) { { label: "Tokens", align: "right" }, { label: "Tokens/s", align: "right" }, { label: "TTFT", align: "right" }, - { label: "14d Trend", align: "center" }, + { label: meta.trendLabel, align: "center" }, ]} /> @@ -214,12 +220,17 @@ function PerformanceChart({ return ; } -function buildModelPerformanceLookup(points: ModelPerformancePoint[], days = 14): Map { - const dayMs = 24 * 60 * 60 * 1000; +function buildModelPerformanceLookup( + points: ModelPerformancePoint[], + bucketCount: number, + bucketMs: number, +): Map { const maxTimestamp = points.reduce((max, point) => Math.max(max, point.timestamp), 0); - const anchor = maxTimestamp > 0 ? maxTimestamp : Math.floor(Date.now() / dayMs) * dayMs; - const start = anchor - (days - 1) * dayMs; - const buckets = Array.from({ length: days }, (_, index) => start + index * dayMs); + const anchor = maxTimestamp > 0 ? maxTimestamp : Math.floor(Date.now() / bucketMs) * bucketMs; + const uniqueTimestamps = new Set(points.map(p => p.timestamp)); + const effectiveCount = bucketCount > 0 ? bucketCount : Math.max(1, uniqueTimestamps.size); + const start = anchor - (effectiveCount - 1) * bucketMs; + const buckets = Array.from({ length: effectiveCount }, (_, index) => start + index * bucketMs); const bucketIndex = new Map(buckets.map((timestamp, index) => [timestamp, index])); const seriesByKey = new Map(); diff --git a/packages/stats/src/client/components/range-meta.ts b/packages/stats/src/client/components/range-meta.ts new file mode 100644 index 000000000..dcc05dbcc --- /dev/null +++ b/packages/stats/src/client/components/range-meta.ts @@ -0,0 +1,72 @@ +/** + * Display metadata for a `TimeRange` — keeps chart labels, sparkline bucket + * counts, and x-axis date formatting in sync with the server-side bucketing + * defined in `aggregator.ts`. + */ + +import { format } from "date-fns"; +import type { TimeRange } from "../types"; + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +export interface RangeMeta { + /** Human label used in chart subtitles ("the last 24 hours"). */ + windowLabel: string; + /** Short prefix used in compact column headers ("24h Trend"). */ + trendLabel: string; + /** Bucket size matching the server query for this range. */ + bucketMs: number; + /** Number of buckets the server is expected to return for this range. */ + bucketCount: number; + /** date-fns format string for x-axis labels and tooltip headings. */ + tickFormat: string; +} + +const RANGE_META: Record = { + "1h": { + windowLabel: "the last hour", + trendLabel: "1h Trend", + bucketMs: HOUR_MS, + bucketCount: 1, + tickFormat: "HH:mm", + }, + "24h": { + windowLabel: "the last 24 hours", + trendLabel: "24h Trend", + bucketMs: HOUR_MS, + bucketCount: 24, + tickFormat: "HH:mm", + }, + "7d": { + windowLabel: "the last 7 days", + trendLabel: "7d Trend", + bucketMs: DAY_MS, + bucketCount: 7, + tickFormat: "MMM d", + }, + "30d": { + windowLabel: "the last 30 days", + trendLabel: "30d Trend", + bucketMs: DAY_MS, + bucketCount: 30, + tickFormat: "MMM d", + }, + "90d": { + windowLabel: "the last 90 days", + trendLabel: "90d Trend", + bucketMs: DAY_MS, + bucketCount: 90, + tickFormat: "MMM d", + }, + all: { windowLabel: "all time", trendLabel: "Trend", bucketMs: DAY_MS, bucketCount: 0, tickFormat: "MMM d" }, +}; + +export function rangeMeta(range: TimeRange): RangeMeta { + return RANGE_META[range]; +} + +/** Format a bucket timestamp using the active range's tick format. */ +export function formatRangeTick(timestamp: number, range: TimeRange): string { + return format(new Date(timestamp), RANGE_META[range].tickFormat); +} diff --git a/packages/stats/src/db.ts b/packages/stats/src/db.ts index 6edd82f0c..2720fd801 100644 --- a/packages/stats/src/db.ts +++ b/packages/stats/src/db.ts @@ -554,7 +554,11 @@ export function getTimeSeries(hours = 24, cutoff?: number | null, bucketMs = 60 /** * Get daily model usage time series data for the last N days. */ -export function getModelTimeSeries(days = 14, cutoff?: number | null): ModelTimeSeriesPoint[] { +export function getModelTimeSeries( + days = 14, + cutoff?: number | null, + bucketMs = 24 * 60 * 60 * 1000, +): ModelTimeSeriesPoint[] { if (!db) return []; const hasCutoff = cutoff !== null; @@ -562,7 +566,7 @@ export function getModelTimeSeries(days = 14, cutoff?: number | null): ModelTime const stmt = db.prepare(` SELECT - (timestamp / 86400000) * 86400000 as bucket, + (timestamp / ?) * ? as bucket, model, provider, COUNT(*) as requests @@ -572,7 +576,8 @@ export function getModelTimeSeries(days = 14, cutoff?: number | null): ModelTime ORDER BY bucket ASC `); - const rows = hasCutoff ? (stmt.all(seriesCutoff) as any[]) : (stmt.all() as any[]); + const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs); + const rows = rowsRaw as Array<{ bucket: number; model: string; provider: string; requests: number }>; return rows.map(row => ({ timestamp: row.bucket, model: row.model, @@ -584,7 +589,11 @@ export function getModelTimeSeries(days = 14, cutoff?: number | null): ModelTime /** * Get daily model performance time series data for the last N days. */ -export function getModelPerformanceSeries(days = 14, cutoff?: number | null): ModelPerformancePoint[] { +export function getModelPerformanceSeries( + days = 14, + cutoff?: number | null, + bucketMs = 24 * 60 * 60 * 1000, +): ModelPerformancePoint[] { if (!db) return []; const hasCutoff = cutoff !== null; @@ -592,7 +601,7 @@ export function getModelPerformanceSeries(days = 14, cutoff?: number | null): Mo const stmt = db.prepare(` SELECT - (timestamp / 86400000) * 86400000 as bucket, + (timestamp / ?) * ? as bucket, model, provider, COUNT(*) as requests, @@ -604,7 +613,15 @@ export function getModelPerformanceSeries(days = 14, cutoff?: number | null): Mo ORDER BY bucket ASC `); - const rows = hasCutoff ? (stmt.all(seriesCutoff) as any[]) : (stmt.all() as any[]); + const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs); + const rows = rowsRaw as Array<{ + bucket: number; + model: string; + provider: string; + requests: number; + avg_ttft: number | null; + avg_tokens_per_second: number | null; + }>; return rows.map(row => ({ timestamp: row.bucket, model: row.model,