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 {children}
}
const SECTIONS: DocSection[] = [
{
id: 'general',
title: 'General idea',
body: (
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.
{'cli_command --verbose | ottyl'}
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{' '} profile or change a setting.
CLI arguments
--port — port to serve the API and webapp on (default 8420).
--static-dir — directory containing the built webapp (default webapp/dist).
--settings-path — path to the settings file; defaults to a per-OS config location (e.g.{' '}
~/.config/ottyl/settings.json on Linux).
Settings
There is exactly one settings 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.
Profiles
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 Default 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.
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 and/or:
{'regex("error") and sequence<5 or timestamp>12:00'}
{'metric_name[param]<5'} compares a metric's captured param value against a number, string, or
timestamp. sequence and timestamp are reserved metric names — every ingested log
line has both, so they're always available to filter on without declaring anything.
Date/time formats
Timestamp comparisons accept several forms:
5h, -3m
The same filter string is sent to both GET /api/logs and GET /api/timeline as the{' '}
filter 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.
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 (string, number,
or timestamp). Once declared, every already-ingested line gets retroactively indexed for that
param, and every new line is checked against it as it arrives.
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 duration param, for instance, makes duration available as a column in
the log table and as a comparable value in the filter bar.
Declaring a param independent of a metric is supported by the API (POST /api/params), 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.
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.
The preset buttons (All, 5m, 15m, 1h,{' '}
24h) are Grafana-style shortcuts for the same thing: All clears the range
entirely, and a relative preset is rolling — 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.
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.
Every metric is fundamentally 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.
Each param is either a label (the default) or the metric's single value{' '}
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 duration param marked{' '}
value 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 1 (a pure occurrence count).
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.
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{' '} derived from a counter's value param rather than stored separately:
histogram(metric_name[width])
{' '}
— buckets every 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.
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.
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.
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:
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 — regex(...), sequence,{' '}
timestamp, 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 like 30s,{' '}
5m, 1h.
Every rule delivers to an ntfy sink: a topic (required) and an optional server override (defaults
to https://ntfy.sh). "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.
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.
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 General idea{' '} section above for what a profile stores.