Add Dashboards UI: PromQL-backed bar/line/pie panels saved per profile (ticket 014)

Backend: replace Profile.dashboards' opaque JSON stub with typed
Dashboard/Panel structs, add ProfileStore CRUD (create/save/list/delete
dashboard) and matching /api/settings/profiles/:name/dashboards[/:id] routes,
with tests covering the HTTP round trip and persistence across a simulated
restart.

Frontend: add recharts (lightweight, React 19-compatible) and a Dashboards
component with a dashboard builder (create dashboards, add/remove/edit panels
with a PromQL query + chart type), live per-panel previews that respect the
active time range and show malformed-query errors inline instead of crashing,
and load/save wired to the new profile-backed persistence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 13:29:45 +01:00
co-authored by Claude Sonnet 5
parent ae21a7a0ce
commit 41362aa3d8
9 changed files with 1445 additions and 15 deletions
+161
View File
@@ -0,0 +1,161 @@
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import type { ChartType, PromqlSeries } from '../lib/api'
/// Renders a label combination as a short, stable string for use as a series
/// key/legend entry — e.g. `{"path":"/a"}` -> `path=/a`. A series with no
/// labels at all (a bare metric name with no matchers) 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(', ')
}
// Fixed, small categorical palette (rather than pulling in a colour-scale
// dependency) — cycles if there are more series than colours.
const COLORS = ['#38bdf8', '#f472b6', '#a78bfa', '#4ade80', '#fbbf24', '#fb7185', '#2dd4bf', '#f97316']
/// A line chart with time on the x-axis, value on the y-axis, and one line
/// per label combination (series). Points across series are merged into one
/// row-per-timestamp dataset (recharts wants a single array of objects keyed
/// by series name) — timestamps that only one series has are left `undefined`
/// for the others, which recharts simply 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 charts need a single scalar per series, but the input is a time
/// series — inherently lossy. Aggregation choices (documented here rather
/// than hidden in the code):
/// - bar: the *latest* point per series (a snapshot of "where things are
/// now" — the common Grafana single-stat-per-series bar use case).
/// - pie: the *sum* of all points per series, shown as a share of the total
/// across all series (proportion of total volume, the common Grafana pie
/// use case — e.g. "which path got the most requests over this range").
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 }: { series: PromqlSeries[]; chartType: ChartType }) {
if (series.length === 0) {
return <div className="flex h-48 items-center justify-center text-xs text-neutral-600">No data in range.</div>
}
if (chartType === 'line') {
const { rows, keys } = toLineData(series)
return (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={rows} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#262626" />
<XAxis
dataKey="timestamp_ms"
tickFormatter={formatTimestamp}
stroke="#737373"
tick={{ fontSize: 11, fill: '#a3a3a3' }}
/>
<YAxis stroke="#737373" tick={{ fontSize: 11, fill: '#a3a3a3' }} width={48} />
<Tooltip
labelFormatter={(ts) => formatTimestamp(Number(ts))}
contentStyle={{ background: '#171717', border: '1px solid #404040', 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 = 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="#262626" />
<XAxis dataKey="name" stroke="#737373" tick={{ fontSize: 11, fill: '#a3a3a3' }} />
<YAxis stroke="#737373" tick={{ fontSize: 11, fill: '#a3a3a3' }} width={48} />
<Tooltip contentStyle={{ background: '#171717', border: '1px solid #404040', fontSize: 12 }} />
<Bar dataKey="value" isAnimationActive={false}>
{rows.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)
}
// Pie: share of total across label combinations (see aggregation note above).
const rows = 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-neutral-600">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: '#171717', border: '1px solid #404040', fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
</PieChart>
</ResponsiveContainer>
)
}