diff --git a/webapp/src/components/Dashboards.tsx b/webapp/src/components/Dashboards.tsx index a693d67..fbdc77d 100644 --- a/webapp/src/components/Dashboards.tsx +++ b/webapp/src/components/Dashboards.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { TimeRange } from './LogTimeline' import { PanelChart } from './PanelChart' import { @@ -18,6 +18,20 @@ 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 @@ -70,6 +84,21 @@ function PanelPreview({ panel, range }: { panel: Panel; range: TimeRange | null 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([]) @@ -77,7 +106,13 @@ export function Dashboards({ range }: { range: TimeRange | null }) { const [draft, setDraft] = useState(null) const [newDashboardName, setNewDashboardName] = useState('') const [error, setError] = useState(null) - const [saving, setSaving] = useState(false) + 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 { @@ -108,9 +143,49 @@ export function Dashboards({ range }: { range: TimeRange | null }) { }, []) useEffect(() => { - setDraft(dashboards.find((d) => d.id === selectedId) ?? null) + 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 @@ -152,21 +227,6 @@ export function Dashboards({ range }: { range: TimeRange | null }) { 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 (
@@ -239,7 +299,8 @@ export function Dashboards({ range }: { range: TimeRange | null }) { value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} /> -
+
+ -