- 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
463 lines
19 KiB
TypeScript
463 lines
19 KiB
TypeScript
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>
|
||
)
|
||
}
|