Files
ottyl/webapp/src/App.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

582 lines
22 KiB
TypeScript

import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
import { NavMenu } from './components/NavMenu'
import { FilterBar } from './components/FilterBar'
import {
LogTimeline,
TIME_RANGE_PRESETS,
computeBucketMs,
computePresetRange,
loadLogSortSettings,
saveLogSortSettings,
type LogSortSettings,
type TimeRange,
type TimeRangePresetId,
} from './components/LogTimeline'
import { LogPreview } from './components/LogPreview'
import { Params } from './components/Params'
import { Metrics } from './components/Metrics'
import { Dashboards } from './components/Dashboards'
import { Alerts } from './components/Alerts'
import { CollapseToggle, PopOutButton, type PopOutSectionId, useSectionCollapsed } from './components/CollapsibleSection'
import {
fetchLogs,
fetchParamDeclarations,
fetchStats,
fetchTimeline,
type LogEntry,
type ParamDeclaration,
type Stats,
type TimelineBucket,
} from './lib/api'
import { DocsNavProvider } from './lib/docsNav'
import { useSettingsPanel } from './lib/useSettingsPanel'
import { POLL_MS } from './lib/polling'
// Height (px) the log viewer had before it became resizable — kept as the default so existing
// layouts don't jump on first load after this change.
const DEFAULT_LOG_VIEWER_HEIGHT_PX = 352
const MIN_LOG_VIEWER_HEIGHT_PX = 200
// Leaves room below the log viewer for the drag handle and at least a sliver of the next
// section, so a drag can never grow it to swallow the whole viewport.
const LOG_VIEWER_HEIGHT_VIEWPORT_MARGIN_PX = 160
// Namespaced under `ottyl.` per the app's existing localStorage convention (see
// CollapsibleSection.tsx's COLLAPSED_SECTIONS_STORAGE_KEY, LogPreview.tsx's
// VISIBLE_PARAM_COLUMNS_STORAGE_KEY).
const LOG_VIEWER_HEIGHT_STORAGE_KEY = 'ottyl.logViewerHeightPx'
function clampLogViewerHeight(px: number): number {
const max = Math.max(MIN_LOG_VIEWER_HEIGHT_PX, window.innerHeight - LOG_VIEWER_HEIGHT_VIEWPORT_MARGIN_PX)
return Math.min(max, Math.max(MIN_LOG_VIEWER_HEIGHT_PX, px))
}
function loadLogViewerHeight(): number {
try {
const raw = localStorage.getItem(LOG_VIEWER_HEIGHT_STORAGE_KEY)
if (!raw) return DEFAULT_LOG_VIEWER_HEIGHT_PX
const parsed = Number(raw)
return Number.isFinite(parsed) ? clampLogViewerHeight(parsed) : DEFAULT_LOG_VIEWER_HEIGHT_PX
} catch {
return DEFAULT_LOG_VIEWER_HEIGHT_PX
}
}
function saveLogViewerHeight(px: number) {
try {
localStorage.setItem(LOG_VIEWER_HEIGHT_STORAGE_KEY, String(px))
} catch {
// best-effort — private browsing or full storage just means the size won't persist across reloads
}
}
/// Thin drag handle between the log viewer and the sections below it. Reports each pointer-move
/// delta via `onResize` (letting the caller clamp/apply it) and fires `onResizeEnd` on release so
/// the final size can be persisted once, not on every pixel of movement.
function ResizeHandle({ onResize, onResizeEnd }: { onResize: (deltaY: number) => void; onResizeEnd: () => void }) {
const draggingRef = useRef(false)
function handlePointerDown(e: ReactPointerEvent) {
e.preventDefault()
draggingRef.current = true
let lastY = e.clientY
function handlePointerMove(ev: PointerEvent) {
if (!draggingRef.current) return
onResize(ev.clientY - lastY)
lastY = ev.clientY
}
function handlePointerUp() {
draggingRef.current = false
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
onResizeEnd()
}
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', handlePointerUp)
}
return (
<div
role="separator"
aria-orientation="horizontal"
aria-label="Resize log viewer"
title="Drag to resize"
onPointerDown={handlePointerDown}
className="group relative h-1.5 shrink-0 cursor-ns-resize touch-none"
>
<div className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-border-soft group-hover:bg-border-strong" />
</div>
)
}
/// Reads the routing params `PopOutButton` writes: `section` picks which primary section to render full-page. `preset` carries over which rolling preset (e.g. `5m`) was active, so the popped-out tab can resolve and keep re-resolving its own independent window of the same size instead of freezing whatever absolute bounds happened to be active at click time. `startMs`/`endMs` carry over a fixed/dragged (non-preset) range as absolute bounds, same as before.
function parsePopOutQuery(): { section: PopOutSectionId | null; presetId: TimeRangePresetId | null; fixedRange: TimeRange | null } {
const query = new URLSearchParams(window.location.search)
const section = query.get('section') as PopOutSectionId | null
const presetParam = query.get('preset')
const presetId = TIME_RANGE_PRESETS.some((p) => p.id === presetParam) ? (presetParam as TimeRangePresetId) : null
const startMs = Number(query.get('startMs'))
const endMs = Number(query.get('endMs'))
const fixedRange = query.has('startMs') && query.has('endMs') && Number.isFinite(startMs) && Number.isFinite(endMs)
? { startMs, endMs }
: null
return { section, presetId, fixedRange }
}
// Popped-out routes render outside NavMenu, which is otherwise the only place that calls `useSettingsPanel()` and applies the
// colour scheme / custom theme CSS as a side effect. Calling it here (state unused) keeps standalone tabs themed the same as
// the main window, including staying live if settings change while the tab is open.
function StandalonePage({ children }: { children: ReactNode }) {
useSettingsPanel()
return (
<DocsNavProvider>
<div className="h-screen w-screen overflow-auto">{children}</div>
</DocsNavProvider>
)
}
/// Popped out on its own, Params has no lifted-state sibling to share `params`/`loadParams` with, so it owns a minimal fetch here instead.
function StandaloneParams() {
const [params, setParams] = useState<ParamDeclaration[]>([])
async function loadParams() {
try {
setParams(await fetchParamDeclarations())
} catch {
// engine not reachable yet; leave the previous list in place
}
}
useEffect(() => {
loadParams()
}, [])
return (
<StandalonePage>
<Params params={params} onDeclared={loadParams} />
</StandalonePage>
)
}
/// Metrics/Dashboards own their own data fetching — they only need the range. Unlike the log viewer's standalone page, there's no pre-existing per-section timeline to seed buckets from, so this owns its own minimal timeline poll purely to drive `LogTimeline`'s picker UI (preset buttons + drag-to-select) and, when a preset was carried over, to keep independently re-resolving that rolling window on its own cadence — it never tracks or freezes to whatever the source tab's window does after this tab was opened.
function StandaloneRanged({
section,
initialPresetId,
initialFixedRange,
}: {
section: 'metrics' | 'dashboards'
initialPresetId: TimeRangePresetId | null
initialFixedRange: TimeRange | null
}) {
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
const [range, setRange] = useState<TimeRange | null>(
initialPresetId ? computePresetRange(initialPresetId) : initialFixedRange,
)
// No preset and no fixed range means the popout was opened with nothing carried over (e.g. a bare URL) — default to 'all', matching FullApp's own default.
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>(
initialPresetId ?? (initialFixedRange ? null : 'all'),
)
const rangeRef = useRef(range)
useEffect(() => {
rangeRef.current = range
}, [range])
function selectPreset(id: TimeRangePresetId) {
setActivePreset(id)
setRange(computePresetRange(id))
}
function handleManualRangeSelect(next: TimeRange | null) {
if (next === null) {
selectPreset('all')
} else {
setActivePreset(null)
setRange(next)
}
}
useEffect(() => {
let cancelled = false
async function poll() {
// Rolling presets re-resolve on this same polling cadence instead of freezing the window at selection time — this is what makes a carried-over preset independently slide rather than track the source tab.
const effectiveRange =
activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current
if (activePreset && activePreset !== 'all') {
setRange((current) =>
current && effectiveRange && current.startMs === effectiveRange.startMs && current.endMs === effectiveRange.endMs
? current
: effectiveRange,
)
}
try {
const nextBuckets = await fetchTimeline({
startMs: effectiveRange?.startMs,
endMs: effectiveRange?.endMs,
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
})
if (!cancelled) setBuckets(nextBuckets)
} catch {
// engine not reachable yet; leave the previous buckets in place
}
}
poll()
const id = setInterval(poll, POLL_MS)
return () => {
cancelled = true
clearInterval(id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activePreset])
return (
<StandalonePage>
<div className="flex h-full min-h-0 flex-col overflow-y-auto">
<LogTimeline
buckets={buckets}
range={range}
onSelectRange={handleManualRangeSelect}
activePreset={activePreset}
onSelectPreset={selectPreset}
/>
{section === 'metrics' ? (
<Metrics range={range} activePreset={activePreset} />
) : (
<Dashboards range={range} activePreset={activePreset} />
)}
</div>
</StandalonePage>
)
}
/// Ticket 058: only replaces `params` state when the declaration list actually changed, so a param
/// list refetched on every poll tick doesn't force `Params.tsx` to re-render when nothing changed.
function updateParamsIfChanged(
setParams: (updater: (current: ParamDeclaration[]) => ParamDeclaration[]) => void,
next: ParamDeclaration[],
) {
setParams((current) => (JSON.stringify(current) === JSON.stringify(next) ? current : next))
}
/// Ticket 066: text shown under the filter bar when the user hasn't typed a manual filter, so an active preset/sort order is still visible somewhere near the filter input instead of leaving it looking empty/stale.
function describeActiveState(activePreset: TimeRangePresetId | null, sortSettings: LogSortSettings): string | null {
const parts: string[] = []
if (activePreset && activePreset !== 'all') {
const preset = TIME_RANGE_PRESETS.find((p) => p.id === activePreset)
parts.push(`last ${preset?.label ?? activePreset}`)
}
if (sortSettings.sortBy) {
parts.push(`sorted by ${sortSettings.sortBy}`)
}
return parts.length > 0 ? parts.join(', ') : null
}
/// The log viewer has its own `LogTimeline`, so it needs its own copy of App's log/timeline polling state instead of a carried-over range.
function StandaloneLog() {
const [logs, setLogs] = useState<LogEntry[]>([])
const [params, setParams] = useState<ParamDeclaration[]>([])
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
const [range, setRange] = useState<TimeRange | null>(null)
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>('all')
const [filter, setFilter] = useState('')
const [filterError, setFilterError] = useState<string | null>(null)
const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings)
function handleSortSettingsChange(next: LogSortSettings) {
setSortSettings(next)
saveLogSortSettings(next)
}
const rangeRef = useRef(range)
useEffect(() => {
rangeRef.current = range
}, [range])
useEffect(() => {
fetchParamDeclarations()
.then(setParams)
.catch(() => {
// engine not reachable yet; leave the previous list in place
})
}, [])
function selectPreset(id: TimeRangePresetId) {
setActivePreset(id)
setRange(computePresetRange(id))
}
function handleManualRangeSelect(next: TimeRange | null) {
if (next === null) {
selectPreset('all')
} else {
setActivePreset(null)
setRange(next)
}
}
useEffect(() => {
let cancelled = false
async function poll() {
const effectiveRange =
activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current
if (activePreset && activePreset !== 'all') {
setRange((current) =>
current && effectiveRange && current.startMs === effectiveRange.startMs && current.endMs === effectiveRange.endMs
? current
: effectiveRange,
)
}
try {
const [nextBuckets, nextLogs, nextParams] = await Promise.all([
fetchTimeline({
startMs: effectiveRange?.startMs,
endMs: effectiveRange?.endMs,
// Only meaningful for a fixed window — 'all' has no span to derive a resolution from, so it keeps the server's default bucketing.
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
filter: filter || undefined,
}),
fetchLogs({
startMs: effectiveRange?.startMs,
endMs: effectiveRange?.endMs,
limit: 1000,
filter: filter || undefined,
sortBy: sortSettings.sortBy ?? undefined,
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined,
}),
// Ticket 058: params can get auto-declared from freshly ingested lines mid-run, not just from a user's own edit.
fetchParamDeclarations(),
])
if (!cancelled) {
setBuckets(nextBuckets)
setLogs(nextLogs)
updateParamsIfChanged(setParams, nextParams)
setFilterError(null)
}
} catch (err) {
if (!cancelled) {
setFilterError(err instanceof Error ? err.message : String(err))
}
}
}
poll()
const id = setInterval(poll, POLL_MS)
return () => {
cancelled = true
clearInterval(id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filter, activePreset, sortSettings])
return (
<StandalonePage>
<div className="flex h-full min-h-0 flex-col">
<FilterBar
value={filter}
onChange={setFilter}
error={filterError}
activeSummary={describeActiveState(activePreset, sortSettings)}
/>
<div className="flex min-h-0 flex-1 flex-col">
<LogTimeline
buckets={buckets}
range={range}
onSelectRange={handleManualRangeSelect}
activePreset={activePreset}
onSelectPreset={selectPreset}
paramNames={params.map((p) => p.name)}
sortSettings={sortSettings}
onSortSettingsChange={handleSortSettingsChange}
/>
<LogPreview logs={logs} params={params} />
</div>
</div>
</StandalonePage>
)
}
export default function App() {
const { section, presetId, fixedRange } = parsePopOutQuery()
if (section === 'log') return <StandaloneLog />
if (section === 'params') return <StandaloneParams />
if (section === 'metrics' || section === 'dashboards')
return <StandaloneRanged section={section} initialPresetId={presetId} initialFixedRange={fixedRange} />
return <FullApp />
}
function FullApp() {
const [stats, setStats] = useState<Stats | null>(null)
const [logs, setLogs] = useState<LogEntry[]>([])
const [params, setParams] = useState<ParamDeclaration[]>([])
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
const [range, setRange] = useState<TimeRange | null>(null)
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>('all')
const [filter, setFilter] = useState('')
const [filterError, setFilterError] = useState<string | null>(null)
const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer')
const [logViewerHeight, setLogViewerHeight] = useState(() => loadLogViewerHeight())
const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings)
function handleSortSettingsChange(next: LogSortSettings) {
setSortSettings(next)
saveLogSortSettings(next)
}
function handleLogViewerResize(deltaY: number) {
setLogViewerHeight((current) => clampLogViewerHeight(current + deltaY))
}
function handleLogViewerResizeEnd() {
setLogViewerHeight((current) => {
saveLogViewerHeight(current)
return current
})
}
// Read via ref inside the poll loop instead of depending on `range` directly, which would restart the interval every rolling-preset tick.
const rangeRef = useRef(range)
useEffect(() => {
rangeRef.current = range
}, [range])
async function loadParams() {
try {
setParams(await fetchParamDeclarations())
} catch {
// engine not reachable yet; leave the previous list in place
}
}
useEffect(() => {
loadParams()
}, [])
function selectPreset(id: TimeRangePresetId) {
setActivePreset(id)
setRange(computePresetRange(id))
}
// A drag always yields a fixed (non-rolling) range, overriding any active preset.
function handleManualRangeSelect(next: TimeRange | null) {
if (next === null) {
selectPreset('all')
} else {
setActivePreset(null)
setRange(next)
}
}
useEffect(() => {
let cancelled = false
async function poll() {
// Rolling presets re-resolve on this same polling cadence instead of freezing the window at selection time.
const effectiveRange =
activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current
if (activePreset && activePreset !== 'all') {
setRange((current) =>
current && effectiveRange && current.startMs === effectiveRange.startMs && current.endMs === effectiveRange.endMs
? current
: effectiveRange,
)
}
try {
const [nextStats, nextBuckets, nextLogs, nextParams] = await Promise.all([
fetchStats(),
fetchTimeline({
startMs: effectiveRange?.startMs,
endMs: effectiveRange?.endMs,
// Only meaningful for a fixed window — 'all' has no span to derive a resolution from, so it keeps the server's default bucketing.
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
filter: filter || undefined,
}),
fetchLogs({
startMs: effectiveRange?.startMs,
endMs: effectiveRange?.endMs,
limit: 1000,
filter: filter || undefined,
sortBy: sortSettings.sortBy ?? undefined,
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined,
}),
// Ticket 058: params can get auto-declared from freshly ingested lines mid-run, not just from a user's own edit.
fetchParamDeclarations(),
])
if (!cancelled) {
setStats(nextStats)
setBuckets(nextBuckets)
setLogs(nextLogs)
updateParamsIfChanged(setParams, nextParams)
setFilterError(null)
}
} catch (err) {
if (!cancelled) {
setFilterError(err instanceof Error ? err.message : String(err))
}
}
}
poll()
const id = setInterval(poll, POLL_MS)
return () => {
cancelled = true
clearInterval(id)
}
// Intentionally excludes `range` to avoid restarting this interval on every rolling-preset tick.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filter, activePreset, sortSettings])
return (
<DocsNavProvider>
<div className="flex h-screen w-screen overflow-hidden">
<NavMenu stats={stats} />
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto">
<FilterBar
value={filter}
onChange={setFilter}
error={filterError}
activeSummary={describeActiveState(activePreset, sortSettings)}
collapseToggle={
<>
<CollapseToggle collapsed={logViewerCollapsed} onToggle={() => setLogViewerCollapsed(!logViewerCollapsed)} />
<PopOutButton section="log" />
</>
}
/>
{/* An explicit height (user-resizable via the handle below, persisted to localStorage) keeps this
from being squeezed by the sections below it in the shared scroll container, and bounds
LogPreview's own internal `flex-1 overflow-auto` scroll area. */}
<div
className="flex shrink-0 flex-col overflow-hidden"
style={{ height: logViewerHeight }}
hidden={logViewerCollapsed}
>
<LogTimeline
buckets={buckets}
range={range}
onSelectRange={handleManualRangeSelect}
activePreset={activePreset}
onSelectPreset={selectPreset}
paramNames={params.map((p) => p.name)}
sortSettings={sortSettings}
onSortSettingsChange={handleSortSettingsChange}
/>
<LogPreview logs={logs} params={params} />
</div>
{!logViewerCollapsed && <ResizeHandle onResize={handleLogViewerResize} onResizeEnd={handleLogViewerResizeEnd} />}
<Params params={params} onDeclared={loadParams} />
<Metrics range={range} activePreset={activePreset} />
<Dashboards range={range} activePreset={activePreset} />
<Alerts />
</div>
</div>
</DocsNavProvider>
)
}