diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx index 80c86846..7543be3b 100644 --- a/src/components/benchmark-body.tsx +++ b/src/components/benchmark-body.tsx @@ -11,7 +11,7 @@ import { RankedBarChart } from "@/components/ranked-bar-chart"; import { DistributionChart } from "@/components/distribution-chart"; import { DonutChart } from "@/components/donut-chart"; import { RegionGrid } from "@/components/region-grid"; -import { MetricPanelGrid } from "@/components/metric-panel-grid"; +import { MetricViewTabs } from "@/components/metric-view-tabs"; import { CountLeaderboard } from "@/components/count-leaderboard"; import { SummaryStat } from "@/components/summary-stat"; import { ViewSwitcher } from "@/components/view-switcher"; @@ -209,6 +209,14 @@ export function BenchmarkBody({ const chartRegions = chartOnlyRegions(benchmark); const showChartRegionRow = regionOptions.length === 0 && chartRegions.length > 1; const [chartRegion, setChartRegion] = useState("all"); + + // Active companion-metric panel. null = main spec metric (default chart + // data, default unit, default header). When a panel id is set, the chart + // pulls its per-provider series from panel.seriesByProvider, swaps the + // header label to panel.label, and the Y-axis unit to panel.unit. + const [activePanelId, setActivePanelId] = useState(null); + const activePanel = + benchmark.metricPanels?.find((p) => p.id === activePanelId) ?? null; const chartRegionOptions: ChainOption[] = useMemo( () => [ { value: "all", label: "All" }, @@ -336,20 +344,38 @@ export function BenchmarkBody({ /> )} {view === "timeseries" && ( - 0 - ? (region ?? fallbackRegion ?? undefined) - : showChartRegionRow - ? chartRegion - : undefined - } - excluded={excluded} - onToggleExclude={toggleExclude} - onResetExcluded={resetExcluded} - headerActions={} - /> + <> + {benchmark.metricPanels && benchmark.metricPanels.length > 0 && ( + + )} + 0 + ? (region ?? fallbackRegion ?? undefined) + : showChartRegionRow + ? chartRegion + : undefined + } + excluded={excluded} + onToggleExclude={toggleExclude} + onResetExcluded={resetExcluded} + headerActions={} + seriesOverride={activePanel?.seriesByProvider} + metricLabelOverride={activePanel?.label} + unitOverride={activePanel?.unit} + /> + {activePanel?.description && ( +

+ {activePanel.description} +

+ )} + )} @@ -363,12 +389,6 @@ export function BenchmarkBody({ - {benchmark.metricPanels && benchmark.metricPanels.length > 0 && ( -
- -
- )} - {benchmark.unit !== "count" && Object.keys(benchmark.extras.regions).length > 0 && (
diff --git a/src/components/metric-panel-grid.tsx b/src/components/metric-panel-grid.tsx deleted file mode 100644 index 05246a2a..00000000 --- a/src/components/metric-panel-grid.tsx +++ /dev/null @@ -1,179 +0,0 @@ -"use client"; - -import { useState } from "react"; -import type { Benchmark, MetricPanel } from "@/types/benchmark"; -import { fmtUnit } from "@/lib/format"; - -/** - * Switchable mini leaderboard surfaced under the main ledger. - * - * Each panel declares one Prom metric (already fetched server side and - * stored in `benchmark.metricPanels`). The reader toggles between panels; - * the visible table re-ranks the same provider set by the active panel's - * metric, applying its higher_is_better direction. - * - * Renders nothing when `benchmark.metricPanels` is empty, so non-HL - * benches that do not declare any panels stay unaffected. - */ -export function MetricPanelGrid({ benchmark }: { benchmark: Benchmark }) { - const panels = benchmark.metricPanels ?? []; - const [activeId, setActiveId] = useState(panels[0]?.id ?? null); - - if (panels.length === 0) return null; - - const active = panels.find((p) => p.id === activeId) ?? panels[0]; - - return ( -
-
-
-

Companion metrics

- {active.description && ( -

- {active.description} -

- )} -
- -
- - -
- ); -} - -function PanelTabs({ - panels, - active, - onSelect, -}: { - panels: MetricPanel[]; - active: string; - onSelect: (id: string) => void; -}) { - return ( -
- {panels.map((p) => { - const on = p.id === active; - return ( - - ); - })} -
- ); -} - -function MetricPanelTable({ - panel, - benchmark, -}: { - panel: MetricPanel; - benchmark: Benchmark; -}) { - // Build rows from the live provider set so unknown/extra slugs in - // panel.values get ignored (defensive against a stale label rotation). - const rows = benchmark.results - .map((r) => ({ - slug: r.slug, - name: r.name, - value: panel.values[r.slug], - })) - .filter((r) => Number.isFinite(r.value)); - - rows.sort((a, b) => - panel.higherIsBetter ? b.value - a.value : a.value - b.value - ); - - const noData = benchmark.results.filter( - (r) => !Number.isFinite(panel.values[r.slug]) - ); - - if (rows.length === 0) { - return ( -

- Currently no data for this metric. Will populate within ~1 hour once - the harness fills its rolling window. -

- ); - } - - return ( -
- - - - - - - - - - {rows.map((row, i) => ( - - - - - - ))} - {noData.map((p) => ( - - - - - - ))} - -
- Provider - - {panel.label} - - Rank -
{row.name} - {formatPanelValue(row.value, panel.unit)} - - #{i + 1} -
{p.name}no data - {"—"} -
-
- ); -} - -function formatPanelValue( - value: number, - unit: MetricPanel["unit"] -): string { - if (unit === "pct") { - // Detect both 0..1 fractions and already-scaled percentages. - const v = value > 1 ? value : value * 100; - return `${v.toFixed(1)}%`; - } - if (unit === "count") { - if (value >= 1000) return value.toFixed(0); - if (value >= 10) return value.toFixed(1); - return value.toFixed(2); - } - if (unit === "s") { - if (value >= 3600) return `${(value / 3600).toFixed(1)}h`; - if (value >= 60) return `${(value / 60).toFixed(1)}min`; - return `${value.toFixed(0)}s`; - } - return fmtUnit(value, unit); -} diff --git a/src/components/metric-view-tabs.tsx b/src/components/metric-view-tabs.tsx new file mode 100644 index 00000000..31bb405b --- /dev/null +++ b/src/components/metric-view-tabs.tsx @@ -0,0 +1,69 @@ +"use client"; + +import type { MetricPanel } from "@/types/benchmark"; + +/** + * Tab row that lives directly above the time-series chart. One pill per + * metric panel, plus a "Default" pill at the front that switches the chart + * back to the bench's main spec-defined metric. + * + * Pure presentation: holds no state. The parent (benchmark-body) owns the + * active panel id and passes it down with the swap callback. + */ +export function MetricViewTabs({ + panels, + mainLabel, + activeId, + onSelect, +}: { + panels: MetricPanel[]; + mainLabel: string; + activeId: string | null; + onSelect: (id: string | null) => void; +}) { + return ( +
+ + View + + onSelect(null)} /> + {panels.map((p) => ( + onSelect(p.id)} + title={p.metric} + /> + ))} +
+ ); +} + +function Tab({ + label, + active, + onClick, + title, +}: { + label: string; + active: boolean; + onClick: () => void; + title?: string; +}) { + return ( + + ); +} diff --git a/src/components/time-series-chart.tsx b/src/components/time-series-chart.tsx index 6f1aa4d2..753a4d3a 100644 --- a/src/components/time-series-chart.tsx +++ b/src/components/time-series-chart.tsx @@ -24,6 +24,14 @@ type Props = { onResetExcluded?: () => void; /** Optional slot rendered in the chart's header row, right-aligned. */ headerActions?: import("react").ReactNode; + /** Optional metric-panel override. When set, the chart pulls its per- + * provider series from `seriesOverride[slug]` instead of + * `benchmark.extras.series24h[slug]`, swaps the metric name in the + * header, and switches the Y-axis unit. Used by the bench page when + * the reader selects a companion metric from the panel tab row. */ + seriesOverride?: Record; + metricLabelOverride?: string; + unitOverride?: Benchmark["unit"]; }; type Range = "1h" | "6h" | "24h" | "7d" | "30d"; @@ -58,6 +66,9 @@ export function TimeSeriesChart({ region: regionProp, excluded: controlledExcluded, onToggleExclude, + seriesOverride, + metricLabelOverride, + unitOverride, onResetExcluded, headerActions, }: Props) { @@ -116,14 +127,16 @@ export function TimeSeriesChart({ slug: r.slug, name: r.name, color: colors.get(r.slug) ?? "var(--color-ink-soft)", - values: pickSeries(benchmark, r.slug, range, region), + values: seriesOverride + ? (seriesOverride[r.slug] ?? []) + : pickSeries(benchmark, r.slug, range, region), excluded: excluded.has(r.slug), })) .filter((l) => l.values.length > 0); built.sort((a, b) => mean(b.values.slice(-6)) - mean(a.values.slice(-6))); return built; - }, [benchmark, range, region, colors, excluded]); + }, [benchmark, range, region, colors, excluded, seriesOverride]); // A key that flips when the data shape changes. used to retrigger // the line-draw animation. @@ -163,7 +176,7 @@ export function TimeSeriesChart({

