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:
co-authored by
Claude Sonnet 5
parent
abc061a7b1
commit
7e53574408
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user