Dashboards: remove manual save, autosave everything (ticket 027)

Removes the explicit "Save dashboard" button and its saving-state
gating. A debounced effect (600ms, keyed on the draft) now persists
dashboard/panel edits automatically — renames, panel add/remove, and
per-field edits (title, PromQL, chart type) all flow through the same
`draft` state that already backed the old button, so no separate
autosave plumbing per mutation was needed. A `lastSavedRef` snapshot
of the last-persisted { name, panels } shape lets the effect skip
scheduling a save when nothing actually changed, avoiding a spurious
autosave both on dashboard selection and on the re-render that
follows the effect's own successful save.

Save state (idle/saving/saved/error) surfaces as small inline text
near the panel-add button rather than a blocking dialog; a failed
autosave leaves the in-progress edit in place so it isn't lost.
Verified against a live server: 18 rapid keystrokes produce exactly
one PUT, edits persist across a reload, and a simulated 500 shows
"Autosave failed" without reverting the field.

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 15:52:38 +01:00
co-authored by Claude Sonnet 5
parent abc061a7b1
commit 7e53574408
+80 -27
View File
@@ -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 <PanelChart series={series} chartType={panel.chart_type} />
}
/// 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 <span className="text-xs text-text-faint">Saving…</span>
if (status === 'saved') return <span className="text-xs text-text-faint">Saved</span>
if (status === 'error') {
return (
<span className="text-xs text-danger-text" title={error ?? undefined}>
Autosave failed{error ? `: ${error}` : ''}
</span>
)
}
return null
}
export function Dashboards({ range }: { range: TimeRange | null }) {
const [activeProfile, setActiveProfile] = useState<string | null>(null)
const [dashboards, setDashboards] = useState<Dashboard[]>([])
@@ -77,7 +106,13 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
const [draft, setDraft] = useState<Dashboard | null>(null)
const [newDashboardName, setNewDashboardName] = useState('')
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [saveError, setSaveError] = useState<string | null>(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<string | null>(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 (
<div className="flex items-center gap-1.5 border-b border-border p-3 text-xs text-text-faint">
@@ -239,7 +299,8 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
<div className="flex gap-1">
<div className="flex items-center gap-2">
<AutosaveStatus status={saveStatus} error={saveError} />
<button
type="button"
onClick={addPanel}
@@ -247,14 +308,6 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
>
+ Add panel
</button>
<button
type="button"
onClick={handleSaveDraft}
disabled={saving}
className="rounded border border-accent-soft bg-accent/10 px-2 py-1 text-xs text-accent-text hover:bg-accent/20 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save dashboard'}
</button>
</div>
</div>