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 (
+
+
diff --git a/webapp/src/components/CollapsibleSection.tsx b/webapp/src/components/CollapsibleSection.tsx
index 364d537..5b8920d 100644
--- a/webapp/src/components/CollapsibleSection.tsx
+++ b/webapp/src/components/CollapsibleSection.tsx
@@ -1,4 +1,5 @@
import { useState } from 'react'
+import type { TimeRange } from './LogTimeline'
/// localStorage key for which of the four primary sections (log viewer,
/// params, metrics, dashboards) are collapsed (ticket 035). Mirrors
@@ -57,3 +58,34 @@ export function CollapseToggle({ collapsed, onToggle }: { collapsed: boolean; on
)
}
+
+/// The four primary sections that can be popped out into their own tab
+/// (ticket 036) — matches the `?section=` query param a standalone page reads.
+export type PopOutSectionId = 'log' | 'params' | 'metrics' | 'dashboards'
+
+function popOutUrl(section: PopOutSectionId, range?: TimeRange | null): string {
+ const query = new URLSearchParams({ section })
+ if (range) {
+ query.set('startMs', String(range.startMs))
+ query.set('endMs', String(range.endMs))
+ }
+ return `?${query.toString()}`
+}
+
+/// Opens this section, full-page and with no surrounding chrome, in a new
+/// tab. `range` is only needed for sections without their own LogTimeline —
+/// the popped-out page has no way to pick a range itself, so the current one
+/// is carried over via query params (see `App.tsx`'s standalone rendering).
+export function PopOutButton({ section, range }: { section: PopOutSectionId; range?: TimeRange | null }) {
+ return (
+
+ )
+}
diff --git a/webapp/src/components/Dashboards.tsx b/webapp/src/components/Dashboards.tsx
index 9a43aa2..2e9425d 100644
--- a/webapp/src/components/Dashboards.tsx
+++ b/webapp/src/components/Dashboards.tsx
@@ -14,7 +14,7 @@ import {
type Panel,
type PromqlSeries,
} from '../lib/api'
-import { CollapseToggle, useSectionCollapsed } from './CollapsibleSection'
+import { CollapseToggle, PopOutButton, useSectionCollapsed } from './CollapsibleSection'
import { HelpIcon } from './HelpIcon'
const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie']
@@ -234,6 +234,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
setCollapsed(!collapsed)} />
+
Dashboards — activate a profile (see the settings menu) to create and save dashboards.
)
@@ -244,6 +245,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {