CollapsibleSection.tsx and LogTimeline.tsx imported from each other (TimeRange/TimeRangePresetId one way, sectionHeaderClassName the other). Move the range types/helpers into a dependency-free webapp/src/lib/timeRange.ts and repoint all importers there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
195 lines
8.2 KiB
TypeScript
195 lines
8.2 KiB
TypeScript
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, string>): 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<string, number | string>[]; keys: string[] } {
|
|
const keys = series.map((s) => seriesLabel(s.labels))
|
|
const byTimestamp = new Map<number, Record<string, number | string>>()
|
|
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 <div className="flex h-48 items-center justify-center text-xs text-text-faint">No data in range.</div>
|
|
}
|
|
|
|
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 (
|
|
<ResponsiveContainer width="100%" height={220}>
|
|
<LineChart data={rows} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--c-border)" />
|
|
<XAxis
|
|
dataKey="timestamp_ms"
|
|
type="number"
|
|
domain={range ? [range.startMs, range.endMs] : ['dataMin', 'dataMax']}
|
|
allowDataOverflow
|
|
tickFormatter={formatTimestamp}
|
|
stroke="var(--c-text-subtle)"
|
|
tick={{ fontSize: 11, fill: 'var(--c-text-muted)' }}
|
|
/>
|
|
<YAxis stroke="var(--c-text-subtle)" tick={{ fontSize: 11, fill: 'var(--c-text-muted)' }} width={48} />
|
|
<Tooltip
|
|
labelFormatter={(ts) => formatTimestamp(Number(ts))}
|
|
contentStyle={{ background: 'var(--c-surface-1)', border: '1px solid var(--c-border-soft)', fontSize: 12 }}
|
|
/>
|
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
|
{keys.map((key, i) => (
|
|
<Line
|
|
key={key}
|
|
type="monotone"
|
|
dataKey={key}
|
|
stroke={COLORS[i % COLORS.length]}
|
|
dot={false}
|
|
connectNulls
|
|
isAnimationActive={false}
|
|
/>
|
|
))}
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
)
|
|
}
|
|
|
|
if (chartType === 'bar') {
|
|
const rows = squashRows(series.map((s) => ({ name: seriesLabel(s.labels), value: latestValue(s) })))
|
|
return (
|
|
<ResponsiveContainer width="100%" height={220}>
|
|
<BarChart data={rows} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--c-border)" />
|
|
<XAxis dataKey="name" stroke="var(--c-text-subtle)" tick={{ fontSize: 11, fill: 'var(--c-text-muted)' }} />
|
|
<YAxis stroke="var(--c-text-subtle)" tick={{ fontSize: 11, fill: 'var(--c-text-muted)' }} width={48} />
|
|
<Tooltip contentStyle={{ background: 'var(--c-surface-1)', border: '1px solid var(--c-border-soft)', fontSize: 12 }} />
|
|
<Bar dataKey="value" isAnimationActive={false}>
|
|
{rows.map((_, i) => (
|
|
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
)
|
|
}
|
|
|
|
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 <div className="flex h-48 items-center justify-center text-xs text-text-faint">No non-zero values in range.</div>
|
|
}
|
|
return (
|
|
<ResponsiveContainer width="100%" height={220}>
|
|
<PieChart margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
|
<Pie data={rows} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} isAnimationActive={false}>
|
|
{rows.map((_, i) => (
|
|
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip
|
|
formatter={(value) => {
|
|
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 }}
|
|
/>
|
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
)
|
|
}
|