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
@@ -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)
|
|
||||||
+166
-2
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
import { NavMenu } from './components/NavMenu'
|
import { NavMenu } from './components/NavMenu'
|
||||||
import { FilterBar } from './components/FilterBar'
|
import { FilterBar } from './components/FilterBar'
|
||||||
import { LogTimeline, computePresetRange, type TimeRange, type TimeRangePresetId } from './components/LogTimeline'
|
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 { Metrics } from './components/Metrics'
|
||||||
import { Dashboards } from './components/Dashboards'
|
import { Dashboards } from './components/Dashboards'
|
||||||
import { Alerts } from './components/Alerts'
|
import { Alerts } from './components/Alerts'
|
||||||
import { CollapseToggle, useSectionCollapsed } from './components/CollapsibleSection'
|
import { CollapseToggle, PopOutButton, type PopOutSectionId, useSectionCollapsed } from './components/CollapsibleSection'
|
||||||
import {
|
import {
|
||||||
fetchLogs,
|
fetchLogs,
|
||||||
fetchParamDeclarations,
|
fetchParamDeclarations,
|
||||||
@@ -22,7 +22,168 @@ import { DocsNavProvider } from './lib/docsNav'
|
|||||||
|
|
||||||
const POLL_MS = 2000
|
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() {
|
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 [stats, setStats] = useState<Stats | null>(null)
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||||
const [params, setParams] = useState<ParamDeclaration[]>([])
|
const [params, setParams] = useState<ParamDeclaration[]>([])
|
||||||
@@ -130,7 +291,10 @@ export default function App() {
|
|||||||
onChange={setFilter}
|
onChange={setFilter}
|
||||||
error={filterError}
|
error={filterError}
|
||||||
collapseToggle={
|
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}>
|
<div className="flex min-h-0 flex-1 flex-col" hidden={logViewerCollapsed}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import type { TimeRange } from './LogTimeline'
|
||||||
|
|
||||||
/// localStorage key for which of the four primary sections (log viewer,
|
/// localStorage key for which of the four primary sections (log viewer,
|
||||||
/// params, metrics, dashboards) are collapsed (ticket 035). Mirrors
|
/// params, metrics, dashboards) are collapsed (ticket 035). Mirrors
|
||||||
@@ -57,3 +58,34 @@ export function CollapseToggle({ collapsed, onToggle }: { collapsed: boolean; on
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.open(popOutUrl(section, range), '_blank')}
|
||||||
|
title="Open in new tab"
|
||||||
|
aria-label="Open in new tab"
|
||||||
|
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-border-soft text-[10px] leading-none text-text-faint hover:border-border-strong hover:text-text-secondary"
|
||||||
|
>
|
||||||
|
↗
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
type Panel,
|
type Panel,
|
||||||
type PromqlSeries,
|
type PromqlSeries,
|
||||||
} from '../lib/api'
|
} from '../lib/api'
|
||||||
import { CollapseToggle, useSectionCollapsed } from './CollapsibleSection'
|
import { CollapseToggle, PopOutButton, useSectionCollapsed } from './CollapsibleSection'
|
||||||
import { HelpIcon } from './HelpIcon'
|
import { HelpIcon } from './HelpIcon'
|
||||||
|
|
||||||
const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie']
|
const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie']
|
||||||
@@ -234,6 +234,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
|
|||||||
<div className="flex items-center gap-1.5 border-b border-border p-3 text-xs text-text-faint">
|
<div className="flex items-center gap-1.5 border-b border-border p-3 text-xs text-text-faint">
|
||||||
<HelpIcon sectionId="dashboards" label="Help: Dashboards" />
|
<HelpIcon sectionId="dashboards" label="Help: Dashboards" />
|
||||||
<CollapseToggle collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
<CollapseToggle collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
||||||
|
<PopOutButton section="dashboards" range={range} />
|
||||||
Dashboards — activate a profile (see the settings menu) to create and save dashboards.
|
Dashboards — activate a profile (see the settings menu) to create and save dashboards.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -244,6 +245,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
|
|||||||
<div className="mb-2 flex items-center gap-1.5 text-xs text-text-subtle">
|
<div className="mb-2 flex items-center gap-1.5 text-xs text-text-subtle">
|
||||||
<HelpIcon sectionId="dashboards" label="Help: Dashboards" />
|
<HelpIcon sectionId="dashboards" label="Help: Dashboards" />
|
||||||
<CollapseToggle collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
<CollapseToggle collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
||||||
|
<PopOutButton section="dashboards" range={range} />
|
||||||
Dashboards — profile "{activeProfile}"
|
Dashboards — profile "{activeProfile}"
|
||||||
</div>
|
</div>
|
||||||
{error && !collapsed && (
|
{error && !collapsed && (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type MetricDeclaration,
|
type MetricDeclaration,
|
||||||
type MetricParamMatcher,
|
type MetricParamMatcher,
|
||||||
} from '../lib/api'
|
} from '../lib/api'
|
||||||
import { CollapseToggle, useSectionCollapsed } from './CollapsibleSection'
|
import { CollapseToggle, PopOutButton, useSectionCollapsed } from './CollapsibleSection'
|
||||||
import { HelpIcon } from './HelpIcon'
|
import { HelpIcon } from './HelpIcon'
|
||||||
|
|
||||||
const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' }
|
const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' }
|
||||||
@@ -163,6 +163,7 @@ export function Metrics({ range }: { range: TimeRange | null }) {
|
|||||||
Metrics
|
Metrics
|
||||||
<HelpIcon sectionId="metrics" label="Help: Metrics" />
|
<HelpIcon sectionId="metrics" label="Help: Metrics" />
|
||||||
<CollapseToggle collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
<CollapseToggle collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
||||||
|
<PopOutButton section="metrics" range={range} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4" hidden={collapsed}>
|
<div className="flex gap-4" hidden={collapsed}>
|
||||||
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
|
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { ApiError, declareParam, type ParamDeclaration } from '../lib/api'
|
import { ApiError, declareParam, type ParamDeclaration } from '../lib/api'
|
||||||
import { CollapseToggle, useSectionCollapsed } from './CollapsibleSection'
|
import { CollapseToggle, PopOutButton, useSectionCollapsed } from './CollapsibleSection'
|
||||||
import { HelpIcon } from './HelpIcon'
|
import { HelpIcon } from './HelpIcon'
|
||||||
|
|
||||||
const EMPTY_KIND: ParamDeclaration['kind'] = 'string'
|
const EMPTY_KIND: ParamDeclaration['kind'] = 'string'
|
||||||
@@ -38,6 +38,7 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
|||||||
Params
|
Params
|
||||||
<HelpIcon sectionId="parameters" label="Help: Params" />
|
<HelpIcon sectionId="parameters" label="Help: Params" />
|
||||||
<CollapseToggle collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
<CollapseToggle collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
||||||
|
<PopOutButton section="params" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4" hidden={collapsed}>
|
<div className="flex gap-4" hidden={collapsed}>
|
||||||
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
|
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user