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
365 lines
15 KiB
TypeScript
365 lines
15 KiB
TypeScript
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>
|
||
)
|
||
}
|