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.
This commit is contained in:
can1357
2026-05-17 02:15:37 +02:00
parent 0bb385f8ab
commit 00bb61b054
6 changed files with 154 additions and 28 deletions
+30 -7
View File
@@ -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<TimeRange, Omit<TimeRangeConfig, "cutoff">> =
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<DashboardStats> {
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<Pick<DashboardStats, "byModel" | "modelSeries" | "modelPerformanceSeries">> {
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),
};
}
+2 -1
View File
@@ -155,10 +155,11 @@ export default function App() {
<div className="space-y-6 animate-fade-in">
{modelStats ? (
<>
<ChartsContainer modelSeries={modelStats.modelSeries} />
<ChartsContainer modelSeries={modelStats.modelSeries} timeRange={timeRange} />
<ModelsTable
models={modelStats.byModel}
performanceSeries={modelStats.modelPerformanceSeries}
timeRange={timeRange}
/>
</>
) : (
@@ -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) {
<div className="surface overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--border-subtle)]">
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Model Preference</h3>
<p className="text-xs text-[var(--text-muted)] mt-1">Share of requests over the last 14 days</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Share of requests over {meta.windowLabel}</p>
</div>
<div className="p-5 min-h-[320px]">
{chartData.data.length === 0 ? (
@@ -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<string | null>(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 <Line data={chartData} options={options} />;
}
function buildModelPerformanceLookup(points: ModelPerformancePoint[], days = 14): Map<string, ModelPerformanceSeries> {
const dayMs = 24 * 60 * 60 * 1000;
function buildModelPerformanceLookup(
points: ModelPerformancePoint[],
bucketCount: number,
bucketMs: number,
): Map<string, ModelPerformanceSeries> {
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<string, ModelPerformanceSeries>();
@@ -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<TimeRange, RangeMeta> = {
"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);
}
+23 -6
View File
@@ -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,