Docs, ticks, working app

This commit is contained in:
Hugh Kaznowski
2026-09-05 15:05:39 +01:00
parent 41362aa3d8
commit 51e577f01e
41 changed files with 1039 additions and 683 deletions
+204
View File
@@ -0,0 +1,204 @@
import { useState, type ReactNode } from 'react'
/// 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 <code className="rounded bg-surface-2 px-1 py-0.5 font-mono text-[11px] text-text">{children}</code>
}
const SECTIONS: DocSection[] = [
{
id: 'general',
title: 'General idea',
body: (
<div className="flex flex-col gap-2">
<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, then serves a webapp dashboard over the same process.
</p>
<p>
<Code>{'cli_command --verbose | ottyl'}</Code>
</p>
<p>
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{' '}
<em>profile</em> or change a <em>setting</em>.
</p>
<p className="font-semibold text-text">CLI arguments</p>
<ul className="list-disc pl-5">
<li>
<Code>--port</Code> — port to serve the API and webapp on (default <Code>8420</Code>).
</li>
<li>
<Code>--static-dir</Code> — directory containing the built webapp (default <Code>webapp/dist</Code>).
</li>
<li>
<Code>--settings-path</Code> — path to the settings file; defaults to a per-OS config location (e.g.{' '}
<Code>~/.config/ottyl/settings.json</Code> on Linux).
</li>
</ul>
<p className="font-semibold text-text">Settings</p>
<p>
There is exactly one <em>settings</em> 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.
</p>
<p className="font-semibold text-text">Profiles</p>
<p>
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 <Code>Default</Code> 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.
</p>
</div>
),
},
{
id: 'filtering',
title: 'Filtering',
body: (
<div className="flex flex-col gap-2">
<p>
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 <Code>and</Code>/<Code>or</Code>:
</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 value against a number, string, or
timestamp. <Code>sequence</Code> and <Code>timestamp</Code> are reserved metric names — every ingested log
line has both, so they're always available to filter on 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">
<li>
relative, humantime-style offsets from now — <Code>5h</Code>, <Code>-3m</Code>
</li>
<li>absolute clock times — e.g. 12:00</li>
<li>full dates</li>
</ul>
<p>
The same filter string is sent to both <Code>GET /api/logs</Code> and <Code>GET /api/timeline</Code> as the{' '}
<Code>filter</Code> 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.
</p>
</div>
),
},
{
id: 'parameters',
title: 'Parameters',
body: (
<div className="flex flex-col gap-2">
<p>
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 (<Code>string</Code>, <Code>number</Code>,
or <Code>timestamp</Code>). Once declared, every already-ingested line gets retroactively indexed for that
param, and every new line is checked against it as it arrives.
</p>
<p>
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 <Code>duration</Code> param, for instance, makes <Code>duration</Code> available as a column in
the log table and as a comparable value in the filter bar.
</p>
<p>
Declaring a param independent of a metric is supported by the API (<Code>POST /api/params</Code>), 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.
</p>
</div>
),
},
{
id: 'metrics',
title: 'Metrics',
body: (
<div className="flex flex-col gap-2">
<p>
A metric declaration bundles a message-match regex with one or more named param matchers (each with its own
capture group and type). Every log line matching the regex produces a <em>bucket value</em>: a timestamped
occurrence, tagged with the labels captured from that line.
</p>
<p>
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.
</p>
<p>
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).
</p>
</div>
),
},
{
id: 'dashboards',
title: 'Dashboards',
body: (
<div className="flex flex-col gap-2">
<p>
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.
</p>
<ul className="list-disc pl-5">
<li>
<span className="font-semibold text-text">Line</span> — one series per label combination, time on the
x-axis.
</li>
<li>
<span className="font-semibold text-text">Bar</span> — the latest value per series (a snapshot of "where
things are now").
</li>
<li>
<span className="font-semibold text-text">Pie</span> — each series' share of the summed total across the
active range.
</li>
</ul>
<p>
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.
</p>
</div>
),
},
]
export function DocsPanel() {
const [openId, setOpenId] = useState<string | null>(SECTIONS[0].id)
return (
<div className="flex flex-col gap-1 text-sm text-text-secondary">
{SECTIONS.map((section) => {
const isOpen = openId === section.id
return (
<div key={section.id} className="rounded border border-border">
<button
type="button"
onClick={() => setOpenId(isOpen ? null : section.id)}
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm font-semibold text-text hover:bg-surface-2"
aria-expanded={isOpen}
>
{section.title}
<span className="text-text-muted">{isOpen ? '−' : '+'}</span>
</button>
{isOpen && <div className="border-t border-border px-3 py-3">{section.body}</div>}
</div>
)
})}
</div>
)
}