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:
co-authored by
Claude Sonnet 5
parent
ce711ab70e
commit
abc061a7b1
@@ -6,6 +6,7 @@ import { LogPreview } from './components/LogPreview'
|
||||
import { Metrics } from './components/Metrics'
|
||||
import { Dashboards } from './components/Dashboards'
|
||||
import { fetchLogs, fetchStats, fetchTimeline, type LogEntry, type Stats, type TimelineBucket } from './lib/api'
|
||||
import { DocsNavProvider } from './lib/docsNav'
|
||||
|
||||
const POLL_MS = 2000
|
||||
|
||||
@@ -49,6 +50,7 @@ export default function App() {
|
||||
}, [range, filter])
|
||||
|
||||
return (
|
||||
<DocsNavProvider>
|
||||
<div className="flex h-screen w-screen overflow-hidden">
|
||||
<NavMenu stats={stats} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
@@ -59,5 +61,6 @@ export default function App() {
|
||||
<Dashboards range={range} />
|
||||
</div>
|
||||
</div>
|
||||
</DocsNavProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type Panel,
|
||||
type PromqlSeries,
|
||||
} from '../lib/api'
|
||||
import { HelpIcon } from './HelpIcon'
|
||||
|
||||
const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie']
|
||||
|
||||
@@ -168,7 +169,8 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
|
||||
|
||||
if (!activeProfile) {
|
||||
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.
|
||||
</div>
|
||||
)
|
||||
@@ -176,7 +178,10 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<div className="mb-2 rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{error}</div>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
/// each page can mix prose, lists, and inline code without pulling in a
|
||||
@@ -122,6 +123,32 @@ const SECTIONS: DocSection[] = [
|
||||
</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',
|
||||
title: 'Metrics',
|
||||
@@ -200,17 +227,75 @@ const SECTIONS: DocSection[] = [
|
||||
</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 [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 (
|
||||
<div className="flex flex-col gap-1 text-sm text-text-secondary">
|
||||
{SECTIONS.map((section) => {
|
||||
const isOpen = openId === section.id
|
||||
const isFlashing = flashId === section.id
|
||||
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
|
||||
type="button"
|
||||
onClick={() => setOpenId(isOpen ? null : section.id)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { HelpIcon } from './HelpIcon'
|
||||
|
||||
/**
|
||||
* 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">
|
||||
<HelpIcon sectionId="filtering" label="Help: Filtering" />
|
||||
<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"
|
||||
placeholder='regex("error") and sequence<5 or timestamp>12:00'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
metricPrimitiveToDisplayString,
|
||||
type LogEntry,
|
||||
} from '../lib/api'
|
||||
import { HelpIcon } from './HelpIcon'
|
||||
|
||||
/// localStorage key for LogPreview's selected param columns. Namespaced
|
||||
/// under `ottyl.` since this is the app's first localStorage usage — kept
|
||||
@@ -126,7 +127,8 @@ export function LogPreview({ logs }: { logs: LogEntry[] }) {
|
||||
|
||||
return (
|
||||
<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} />
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import type { TimelineBucket } from '../lib/api'
|
||||
import { HelpIcon } from './HelpIcon'
|
||||
|
||||
export interface TimeRange {
|
||||
startMs: number
|
||||
@@ -59,7 +60,10 @@ export function LogTimeline({
|
||||
return (
|
||||
<div className="border-b border-border p-3">
|
||||
<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 && (
|
||||
<button className="underline hover:text-text-secondary" onClick={() => onSelectRange(null)}>
|
||||
Clear selection
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type MetricDeclaration,
|
||||
type MetricParamMatcher,
|
||||
} from '../lib/api'
|
||||
import { HelpIcon } from './HelpIcon'
|
||||
|
||||
const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' }
|
||||
|
||||
@@ -156,7 +157,10 @@ export function Metrics({ range }: { range: TimeRange | null }) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
|
||||
<div>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useEffect, useState } from 'react'
|
||||
import type { Stats } from '../lib/api'
|
||||
import type { ColorScheme } from '../lib/api'
|
||||
import { useSettingsPanel } from '../lib/useSettingsPanel'
|
||||
import { useDocsNav, type DocsTarget } from '../lib/docsNav'
|
||||
import { DocsPanel } from './Docs'
|
||||
import { HelpIcon } from './HelpIcon'
|
||||
|
||||
type PanelName = 'settings' | 'presets' | 'docs' | null
|
||||
|
||||
@@ -31,11 +33,30 @@ const COLOR_SCHEMES: ColorScheme[] = [
|
||||
export function NavMenu({ stats }: { stats: Stats | null }) {
|
||||
const [openPanel, setOpenPanel] = useState<PanelName>(null)
|
||||
const panel = useSettingsPanel()
|
||||
const docsNav = useDocsNav()
|
||||
const [docsPanelTarget, setDocsPanelTarget] = useState<DocsTarget | null>(null)
|
||||
|
||||
function togglePanel(name: PanelName) {
|
||||
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 (
|
||||
<div className="flex h-full">
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => togglePanel('docs')}
|
||||
onClick={handleDocsButtonClick}
|
||||
className={`rounded px-2 py-1 text-left hover:bg-surface-2 ${
|
||||
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="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
|
||||
type="button"
|
||||
onClick={() => setOpenPanel(null)}
|
||||
@@ -110,7 +135,7 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
|
||||
)}
|
||||
{openPanel === 'settings' && <SettingsPanel panel={panel} />}
|
||||
{openPanel === 'presets' && <PresetsPanel panel={panel} />}
|
||||
{openPanel === 'docs' && <DocsPanel />}
|
||||
{openPanel === 'docs' && <DocsPanel target={docsPanelTarget} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user