import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { DocsTarget } from '../lib/docsNav'
/// One accordion section: title plus body as plain JSX, avoiding a markdown renderer for these static pages.
interface DocSection {
id: string
title: string
body: ReactNode
}
function Code({ children }: { children: string }) {
return {children}
}
const SECTIONS: DocSection[] = [
{
id: 'general',
title: 'General idea',
body: (
ottyl turns a CLI program's stdout into a dashboard. Pipe a program's output into it:
{'cli_command --verbose | ottyl'}
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 profile or change a setting.
CLI arguments
--port — port to serve on. Example: ottyl --port 9000. Default: an OS-assigned
free port.
--settings-path — path to the settings file. Example:{' '}
ottyl --settings-path ./ottyl-settings.json. Default: a per-OS config location, shown by{' '}
ottyl --help.
--file — tail a file instead of reading stdin, like tail -f. Example:{' '}
ottyl --file /var/log/app.log.
--metrics-url — poll a Prometheus/OpenMetrics endpoint for metrics, alongside log ingestion.
Example: ottyl --metrics-url http://localhost:9100/metrics. Scraped samples are queryable by
metric name in PromQL and dashboards immediately, with no declaration step.
Settings
One settings file per machine holds the active style (a built-in colour scheme or an imported/linked stylesheet — see below), 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.
Profiles
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 keeps your setup. The Default{' '}
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.
Worked example
Piping a webserver's access log — {'tail -f access.log | ottyl'} — into a profile named{' '}
webserver: create the profile from the settings menu, declare params/metrics against it (see
the Parameters, Metrics, and Dashboards sections below), then next time you run
the same command, switch to webserver and everything you declared is already there —
no re-declaring params or rebuilding dashboards.
The filter bar combines regex search over raw log text with comparisons against declared metrics, joined
by and/or. Examples:
{'regex("error")'} — lines containing "error".
{'regex("error") and ottyl_seq<5'} — lines containing "error" with sequence under 5.
{'requests[duration]>100'} — the requests metric's captured{' '}
duration param over 100.
{'ottyl_ts>-5m'} — lines from the last 5 minutes.
{'ottyl_fmt==json'} — lines auto-detected as JSON.
ottyl_seq, ottyl_ts, and ottyl_fmt are reserved metric names present
on every log line, so they're always filterable without declaring anything. ottyl_fmt holds the
auto-detected format of the line's text — log_stream (macOS log stream output),{' '}
json, logfmt, or none. A log_stream line also gets{' '}
log_type, process, and pid params derived automatically from its
fixed-column header. All three reserved names also appear (flagged reserved) in{' '}
GET /api/params for discoverability, though there's no UI to edit or remove them — see also{' '}
Dashboards below for using ottyl_seq/ottyl_ts in a panel query.
Date/time formats
Timestamp comparisons accept three forms:
5h (5 hours from now), -3m (3 minutes ago)
12:00, 09:30:00
2026-01-15, 2026-01-15T09:30:00Z
The filter string is evaluated on the server. A parse error shows as inline text under the filter bar.
Worked examples
{'regex("timeout") and ottyl_ts>-15m'}
requests metric has a value-role{' '}
duration param declared: {'requests[duration]>200'}
requests metric with a path label param —
note that [param] comparisons only work against a declared metric's labels, not a
bare param declared on its own, and values are unquoted (no surrounding "..."):{' '}
{'requests[path]==/checkout and regex("5[0-9]{2}")'}
{'ottyl_ts<2024-06-15T12:00:00Z'}
Every ingested line is classified into one of four formats, exposed as the reserved{' '}
ottyl_fmt param (see Filtering above). The format also decides what — if anything —
gets auto-declared as a param without you doing anything:
log_stream
{' '}
— macOS {'log stream'} output's fixed-column shape (timestamp, thread, type, activity id,
pid, ttl, then process: message). The first such line seen auto-declares{' '}
log_type, process, and pid as params, parsed straight out of those
columns.
json
{' '}
— the line parses as a JSON object (a bare array or scalar doesn't count). Every leaf scalar field
(string/number/bool) auto-declares a param named after its dot-separated path, extracted by JSON field
path rather than regex — a nested field like {'{"request":{"path":"/x"}}'} becomes a param
named request.path. Nested objects are walked into automatically; arrays and null{' '}
fields are skipped since there's no single scalar value to declare a param against.
logfmt
{' '}
— the line contains at least one key=value/{'key="value"'} pair anywhere in its
text (it doesn't need to be logfmt end-to-end — a free-text prefix or suffix around the pairs is fine).
Every distinct key auto-declares a param of the same name, type-inferred as number if its
first-seen value parses as one, else string.
none
{' '}
— none of the above; plain prose with no structure ottyl can key off of. Nothing is auto-declared, though
the line is still fully searchable via regex(...) in the filter bar.
Auto-declared params are flagged auto_derived in GET /api/params and show up as
log-table columns immediately, same as a manually declared param — but they can't be edited or removed
individually, and they're never persisted to a profile (so they're rediscovered fresh each run rather than
traveling with a profile like manual declarations do). A manual param declaration for a name always takes
precedence and is never overwritten by auto-detection. To keep one unusual line from flooding the params
list, auto-detection is capped at 64 auto-derived params total and 8 new ones per line — once either cap is
hit, further would-be auto-declarations from new keys/fields are simply skipped.
A parameter declaration derives a named value from a log line: a regex the message must match, a capture
group, and a type (string, number, or timestamp). Example:
duration
{'duration=(?P\\d+)ms'}
duration
number
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.{' '}
requests[duration]>100), declare it as a param on a metric instead — see the Metrics
section below.
Worked example
Given lines like {'request path=/checkout method=GET duration=182'}, declaring a param named{' '}
duration with regex {String.raw`duration=(?P, capture group{' '}
duration, and type number makes duration show up as a column in the
log table for every line that matched, and comparable in the filter bar once it's also declared as part of
a metric (see Filtering above — bare params aren't queryable by name on their own, only metric
labels/values are).
Given the same kind of lines, declaring a param named IsError with regex{' '}
{String.raw`(?i)error`} and type boolean — no capture group needed — shows{' '}
IsError as true or false on every line in the log table, based on
whether the regex matches that line's text case-insensitively. Unlike string/number
/timestamp/duration params, a boolean param is never omitted for a
non-matching line.
The timeline above the log table renders log volume per bucket over the active window. Drag across it to select a fixed range, narrowing the log table, metrics, and dashboards below to that window.
The preset buttons — All, 1m, 2m, 5m, 15m,{' '}
1h, 24h — are quick-range shortcuts. All clears the range. Every
other preset is rolling: 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.
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).
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.
Every metric is a counter: 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:
requests
{'request path=(?P\\S+) duration=(?P\\d+)ms'}
path (capture group path, type string, role{' '}
label), duration (capture group duration, type number
, role value)
Each param is either a label (the default) or the metric's single value{' '}
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, duration 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 1 (a pure occurrence count).
Declaring a metric backfills it against everything already ingested, then keeps indexing new lines as they arrive — no need to restart ingestion or re-declare after the fact.
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. Dashboard panels query that same data via PromQL (see below), including two forms{' '} derived from a counter's value param rather than stored separately:
histogram(metric_name[width])
{' '}
— buckets each matching event's value into fixed-width buckets of size width, e.g.{' '}
histogram(durations[10]).
gauge(metric_name)
{' '}
— the most-recent value per label combination in the active range, i.e. the counter collapsed to its
latest reading.
Worked example
To count requests per path and track their latency from lines like{' '}
{'request path=/checkout method=GET duration=182'}, declare a metric named{' '}
requests with message-match regex{' '}
{String.raw`request path=(?P{' '}
and two params: path (capture group path, kind string, role{' '}
label) and duration (capture group duration, kind{' '}
number, role value — at most one param can be value). Selecting{' '}
requests in the panel then shows one row per distinct path with its occurrence
count and a trend sparkline. The same declaration also unlocks{' '}
{'histogram(requests[50])'} (duration distribution in 50ms-wide buckets) and{' '}
gauge(requests) (each path's most recent duration) as dashboard panel queries.
A param of kind duration marked value is a special case: rather than parsing the
capture group as a number, its value is derived automatically as the elapsed time since the previous
matching event with the same labels — useful for measuring the gap between repeated events (e.g. time
between retries) without the log line itself reporting a duration.
Dashboards work like Grafana: each 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.
Dashboards are saved per profile, so they travel with it when you switch or copy it — an active profile is required to create or save one; pick or create one from the settings menu first.
The reserved ottyl_seq/ottyl_ts names (see Filtering above) work in panel
queries the same as any declared metric — e.g. count(ottyl_seq) for log volume, or{' '}
rate(ottyl_ts[1m]) for ingest rate — since every log line carries them without a metric
declaration. ottyl_fmt is categorical (json/logfmt/none
), not a number, so it's rejected in a panel query with an explicit error rather than silently charting
nothing — filter on it in the filter bar instead.
An alert rule fires a notification when its condition matches a newly-ingested log line, evaluated per line as it arrives. There are four condition types:
{'regex("panic")'}.
{'regex("error")'}, op{' '}
>=, threshold 10, window 1m.
{'user_id=(?P\\d+)'} , capture group id. Seen values live only in memory, so
a restart forgets them.
{'regex("error")'}, query B {'regex("request")'}, op >=, ratio{' '}
0.1, window 5m.
Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's syntax, restricted to{' '}
regex(...), ottyl_seq, ottyl_ts, ottyl_fmt, and{' '}
and/or. A metric_name[param] comparison isn't supported in an alert
query.
A window accepts a plain number of milliseconds or a humantime-style duration: 30s,{' '}
5m, 1h.
Every rule delivers to the single ntfy sink configured in Settings: a topic and an optional server override
(default https://ntfy.sh). Configure the sink and send a test notification from the Settings
panel — the Alerts panel only defines rule conditions.
Worked example: build a "requests" dashboard
Assuming a requests metric declared as in the Metrics example above (a path{' '}
label and a duration value param), a useful dashboard is three panels:
requests — one line per path,
showing every request occurrence over time.
gauge(requests) — each path's
most recent request duration side by side, a quick "who's slow right now" snapshot.
{'requests{path="/checkout"}'}{' '}
— label matchers filter a query down to one series; here, just the checkout path's share of its own
occurrences over the range (mostly useful once more matchers are added to compare paths against each
other).
Other query shapes worth knowing: {'sum(requests)'} collapses every path into one combined
count-over-time series, and {'rate(requests[5m])'} gives each path's occurrences-per-second
over a trailing 5-minute window — both work with any of the three chart types, since chart type is just how
the returned series get drawn, not part of the query itself. Add a panel, type a query, and it previews
live; a malformed query (e.g. mismatched braces, or histogram() missing its{' '}
[width]) shows its error inline in the panel instead of breaking the dashboard — a syntactically
valid query for a metric that doesn't exist just renders "No data in range."
The Settings panel controls process-wide state: the active style — one unified list of built-in colour schemes and any imported/linked stylesheets, any of which can be a simple palette or a full layout redesign — the ntfy sink alert rules deliver to, and the max number of logs kept in memory (blank means unlimited). Changes apply immediately and persist to the settings file on disk. There is no save button.
Worked example
Ingesting a very chatty process and only care about the last hour of activity: set{' '}
max logs in memory to something like 50000 so older lines are dropped as new ones
arrive rather than growing memory use unbounded for the life of the process. Leave it blank to keep
everything ingested — the default, and fine for shorter-lived commands.
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 + button prompts for a
name and creates a new profile as a copy of the active one, then switches to it. The × button
deletes the active profile; it's disabled for Default, which can't be deleted. See{' '}
General idea above for what a profile stores.