312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
import { NavMenu } from './components/NavMenu'
|
|
import { FilterBar } from './components/FilterBar'
|
|
import { LogTimeline, computeBucketMs, computePresetRange, 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'
|
|
|
|
const POLL_MS = 2000
|
|
|
|
/// Reads the routing params `PopOutButton` writes: `section` picks which primary section to render full-page, and `startMs`/`endMs` carry over the range for sections with no `LogTimeline` of their own to pick one.
|
|
function parsePopOutQuery(): { section: PopOutSectionId | null; range: TimeRange | null } {
|
|
const query = new URLSearchParams(window.location.search)
|
|
const section = query.get('section') as PopOutSectionId | null
|
|
const startMs = Number(query.get('startMs'))
|
|
const endMs = Number(query.get('endMs'))
|
|
const range = query.has('startMs') && query.has('endMs') && Number.isFinite(startMs) && Number.isFinite(endMs)
|
|
? { startMs, endMs }
|
|
: null
|
|
return { section, range }
|
|
}
|
|
|
|
function StandalonePage({ children }: { children: ReactNode }) {
|
|
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 carried over via query params.
|
|
function StandaloneRanged({ section, range }: { section: 'metrics' | 'dashboards'; range: TimeRange | null }) {
|
|
return <StandalonePage>{section === 'metrics' ? <Metrics range={range} /> : <Dashboards range={range} />}</StandalonePage>
|
|
}
|
|
|
|
/// 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 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] = 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 }),
|
|
])
|
|
if (!cancelled) {
|
|
setBuckets(nextBuckets)
|
|
setLogs(nextLogs)
|
|
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])
|
|
|
|
return (
|
|
<StandalonePage>
|
|
<div className="flex h-full min-h-0 flex-col">
|
|
<FilterBar value={filter} onChange={setFilter} error={filterError} />
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<LogTimeline
|
|
buckets={buckets}
|
|
range={range}
|
|
onSelectRange={handleManualRangeSelect}
|
|
activePreset={activePreset}
|
|
onSelectPreset={selectPreset}
|
|
/>
|
|
<LogPreview logs={logs} params={params} />
|
|
</div>
|
|
</div>
|
|
</StandalonePage>
|
|
)
|
|
}
|
|
|
|
export default function App() {
|
|
const { section, range: standaloneRange } = parsePopOutQuery()
|
|
if (section === 'log') return <StandaloneLog />
|
|
if (section === 'params') return <StandaloneParams />
|
|
if (section === 'metrics' || section === 'dashboards') return <StandaloneRanged section={section} range={standaloneRange} />
|
|
|
|
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')
|
|
|
|
// 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] = 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 }),
|
|
])
|
|
if (!cancelled) {
|
|
setStats(nextStats)
|
|
setBuckets(nextBuckets)
|
|
setLogs(nextLogs)
|
|
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])
|
|
|
|
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}
|
|
collapseToggle={
|
|
<>
|
|
<CollapseToggle collapsed={logViewerCollapsed} onToggle={() => setLogViewerCollapsed(!logViewerCollapsed)} />
|
|
<PopOutButton section="log" />
|
|
</>
|
|
}
|
|
/>
|
|
{/* min-h-[...] keeps this from being squeezed to 0 by the sections below it in the shared scroll container — flex-1 alone lets a flex item shrink past its content, which let this section's absolutely-unclipped content spill over Params et al instead of the column just scrolling. */}
|
|
<div className="flex min-h-[22rem] flex-1 flex-col" hidden={logViewerCollapsed}>
|
|
<LogTimeline
|
|
buckets={buckets}
|
|
range={range}
|
|
onSelectRange={handleManualRangeSelect}
|
|
activePreset={activePreset}
|
|
onSelectPreset={selectPreset}
|
|
/>
|
|
<LogPreview logs={logs} params={params} />
|
|
</div>
|
|
<Params params={params} onDeclared={loadParams} />
|
|
<Metrics range={range} />
|
|
<Dashboards range={range} />
|
|
<Alerts />
|
|
</div>
|
|
</div>
|
|
</DocsNavProvider>
|
|
)
|
|
}
|