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 } } // Ticket 074: a sort helper index's one-time full-scan build (`build_sort_helper_index`) runs // inline in the engine actor, so a `sort_by` poll can occasionally take much longer than the usual // sub-poll-interval round trip — without this, that stall looks exactly like "sorting is broken" // rather than "still indexing". Only flips `setBuilding(true)` if the fetch is still in flight after // `thresholdMs`, so an ordinary fast poll (helper index already built/incrementally maintained) // never flashes the indicator. const SORT_BUILDING_INDICATOR_THRESHOLD_MS = 400 function trackSlowSortedFetch(sortBy: string | null, setBuilding: (building: boolean) => void, promise: Promise): Promise { if (!sortBy) return promise const timer = setTimeout(() => setBuilding(true), SORT_BUILDING_INDICATOR_THRESHOLD_MS) return promise.finally(() => { clearTimeout(timer) setBuilding(false) }) } /// 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 (
) } /// 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 (
{children}
) } /// 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([]) async function loadParams() { try { setParams(await fetchParamDeclarations()) } catch { // engine not reachable yet; leave the previous list in place } } useEffect(() => { loadParams() }, []) return ( ) } /// 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([]) const [range, setRange] = useState( 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( 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 (
{section === 'metrics' ? ( ) : ( )}
) } /// 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([]) const [params, setParams] = useState([]) const [buckets, setBuckets] = useState([]) const [range, setRange] = useState(null) const [activePreset, setActivePreset] = useState('all') const [filter, setFilter] = useState('') const [filterError, setFilterError] = useState(null) const [sortSettings, setSortSettings] = useState(loadLogSortSettings) const [sortIndexBuilding, setSortIndexBuilding] = useState(false) 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, }), trackSlowSortedFetch( sortSettings.sortBy, setSortIndexBuilding, 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 (
p.name)} sortSettings={sortSettings} onSortSettingsChange={handleSortSettingsChange} sortIndexBuilding={sortIndexBuilding} />
) } export default function App() { const { section, presetId, fixedRange } = parsePopOutQuery() if (section === 'log') return if (section === 'params') return if (section === 'metrics' || section === 'dashboards') return return } function FullApp() { const [stats, setStats] = useState(null) const [logs, setLogs] = useState([]) const [params, setParams] = useState([]) const [buckets, setBuckets] = useState([]) const [range, setRange] = useState(null) const [activePreset, setActivePreset] = useState('all') const [filter, setFilter] = useState('') const [filterError, setFilterError] = useState(null) const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer') const [logViewerHeight, setLogViewerHeight] = useState(() => loadLogViewerHeight()) const [sortSettings, setSortSettings] = useState(loadLogSortSettings) const [sortIndexBuilding, setSortIndexBuilding] = useState(false) 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, }), trackSlowSortedFetch( sortSettings.sortBy, setSortIndexBuilding, 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 (
setLogViewerCollapsed(!logViewerCollapsed)} /> } /> {/* 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. */} {!logViewerCollapsed && }
) }