Add collapsible primary UI sections (ticket 035)
Log viewer (FilterBar+LogTimeline+LogPreview), Params, Metrics, and Dashboards can each be collapsed/expanded via a toggle next to their existing help icon. Collapsed state persists per-section in localStorage (ottyl.collapsedSections) and content is hidden with the `hidden` attribute rather than unmounted, so in-progress form state (a half-filled Metrics/Params form, in-progress Dashboards panel edits) survives a collapse/expand cycle. 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
dd1a0a234d
commit
7db1e0f055
@@ -1,34 +0,0 @@
|
|||||||
# 035 — Collapsible primary UI sections
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
`App.tsx` stacks the Log viewer (`LogTimeline` + `LogPreview`), Params
|
|
||||||
([034](034-params-declaration-ui.md)), `Metrics`, and `Dashboards` vertically
|
|
||||||
with no way to collapse any of them. On a small screen — or just when only
|
|
||||||
one section is being worked on — the others take up space and force
|
|
||||||
scrolling. Make each section collapsible.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Each of the four sections (Log viewer, Params, Metrics, Dashboards) gets a
|
|
||||||
collapse/expand toggle in its header, next to its existing "?" help icon.
|
|
||||||
- Collapsed state persists across reloads per-section, via `localStorage`
|
|
||||||
(same pattern as `LogPreview`'s `VISIBLE_PARAM_COLUMNS_STORAGE_KEY` at
|
|
||||||
`webapp/src/components/LogPreview.tsx:13`).
|
|
||||||
- A collapsed section still shows its header (title + toggle), just not its
|
|
||||||
body/content.
|
|
||||||
- Collapsing a section must not tear down state that would be lost/reset on
|
|
||||||
re-expand (e.g. `Dashboards`' in-progress panel edits, `Metrics`' declare
|
|
||||||
form) — hide with CSS/conditional render at the container level, not by
|
|
||||||
unmounting stateful subtrees.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- Each section can be independently collapsed and re-expanded.
|
|
||||||
- Reloading the page preserves each section's collapsed/expanded state.
|
|
||||||
- Collapsing a section with unsaved in-progress input (e.g. a half-filled
|
|
||||||
Metrics declare form) and re-expanding it preserves that input.
|
|
||||||
|
|
||||||
## Depends on
|
|
||||||
|
|
||||||
- [034 — Params declaration UI](034-params-declaration-ui.md)
|
|
||||||
+19
-8
@@ -7,6 +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 {
|
import {
|
||||||
fetchLogs,
|
fetchLogs,
|
||||||
fetchParamDeclarations,
|
fetchParamDeclarations,
|
||||||
@@ -30,6 +31,7 @@ export default function App() {
|
|||||||
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>('all')
|
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>('all')
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
const [filterError, setFilterError] = useState<string | null>(null)
|
const [filterError, setFilterError] = useState<string | null>(null)
|
||||||
|
const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer')
|
||||||
|
|
||||||
// Read from inside the poll loop below without making it a dependency —
|
// Read from inside the poll loop below without making it a dependency —
|
||||||
// a rolling preset's own re-resolution (ticket 024) updates `range`, and
|
// a rolling preset's own re-resolution (ticket 024) updates `range`, and
|
||||||
@@ -123,15 +125,24 @@ export default function App() {
|
|||||||
<div className="flex h-screen w-screen overflow-hidden">
|
<div className="flex h-screen w-screen overflow-hidden">
|
||||||
<NavMenu stats={stats} />
|
<NavMenu stats={stats} />
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<FilterBar value={filter} onChange={setFilter} error={filterError} />
|
<FilterBar
|
||||||
<LogTimeline
|
value={filter}
|
||||||
buckets={buckets}
|
onChange={setFilter}
|
||||||
range={range}
|
error={filterError}
|
||||||
onSelectRange={handleManualRangeSelect}
|
collapseToggle={
|
||||||
activePreset={activePreset}
|
<CollapseToggle collapsed={logViewerCollapsed} onToggle={() => setLogViewerCollapsed(!logViewerCollapsed)} />
|
||||||
onSelectPreset={selectPreset}
|
}
|
||||||
/>
|
/>
|
||||||
<LogPreview logs={logs} params={params} />
|
<div className="flex min-h-0 flex-1 flex-col" hidden={logViewerCollapsed}>
|
||||||
|
<LogTimeline
|
||||||
|
buckets={buckets}
|
||||||
|
range={range}
|
||||||
|
onSelectRange={handleManualRangeSelect}
|
||||||
|
activePreset={activePreset}
|
||||||
|
onSelectPreset={selectPreset}
|
||||||
|
/>
|
||||||
|
<LogPreview logs={logs} params={params} />
|
||||||
|
</div>
|
||||||
<Params params={params} onDeclared={loadParams} />
|
<Params params={params} onDeclared={loadParams} />
|
||||||
<Metrics range={range} />
|
<Metrics range={range} />
|
||||||
<Dashboards range={range} />
|
<Dashboards range={range} />
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
/// localStorage key for which of the four primary sections (log viewer,
|
||||||
|
/// params, metrics, dashboards) are collapsed (ticket 035). Mirrors
|
||||||
|
/// LogPreview's VISIBLE_PARAM_COLUMNS_STORAGE_KEY pattern — one key holding
|
||||||
|
/// a record of section id -> collapsed.
|
||||||
|
const COLLAPSED_SECTIONS_STORAGE_KEY = 'ottyl.collapsedSections'
|
||||||
|
|
||||||
|
function loadCollapsedSections(): Record<string, boolean> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(COLLAPSED_SECTIONS_STORAGE_KEY)
|
||||||
|
if (!raw) return {}
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCollapsedSections(sections: Record<string, boolean>) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(COLLAPSED_SECTIONS_STORAGE_KEY, JSON.stringify(sections))
|
||||||
|
} catch {
|
||||||
|
// best-effort — a private browsing mode or full storage just means the
|
||||||
|
// collapsed state won't persist across reloads.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists a section's collapsed state across reloads. Deliberately
|
||||||
|
/// returns only the boolean + setter — callers keep rendering their
|
||||||
|
/// subtree, just hide it with CSS (`hidden`), so collapsing never unmounts
|
||||||
|
/// in-progress form state.
|
||||||
|
export function useSectionCollapsed(sectionId: string): [boolean, (next: boolean) => void] {
|
||||||
|
const [collapsed, setCollapsedState] = useState(() => loadCollapsedSections()[sectionId] ?? false)
|
||||||
|
|
||||||
|
function setCollapsed(next: boolean) {
|
||||||
|
setCollapsedState(next)
|
||||||
|
const all = loadCollapsedSections()
|
||||||
|
all[sectionId] = next
|
||||||
|
saveCollapsedSections(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [collapsed, setCollapsed]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CollapseToggle({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
title={collapsed ? 'Expand section' : 'Collapse section'}
|
||||||
|
aria-label={collapsed ? 'Expand section' : 'Collapse section'}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{collapsed ? '▸' : '▾'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type Panel,
|
type Panel,
|
||||||
type PromqlSeries,
|
type PromqlSeries,
|
||||||
} from '../lib/api'
|
} from '../lib/api'
|
||||||
|
import { CollapseToggle, 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']
|
||||||
@@ -113,6 +114,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
|
|||||||
// nothing actually changed (e.g. re-selecting the same dashboard, or the
|
// nothing actually changed (e.g. re-selecting the same dashboard, or the
|
||||||
// re-render that follows a successful save itself).
|
// re-render that follows a successful save itself).
|
||||||
const lastSavedRef = useRef<string | null>(null)
|
const lastSavedRef = useRef<string | null>(null)
|
||||||
|
const [collapsed, setCollapsed] = useSectionCollapsed('dashboards')
|
||||||
|
|
||||||
async function loadDashboards(profileName: string, selectAfterLoad?: string) {
|
async function loadDashboards(profileName: string, selectAfterLoad?: string) {
|
||||||
try {
|
try {
|
||||||
@@ -231,6 +233,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
|
|||||||
return (
|
return (
|
||||||
<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)} />
|
||||||
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>
|
||||||
)
|
)
|
||||||
@@ -240,12 +243,13 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
|
|||||||
<div className="border-b border-border p-3 text-sm">
|
<div className="border-b border-border p-3 text-sm">
|
||||||
<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)} />
|
||||||
Dashboards — profile "{activeProfile}"
|
Dashboards — profile "{activeProfile}"
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && !collapsed && (
|
||||||
<div className="mb-2 rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{error}</div>
|
<div className="mb-2 rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{error}</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4" hidden={collapsed}>
|
||||||
<div className="w-56 shrink-0 space-y-3">
|
<div className="w-56 shrink-0 space-y-3">
|
||||||
<form onSubmit={handleCreateDashboard} className="flex gap-1">
|
<form onSubmit={handleCreateDashboard} className="flex gap-1">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { HelpIcon } from './HelpIcon'
|
import { HelpIcon } from './HelpIcon'
|
||||||
|
|
||||||
@@ -18,10 +19,12 @@ export function FilterBar({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
error,
|
error,
|
||||||
|
collapseToggle,
|
||||||
}: {
|
}: {
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
error?: string | null
|
error?: string | null
|
||||||
|
collapseToggle?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState(value)
|
const [draft, setDraft] = useState(value)
|
||||||
|
|
||||||
@@ -35,6 +38,7 @@ export function FilterBar({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<HelpIcon sectionId="filtering" label="Help: Filtering" />
|
<HelpIcon sectionId="filtering" label="Help: Filtering" />
|
||||||
|
{collapseToggle}
|
||||||
<input
|
<input
|
||||||
className={queryInputClassName}
|
className={queryInputClassName}
|
||||||
placeholder='regex("error") and sequence<5 or timestamp>12:00'
|
placeholder='regex("error") and sequence<5 or timestamp>12:00'
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type MetricDeclaration,
|
type MetricDeclaration,
|
||||||
type MetricParamMatcher,
|
type MetricParamMatcher,
|
||||||
} from '../lib/api'
|
} from '../lib/api'
|
||||||
|
import { CollapseToggle, 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' }
|
||||||
@@ -82,6 +83,7 @@ export function Metrics({ range }: { range: TimeRange | null }) {
|
|||||||
const [matchers, setMatchers] = useState<MetricParamMatcher[]>([{ ...EMPTY_MATCHER }])
|
const [matchers, setMatchers] = useState<MetricParamMatcher[]>([{ ...EMPTY_MATCHER }])
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [collapsed, setCollapsed] = useSectionCollapsed('metrics')
|
||||||
|
|
||||||
async function loadMetrics() {
|
async function loadMetrics() {
|
||||||
try {
|
try {
|
||||||
@@ -160,8 +162,9 @@ export function Metrics({ 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">
|
||||||
Metrics
|
Metrics
|
||||||
<HelpIcon sectionId="metrics" label="Help: Metrics" />
|
<HelpIcon sectionId="metrics" label="Help: Metrics" />
|
||||||
|
<CollapseToggle collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<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">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-text-subtle">Name</label>
|
<label className="mb-1 block text-xs text-text-subtle">Name</label>
|
||||||
|
|||||||
@@ -1,5 +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 { HelpIcon } from './HelpIcon'
|
import { HelpIcon } from './HelpIcon'
|
||||||
|
|
||||||
const EMPTY_KIND: ParamDeclaration['kind'] = 'string'
|
const EMPTY_KIND: ParamDeclaration['kind'] = 'string'
|
||||||
@@ -11,6 +12,7 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
|||||||
const [kind, setKind] = useState<ParamDeclaration['kind']>(EMPTY_KIND)
|
const [kind, setKind] = useState<ParamDeclaration['kind']>(EMPTY_KIND)
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [collapsed, setCollapsed] = useSectionCollapsed('params')
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -35,8 +37,9 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
|||||||
<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">
|
||||||
Params
|
Params
|
||||||
<HelpIcon sectionId="parameters" label="Help: Params" />
|
<HelpIcon sectionId="parameters" label="Help: Params" />
|
||||||
|
<CollapseToggle collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<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">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-text-subtle">Name</label>
|
<label className="mb-1 block text-xs text-text-subtle">Name</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user