Docs, ticks, working app
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user