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:
co-authored by
Claude Sonnet 5
parent
ae21a7a0ce
commit
41362aa3d8
@@ -0,0 +1,306 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { TimeRange } from './LogTimeline'
|
||||
import { PanelChart } from './PanelChart'
|
||||
import {
|
||||
ApiError,
|
||||
createDashboard,
|
||||
deleteDashboard,
|
||||
fetchDashboards,
|
||||
fetchPromqlSeries,
|
||||
fetchSettings,
|
||||
saveDashboard,
|
||||
type ChartType,
|
||||
type Dashboard,
|
||||
type Panel,
|
||||
type PromqlSeries,
|
||||
} from '../lib/api'
|
||||
|
||||
const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie']
|
||||
|
||||
function newPanelId(): string {
|
||||
// Client-side scratch id for a not-yet-saved panel; the backend treats
|
||||
// panel ids as opaque strings, so this only needs to be unique within one
|
||||
// dashboard's editing session.
|
||||
return `panel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
/// One panel's live preview: fetches its PromQL series whenever the query,
|
||||
/// chart type, or active time range changes, and renders either the chart, a
|
||||
/// loading state, or an inline error (a malformed query must not crash the
|
||||
/// page — see ticket 014's acceptance criteria).
|
||||
function PanelPreview({ panel, range }: { panel: Panel; range: TimeRange | null }) {
|
||||
const [series, setSeries] = useState<PromqlSeries[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!panel.promql.trim()) {
|
||||
setSeries([])
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetchPromqlSeries({ promql: panel.promql, startMs: range?.startMs, endMs: range?.endMs })
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
setSeries(result)
|
||||
setError(null)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
setSeries([])
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [panel.promql, panel.chart_type, range?.startMs, range?.endMs])
|
||||
|
||||
if (error) {
|
||||
return <div className="rounded border border-red-900 bg-red-950/50 px-2 py-1 text-xs text-red-400">{error}</div>
|
||||
}
|
||||
if (loading && series.length === 0) {
|
||||
return <div className="flex h-48 items-center justify-center text-xs text-neutral-600">Loading…</div>
|
||||
}
|
||||
return <PanelChart series={series} chartType={panel.chart_type} />
|
||||
}
|
||||
|
||||
export function Dashboards({ range }: { range: TimeRange | null }) {
|
||||
const [activeProfile, setActiveProfile] = useState<string | null>(null)
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [draft, setDraft] = useState<Dashboard | null>(null)
|
||||
const [newDashboardName, setNewDashboardName] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function loadDashboards(profileName: string, selectAfterLoad?: string) {
|
||||
try {
|
||||
const loaded = await fetchDashboards(profileName)
|
||||
setDashboards(loaded)
|
||||
const toSelect = selectAfterLoad ?? selectedId ?? loaded[0]?.id ?? null
|
||||
setSelectedId(toSelect)
|
||||
setDraft(loaded.find((d) => d.id === toSelect) ?? null)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
.then((settings) => {
|
||||
setActiveProfile(settings.active_profile)
|
||||
if (settings.active_profile) {
|
||||
loadDashboards(settings.active_profile)
|
||||
}
|
||||
})
|
||||
.catch(() => setActiveProfile(null))
|
||||
// Only load once on mount; switching the active profile elsewhere in the
|
||||
// app (NavMenu) requires a reload for now, same as other panels don't
|
||||
// watch for profile changes mid-session.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(dashboards.find((d) => d.id === selectedId) ?? null)
|
||||
}, [selectedId, dashboards])
|
||||
|
||||
async function handleCreateDashboard(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!activeProfile || !newDashboardName.trim()) return
|
||||
try {
|
||||
const created = await createDashboard(activeProfile, newDashboardName.trim())
|
||||
setNewDashboardName('')
|
||||
await loadDashboards(activeProfile, created.id)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Failed to create dashboard')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDashboard(id: string) {
|
||||
if (!activeProfile) return
|
||||
try {
|
||||
await deleteDashboard(activeProfile, id)
|
||||
await loadDashboards(activeProfile, undefined)
|
||||
setSelectedId((prev) => (prev === id ? null : prev))
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Failed to delete dashboard')
|
||||
}
|
||||
}
|
||||
|
||||
function addPanel() {
|
||||
if (!draft) return
|
||||
setDraft({
|
||||
...draft,
|
||||
panels: [...draft.panels, { id: newPanelId(), title: '', promql: '', chart_type: 'line' }],
|
||||
})
|
||||
}
|
||||
|
||||
function updatePanel(id: string, patch: Partial<Panel>) {
|
||||
if (!draft) return
|
||||
setDraft({ ...draft, panels: draft.panels.map((p) => (p.id === id ? { ...p, ...patch } : p)) })
|
||||
}
|
||||
|
||||
function removePanel(id: string) {
|
||||
if (!draft) return
|
||||
setDraft({ ...draft, panels: draft.panels.filter((p) => p.id !== id) })
|
||||
}
|
||||
|
||||
async function handleSaveDraft() {
|
||||
if (!activeProfile || !draft) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const saved = await saveDashboard(activeProfile, draft.id, { name: draft.name, panels: draft.panels })
|
||||
setDashboards((prev) => prev.map((d) => (d.id === saved.id ? saved : d)))
|
||||
setDraft(saved)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Failed to save dashboard')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeProfile) {
|
||||
return (
|
||||
<div className="border-b border-neutral-800 p-3 text-xs text-neutral-600">
|
||||
Dashboards — activate a profile (see the settings menu) to create and save dashboards.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-neutral-800 p-3 text-sm">
|
||||
<div className="mb-2 text-xs text-neutral-500">Dashboards — profile "{activeProfile}"</div>
|
||||
{error && (
|
||||
<div className="mb-2 rounded border border-red-900 bg-red-950/50 px-2 py-1 text-xs text-red-400">{error}</div>
|
||||
)}
|
||||
<div className="flex gap-4">
|
||||
<div className="w-56 shrink-0 space-y-3">
|
||||
<form onSubmit={handleCreateDashboard} className="flex gap-1">
|
||||
<input
|
||||
className="w-full min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
|
||||
value={newDashboardName}
|
||||
onChange={(e) => setNewDashboardName(e.target.value)}
|
||||
placeholder="New dashboard name"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-300 hover:bg-neutral-800"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</form>
|
||||
<div className="space-y-1">
|
||||
{dashboards.length === 0 && <div className="text-xs text-neutral-600">No dashboards saved yet.</div>}
|
||||
{dashboards.map((d) => (
|
||||
<div
|
||||
key={d.id}
|
||||
className={`flex items-center justify-between rounded border px-2 py-1 text-xs ${
|
||||
selectedId === d.id
|
||||
? 'border-sky-500 bg-sky-500/10 text-sky-300'
|
||||
: 'border-neutral-700 text-neutral-300 hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<button type="button" className="min-w-0 flex-1 truncate text-left" onClick={() => setSelectedId(d.id)}>
|
||||
{d.name} ({d.panels.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteDashboard(d.id)}
|
||||
className="ml-1 shrink-0 text-neutral-500 hover:text-red-400"
|
||||
title="Delete dashboard"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{!draft ? (
|
||||
<div className="text-xs text-neutral-600">Select or create a dashboard to edit its panels.</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<input
|
||||
className="w-64 rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-sm text-neutral-100 focus:border-neutral-500 focus:outline-none"
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPanel}
|
||||
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-300 hover:bg-neutral-800"
|
||||
>
|
||||
+ Add panel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={saving}
|
||||
className="rounded border border-sky-700 bg-sky-500/10 px-2 py-1 text-xs text-sky-300 hover:bg-sky-500/20 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save dashboard'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft.panels.length === 0 && (
|
||||
<div className="text-xs text-neutral-600">No panels yet — add one above.</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{draft.panels.map((panel) => (
|
||||
<div key={panel.id} className="rounded border border-neutral-800 p-2">
|
||||
<div className="mb-2 flex items-center gap-1">
|
||||
<input
|
||||
className="w-28 min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-900 px-1.5 py-1 text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
|
||||
value={panel.title}
|
||||
onChange={(e) => updatePanel(panel.id, { title: e.target.value })}
|
||||
placeholder="Panel title"
|
||||
/>
|
||||
<input
|
||||
className="w-40 min-w-0 flex-[2] rounded border border-neutral-700 bg-neutral-900 px-1.5 py-1 font-mono text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
|
||||
value={panel.promql}
|
||||
onChange={(e) => updatePanel(panel.id, { promql: e.target.value })}
|
||||
placeholder={String.raw`requests{path="/a"}`}
|
||||
/>
|
||||
<select
|
||||
className="rounded border border-neutral-700 bg-neutral-900 px-1 py-1 text-xs text-neutral-100 focus:border-neutral-500 focus:outline-none"
|
||||
value={panel.chart_type}
|
||||
onChange={(e) => updatePanel(panel.id, { chart_type: e.target.value as ChartType })}
|
||||
>
|
||||
{CHART_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePanel(panel.id)}
|
||||
className="rounded border border-neutral-700 px-1.5 py-1 text-xs text-neutral-400 hover:bg-neutral-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{panel.title && <div className="mb-1 text-xs text-neutral-500">{panel.title}</div>}
|
||||
<PanelPreview panel={panel} range={range} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user