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

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.

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

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:

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.

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

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.

), }, { 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 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.

), }, { 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 for it (or a blank if it didn't match) — a quick way to eyeball params without switching to the Metrics panel.

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

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:

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

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.

), }, { 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, 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.

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

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.

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

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.

), }, ] /** How long a flashed section stays highlighted before settling back to normal. */ 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 in a row re-triggers the flash rather than being a no-op // because the panel is already open to that section. 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}
}
) })}
) }