- 076: gate auto-declared log-stream column/message params (`ParamDeclaration::log_stream_gated`) behind `is_log_stream_line` at extraction time, since their fixed-offset regex has no content anchor and previously extracted garbage from continuation lines and the header row itself (the regex crate doesn't support lookaround, so this couldn't be baked into the regex text). - 074: end-to-end regression test confirms sorting a real `log stream` capture by a log-stream column (e.g. PID) now actually reorders results, since 076's fix removes the garbage values that were corrupting the sort helper index; also add a "Building sort index…" indicator in the timeline UI for a slow first-time sort. - 072: attach the reserved `ottyl_fmt` pseudo-param to each `LogEntryDto`'s params so it can be picked as a Log Preview column (previously listed as pickable but always blank). - 073: track a rolling 5s high-watermark of ingestion-to-engine-processing lag and surface it in the nav stats popover next to approx_bytes_total. - 071: group the regex(...) help button back with the filter bar's other controls, and restructure the Filtering docs section into scannable per-feature subsections (also fixes stale log_type/process/pid mention). Tickets 070 (CLI scoping) and 075 (API unresponsiveness investigation) are left in place per their own scope — both are explicitly conversation/investigation tasks, not implementation tickets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
611 lines
23 KiB
TypeScript
611 lines
23 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
|
|
}
|
|
}
|
|
|
|
// 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<T>(sortBy: string | null, setBuilding: (building: boolean) => void, promise: Promise<T>): Promise<T> {
|
|
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 (
|
|
<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)
|
|
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 (
|
|
<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}
|
|
sortIndexBuilding={sortIndexBuilding}
|
|
/>
|
|
<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)
|
|
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 (
|
|
<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}
|
|
sortIndexBuilding={sortIndexBuilding}
|
|
/>
|
|
<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>
|
|
)
|
|
}
|