Add contextual "?" help icons linking into Docs (ticket 021)

New HelpIcon component + a DocsNavProvider context (webapp/src/lib/docsNav.tsx)
let any component request "open Docs to section X", bumping a nonce each
time so a repeat click re-triggers even if Docs is already open to that
section. NavMenu forwards that request into DocsPanel as a `target` prop,
which expands the right accordion section, scrolls it into view, and
flashes it (a brief accent-tinted background/border, settling back after
1.2s) — verified visually with a live server + Playwright. Opening Docs via
the plain NavMenu button explicitly clears any pending target, so that path
is unchanged: no forced section, no flash.

Added a "?" icon to FilterBar, LogTimeline, LogPreview, Metrics, Dashboards,
and NavMenu's own Settings/Presets panels, plus new Docs sections for
Timeline, Log preview, Settings, and Presets (previously undocumented).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 15:49:49 +01:00
co-authored by Claude Sonnet 5
parent ce711ab70e
commit abc061a7b1
10 changed files with 206 additions and 20 deletions
+12 -9
View File
@@ -6,6 +6,7 @@ import { LogPreview } from './components/LogPreview'
import { Metrics } from './components/Metrics' import { Metrics } from './components/Metrics'
import { Dashboards } from './components/Dashboards' import { Dashboards } from './components/Dashboards'
import { fetchLogs, fetchStats, fetchTimeline, type LogEntry, type Stats, type TimelineBucket } from './lib/api' import { fetchLogs, fetchStats, fetchTimeline, type LogEntry, type Stats, type TimelineBucket } from './lib/api'
import { DocsNavProvider } from './lib/docsNav'
const POLL_MS = 2000 const POLL_MS = 2000
@@ -49,15 +50,17 @@ export default function App() {
}, [range, filter]) }, [range, filter])
return ( return (
<div className="flex h-screen w-screen overflow-hidden"> <DocsNavProvider>
<NavMenu stats={stats} /> <div className="flex h-screen w-screen overflow-hidden">
<div className="flex min-w-0 flex-1 flex-col"> <NavMenu stats={stats} />
<FilterBar value={filter} onChange={setFilter} error={filterError} /> <div className="flex min-w-0 flex-1 flex-col">
<LogTimeline buckets={buckets} range={range} onSelectRange={setRange} /> <FilterBar value={filter} onChange={setFilter} error={filterError} />
<LogPreview logs={logs} /> <LogTimeline buckets={buckets} range={range} onSelectRange={setRange} />
<Metrics range={range} /> <LogPreview logs={logs} />
<Dashboards range={range} /> <Metrics range={range} />
<Dashboards range={range} />
</div>
</div> </div>
</div> </DocsNavProvider>
) )
} }
+7 -2
View File
@@ -14,6 +14,7 @@ import {
type Panel, type Panel,
type PromqlSeries, type PromqlSeries,
} from '../lib/api' } from '../lib/api'
import { HelpIcon } from './HelpIcon'
const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie'] const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie']
@@ -168,7 +169,8 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
if (!activeProfile) { if (!activeProfile) {
return ( return (
<div className="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" />
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>
) )
@@ -176,7 +178,10 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
return ( return (
<div className="border-b border-border p-3 text-sm"> <div className="border-b border-border p-3 text-sm">
<div className="mb-2 text-xs text-text-subtle">Dashboards — profile "{activeProfile}"</div> <div className="mb-2 flex items-center gap-1.5 text-xs text-text-subtle">
<HelpIcon sectionId="dashboards" label="Help: Dashboards" />
Dashboards — profile "{activeProfile}"
</div>
{error && ( {error && (
<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>
)} )}
+88 -3
View File
@@ -1,4 +1,5 @@
import { useState, type ReactNode } from 'react' import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { DocsTarget } from '../lib/docsNav'
/// One accordion section: a title plus its body, rendered as plain JSX so /// One accordion section: a title plus its body, rendered as plain JSX so
/// each page can mix prose, lists, and inline code without pulling in a /// each page can mix prose, lists, and inline code without pulling in a
@@ -122,6 +123,32 @@ const SECTIONS: DocSection[] = [
</div> </div>
), ),
}, },
{
id: 'timeline',
title: 'Timeline',
body: (
<div className="flex flex-col gap-2">
<p>
The timeline above the log table renders log volume per bucket over the active window. Drag across it to
select a fixed time range — it narrows the log table, metrics, and dashboards below to that window; click
"Clear selection" to go back to the default (rolling "all time") view.
</p>
</div>
),
},
{
id: 'log-preview',
title: 'Log preview',
body: (
<div className="flex flex-col gap-2">
<p>
The log table lists ingested lines newest-last, each with its sequence number, timestamp, and raw text. The
column picker (top-right) adds a column per declared param, showing that line's derived value for it (or a
blank if it didn't match) — a quick way to eyeball params without switching to the Metrics panel.
</p>
</div>
),
},
{ {
id: 'metrics', id: 'metrics',
title: 'Metrics', title: 'Metrics',
@@ -200,17 +227,75 @@ const SECTIONS: DocSection[] = [
</div> </div>
), ),
}, },
{
id: 'settings',
title: 'Settings',
body: (
<div className="flex flex-col gap-2">
<p>
The Settings panel controls process-wide state: colour scheme, the maximum number of logs kept in memory
(blank means unlimited), and imported/linked CSS. These apply immediately and persist to the single
settings file on disk — no save button needed.
</p>
</div>
),
},
{
id: 'presets',
title: 'Presets',
body: (
<div className="flex flex-col gap-2">
<p>
The Presets panel manages profiles: create, switch, and delete them. Switching activates a profile's
params/metrics in the running engine immediately, with no restart needed — see the <em>General idea</em>{' '}
section above for what a profile stores.
</p>
</div>
),
},
] ]
export function DocsPanel() { /** How long a flashed section stays highlighted before settling back to normal. */
const FLASH_DURATION_MS = 1200
export function DocsPanel({ target }: { target?: DocsTarget | null }) {
const [openId, setOpenId] = useState<string | null>(SECTIONS[0].id) const [openId, setOpenId] = useState<string | null>(SECTIONS[0].id)
const [flashId, setFlashId] = useState<string | null>(null)
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
// Keyed on `target?.nonce` (not `target?.sectionId`) so clicking the same
// "?" icon twice in a row re-triggers the flash rather than being a no-op
// because the panel is already open to that section.
useEffect(() => {
if (!target) return
setOpenId(target.sectionId)
const raf = requestAnimationFrame(() => {
sectionRefs.current[target.sectionId]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
setFlashId(target.sectionId)
const timeout = setTimeout(() => setFlashId(null), FLASH_DURATION_MS)
return () => {
cancelAnimationFrame(raf)
clearTimeout(timeout)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target?.nonce])
return ( return (
<div className="flex flex-col gap-1 text-sm text-text-secondary"> <div className="flex flex-col gap-1 text-sm text-text-secondary">
{SECTIONS.map((section) => { {SECTIONS.map((section) => {
const isOpen = openId === section.id const isOpen = openId === section.id
const isFlashing = flashId === section.id
return ( return (
<div key={section.id} className="rounded border border-border"> <div
key={section.id}
ref={(el) => {
sectionRefs.current[section.id] = el
}}
className={`rounded border transition-colors duration-500 ${
isFlashing ? 'border-accent bg-accent-soft/25' : 'border-border'
}`}
>
<button <button
type="button" type="button"
onClick={() => setOpenId(isOpen ? null : section.id)} onClick={() => setOpenId(isOpen ? null : section.id)}
+2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { HelpIcon } from './HelpIcon'
/** /**
* Input for the syntax-aware pest-parsed filter language described in the * Input for the syntax-aware pest-parsed filter language described in the
@@ -27,6 +28,7 @@ export function FilterBar({
}} }}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<HelpIcon sectionId="filtering" label="Help: Filtering" />
<input <input
className="flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none" className="flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
placeholder='regex("error") and sequence<5 or timestamp>12:00' placeholder='regex("error") and sequence<5 or timestamp>12:00'
+21
View File
@@ -0,0 +1,21 @@
import { useDocsNav } from '../lib/docsNav'
/**
* A small "?" affordance that opens the Docs panel scrolled to (and
* flashing) a specific section (ticket 021), instead of the user having to
* find it themselves in the Docs accordion.
*/
export function HelpIcon({ sectionId, label }: { sectionId: string; label?: string }) {
const { openSection } = useDocsNav()
return (
<button
type="button"
onClick={() => openSection(sectionId)}
title="Open docs"
aria-label={label ?? 'Open docs for this section'}
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>
)
}
+3 -1
View File
@@ -4,6 +4,7 @@ import {
metricPrimitiveToDisplayString, metricPrimitiveToDisplayString,
type LogEntry, type LogEntry,
} from '../lib/api' } from '../lib/api'
import { HelpIcon } from './HelpIcon'
/// localStorage key for LogPreview's selected param columns. Namespaced /// localStorage key for LogPreview's selected param columns. Namespaced
/// under `ottyl.` since this is the app's first localStorage usage — kept /// under `ottyl.` since this is the app's first localStorage usage — kept
@@ -126,7 +127,8 @@ export function LogPreview({ logs }: { logs: LogEntry[] }) {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<div className="flex justify-end border-b border-border-faint px-3 py-1"> <div className="flex items-center justify-between border-b border-border-faint px-3 py-1">
<HelpIcon sectionId="log-preview" label="Help: Log preview" />
<ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} /> <ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} />
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
+5 -1
View File
@@ -1,5 +1,6 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import type { TimelineBucket } from '../lib/api' import type { TimelineBucket } from '../lib/api'
import { HelpIcon } from './HelpIcon'
export interface TimeRange { export interface TimeRange {
startMs: number startMs: number
@@ -59,7 +60,10 @@ export function LogTimeline({
return ( return (
<div className="border-b border-border p-3"> <div className="border-b border-border p-3">
<div className="mb-1 flex items-center justify-between text-xs text-text-subtle"> <div className="mb-1 flex items-center justify-between text-xs text-text-subtle">
<span>Timeline</span> <span className="flex items-center gap-1.5">
Timeline
<HelpIcon sectionId="timeline" label="Help: Timeline" />
</span>
{range && ( {range && (
<button className="underline hover:text-text-secondary" onClick={() => onSelectRange(null)}> <button className="underline hover:text-text-secondary" onClick={() => onSelectRange(null)}>
Clear selection Clear selection
+5 -1
View File
@@ -10,6 +10,7 @@ import {
type MetricDeclaration, type MetricDeclaration,
type MetricParamMatcher, type MetricParamMatcher,
} from '../lib/api' } from '../lib/api'
import { HelpIcon } from './HelpIcon'
const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' } const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' }
@@ -156,7 +157,10 @@ export function Metrics({ range }: { range: TimeRange | null }) {
return ( return (
<div className="border-b border-border p-3 text-sm"> <div className="border-b border-border p-3 text-sm">
<div className="mb-2 text-xs text-text-subtle">Metrics</div> <div className="mb-2 flex items-center gap-1.5 text-xs text-text-subtle">
Metrics
<HelpIcon sectionId="metrics" label="Help: Metrics" />
</div>
<div className="flex gap-4"> <div className="flex gap-4">
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2"> <form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
<div> <div>
+28 -3
View File
@@ -2,7 +2,9 @@ import { useEffect, useState } from 'react'
import type { Stats } from '../lib/api' import type { Stats } from '../lib/api'
import type { ColorScheme } from '../lib/api' import type { ColorScheme } from '../lib/api'
import { useSettingsPanel } from '../lib/useSettingsPanel' import { useSettingsPanel } from '../lib/useSettingsPanel'
import { useDocsNav, type DocsTarget } from '../lib/docsNav'
import { DocsPanel } from './Docs' import { DocsPanel } from './Docs'
import { HelpIcon } from './HelpIcon'
type PanelName = 'settings' | 'presets' | 'docs' | null type PanelName = 'settings' | 'presets' | 'docs' | null
@@ -31,11 +33,30 @@ const COLOR_SCHEMES: ColorScheme[] = [
export function NavMenu({ stats }: { stats: Stats | null }) { export function NavMenu({ stats }: { stats: Stats | null }) {
const [openPanel, setOpenPanel] = useState<PanelName>(null) const [openPanel, setOpenPanel] = useState<PanelName>(null)
const panel = useSettingsPanel() const panel = useSettingsPanel()
const docsNav = useDocsNav()
const [docsPanelTarget, setDocsPanelTarget] = useState<DocsTarget | null>(null)
function togglePanel(name: PanelName) { function togglePanel(name: PanelName) {
setOpenPanel((current) => (current === name ? null : name)) setOpenPanel((current) => (current === name ? null : name))
} }
// A "?" icon (ticket 021) always opens Docs to that section and flashes it,
// even if Docs is already open to a different section — hence forcing
// `openPanel` here rather than toggling.
useEffect(() => {
if (!docsNav.target) return
setOpenPanel('docs')
setDocsPanelTarget(docsNav.target)
}, [docsNav.target])
// Opening Docs the normal way (this button, not a "?" icon) behaves as
// before: no forced section, no flash — so any pending help-triggered
// target is cleared.
function handleDocsButtonClick() {
togglePanel('docs')
setDocsPanelTarget(null)
}
return ( return (
<div className="flex h-full"> <div className="flex h-full">
<div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-border p-3"> <div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-border p-3">
@@ -63,7 +84,7 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
</button> </button>
<button <button
type="button" type="button"
onClick={() => togglePanel('docs')} onClick={handleDocsButtonClick}
className={`rounded px-2 py-1 text-left hover:bg-surface-2 ${ className={`rounded px-2 py-1 text-left hover:bg-surface-2 ${
openPanel === 'docs' ? 'bg-surface-2 text-text' : '' openPanel === 'docs' ? 'bg-surface-2 text-text' : ''
}`} }`}
@@ -93,7 +114,11 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
}`} }`}
> >
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-text">{PANEL_TITLES[openPanel]}</h2> <div className="flex items-center gap-1.5">
<h2 className="text-sm font-semibold text-text">{PANEL_TITLES[openPanel]}</h2>
{openPanel === 'settings' && <HelpIcon sectionId="settings" label="Help: Settings" />}
{openPanel === 'presets' && <HelpIcon sectionId="presets" label="Help: Presets" />}
</div>
<button <button
type="button" type="button"
onClick={() => setOpenPanel(null)} onClick={() => setOpenPanel(null)}
@@ -110,7 +135,7 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
)} )}
{openPanel === 'settings' && <SettingsPanel panel={panel} />} {openPanel === 'settings' && <SettingsPanel panel={panel} />}
{openPanel === 'presets' && <PresetsPanel panel={panel} />} {openPanel === 'presets' && <PresetsPanel panel={panel} />}
{openPanel === 'docs' && <DocsPanel />} {openPanel === 'docs' && <DocsPanel target={docsPanelTarget} />}
</div> </div>
)} )}
</div> </div>
+35
View File
@@ -0,0 +1,35 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'
/** One request to open the Docs panel scrolled to a specific section (ticket 021). */
export interface DocsTarget {
sectionId: string
/** Bumped on every request, even to the same section, so a repeat click re-triggers the flash. */
nonce: number
}
interface DocsNavValue {
target: DocsTarget | null
openSection: (sectionId: string) => void
}
const DocsNavContext = createContext<DocsNavValue | null>(null)
/** Wraps the app so any component can open Docs to a specific section via `useDocsNav`. */
export function DocsNavProvider({ children }: { children: ReactNode }) {
const [target, setTarget] = useState<DocsTarget | null>(null)
const nonceRef = useRef(0)
const openSection = useCallback((sectionId: string) => {
nonceRef.current += 1
setTarget({ sectionId, nonce: nonceRef.current })
}, [])
const value = useMemo(() => ({ target, openSection }), [target, openSection])
return <DocsNavContext.Provider value={value}>{children}</DocsNavContext.Provider>
}
export function useDocsNav(): DocsNavValue {
const ctx = useContext(DocsNavContext)
if (!ctx) throw new Error('useDocsNav must be used within a DocsNavProvider')
return ctx
}