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', title: 'General idea',
body: ( body: (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>ottyl turns a CLI program's stdout into a dashboard. Pipe a program's output into it:</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> <p>
<Code>{'cli_command --verbose | ottyl'}</Code> <Code>{'cli_command --verbose | ottyl'}</Code>
</p> </p>
<p> <p>
Everything — log lines, params, metrics, and derived buckets — lives in one in-memory index for the It indexes each line in memory as it arrives and serves the dashboard from the same process. Everything —
process's lifetime. Nothing touches disk unless you save a <em>profile</em> or change a <em>setting</em>. 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>
<p className="font-semibold text-text">CLI arguments</p> <p className="font-semibold text-text">CLI arguments</p>
<ul className="list-disc pl-5"> <ul className="list-disc pl-5">
<li> <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>
<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>
<li> <li>
<Code>--settings-path</Code> — path to the settings file; defaults to a per-OS config location (e.g.{' '} <Code>--file</Code> — tail a file instead of reading stdin, like <Code>tail -f</Code>. Example:{' '}
<Code>~/.config/ottyl/settings.json</Code> on Linux). <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> </li>
</ul> </ul>
<p className="font-semibold text-text">Settings</p> <p className="font-semibold text-text">Settings</p>
<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, 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. dashboards) are stored separately, next to the settings file by default.
</p> </p>
<p className="font-semibold text-text">Profiles</p> <p className="font-semibold text-text">Profiles</p>
<p> <p>
A profile bundles the parsing/indexing rules for one CLI app — its params and metrics, plus any saved 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>{' '} dashboards — so restarting ottyl against the same program keeps your setup. The <Code>Default</Code>{' '}
profile always exists and can't be deleted. Creating a new profile copies the active one (or starts empty profile always exists and can't be deleted. The profile dropdown's "+" button creates a new profile as a
if none is active), so variations build on what you already have. Switching profiles hot-swaps the running copy of the active one, and its "×" button deletes the active profile. Switching profiles hot-swaps the
engine's declared params/metrics immediately, with no restart needed. running engine's declared params/metrics immediately, with no restart.
</p> </p>
</div> </div>
), ),
@@ -65,30 +71,43 @@ const SECTIONS: DocSection[] = [
body: ( body: (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>
The filter bar takes a query combining regex search over raw log text with comparisons against declared The filter bar combines regex search over raw log text with comparisons against declared metrics, joined
metrics, joined by <Code>and</Code>/<Code>or</Code>: by <Code>and</Code>/<Code>or</Code>. Examples:
</p> </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"> <ul className="list-disc pl-5">
<li> <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>
<li>absolute clock times — e.g. 12:00</li>
<li>full dates</li>
</ul> </ul>
<p> <p>
The same filter string goes to both <Code>GET /api/logs</Code> and <Code>GET /api/timeline</Code> as the{' '} <Code>sequence</Code> and <Code>timestamp</Code> are reserved metric names present on every log line, so
<Code>filter</Code> query param and is evaluated on the server; a parse error surfaces as inline text under they're always filterable without declaring anything.
the filter bar rather than crashing the page. </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> </p>
</div> </div>
), ),
@@ -99,20 +118,28 @@ const SECTIONS: DocSection[] = [
body: ( body: (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>
A parameter declaration tells ottyl how to derive a named value from a log line: a regex the message must A parameter declaration derives a named value from a log line: a regex the message must match, a capture
match, a capture group, and a type (<Code>string</Code>, <Code>number</Code>, or <Code>timestamp</Code>). group, and a type (<Code>string</Code>, <Code>number</Code>, or <Code>timestamp</Code>). Example:
Declaring one retroactively indexes every already-ingested line and checks every new line as it arrives.
</p> </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> <p>
Params are the building blocks metrics are made of (a metric is really a message-match regex plus a set of Declaring a param indexes every already-ingested line immediately, then checks every new line as it
param matchers), and they drive filtering and the log preview's column picker — declaring a{' '} arrives. It shows up right away as a column in the log table. To filter on it (e.g.{' '}
<Code>duration</Code> param, for instance, makes it available as both a log-table column and a comparable <Code>requests[duration]&gt;100</Code>), declare it as a param on a metric instead — see the Metrics
value in the filter bar. section below.
</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.
</p> </p>
</div> </div>
), ),
@@ -127,11 +154,10 @@ const SECTIONS: DocSection[] = [
select a fixed range, narrowing the log table, metrics, and dashboards below to that window. select a fixed range, narrowing the log table, metrics, and dashboards below to that window.
</p> </p>
<p> <p>
The preset buttons (<Code>All</Code>, <Code>5m</Code>, <Code>15m</Code>, <Code>1h</Code>,{' '} The preset buttons — <Code>All</Code>, <Code>1m</Code>, <Code>2m</Code>, <Code>5m</Code>, <Code>15m</Code>,{' '}
<Code>24h</Code>) are Grafana-style shortcuts: <Code>All</Code> clears the range entirely, and a relative <Code>1h</Code>, <Code>24h</Code> — are quick-range shortcuts. <Code>All</Code> clears the range. Every
preset is <em>rolling</em> — its window keeps advancing to stay "last N minutes from now" instead of 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 (non-rolling) range, same freezing at selection time. Dragging while a preset is active switches to a fixed range.
as a normal drag.
</p> </p>
</div> </div>
), ),
@@ -144,7 +170,11 @@ const SECTIONS: DocSection[] = [
<p> <p>
The log table lists ingested lines newest-last, each with its sequence number, timestamp, and raw text. The 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 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> </p>
</div> </div>
), ),
@@ -155,16 +185,29 @@ const SECTIONS: DocSection[] = [
body: ( body: (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>
Every metric is fundamentally a <em>counter</em>: a message-match regex paired with one or more named param Every metric is a <em>counter</em>: a message-match regex paired with one or more named param matchers
matchers (each with its own capture group and type). Every log line matching the regex is one counter (each with its own capture group and type). Every log line matching the regex is one counter increment — a
increment — a timestamped occurrence, tagged with the labels captured from that line. timestamped occurrence, tagged with the labels captured from that line. Example:
</p> </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> <p>
Each param is either a <strong>label</strong> (the default) or the metric's single <strong>value</strong>{' '} 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 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>{' '} occurrence's stored value instead of a label — in the example above, <Code>duration</Code> lets you query
lets you query the distribution or latest reading, not just how often it occurred. A metric with only the distribution or latest reading, not just how often the line occurred. A metric with only label params
label params defaults every occurrence's value to a fixed <Code>1</Code> (a pure occurrence count). defaults every occurrence's value to a fixed <Code>1</Code> (a pure occurrence count).
</p> </p>
<p> <p>
Declaring a metric backfills it against everything already ingested, then keeps indexing new lines as they 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"> <div className="flex flex-col gap-2">
<p> <p>
An alert rule fires a notification when its condition matches a newly-ingested log line, evaluated per line 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> </p>
<ul className="list-disc pl-5"> <ul className="list-disc pl-5">
<li> <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>
<li> <li>
<span className="font-semibold text-text">Threshold</span> — fires when a query's match count within a <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>
<li> <li>
<span className="font-semibold text-text">NewValue</span> — fires the first time a field regex captures a <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>
<li> <li>
<span className="font-semibold text-text">Ratio</span> — fires when the ratio of two queries' match <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> </li>
</ul> </ul>
<p> <p>
Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's syntax, but only the subset that Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's syntax, restricted to{' '}
evaluates a single line's own fields — <Code>regex(...)</Code>, <Code>sequence</Code>,{' '} <Code>regex(...)</Code>, <Code>sequence</Code>, <Code>timestamp</Code>, and <Code>and</Code>/<Code>or</Code>
<Code>timestamp</Code>, and <Code>and</Code>/<Code>or</Code>. A <Code>metric_name[param]</Code> comparison . A <Code>metric_name[param]</Code> comparison isn't supported in an alert query.
isn't supported in an alert query.
</p> </p>
<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>. <Code>5m</Code>, <Code>1h</Code>.
</p> </p>
<p> <p>
Every rule delivers to an <em>ntfy</em> sink: a topic (required) and an optional server override (default{' '} Every rule delivers to the single ntfy sink configured in Settings: a topic and an optional server override
<Code>https://ntfy.sh</Code>). "Send test notification" delivers to the form's current topic/server without (default <Code>https://ntfy.sh</Code>). Configure the sink and send a test notification from the Settings
needing to save the rule first. panel — the Alerts panel only defines rule conditions.
</p> </p>
</div> </div>
), ),
@@ -275,9 +323,9 @@ const SECTIONS: DocSection[] = [
body: ( body: (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>
The Settings panel controls process-wide state: colour scheme, the max number of logs kept in memory The Settings panel controls process-wide state: colour scheme, the ntfy sink alert rules deliver to, the
(blank means unlimited), and imported/linked CSS. Changes apply immediately and persist to the settings max number of logs kept in memory (blank means unlimited), and imported/linked CSS. Changes apply
file on disk — no save button needed. immediately and persist to the settings file on disk. There is no save button.
</p> </p>
</div> </div>
), ),
@@ -288,9 +336,11 @@ const SECTIONS: DocSection[] = [
body: ( body: (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>
The Presets panel manages profiles: create, switch, and delete them. Switching activates a profile's The profile dropdown in the sidebar manages profiles. Selecting one switches to it and activates its
params/metrics in the running engine immediately, no restart needed — see <em>General idea</em> above for params/metrics in the running engine immediately, with no restart. The <Code>+</Code> button prompts for a
what a profile stores. 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> </p>
</div> </div>
), ),