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
366 lines
16 KiB
TypeScript
366 lines
16 KiB
TypeScript
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>
|
||
)
|
||
}
|