From abc061a7b139067da53a68dd350149bb0d30950b Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sat, 5 Sep 2026 15:49:49 +0100 Subject: [PATCH] Add contextual "?" help icons linking into Docs (ticket 021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o --- webapp/src/App.tsx | 21 ++++--- webapp/src/components/Dashboards.tsx | 9 ++- webapp/src/components/Docs.tsx | 91 ++++++++++++++++++++++++++- webapp/src/components/FilterBar.tsx | 2 + webapp/src/components/HelpIcon.tsx | 21 +++++++ webapp/src/components/LogPreview.tsx | 4 +- webapp/src/components/LogTimeline.tsx | 6 +- webapp/src/components/Metrics.tsx | 6 +- webapp/src/components/NavMenu.tsx | 31 ++++++++- webapp/src/lib/docsNav.tsx | 35 +++++++++++ 10 files changed, 206 insertions(+), 20 deletions(-) create mode 100644 webapp/src/components/HelpIcon.tsx create mode 100644 webapp/src/lib/docsNav.tsx diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 0680619..07565e9 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -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,15 +50,17 @@ export default function App() { }, [range, filter]) return ( -
- -
- - - - - + +
+ +
+ + + + + +
-
+ ) } diff --git a/webapp/src/components/Dashboards.tsx b/webapp/src/components/Dashboards.tsx index 5d41c8a..a693d67 100644 --- a/webapp/src/components/Dashboards.tsx +++ b/webapp/src/components/Dashboards.tsx @@ -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 ( -
+
+ Dashboards — activate a profile (see the settings menu) to create and save dashboards.
) @@ -176,7 +178,10 @@ export function Dashboards({ range }: { range: TimeRange | null }) { return (
-
Dashboards — profile "{activeProfile}"
+
+ + Dashboards — profile "{activeProfile}" +
{error && (
{error}
)} diff --git a/webapp/src/components/Docs.tsx b/webapp/src/components/Docs.tsx index 89a2d80..8129301 100644 --- a/webapp/src/components/Docs.tsx +++ b/webapp/src/components/Docs.tsx @@ -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[] = [
), }, + { + id: 'timeline', + title: 'Timeline', + body: ( +
+

+ 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. +

+
+ ), + }, + { + id: 'log-preview', + title: 'Log preview', + body: ( +
+

+ 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. +

+
+ ), + }, { id: 'metrics', title: 'Metrics', @@ -200,17 +227,75 @@ const SECTIONS: DocSection[] = [
), }, + { + id: 'settings', + title: 'Settings', + body: ( +
+

+ 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. +

+
+ ), + }, + { + id: 'presets', + title: 'Presets', + body: ( +
+

+ 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 General idea{' '} + section above for what a profile stores. +

+
+ ), + }, ] -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(SECTIONS[0].id) + const [flashId, setFlashId] = useState(null) + const sectionRefs = useRef>({}) + + // 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 (
{SECTIONS.map((section) => { const isOpen = openId === section.id + const isFlashing = flashId === section.id return ( -
+
{ + sectionRefs.current[section.id] = el + }} + className={`rounded border transition-colors duration-500 ${ + isFlashing ? 'border-accent bg-accent-soft/25' : 'border-border' + }`} + > + ) +} diff --git a/webapp/src/components/LogPreview.tsx b/webapp/src/components/LogPreview.tsx index 439646f..1948f80 100644 --- a/webapp/src/components/LogPreview.tsx +++ b/webapp/src/components/LogPreview.tsx @@ -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 (
-
+
+
diff --git a/webapp/src/components/LogTimeline.tsx b/webapp/src/components/LogTimeline.tsx index 8a4e94d..b30d9b4 100644 --- a/webapp/src/components/LogTimeline.tsx +++ b/webapp/src/components/LogTimeline.tsx @@ -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 (
- Timeline + + Timeline + + {range && (
)}
diff --git a/webapp/src/lib/docsNav.tsx b/webapp/src/lib/docsNav.tsx new file mode 100644 index 0000000..805bbdf --- /dev/null +++ b/webapp/src/lib/docsNav.tsx @@ -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(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(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 {children} +} + +export function useDocsNav(): DocsNavValue { + const ctx = useContext(DocsNavContext) + if (!ctx) throw new Error('useDocsNav must be used within a DocsNavProvider') + return ctx +}