Files
ottyl/webapp/src/components/Dashboards.tsx
T
Hugh KaznowskiandClaude Sonnet 5 a7345c1300 Merge ticket/023-dashboards-refresh-with-ui
Resolves a POLL_MS import conflict with ticket 025 in App.tsx.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 19:42:44 +01:00

445 lines
18 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 { POLL_MS } from '../lib/polling'
import {
CollapseToggle,
handleSectionHeaderKeyDown,
PopOutButton,
sectionHeaderInteractiveClassName,
sectionHeaderLeftClassName,
useSectionCollapsed,
} from './CollapsibleSection'
import { HelpIcon } from './HelpIcon'
const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie']
/// Virtual selection id for the "All dashboards" stacked/print view; distinct from any real dashboard id.
const ALL_DASHBOARDS_ID = '__all__'
/// Long enough that rapid typing doesn't spam the save endpoint, short enough that a pause reads as "done" 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; only needs to be unique within one editing session since the backend treats panel ids as opaque strings.
return `panel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
/// Fetches the panel's PromQL series on query/chart-type/range change, and also refetches on
/// the same cadence as the rest of the app's polling (`POLL_MS`) so a panel doesn't go stale
/// between rolling-preset ticks or under a fixed range. `tick` is the only thing that interval
/// touches; it's folded into this single effect's dependency array (rather than driving a
/// second, independent fetch effect) so a range change landing on the same tick still results
/// in exactly one fetch instead of two racing ones.
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)
const [tick, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), POLL_MS)
return () => clearInterval(id)
}, [])
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
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [panel.promql, panel.chart_type, range?.startMs, range?.endMs, tick])
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} />
}
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
}
/// Read-only, print-friendly view of every dashboard stacked one after another, each preceded by a heading naming it.
/// Deliberately uses normal document flow (no scroll containers, no fixed heights) so the browser's native print
/// function can lay the full stack out across as many pages as it needs.
function AllDashboardsView({ dashboards, range }: { dashboards: Dashboard[]; range: TimeRange | null }) {
if (dashboards.length === 0) {
return <div className="text-xs text-text-faint">No dashboards saved yet.</div>
}
return (
<div className="space-y-6">
{dashboards.map((d) => (
<section key={d.id} className="break-inside-avoid-page">
<h3 className="mb-2 border-b border-border pb-1 text-sm font-medium text-text">{d.name}</h3>
{d.panels.length === 0 ? (
<div className="text-xs text-text-faint">No panels in this dashboard.</div>
) : (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2 print:grid-cols-1">
{d.panels.map((panel) => (
<div key={panel.id} className="break-inside-avoid-page rounded border border-border p-2">
{panel.title && <div className="mb-1 text-xs text-text-subtle">{panel.title}</div>}
<PanelPreview panel={panel} range={range} />
</div>
))}
</div>
)}
</section>
))}
</div>
)
}
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)
// Last-persisted shape for the current draft, so the autosave effect can skip when nothing actually changed.
const lastSavedRef = useRef<string | null>(null)
const [collapsed, setCollapsed] = useSectionCollapsed('dashboards')
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 profiles elsewhere (NavMenu) requires a reload for now.
// 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])
// Skips entirely once the draft matches what's already persisted — covers both re-selecting an unchanged dashboard and this effect's own post-save re-render.
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 so it reads as a confirmation, not 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="border-b border-border text-sm">
<div
className={sectionHeaderInteractiveClassName}
role="button"
tabIndex={0}
aria-expanded={!collapsed}
onClick={() => setCollapsed(!collapsed)}
onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))}
>
<div className={sectionHeaderLeftClassName}>
Dashboards
<HelpIcon sectionId="dashboards" label="Help: Dashboards" />
</div>
<div className="flex items-center gap-1.5">
<CollapseToggle collapsed={collapsed} />
<PopOutButton section="dashboards" range={range} />
</div>
</div>
<div className="p-3 text-xs text-text-faint">
Activate a profile (see the settings menu) to create and save dashboards.
</div>
</div>
)
}
return (
<div className="border-b border-border text-sm">
<div
className={sectionHeaderInteractiveClassName}
role="button"
tabIndex={0}
aria-expanded={!collapsed}
onClick={() => setCollapsed(!collapsed)}
onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))}
>
<div className={sectionHeaderLeftClassName}>
Dashboards
<HelpIcon sectionId="dashboards" label="Help: Dashboards" />
</div>
<div className="flex items-center gap-1.5">
<CollapseToggle collapsed={collapsed} />
<PopOutButton section="dashboards" range={range} />
</div>
</div>
{error && !collapsed && (
<div className="mx-3 mt-3 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 p-3" hidden={collapsed}>
<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 && (
<button
type="button"
className={`flex w-full items-center rounded border px-2 py-1 text-left text-xs ${
selectedId === ALL_DASHBOARDS_ID
? 'border-accent bg-accent/10 text-accent-text'
: 'border-border-soft text-text-secondary hover:bg-surface-2'
}`}
onClick={() => setSelectedId(ALL_DASHBOARDS_ID)}
>
All dashboards
</button>
)}
{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">
{selectedId === ALL_DASHBOARDS_ID ? (
<AllDashboardsView dashboards={dashboards} range={range} />
) : !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>
)
}