Simplify docs and add concrete examples for input fields

Rewrites doc-panel descriptions to be direct and absolute (no edge-case
hedging), and adds concrete example values for params, metrics, filter
queries, alert conditions, and CLI arguments. Also updates the Alerts/
Settings/Presets sections to describe the ntfy-sink-in-Settings and
profile-dropdown changes.

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 18:19:37 +01:00
co-authored by Claude Sonnet 5
parent e69ce4e0ba
commit 0a21582282
+127 -77
View File
@@ -18,43 +18,49 @@ const SECTIONS: DocSection[] = [
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 while serving a dashboard from the same process.
</p>
<p>ottyl turns a CLI program's stdout into a dashboard. Pipe a program's output into it:</p>
<p>
<Code>{'cli_command --verbose | ottyl'}</Code>
</p>
<p>
Everything — log lines, params, metrics, and derived buckets — lives in one in-memory index for the
process's lifetime. Nothing touches disk unless you save a <em>profile</em> or change a <em>setting</em>.
It indexes each line in memory as it arrives and serves the dashboard from the same process. Everything —
log lines, params, metrics, and derived buckets — lives in that in-memory index for the process's
lifetime. Nothing touches disk unless you 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>).
<Code>--port</Code> — port to serve on. Example: <Code>ottyl --port 9000</Code>. Default: an OS-assigned
free port.
</li>
<li>
<Code>--static-dir</Code> — directory containing the built webapp (default <Code>webapp/dist</Code>).
<Code>--settings-path</Code> — path to the settings file. Example:{' '}
<Code>ottyl --settings-path ./ottyl-settings.json</Code>. Default: a per-OS config location, shown by{' '}
<Code>ottyl --help</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).
<Code>--file</Code> — tail a file instead of reading stdin, like <Code>tail -f</Code>. Example:{' '}
<Code>ottyl --file /var/log/app.log</Code>.
</li>
<li>
<Code>--metrics-url</Code> — poll a Prometheus/OpenMetrics endpoint for metrics, alongside log ingestion.
Example: <Code>ottyl --metrics-url http://localhost:9100/metrics</Code>. Scraped samples are queryable by
metric name in PromQL and dashboards immediately, with no declaration step.
</li>
</ul>
<p className="font-semibold text-text">Settings</p>
<p>
There is one <em>settings</em> file per machine, holding the colour scheme, any imported/linked CSS, and
One <em>settings</em> file per machine holds the colour scheme, imported/linked CSS, the ntfy sink, and
which profile is active. Settings only tracks profile pointers — 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 bundles the parsing/indexing rules for one CLI app — its params and metrics, plus any saved
dashboards — so restarting ottyl against the same program doesn't lose your setup. A <Code>Default</Code>{' '}
profile always exists and can't be deleted. Creating a new profile copies the active one (or starts empty
if none is active), so variations build on what you already have. Switching profiles hot-swaps the running
engine's declared params/metrics immediately, with no restart needed.
dashboards — so restarting ottyl against the same program keeps your setup. The <Code>Default</Code>{' '}
profile always exists and can't be deleted. The profile dropdown's "+" button creates a new profile as a
copy of the active one, and its "×" button deletes the active profile. Switching profiles hot-swaps the
running engine's declared params/metrics immediately, with no restart.
</p>
</div>
),
@@ -65,30 +71,43 @@ const SECTIONS: DocSection[] = [
body: (
<div className="flex flex-col gap-2">
<p>
The filter bar takes a query combining regex search over raw log text with comparisons against declared
metrics, joined by <Code>and</Code>/<Code>or</Code>:
The filter bar combines regex search over raw log text with comparisons against declared metrics, joined
by <Code>and</Code>/<Code>or</Code>. Examples:
</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 against a number, string, or
timestamp. <Code>sequence</Code> and <Code>timestamp</Code> are reserved metric names present on every log
line, so they're always filterable 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>
<Code>{'regex("error")'}</Code> — lines containing "error".
</li>
<li>
<Code>{'regex("error") and sequence<5'}</Code> — lines containing "error" with sequence under 5.
</li>
<li>
<Code>{'requests[duration]>100'}</Code> — the <Code>requests</Code> metric's captured{' '}
<Code>duration</Code> param over 100.
</li>
<li>
<Code>{'timestamp>-5m'}</Code> — lines from the last 5 minutes.
</li>
<li>absolute clock times — e.g. 12:00</li>
<li>full dates</li>
</ul>
<p>
The same filter string goes to both <Code>GET /api/logs</Code> and <Code>GET /api/timeline</Code> as the{' '}
<Code>filter</Code> query param and is evaluated on the server; a parse error surfaces as inline text under
the filter bar rather than crashing the page.
<Code>sequence</Code> and <Code>timestamp</Code> are reserved metric names present on every log line, so
they're always filterable without declaring anything.
</p>
<p className="font-semibold text-text">Date/time formats</p>
<p>Timestamp comparisons accept three forms:</p>
<ul className="list-disc pl-5">
<li>
relative offsets from now — <Code>5h</Code> (5 hours from now), <Code>-3m</Code> (3 minutes ago)
</li>
<li>
absolute clock times — <Code>12:00</Code>, <Code>09:30:00</Code>
</li>
<li>
full dates — <Code>2026-01-15</Code>, <Code>2026-01-15T09:30:00Z</Code>
</li>
</ul>
<p>
The filter string is evaluated on the server. A parse error shows as inline text under the filter bar.
</p>
</div>
),
@@ -99,20 +118,28 @@ const SECTIONS: DocSection[] = [
body: (
<div className="flex flex-col gap-2">
<p>
A parameter declaration tells ottyl how to derive a named value from a log line: a regex the message must
match, a capture group, and a type (<Code>string</Code>, <Code>number</Code>, or <Code>timestamp</Code>).
Declaring one retroactively indexes every already-ingested line and checks every new line as it arrives.
A parameter declaration derives a named value from a log line: a regex the message must match, a capture
group, and a type (<Code>string</Code>, <Code>number</Code>, or <Code>timestamp</Code>). Example:
</p>
<ul className="list-disc pl-5">
<li>
Name: <Code>duration</Code>
</li>
<li>
Regex: <Code>{'duration=(?P<duration>\\d+)ms'}</Code>
</li>
<li>
Capture group: <Code>duration</Code>
</li>
<li>
Type: <Code>number</Code>
</li>
</ul>
<p>
Params are the building blocks metrics are made of (a metric is really a message-match regex plus a set of
param matchers), and they drive filtering and the log preview's column picker — declaring a{' '}
<Code>duration</Code> param, for instance, makes it available as both a log-table column and a comparable
value in the filter bar.
</p>
<p>
The Params panel declares a bare param directly (<Code>POST /api/params</Code>) — the same request the
Metrics panel's declaration form makes per param matcher. Declaring one there updates the log preview's
column picker immediately, with no page reload needed.
Declaring a param indexes every already-ingested line immediately, then checks every new line as it
arrives. It shows up right away as a column in the log table. To filter on it (e.g.{' '}
<Code>requests[duration]&gt;100</Code>), declare it as a param on a metric instead — see the Metrics
section below.
</p>
</div>
),
@@ -127,11 +154,10 @@ const SECTIONS: DocSection[] = [
select a fixed range, narrowing 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: <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" instead of
freezing at selection time. Dragging while a preset is active switches to a fixed (non-rolling) range, same
as a normal drag.
The preset buttons — <Code>All</Code>, <Code>1m</Code>, <Code>2m</Code>, <Code>5m</Code>, <Code>15m</Code>,{' '}
<Code>1h</Code>, <Code>24h</Code> — are quick-range shortcuts. <Code>All</Code> clears the range. Every
other preset is <em>rolling</em>: its window keeps advancing to stay "last N minutes from now" instead of
freezing at selection time. Dragging while a preset is active switches to a fixed range.
</p>
</div>
),
@@ -144,7 +170,11 @@ const SECTIONS: DocSection[] = [
<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 (blank if it
didn't match) — a quick way to eyeball params without switching to the Metrics panel.
didn't match).
</p>
<p>
Scrolling stays anchored to the row you're looking at as new lines arrive or old ones age out of a rolling
window — the view doesn't jump when content is pruned from the top.
</p>
</div>
),
@@ -155,16 +185,29 @@ const SECTIONS: DocSection[] = [
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.
Every metric is 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. Example:
</p>
<ul className="list-disc pl-5">
<li>
Name: <Code>requests</Code>
</li>
<li>
Regex: <Code>{'request path=(?P<path>\\S+) duration=(?P<duration>\\d+)ms'}</Code>
</li>
<li>
Params: <Code>path</Code> (capture group <Code>path</Code>, type <Code>string</Code>, role{' '}
<Code>label</Code>), <Code>duration</Code> (capture group <Code>duration</Code>, type <Code>number</Code>
, role <Code>value</Code>)
</li>
</ul>
<p>
Each param is either a <strong>label</strong> (the default) or the metric's single <strong>value</strong>{' '}
param. Labels identify which bucket an occurrence belongs to; a value param's captured number becomes the
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, not just how often it occurred. A metric with only
label params defaults every occurrence's value to a fixed <Code>1</Code> (a pure occurrence count).
param. Labels identify which bucket an occurrence belongs to. A value param's captured number becomes the
occurrence's stored value instead of a label — in the example above, <Code>duration</Code> lets you query
the distribution or latest reading, not just how often the line occurred. A metric with only label params
defaults every occurrence's value to a fixed <Code>1</Code> (a pure occurrence count).
</p>
<p>
Declaring a metric backfills it against everything already ingested, then keeps indexing new lines as they
@@ -232,39 +275,44 @@ const SECTIONS: DocSection[] = [
<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 rather than backfilled against history. There are four condition types:
as it arrives. There are four condition types:
</p>
<ul className="list-disc pl-5">
<li>
<span className="font-semibold text-text">Immediate</span> — fires once per matching line.
<span className="font-semibold text-text">Immediate</span> — fires once per matching line. Example query:{' '}
<Code>{'regex("panic")'}</Code>.
</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.
rolling window crosses a numeric threshold. Example: query <Code>{'regex("error")'}</Code>, op{' '}
<Code>&gt;=</Code>, threshold <Code>10</Code>, window <Code>1m</Code>.
</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).
value not seen before by this rule. Example: field regex{' '}
<Code>{'user_id=(?P<id>\\d+)'}</Code>, capture group <Code>id</Code>. 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.
counts within a rolling window crosses a numeric ratio. Example: query A{' '}
<Code>{'regex("error")'}</Code>, query B <Code>{'regex("request")'}</Code>, op <Code>&gt;=</Code>, ratio{' '}
<Code>0.1</Code>, window <Code>5m</Code>.
</li>
</ul>
<p>
Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's syntax, but only the subset that
evaluates 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.
Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's syntax, restricted to{' '}
<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>,{' '}
A window accepts a plain number of milliseconds or a humantime-style duration: <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 (default{' '}
<Code>https://ntfy.sh</Code>). "Send test notification" delivers to the form's current topic/server without
needing to save the rule first.
Every rule delivers to the single ntfy sink configured in Settings: a topic and an optional server override
(default <Code>https://ntfy.sh</Code>). Configure the sink and send a test notification from the Settings
panel — the Alerts panel only defines rule conditions.
</p>
</div>
),
@@ -275,9 +323,9 @@ const SECTIONS: DocSection[] = [
body: (
<div className="flex flex-col gap-2">
<p>
The Settings panel controls process-wide state: colour scheme, the max number of logs kept in memory
(blank means unlimited), and imported/linked CSS. Changes apply immediately and persist to the settings
file on disk — no save button needed.
The Settings panel controls process-wide state: colour scheme, the ntfy sink alert rules deliver to, the
max number of logs kept in memory (blank means unlimited), and imported/linked CSS. Changes apply
immediately and persist to the settings file on disk. There is no save button.
</p>
</div>
),
@@ -288,9 +336,11 @@ const SECTIONS: DocSection[] = [
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, no restart needed — see <em>General idea</em> above for
what a profile stores.
The profile dropdown in the sidebar manages profiles. Selecting one switches to it and activates its
params/metrics in the running engine immediately, with no restart. The <Code>+</Code> button prompts for a
name and creates a new profile as a copy of the active one, then switches to it. The <Code>×</Code> button
deletes the active profile; it's disabled for <Code>Default</Code>, which can't be deleted. See{' '}
<em>General idea</em> above for what a profile stores.
</p>
</div>
),