Files
ottyl/webapp/src/components/Docs.tsx
T
Hugh KaznowskiandClaude Sonnet 5 7bf323dd50 Add Alerts webapp UI (ticket 020)
New Alerts panel: lists rules with condition summaries and an enabled
toggle, and a condition-type-specific editor (Immediate/Threshold/NewValue/
Ratio) with client-side validation before save, reusing FilterBar's query
input styling for query-based conditions. Adds a test-notification endpoint
(POST /api/settings/alerts/test) so an ntfy topic/server can be verified
without saving a rule first, plus a docs section and help icons matching
ticket 021's pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 16:34:14 +01:00

366 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
/// markdown renderer for what is currently five static pages.
interface DocSection {
id: string
title: string
body: ReactNode
}
function Code({ children }: { children: string }) {
return <code className="rounded bg-surface-2 px-1 py-0.5 font-mono text-[11px] text-text">{children}</code>
}
const SECTIONS: DocSection[] = [
{
id: 'general',
title: 'General idea',
body: (
<div className="flex flex-col gap-2">
<p>
ottyl turns a CLI program's stdout into a Grafana-like dashboard: pipe a program's output into it, and it
indexes each line in memory as it arrives, then serves a webapp dashboard over the same process.
</p>
<p>
<Code>{'cli_command --verbose | ottyl'}</Code>
</p>
<p>
Everything — ingested log lines, declared params, declared metrics, and derived buckets — lives in one
in-memory index for the life of the process. Nothing is written to disk unless you explicitly save a{' '}
<em>profile</em> or change a <em>setting</em>.
</p>
<p className="font-semibold text-text">CLI arguments</p>
<ul className="list-disc pl-5">
<li>
<Code>--port</Code> — port to serve the API and webapp on (default <Code>8420</Code>).
</li>
<li>
<Code>--static-dir</Code> — directory containing the built webapp (default <Code>webapp/dist</Code>).
</li>
<li>
<Code>--settings-path</Code> — path to the settings file; defaults to a per-OS config location (e.g.{' '}
<Code>~/.config/ottyl/settings.json</Code> on Linux).
</li>
</ul>
<p className="font-semibold text-text">Settings</p>
<p>
There is exactly one <em>settings</em> file per machine, holding the colour scheme, any imported/linked CSS,
and which profile is active. Settings "owns" profiles in that sense — it only tracks pointers (known
profile names, which one is active); the profiles themselves (params, metrics, dashboards) are stored
separately, next to the settings file by default.
</p>
<p className="font-semibold text-text">Profiles</p>
<p>
A profile is a named bundle of parsing/indexing rules for a particular CLI app — which params and metrics
to derive from its log output, plus any saved dashboards — so restarting ottyl against the same program
doesn't lose your setup. There is always a <Code>Default</Code> profile, and it can't be deleted. Creating
a new profile copies the currently active one (or starts empty if none is active), so variations build on
what you already have rather than starting from scratch. Switching profiles hot-swaps the running engine's
declared params/metrics immediately, with no restart needed.
</p>
</div>
),
},
{
id: 'filtering',
title: 'Filtering',
body: (
<div className="flex flex-col gap-2">
<p>
The filter bar above the timeline takes a syntax-aware query combining regex search over raw log text with
comparisons against declared metrics, joined by <Code>and</Code>/<Code>or</Code>:
</p>
<p>
<Code>{'regex("error") and sequence<5 or timestamp>12:00'}</Code>
</p>
<p>
<Code>{'metric_name[param]<5'}</Code> compares a metric's captured param value against a number, string, or
timestamp. <Code>sequence</Code> and <Code>timestamp</Code> are reserved metric names — every ingested log
line has both, so they're always available to filter on without declaring anything.
</p>
<p className="font-semibold text-text">Date/time formats</p>
<p>Timestamp comparisons accept several forms:</p>
<ul className="list-disc pl-5">
<li>
relative, humantime-style offsets from now — <Code>5h</Code>, <Code>-3m</Code>
</li>
<li>absolute clock times — e.g. 12:00</li>
<li>full dates</li>
</ul>
<p>
The same filter string is sent to both <Code>GET /api/logs</Code> and <Code>GET /api/timeline</Code> as the{' '}
<Code>filter</Code> query param and evaluated on the server; a parse error comes back from the server and
surfaces as inline text under the filter bar rather than crashing the page.
</p>
</div>
),
},
{
id: 'parameters',
title: 'Parameters',
body: (
<div className="flex flex-col gap-2">
<p>
A parameter declaration tells ottyl how to derive a named value from a raw log line: a regex that must
match the message, plus a capture group within it, plus a type (<Code>string</Code>, <Code>number</Code>,
or <Code>timestamp</Code>). Once declared, every already-ingested line gets retroactively indexed for that
param, and every new line is checked against it as it arrives.
</p>
<p>
Params are the building block metrics are made of (a metric declaration is really a message-match regex
plus a set of param matchers), and they're also what drives filtering and the log preview's column picker —
declaring a <Code>duration</Code> param, for instance, makes <Code>duration</Code> available as a column in
the log table and as a comparable value in the filter bar.
</p>
<p>
Declaring a param independent of a metric is supported by the API (<Code>POST /api/params</Code>), used
today via the Metrics panel's declaration form and read back by the log preview's column picker; there
isn't yet a standalone "declare a bare param" form in the UI.
</p>
</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.
</p>
<p>
The preset buttons (<Code>All</Code>, <Code>5m</Code>, <Code>15m</Code>, <Code>1h</Code>,{' '}
<Code>24h</Code>) are Grafana-style shortcuts for the same thing: <Code>All</Code> clears the range
entirely, and a relative preset is <em>rolling</em> — its window keeps advancing to stay "last N minutes
from now" rather than freezing at the moment you picked it. Dragging on the timeline while a preset is
active switches back to a fixed (non-rolling) range, same as dragging normally would.
</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',
body: (
<div className="flex flex-col gap-2">
<p>
Every metric is fundamentally a <em>counter</em>: a message-match regex paired with one or more named param
matchers (each with its own capture group and type). Every log line matching the regex is one counter
increment — a timestamped occurrence, tagged with the labels captured from that line.
</p>
<p>
Each param is either a <strong>label</strong> (the default) or the metric's single <strong>value</strong>{' '}
param. Label params identify which bucket an occurrence belongs to; a value param's captured number becomes
that occurrence's stored value instead of a label — e.g. a <Code>duration</Code> param marked{' '}
<em>value</em> lets you query the distribution or latest reading of durations, not just how often they
occurred. A metric with only label params still works exactly as before: every occurrence's value defaults
to a fixed <Code>1</Code> (a pure occurrence count).
</p>
<p>
Declaring a metric backfills it against everything already ingested, then continues indexing new lines as
they arrive — there's no need to restart ingestion or re-declare after the fact.
</p>
<p>
The Metrics panel lets you declare a metric, pick one from the declared list, and see its bucket values
grouped by label combination over the active time range — occurrence count per group, plus a small trend
sparkline. That same data is what dashboard panels query via PromQL (see below), including two forms{' '}
<em>derived</em> from a counter's value param rather than stored separately:
</p>
<ul className="list-disc pl-5">
<li>
<span className="font-semibold text-text">
<Code>histogram(metric_name[width])</Code>
</span>{' '}
— buckets every matching event's value into fixed-width buckets of size <Code>width</Code>, e.g.{' '}
<Code>histogram(durations[10])</Code>.
</li>
<li>
<span className="font-semibold text-text">
<Code>gauge(metric_name)</Code>
</span>{' '}
— the most-recent value per label combination in the active range, i.e. the counter collapsed to its
latest reading.
</li>
</ul>
</div>
),
},
{
id: 'dashboards',
title: 'Dashboards',
body: (
<div className="flex flex-col gap-2">
<p>
Dashboards work like Grafana: each one is a named set of panels, and each panel is a PromQL query paired
with a chart type — bar, line, or pie. Panels preview live as you edit the query, and a malformed query
shows an inline error instead of breaking the page.
</p>
<ul className="list-disc pl-5">
<li>
<span className="font-semibold text-text">Line</span> — one series per label combination, time on the
x-axis.
</li>
<li>
<span className="font-semibold text-text">Bar</span> — the latest value per series (a snapshot of "where
things are now").
</li>
<li>
<span className="font-semibold text-text">Pie</span> — each series' share of the summed total across the
active range.
</li>
</ul>
<p>
Dashboards are saved per profile, so they travel with a profile when you switch or copy it, and require an
active profile to create or save — pick or create one from the settings menu first.
</p>
</div>
),
},
{
id: 'alerts',
title: 'Alerts',
body: (
<div className="flex flex-col gap-2">
<p>
An alert rule fires a notification when its condition matches a newly-ingested log line — evaluated per
line as it arrives, not backfilled against history. There are four condition types:
</p>
<ul className="list-disc pl-5">
<li>
<span className="font-semibold text-text">Immediate</span> — fires once per line matching a query.
</li>
<li>
<span className="font-semibold text-text">Threshold</span> — fires when a query's match count within a
rolling window crosses a numeric threshold.
</li>
<li>
<span className="font-semibold text-text">NewValue</span> — fires the first time a field regex captures a
value not seen before by this rule (seen values live only in memory, so a restart forgets them).
</li>
<li>
<span className="font-semibold text-text">Ratio</span> — fires when the ratio of two queries' match
counts within a rolling window crosses a numeric ratio.
</li>
</ul>
<p>
Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's query syntax, but only the subset
that evaluates against a single line's own fields — <Code>regex(...)</Code>, <Code>sequence</Code>,{' '}
<Code>timestamp</Code>, and <Code>and</Code>/<Code>or</Code>. A <Code>metric_name[param]</Code> comparison
isn't supported in an alert query.
</p>
<p>
A window accepts a plain number of milliseconds or a humantime-style duration like <Code>30s</Code>,{' '}
<Code>5m</Code>, <Code>1h</Code>.
</p>
<p>
Every rule delivers to an <em>ntfy</em> sink: a topic (required) and an optional server override (defaults
to <Code>https://ntfy.sh</Code>). "Send test notification" delivers to whatever topic/server is currently
entered in the form, without needing to save the rule first — handy for verifying the sink before
committing to it.
</p>
</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>
),
},
]
/** 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}
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)}
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm font-semibold text-text hover:bg-surface-2"
aria-expanded={isOpen}
>
{section.title}
<span className="text-text-muted">{isOpen ? '−' : '+'}</span>
</button>
{isOpen && <div className="border-t border-border px-3 py-3">{section.body}</div>}
</div>
)
})}
</div>
)
}