Files
ottyl/webapp/src/components/Dashboards.tsx
T
Hugh KaznowskiandClaude Sonnet 5 7e53574408 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
2026-09-05 15:52:38 +01:00

365 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<PromqlSeries[]>([])
const [error, setError] = useState<string | null>(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 <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{error}</div>
}
if (loading && series.length === 0) {
return <div className="flex h-48 items-center justify-center text-xs text-text-faint">Loading…</div>
}
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[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [draft, setDraft] = useState<Dashboard | null>(null)
const [newDashboardName, setNewDashboardName] = useState('')
const [error, setError] = useState<string | null>(null)
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 {
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<Panel>) {
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 (
<div className="flex items-center gap-1.5 border-b border-border p-3 text-xs text-text-faint">
<HelpIcon sectionId="dashboards" label="Help: Dashboards" />
Dashboards — activate a profile (see the settings menu) to create and save dashboards.
</div>
)
}
return (
<div className="border-b border-border p-3 text-sm">
<div className="mb-2 flex items-center gap-1.5 text-xs text-text-subtle">
<HelpIcon sectionId="dashboards" label="Help: Dashboards" />
Dashboards — profile "{activeProfile}"
</div>
{error && (
<div className="mb-2 rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{error}</div>
)}
<div className="flex gap-4">
<div className="w-56 shrink-0 space-y-3">
<form onSubmit={handleCreateDashboard} className="flex gap-1">
<input
className="w-full min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={newDashboardName}
onChange={(e) => setNewDashboardName(e.target.value)}
placeholder="New dashboard name"
/>
<button
type="submit"
className="rounded border border-border-soft px-2 py-1 text-xs text-text-secondary hover:bg-surface-2"
>
+ Add
</button>
</form>
<div className="space-y-1">
{dashboards.length === 0 && <div className="text-xs text-text-faint">No dashboards saved yet.</div>}
{dashboards.map((d) => (
<div
key={d.id}
className={`flex items-center justify-between rounded border px-2 py-1 text-xs ${
selectedId === d.id
? 'border-accent bg-accent/10 text-accent-text'
: 'border-border-soft text-text-secondary hover:bg-surface-2'
}`}
>
<button type="button" className="min-w-0 flex-1 truncate text-left" onClick={() => setSelectedId(d.id)}>
{d.name} ({d.panels.length})
</button>
<button
type="button"
onClick={() => handleDeleteDashboard(d.id)}
className="ml-1 shrink-0 text-text-subtle hover:text-danger-text"
title="Delete dashboard"
>
×
</button>
</div>
))}
</div>
</div>
<div className="min-w-0 flex-1">
{!draft ? (
<div className="text-xs text-text-faint">Select or create a dashboard to edit its panels.</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<input
className="w-64 rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text focus:border-border-strong focus:outline-none"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
<div className="flex items-center gap-2">
<AutosaveStatus status={saveStatus} error={saveError} />
<button
type="button"
onClick={addPanel}
className="rounded border border-border-soft px-2 py-1 text-xs text-text-secondary hover:bg-surface-2"
>
+ Add panel
</button>
</div>
</div>
{draft.panels.length === 0 && (
<div className="text-xs text-text-faint">No panels yet — add one above.</div>
)}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{draft.panels.map((panel) => (
<div key={panel.id} className="rounded border border-border p-2">
<div className="mb-2 flex items-center gap-1">
<input
className="w-28 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={panel.title}
onChange={(e) => updatePanel(panel.id, { title: e.target.value })}
placeholder="Panel title"
/>
<input
className="w-40 min-w-0 flex-[2] rounded border border-border-soft bg-surface-1 px-1.5 py-1 font-mono text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={panel.promql}
onChange={(e) => updatePanel(panel.id, { promql: e.target.value })}
placeholder={String.raw`requests{path="/a"}`}
/>
<select
className="rounded border border-border-soft bg-surface-1 px-1 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
value={panel.chart_type}
onChange={(e) => updatePanel(panel.id, { chart_type: e.target.value as ChartType })}
>
{CHART_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<button
type="button"
onClick={() => removePanel(panel.id)}
className="rounded border border-border-soft px-1.5 py-1 text-xs text-text-muted hover:bg-surface-2"
>
×
</button>
</div>
{panel.title && <div className="mb-1 text-xs text-text-subtle">{panel.title}</div>}
<PanelPreview panel={panel} range={range} />
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}