import { Bar, BarChart, CartesianGrid, Cell, Legend, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts' import type { ChartType, PromqlSeries } from '../lib/api' import type { TimeRange } from '../lib/timeRange' /// Renders a label combination as a series key/legend string, e.g. `{"path":"/a"}` -> `path=/a`; no labels renders as `value`. export function seriesLabel(labels: Record): string { const entries = Object.entries(labels) if (entries.length === 0) return 'value' return entries.map(([k, v]) => `${k}=${v}`).join(', ') } // Categorical palette drawn from the active theme's accent set (ticket 048: every built-in theme // exposes --c-accent/--c-accent-2/--c-accent-3, not just one accent hue) instead of a hardcoded, // theme-independent hex list. `color-mix` tints/shades those three base accents into a fuller // 8-colour cycle (matching the previous fixed palette's size, so MAX_KEPT_SERIES/squashing below is // unaffected) without any JS colour math or a getComputedStyle read — the browser resolves the CSS // variables per-theme at paint time, same as `stroke="var(--c-border)"` elsewhere in this file. const COLORS = [ 'var(--c-accent)', 'var(--c-accent-2)', 'var(--c-accent-3)', 'color-mix(in srgb, var(--c-accent) 60%, white)', 'color-mix(in srgb, var(--c-accent-2) 60%, white)', 'color-mix(in srgb, var(--c-accent-3) 60%, white)', 'color-mix(in srgb, var(--c-accent) 55%, black)', 'color-mix(in srgb, var(--c-accent-2) 55%, black)', ] // One less than the palette size: above this many series, keep the top MAX_KEPT_SERIES (by value) and squash the // rest into a single "Other" bucket, so the squashed range never exceeds COLORS.length and never repeats a colour. const MAX_KEPT_SERIES = COLORS.length - 1 // Sorts rows by value descending and, if there are more than MAX_KEPT_SERIES, keeps the top MAX_KEPT_SERIES and // collapses the remainder into a single "Other" row summing their values. Below the threshold, rows are returned // unchanged so panels with few series keep their existing bar/slice order and colour assignment. function squashRows(rows: { name: string; value: number }[]): { name: string; value: number }[] { if (rows.length <= MAX_KEPT_SERIES) return rows const sorted = [...rows].sort((a, b) => b.value - a.value) const kept = sorted.slice(0, MAX_KEPT_SERIES) const rest = sorted.slice(MAX_KEPT_SERIES) const otherValue = rest.reduce((sum, r) => sum + r.value, 0) return [...kept, { name: `Other (${rest.length})`, value: otherValue }] } // Merges series into one row-per-timestamp dataset (recharts wants a single array keyed by series name); a timestamp missing from a series is left undefined there, which recharts skips when drawing that line. function toLineData(series: PromqlSeries[]): { rows: Record[]; keys: string[] } { const keys = series.map((s) => seriesLabel(s.labels)) const byTimestamp = new Map>() series.forEach((s, i) => { const key = keys[i] for (const [ts, value] of s.points) { let row = byTimestamp.get(ts) if (!row) { row = { timestamp_ms: ts } byTimestamp.set(ts, row) } row[key] = value } }) const rows = Array.from(byTimestamp.values()).sort( (a, b) => (a.timestamp_ms as number) - (b.timestamp_ms as number), ) return { rows, keys } } function formatTimestamp(ts: number): string { const date = new Date(ts) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) } // Bar/pie need a single scalar per series from a time series: bar uses the latest point (current snapshot), pie uses the sum (share of total volume). function latestValue(series: PromqlSeries): number { if (series.points.length === 0) return 0 return series.points[series.points.length - 1][1] } function sumValue(series: PromqlSeries): number { return series.points.reduce((total, [, value]) => total + value, 0) } export function PanelChart({ series, chartType, range, }: { series: PromqlSeries[] chartType: ChartType range?: TimeRange | null }) { if (series.length === 0) { return
No data in range.
} if (chartType === 'line') { const { rows, keys } = toLineData(series) // Ticket 057: without an explicit numeric domain, recharts auto-scales the x-axis to the // min/max timestamp actually present in `rows` — so a metric whose occurrences only cover a // sliver of the queried range (a burst followed by silence) silently renders as if that // sliver *were* the full range, with no visual sign the axis got narrowed. Pinning the domain // to the panel's actual queried range keeps the axis honest; `type="number"` is required for // a `domain` tuple to be interpreted as a value range rather than a category filter. return ( formatTimestamp(Number(ts))} contentStyle={{ background: 'var(--c-surface-1)', border: '1px solid var(--c-border-soft)', fontSize: 12 }} /> {keys.map((key, i) => ( ))} ) } if (chartType === 'bar') { const rows = squashRows(series.map((s) => ({ name: seriesLabel(s.labels), value: latestValue(s) }))) return ( {rows.map((_, i) => ( ))} ) } const rows = squashRows(series.map((s) => ({ name: seriesLabel(s.labels), value: sumValue(s) }))) const total = rows.reduce((sum, r) => sum + r.value, 0) if (total === 0) { return
No non-zero values in range.
} return ( {rows.map((_, i) => ( ))} { const num = typeof value === 'number' ? value : Number(value ?? 0) return `${num} (${((num / total) * 100).toFixed(1)}%)` }} contentStyle={{ background: 'var(--c-surface-1)', border: '1px solid var(--c-border-soft)', fontSize: 12 }} /> ) }