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 { CollapseToggle, handleSectionHeaderKeyDown, PopOutButton, sectionHeaderInteractiveClassName, sectionHeaderLeftClassName, useSectionCollapsed, } from './CollapsibleSection' import { HelpIcon } from './HelpIcon' const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie'] /// Virtual selection id for the "All dashboards" stacked/print view; distinct from any real dashboard id. const ALL_DASHBOARDS_ID = '__all__' /// Long enough that rapid typing doesn't spam the save endpoint, short enough that a pause reads as "done" 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; only needs to be unique within one editing session since the backend treats panel ids as opaque strings. return `panel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } /// Fetches the panel's PromQL series on query/chart-type/range change; a malformed query must render as an inline error rather than crash the page. 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 } 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 } /// Read-only, print-friendly view of every dashboard stacked one after another, each preceded by a heading naming it. /// Deliberately uses normal document flow (no scroll containers, no fixed heights) so the browser's native print /// function can lay the full stack out across as many pages as it needs. function AllDashboardsView({ dashboards, range }: { dashboards: Dashboard[]; range: TimeRange | null }) { if (dashboards.length === 0) { return
No dashboards saved yet.
} return (
{dashboards.map((d) => (

{d.name}

{d.panels.length === 0 ? (
No panels in this dashboard.
) : (
{d.panels.map((panel) => (
{panel.title &&
{panel.title}
}
))}
)}
))}
) } 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) // Last-persisted shape for the current draft, so the autosave effect can skip when nothing actually changed. const lastSavedRef = useRef(null) const [collapsed, setCollapsed] = useSectionCollapsed('dashboards') 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 profiles elsewhere (NavMenu) requires a reload for now. // 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]) // Skips entirely once the draft matches what's already persisted — covers both re-selecting an unchanged dashboard and this effect's own post-save re-render. 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 so it reads as a confirmation, not 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 (
setCollapsed(!collapsed)} onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))} >
Dashboards
Activate a profile (see the settings menu) to create and save dashboards.
) } return (
setCollapsed(!collapsed)} onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))} >
Dashboards
{error && !collapsed && (
{error}
)}
) }