- {benchmark.metric} · {zoomLabel} + {metricLabelOverride ?? benchmark.metric} · {zoomLabel}

@@ -248,7 +261,7 @@ export function TimeSeriesChart({ = {}; + const seriesByProvider: Record = {}; await Promise.all( spec.providers.map(async (p) => { const q = `${panel.metric}{${panel.label_key}="${escapePromLabelValue(p.slug)}"}`; - const v = await prom.scalar(q); + const [v, series] = await Promise.all([ + prom.scalar(q), + prom.series(q, winSec, 72), + ]); if (v != null && Number.isFinite(v)) values[p.slug] = v; + if (series && series.length > 0) seriesByProvider[p.slug] = series; }) ); metricPanels.push({ @@ -665,6 +670,7 @@ async function tryLoadLive( unit: panel.unit, higherIsBetter: panel.higher_is_better, values, + seriesByProvider, }); } diff --git a/src/types/benchmark.ts b/src/types/benchmark.ts index 04a3c7d7..b1c82fc7 100644 --- a/src/types/benchmark.ts +++ b/src/types/benchmark.ts @@ -68,6 +68,10 @@ export type MetricPanel = { * live data for this metric are omitted; the renderer renders them as * "no data" instead of zero. */ values: Record; + /** Per-provider 24h time-series (72 points by default), keyed by + * provider slug. Powers the multi-line chart view of the panel. + * Providers with no Prom data for the query are absent from the map. */ + seriesByProvider?: Record; }; export type ResultExtras = {