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

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.

), }, { id: 'filtering', title: 'Filtering', body: (

The filter bar combines regex search over raw log text with comparisons against declared metrics, joined by and/or. Examples:

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:

The filter string is evaluated on the server. A parse error shows as inline text under the filter bar.

Worked examples

), }, { id: 'auto-formats', title: 'Auto-detected formats', body: (

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:

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.

), }, { id: 'parameters', title: 'Parameters', body: (

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:

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\d+)`}, 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.

), }, { id: 'timeline', title: 'Timeline', body: (

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.

), }, { id: 'log-preview', title: 'Log preview', body: (

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.

), }, { id: 'metrics', title: 'Metrics', body: (

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:

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:

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\S+) method=(?P\S+) duration=(?P\d+)`}{' '} 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.

), }, { id: 'dashboards', title: 'Dashboards', body: (

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.

), }, { id: 'alerts', title: 'Alerts', body: (

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:

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:

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."

), }, { id: 'settings', title: 'Settings', body: (

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.

), }, { id: 'presets', title: 'Presets', body: (

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.

), }, ] const FLASH_DURATION_MS = 1200 export function DocsPanel({ target }: { target?: DocsTarget | null }) { const [openId, setOpenId] = useState(SECTIONS[0].id) const [flashId, setFlashId] = useState(null) const sectionRefs = useRef>({}) // Keyed on `target?.nonce`, not `target?.sectionId`, so clicking the same "?" icon twice re-triggers the flash instead of no-op'ing when already open. 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 (
{SECTIONS.map((section) => { const isOpen = openId === section.id const isFlashing = flashId === section.id return (
{ sectionRefs.current[section.id] = el }} className={`rounded border transition-colors duration-500 ${ isFlashing ? 'border-accent bg-accent-soft/25' : 'border-border' }`} > {isOpen &&
{section.body}
}
) })}
) }