Add pop-out button to open a section in its own tab (ticket 036)
Each of the four primary sections (Log viewer, Params, Metrics, Dashboards) gets a PopOutButton next to its CollapseToggle/HelpIcon that opens `?section=<id>` in a new tab via window.open. App.tsx branches on that query param to render just the one section full-page, with no NavMenu/FilterBar/sibling chrome — Metrics/Dashboards get the range that was active at pop-out time via startMs/endMs query params (they have no LogTimeline of their own to pick one), Params gets a small self-contained params fetch since it has no LogPreview sibling to share state with, and the popped-out Log viewer owns its own copy of the log/timeline polling state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7db1e0f055
commit
0c4b596e3c
+167
-3
@@ -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 (
|
||||
<DocsNavProvider>
|
||||
<div className="h-screen w-screen overflow-auto">{children}</div>
|
||||
</DocsNavProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/// 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<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 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 <StandalonePage>{section === 'metrics' ? <Metrics range={range} /> : <Dashboards range={range} />}</StandalonePage>
|
||||
}
|
||||
|
||||
/// 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<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, 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[]>([])
|
||||
@@ -130,7 +291,10 @@ export default function App() {
|
||||
onChange={setFilter}
|
||||
error={filterError}
|
||||
collapseToggle={
|
||||
<CollapseToggle collapsed={logViewerCollapsed} onToggle={() => setLogViewerCollapsed(!logViewerCollapsed)} />
|
||||
<>
|
||||
<CollapseToggle collapsed={logViewerCollapsed} onToggle={() => setLogViewerCollapsed(!logViewerCollapsed)} />
|
||||
<PopOutButton section="log" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col" hidden={logViewerCollapsed}>
|
||||
|
||||
Reference in New Issue
Block a user