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