import { useEffect, useRef, 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' import { HelpIcon } from './HelpIcon' const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie'] /// How long to wait after the last edit before autosaving (ticket 027) — long /// enough that rapid typing in a title/query field doesn't spam the save /// endpoint on every keystroke, short enough that a pause reads as "done for /// now" and commits promptly. const AUTOSAVE_DEBOUNCE_MS = 600 type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' /// What actually gets persisted for a dashboard — used both to snapshot "last /// saved" state and to build the `saveDashboard` request body. function dashboardSaveShape(dashboard: Dashboard) { return { name: dashboard.name, panels: dashboard.panels } } 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([]) const [error, setError] = useState(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
{error}
} if (loading && series.length === 0) { return
Loading…
} return } /// Unobtrusive inline autosave indicator (ticket 027) — no blocking dialog, /// just small text near the dashboard header. function AutosaveStatus({ status, error }: { status: SaveStatus; error: string | null }) { if (status === 'saving') return Saving… if (status === 'saved') return Saved if (status === 'error') { return ( Autosave failed{error ? `: ${error}` : ''} ) } return null } export function Dashboards({ range }: { range: TimeRange | null }) { const [activeProfile, setActiveProfile] = useState(null) const [dashboards, setDashboards] = useState([]) const [selectedId, setSelectedId] = useState(null) const [draft, setDraft] = useState(null) const [newDashboardName, setNewDashboardName] = useState('') const [error, setError] = useState(null) const [saveStatus, setSaveStatus] = useState('idle') const [saveError, setSaveError] = useState(null) // JSON snapshot of the last-persisted { name, panels } shape for the // current draft — lets the autosave effect skip scheduling a save when // nothing actually changed (e.g. re-selecting the same dashboard, or the // re-render that follows a successful save itself). const lastSavedRef = useRef(null) 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(() => { const found = dashboards.find((d) => d.id === selectedId) ?? null setDraft(found) lastSavedRef.current = found ? JSON.stringify(dashboardSaveShape(found)) : null }, [selectedId, dashboards]) // Autosave (ticket 027): debounces edits so rapid typing doesn't spam the // save endpoint, and skips entirely once the draft matches what's already // persisted (covers both re-selecting an unchanged dashboard and the // re-render that follows this effect's own successful save). useEffect(() => { if (!draft || !activeProfile) return const snapshot = JSON.stringify(dashboardSaveShape(draft)) if (snapshot === lastSavedRef.current) return const dashboardId = draft.id const timeout = setTimeout(async () => { setSaveStatus('saving') try { const saved = await saveDashboard(activeProfile, dashboardId, dashboardSaveShape(draft)) lastSavedRef.current = JSON.stringify(dashboardSaveShape(saved)) setDashboards((prev) => prev.map((d) => (d.id === saved.id ? saved : d))) setDraft(saved) setSaveStatus('saved') setSaveError(null) } catch (err) { // Deliberately leave `draft` as-is on failure — the in-progress edit // isn't lost, it just hasn't persisted yet. setSaveStatus('error') setSaveError(err instanceof ApiError ? err.message : 'Failed to save dashboard') } }, AUTOSAVE_DEBOUNCE_MS) return () => clearTimeout(timeout) }, [draft, activeProfile]) // Settles a transient "Saved" status back to idle after a moment, so it // reads as a confirmation rather than a permanent label. useEffect(() => { if (saveStatus !== 'saved') return const timeout = setTimeout(() => setSaveStatus('idle'), 1500) return () => clearTimeout(timeout) }, [saveStatus]) 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) { 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) }) } if (!activeProfile) { return (
Dashboards — activate a profile (see the settings menu) to create and save dashboards.
) } return (
Dashboards — profile "{activeProfile}"
{error && (
{error}
)}
setNewDashboardName(e.target.value)} placeholder="New dashboard name" />
{dashboards.length === 0 &&
No dashboards saved yet.
} {dashboards.map((d) => (
))}
{!draft ? (
Select or create a dashboard to edit its panels.
) : (
setDraft({ ...draft, name: e.target.value })} />
{draft.panels.length === 0 && (
No panels yet — add one above.
)}
{draft.panels.map((panel) => (
updatePanel(panel.id, { title: e.target.value })} placeholder="Panel title" /> updatePanel(panel.id, { promql: e.target.value })} placeholder={String.raw`requests{path="/a"}`} />
{panel.title &&
{panel.title}
}
))}
)}
) }