diff --git a/tickets/036-section-open-in-new-tab.md b/tickets/036-section-open-in-new-tab.md deleted file mode 100644 index b568a73..0000000 --- a/tickets/036-section-open-in-new-tab.md +++ /dev/null @@ -1,42 +0,0 @@ -# 036 — Open a section in its own tab - -## Summary - -`App.tsx` has no routing at all — `NavMenu`, `FilterBar`, `LogTimeline` + -`LogPreview`, Params ([034](034-params-declaration-ui.md)), `Metrics`, and -`Dashboards` all render together in one fixed layout -(`webapp/src/App.tsx:94-108`). Add a small icon next to each section's -collapse toggle ([035](035-collapsible-sections.md)) that opens *just that -section* in a new browser tab — no `NavMenu`, no other sections — for -pop-out/multi-monitor use. - -## Scope - -- A "pop out" icon button in each of the four section headers (Log viewer, - Params, Metrics, Dashboards), alongside the collapse toggle and "?" help - icon. -- Clicking it opens a new tab (`window.open`, not same-tab navigation) at a - URL identifying that section, e.g. `?section=metrics`. -- Minimal client-side routing in `App.tsx`/`main.tsx`: when a `section` - query param is present, render only that one section full-page — no - `NavMenu`, no `FilterBar` unless the Log viewer section needs it, no - sibling sections. -- The standalone view keeps the section's own functionality intact (e.g. the - popped-out Metrics tab can still declare metrics; popped-out Dashboards - can still edit/autosave panels) — it's a layout change, not a read-only - view. -- The popped-out tab reflects the current `range` state (time range) at the - moment it's opened, passed via query params, since it has no `LogTimeline` - of its own to select one (unless the Log viewer section itself is what's - popped out). - -## Acceptance criteria - -- Clicking a section's pop-out icon opens a new tab showing only that - section, with no nav bar and no other sections. -- The popped-out section is fully functional (not a static snapshot). -- Closing the popped-out tab has no effect on the original tab's state. - -## Depends on - -- [035 — Collapsible primary UI sections](035-collapsible-sections.md) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 2b4c667..f87ca53 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type ReactNode } from 'react' import { NavMenu } from './components/NavMenu' import { FilterBar } from './components/FilterBar' import { LogTimeline, computePresetRange, type TimeRange, type TimeRangePresetId } from './components/LogTimeline' @@ -7,7 +7,7 @@ import { Params } from './components/Params' import { Metrics } from './components/Metrics' import { Dashboards } from './components/Dashboards' import { Alerts } from './components/Alerts' -import { CollapseToggle, useSectionCollapsed } from './components/CollapsibleSection' +import { CollapseToggle, PopOutButton, type PopOutSectionId, useSectionCollapsed } from './components/CollapsibleSection' import { fetchLogs, fetchParamDeclarations, @@ -22,7 +22,168 @@ import { DocsNavProvider } from './lib/docsNav' const POLL_MS = 2000 +/// Reads the standalone-page routing params written by `PopOutButton` +/// (ticket 036) — a `section` id selects one of the four primary sections to +/// render full-page with no nav/sibling chrome, and `startMs`/`endMs` carry +/// over the range that was active at pop-out time for sections that have 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 ( + +
{children}
+
+ ) +} + +/// Params has no lifted-state sibling to share `params`/`loadParams` with +/// when popped out on its own, so it owns a minimal fetch of its own here +/// rather than App's full `loadParams` machinery. +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 already own their own data fetching — they only need +/// the range that was active at pop-out time, carried over via query params. +function StandaloneRanged({ section, range }: { section: 'metrics' | 'dashboards'; range: TimeRange | null }) { + return {section === 'metrics' ? : } +} + +/// The log viewer has its own `LogTimeline` to pick a range, so it doesn't +/// need one carried over — it just needs its own copy of App's log/timeline +/// polling state, since none of that is shared with any sibling here. +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 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, 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 ( + +
+ +
+ + +
+
+
+ ) +} + export default function App() { + const { section, range: standaloneRange } = 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([]) @@ -130,7 +291,10 @@ export default function App() { onChange={setFilter} error={filterError} collapseToggle={ - setLogViewerCollapsed(!logViewerCollapsed)} /> + <> + setLogViewerCollapsed(!logViewerCollapsed)} /> + + } />