Files
ottyl/webapp/src/components/Dashboards.tsx
T
Hugh KaznowskiandClaude Sonnet 5 4ace654019 Resolve tickets 058, 062-066: frontend UI fixes
- 058: poll loop now refetches param declarations so auto-declared
  params appear live without a page reload
- 062: browser tab title says "ottyl" instead of "webapp"
- 063: declared params list omits the redundant capture-group suffix
  when it matches the param name
- 064: dashboard autosave no longer clobbers in-flight PromQL edits;
  reconciliation is guarded against edits made after the save request
  was sent
- 065: filter bar placeholder/help icons lead with filter-bar syntax
  instead of regex
- 066: filter bar surfaces the active time-range preset/sort order and
  resyncs its draft when the value prop changes externally

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 13:04:26 +01:00

463 lines
19 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, TimeRangePresetId } 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} range={range} />
}
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, activePreset }: { range: TimeRange | null; activePreset?: TimeRangePresetId | 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)
// Ticket 064: latest `draft`, read inside the autosave timeout's async callback so a save response can tell
// whether the user kept typing after the request went out, instead of unconditionally overwriting `draft`.
const draftRef = useRef<Dashboard | null>(draft)
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(() => {
draftRef.current = draft
}, [draft])
// Deliberately keyed only on `selectedId`, not `dashboards` — this effect's job is to load a *newly selected*
// dashboard's content into `draft`. Keying it on `dashboards` too meant every autosave's own `setDashboards`
// call (below) re-ran this effect and clobbered whatever the user had typed since the save was kicked off
// (ticket 064). Reading `dashboards` from the closure here still sees the just-loaded array, since
// `setSelectedId`/`setDashboards` from `loadDashboards` land in the same render.
useEffect(() => {
const found = dashboards.find((d) => d.id === selectedId) ?? null
setDraft(found)
lastSavedRef.current = found ? JSON.stringify(dashboardSaveShape(found)) : null
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId])
// 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 = snapshot
setDashboards((prev) => prev.map((d) => (d.id === saved.id ? saved : d)))
// Only reconcile `saved` into `draft` if nothing newer was typed while the request was in flight —
// otherwise this stale response would overwrite those in-progress keystrokes (ticket 064).
const current = draftRef.current
if (current && current.id === dashboardId && JSON.stringify(dashboardSaveShape(current)) === snapshot) {
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} activePreset={activePreset} />
</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} activePreset={activePreset} />
</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>
)
}