586 lines
30 KiB
TypeScript
586 lines
30 KiB
TypeScript
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 <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 dashboard. Pipe a program's output into it:</p>
|
||
<p>
|
||
<Code>{'cli_command --verbose | ottyl'}</Code>
|
||
</p>
|
||
<p>
|
||
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 <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 on. Example: <Code>ottyl --port 9000</Code>. Default: an OS-assigned
|
||
free port.
|
||
</li>
|
||
<li>
|
||
<Code>--settings-path</Code> — path to the settings file. Example:{' '}
|
||
<Code>ottyl --settings-path ./ottyl-settings.json</Code>. Default: a per-OS config location, shown by{' '}
|
||
<Code>ottyl --help</Code>.
|
||
</li>
|
||
<li>
|
||
<Code>--file</Code> — tail a file instead of reading stdin, like <Code>tail -f</Code>. Example:{' '}
|
||
<Code>ottyl --file /var/log/app.log</Code>.
|
||
</li>
|
||
<li>
|
||
<Code>--metrics-url</Code> — poll a Prometheus/OpenMetrics endpoint for metrics, alongside log ingestion.
|
||
Example: <Code>ottyl --metrics-url http://localhost:9100/metrics</Code>. Scraped samples are queryable by
|
||
metric name in PromQL and dashboards immediately, with no declaration step.
|
||
</li>
|
||
</ul>
|
||
<p className="font-semibold text-text">Settings</p>
|
||
<p>
|
||
One <em>settings</em> 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.
|
||
</p>
|
||
<p className="font-semibold text-text">Profiles</p>
|
||
<p>
|
||
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 <Code>Default</Code>{' '}
|
||
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.
|
||
</p>
|
||
<p className="font-semibold text-text">Worked example</p>
|
||
<p>
|
||
Piping a webserver's access log — <Code>{'tail -f access.log | ottyl'}</Code> — into a profile named{' '}
|
||
<Code>webserver</Code>: create the profile from the settings menu, declare params/metrics against it (see
|
||
the <em>Parameters</em>, <em>Metrics</em>, and <em>Dashboards</em> sections below), then next time you run
|
||
the same command, switch to <Code>webserver</Code> and everything you declared is already there —
|
||
no re-declaring params or rebuilding dashboards.
|
||
</p>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: 'filtering',
|
||
title: 'Filtering',
|
||
body: (
|
||
<div className="flex flex-col gap-2">
|
||
<p>
|
||
The filter bar combines regex search over raw log text with comparisons against declared metrics, joined
|
||
by <Code>and</Code>/<Code>or</Code>. Examples:
|
||
</p>
|
||
<ul className="list-disc pl-5">
|
||
<li>
|
||
<Code>{'regex("error")'}</Code> — lines containing "error".
|
||
</li>
|
||
<li>
|
||
<Code>{'regex("error") and ottyl_seq<5'}</Code> — lines containing "error" with sequence under 5.
|
||
</li>
|
||
<li>
|
||
<Code>{'requests[duration]>100'}</Code> — the <Code>requests</Code> metric's captured{' '}
|
||
<Code>duration</Code> param over 100.
|
||
</li>
|
||
<li>
|
||
<Code>{'ottyl_ts>-5m'}</Code> — lines from the last 5 minutes.
|
||
</li>
|
||
<li>
|
||
<Code>{'ottyl_fmt==json'}</Code> — lines auto-detected as JSON.
|
||
</li>
|
||
</ul>
|
||
<p>
|
||
<Code>ottyl_seq</Code>, <Code>ottyl_ts</Code>, and <Code>ottyl_fmt</Code> are reserved metric names present
|
||
on every log line, so they're always filterable without declaring anything. <Code>ottyl_fmt</Code> holds the
|
||
auto-detected format of the line's text — <Code>log_stream</Code> (macOS <Code>log stream</Code> output),{' '}
|
||
<Code>json</Code>, <Code>logfmt</Code>, or <Code>none</Code>. A <Code>log_stream</Code> line also gets{' '}
|
||
<Code>log_type</Code>, <Code>process</Code>, and <Code>pid</Code> params derived automatically from its
|
||
fixed-column header. All three reserved names also appear (flagged <Code>reserved</Code>) in{' '}
|
||
<Code>GET /api/params</Code> for discoverability, though there's no UI to edit or remove them — see also{' '}
|
||
<em>Dashboards</em> below for using <Code>ottyl_seq</Code>/<Code>ottyl_ts</Code> in a panel query.
|
||
</p>
|
||
<p className="font-semibold text-text">Date/time formats</p>
|
||
<p>Timestamp comparisons accept three forms:</p>
|
||
<ul className="list-disc pl-5">
|
||
<li>
|
||
relative offsets from now — <Code>5h</Code> (5 hours from now), <Code>-3m</Code> (3 minutes ago)
|
||
</li>
|
||
<li>
|
||
absolute clock times — <Code>12:00</Code>, <Code>09:30:00</Code>
|
||
</li>
|
||
<li>
|
||
full dates — <Code>2026-01-15</Code>, <Code>2026-01-15T09:30:00Z</Code>
|
||
</li>
|
||
</ul>
|
||
<p>
|
||
The filter string is evaluated on the server. A parse error shows as inline text under the filter bar.
|
||
</p>
|
||
<p className="font-semibold text-text">Worked examples</p>
|
||
<ul className="list-disc pl-5">
|
||
<li>
|
||
Only lines mentioning "timeout" in the last 15 minutes:{' '}
|
||
<Code>{'regex("timeout") and ottyl_ts>-15m'}</Code>
|
||
</li>
|
||
<li>
|
||
Requests slower than 200ms, once a <Code>requests</Code> metric has a <Code>value</Code>-role{' '}
|
||
<Code>duration</Code> param declared: <Code>{'requests[duration]>200'}</Code>
|
||
</li>
|
||
<li>
|
||
5xx responses on one path, given a <Code>requests</Code> metric with a <Code>path</Code> label param —
|
||
note that <Code>[param]</Code> comparisons only work against a <em>declared metric's</em> labels, not a
|
||
bare param declared on its own, and values are unquoted (no surrounding <Code>"..."</Code>):{' '}
|
||
<Code>{'requests[path]==/checkout and regex("5[0-9]{2}")'}</Code>
|
||
</li>
|
||
<li>
|
||
Everything before noon on a specific date: <Code>{'ottyl_ts<2024-06-15T12:00:00Z'}</Code>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: 'auto-formats',
|
||
title: 'Auto-detected formats',
|
||
body: (
|
||
<div className="flex flex-col gap-2">
|
||
<p>
|
||
Every ingested line is classified into one of four formats, exposed as the reserved{' '}
|
||
<Code>ottyl_fmt</Code> param (see <em>Filtering</em> above). The format also decides what — if anything —
|
||
gets auto-declared as a param without you doing anything:
|
||
</p>
|
||
<ul className="list-disc pl-5">
|
||
<li>
|
||
<span className="font-semibold text-text">
|
||
<Code>log_stream</Code>
|
||
</span>{' '}
|
||
— macOS <Code>{'log stream'}</Code> output's fixed-column shape (timestamp, thread, type, activity id,
|
||
pid, ttl, then <Code>process: message</Code>). The first such line seen auto-declares{' '}
|
||
<Code>log_type</Code>, <Code>process</Code>, and <Code>pid</Code> as params, parsed straight out of those
|
||
columns.
|
||
</li>
|
||
<li>
|
||
<span className="font-semibold text-text">
|
||
<Code>json</Code>
|
||
</span>{' '}
|
||
— the line parses as a JSON object (a bare array or scalar doesn't count). Every leaf scalar field
|
||
(string/number/bool) auto-declares a param named after its dot-separated path, extracted by JSON field
|
||
path rather than regex — a nested field like <Code>{'{"request":{"path":"/x"}}'}</Code> becomes a param
|
||
named <Code>request.path</Code>. Nested objects are walked into automatically; arrays and <Code>null</Code>{' '}
|
||
fields are skipped since there's no single scalar value to declare a param against.
|
||
</li>
|
||
<li>
|
||
<span className="font-semibold text-text">
|
||
<Code>logfmt</Code>
|
||
</span>{' '}
|
||
— the line contains at least one <Code>key=value</Code>/<Code>{'key="value"'}</Code> pair anywhere in its
|
||
text (it doesn't need to be logfmt end-to-end — a free-text prefix or suffix around the pairs is fine).
|
||
Every distinct key auto-declares a param of the same name, type-inferred as <Code>number</Code> if its
|
||
first-seen value parses as one, else <Code>string</Code>.
|
||
</li>
|
||
<li>
|
||
<span className="font-semibold text-text">
|
||
<Code>none</Code>
|
||
</span>{' '}
|
||
— none of the above; plain prose with no structure ottyl can key off of. Nothing is auto-declared, though
|
||
the line is still fully searchable via <Code>regex(...)</Code> in the filter bar.
|
||
</li>
|
||
</ul>
|
||
<p>
|
||
Auto-declared params are flagged <Code>auto_derived</Code> in <Code>GET /api/params</Code> 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.
|
||
</p>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: 'parameters',
|
||
title: 'Parameters',
|
||
body: (
|
||
<div className="flex flex-col gap-2">
|
||
<p>
|
||
A parameter declaration derives a named value from a log line: a regex the message must match, a capture
|
||
group, and a type (<Code>string</Code>, <Code>number</Code>, or <Code>timestamp</Code>). Example:
|
||
</p>
|
||
<ul className="list-disc pl-5">
|
||
<li>
|
||
Name: <Code>duration</Code>
|
||
</li>
|
||
<li>
|
||
Regex: <Code>{'duration=(?P<duration>\\d+)ms'}</Code>
|
||
</li>
|
||
<li>
|
||
Capture group: <Code>duration</Code>
|
||
</li>
|
||
<li>
|
||
Type: <Code>number</Code>
|
||
</li>
|
||
</ul>
|
||
<p>
|
||
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.{' '}
|
||
<Code>requests[duration]>100</Code>), declare it as a param on a metric instead — see the Metrics
|
||
section below.
|
||
</p>
|
||
<p className="font-semibold text-text">Worked example</p>
|
||
<p>
|
||
Given lines like <Code>{'request path=/checkout method=GET duration=182'}</Code>, declaring a param named{' '}
|
||
<Code>duration</Code> with regex <Code>{String.raw`duration=(?P<duration>\d+)`}</Code>, capture group{' '}
|
||
<Code>duration</Code>, and type <Code>number</Code> makes <Code>duration</Code> 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 <em>Filtering</em> above — bare params aren't queryable by name on their own, only metric
|
||
labels/values are).
|
||
</p>
|
||
<p>
|
||
Given the same kind of lines, declaring a param named <Code>IsError</Code> with regex{' '}
|
||
<Code>{String.raw`(?i)error`}</Code> and type <Code>boolean</Code> — no capture group needed — shows{' '}
|
||
<Code>IsError</Code> as <Code>true</Code> or <Code>false</Code> on every line in the log table, based on
|
||
whether the regex matches that line's text case-insensitively. Unlike <Code>string</Code>/<Code>number</Code>
|
||
/<Code>timestamp</Code>/<Code>duration</Code> params, a <Code>boolean</Code> param is never omitted for a
|
||
non-matching line.
|
||
</p>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: 'timeline',
|
||
title: 'Timeline',
|
||
body: (
|
||
<div className="flex flex-col gap-2">
|
||
<p>
|
||
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.
|
||
</p>
|
||
<p>
|
||
The preset buttons — <Code>All</Code>, <Code>1m</Code>, <Code>2m</Code>, <Code>5m</Code>, <Code>15m</Code>,{' '}
|
||
<Code>1h</Code>, <Code>24h</Code> — are quick-range shortcuts. <Code>All</Code> clears the range. Every
|
||
other preset is <em>rolling</em>: 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.
|
||
</p>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: 'log-preview',
|
||
title: 'Log preview',
|
||
body: (
|
||
<div className="flex flex-col gap-2">
|
||
<p>
|
||
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).
|
||
</p>
|
||
<p>
|
||
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.
|
||
</p>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: 'metrics',
|
||
title: 'Metrics',
|
||
body: (
|
||
<div className="flex flex-col gap-2">
|
||
<p>
|
||
Every metric is a <em>counter</em>: 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:
|
||
</p>
|
||
<ul className="list-disc pl-5">
|
||
<li>
|
||
Name: <Code>requests</Code>
|
||
</li>
|
||
<li>
|
||
Regex: <Code>{'request path=(?P<path>\\S+) duration=(?P<duration>\\d+)ms'}</Code>
|
||
</li>
|
||
<li>
|
||
Params: <Code>path</Code> (capture group <Code>path</Code>, type <Code>string</Code>, role{' '}
|
||
<Code>label</Code>), <Code>duration</Code> (capture group <Code>duration</Code>, type <Code>number</Code>
|
||
, role <Code>value</Code>)
|
||
</li>
|
||
</ul>
|
||
<p>
|
||
Each param is either a <strong>label</strong> (the default) or the metric's single <strong>value</strong>{' '}
|
||
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, <Code>duration</Code> 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 <Code>1</Code> (a pure occurrence count).
|
||
</p>
|
||
<p>
|
||
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.
|
||
</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. Dashboard panels query that same data via PromQL (see below), including two forms{' '}
|
||
<em>derived</em> from a counter's value param rather than stored separately:
|
||
</p>
|
||
<ul className="list-disc pl-5">
|
||
<li>
|
||
<span className="font-semibold text-text">
|
||
<Code>histogram(metric_name[width])</Code>
|
||
</span>{' '}
|
||
— buckets each matching event's value into fixed-width buckets of size <Code>width</Code>, e.g.{' '}
|
||
<Code>histogram(durations[10])</Code>.
|
||
</li>
|
||
<li>
|
||
<span className="font-semibold text-text">
|
||
<Code>gauge(metric_name)</Code>
|
||
</span>{' '}
|
||
— the most-recent value per label combination in the active range, i.e. the counter collapsed to its
|
||
latest reading.
|
||
</li>
|
||
</ul>
|
||
<p className="font-semibold text-text">Worked example</p>
|
||
<p>
|
||
To count requests per path and track their latency from lines like{' '}
|
||
<Code>{'request path=/checkout method=GET duration=182'}</Code>, declare a metric named{' '}
|
||
<Code>requests</Code> with message-match regex{' '}
|
||
<Code>{String.raw`request path=(?P<path>\S+) method=(?P<method>\S+) duration=(?P<duration>\d+)`}</Code>{' '}
|
||
and two params: <Code>path</Code> (capture group <Code>path</Code>, kind <Code>string</Code>, role{' '}
|
||
<Code>label</Code>) and <Code>duration</Code> (capture group <Code>duration</Code>, kind{' '}
|
||
<Code>number</Code>, role <Code>value</Code> — at most one param can be <Code>value</Code>). Selecting{' '}
|
||
<Code>requests</Code> in the panel then shows one row per distinct <Code>path</Code> with its occurrence
|
||
count and a trend sparkline. The same declaration also unlocks{' '}
|
||
<Code>{'histogram(requests[50])'}</Code> (duration distribution in 50ms-wide buckets) and{' '}
|
||
<Code>gauge(requests)</Code> (each path's most recent duration) as dashboard panel queries.
|
||
</p>
|
||
<p>
|
||
A param of kind <Code>duration</Code> marked <Code>value</Code> 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.
|
||
</p>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: 'dashboards',
|
||
title: 'Dashboards',
|
||
body: (
|
||
<div className="flex flex-col gap-2">
|
||
<p>
|
||
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.
|
||
</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 "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 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.
|
||
</p>
|
||
<p>
|
||
The reserved <Code>ottyl_seq</Code>/<Code>ottyl_ts</Code> names (see <em>Filtering</em> above) work in panel
|
||
queries the same as any declared metric — e.g. <Code>count(ottyl_seq)</Code> for log volume, or{' '}
|
||
<Code>rate(ottyl_ts[1m])</Code> for ingest rate — since every log line carries them without a metric
|
||
declaration. <Code>ottyl_fmt</Code> is categorical (<Code>json</Code>/<Code>logfmt</Code>/<Code>none</Code>
|
||
), 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.
|
||
</p>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: 'alerts',
|
||
title: 'Alerts',
|
||
body: (
|
||
<div className="flex flex-col gap-2">
|
||
<p>
|
||
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:
|
||
</p>
|
||
<ul className="list-disc pl-5">
|
||
<li>
|
||
<span className="font-semibold text-text">Immediate</span> — fires once per matching line. Example query:{' '}
|
||
<Code>{'regex("panic")'}</Code>.
|
||
</li>
|
||
<li>
|
||
<span className="font-semibold text-text">Threshold</span> — fires when a query's match count within a
|
||
rolling window crosses a numeric threshold. Example: query <Code>{'regex("error")'}</Code>, op{' '}
|
||
<Code>>=</Code>, threshold <Code>10</Code>, window <Code>1m</Code>.
|
||
</li>
|
||
<li>
|
||
<span className="font-semibold text-text">NewValue</span> — fires the first time a field regex captures a
|
||
value not seen before by this rule. Example: field regex{' '}
|
||
<Code>{'user_id=(?P<id>\\d+)'}</Code>, capture group <Code>id</Code>. Seen values live only in memory, so
|
||
a restart forgets them.
|
||
</li>
|
||
<li>
|
||
<span className="font-semibold text-text">Ratio</span> — fires when the ratio of two queries' match
|
||
counts within a rolling window crosses a numeric ratio. Example: query A{' '}
|
||
<Code>{'regex("error")'}</Code>, query B <Code>{'regex("request")'}</Code>, op <Code>>=</Code>, ratio{' '}
|
||
<Code>0.1</Code>, window <Code>5m</Code>.
|
||
</li>
|
||
</ul>
|
||
<p>
|
||
Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's syntax, restricted to{' '}
|
||
<Code>regex(...)</Code>, <Code>ottyl_seq</Code>, <Code>ottyl_ts</Code>, <Code>ottyl_fmt</Code>, and{' '}
|
||
<Code>and</Code>/<Code>or</Code>. A <Code>metric_name[param]</Code> comparison isn't supported in an alert
|
||
query.
|
||
</p>
|
||
<p>
|
||
A window accepts a plain number of milliseconds or a humantime-style duration: <Code>30s</Code>,{' '}
|
||
<Code>5m</Code>, <Code>1h</Code>.
|
||
</p>
|
||
<p>
|
||
Every rule delivers to the single ntfy sink configured in Settings: a topic and an optional server override
|
||
(default <Code>https://ntfy.sh</Code>). Configure the sink and send a test notification from the Settings
|
||
panel — the Alerts panel only defines rule conditions.
|
||
</p>
|
||
<p className="font-semibold text-text">Worked example: build a "requests" dashboard</p>
|
||
<p>
|
||
Assuming a <Code>requests</Code> metric declared as in the <em>Metrics</em> example above (a <Code>path</Code>{' '}
|
||
label and a <Code>duration</Code> value param), a useful dashboard is three panels:
|
||
</p>
|
||
<ul className="list-disc pl-5">
|
||
<li>
|
||
<span className="font-semibold text-text">Line</span>, query <Code>requests</Code> — one line per path,
|
||
showing every request occurrence over time.
|
||
</li>
|
||
<li>
|
||
<span className="font-semibold text-text">Bar</span>, query <Code>gauge(requests)</Code> — each path's
|
||
most recent request duration side by side, a quick "who's slow right now" snapshot.
|
||
</li>
|
||
<li>
|
||
<span className="font-semibold text-text">Pie</span>, query{' '}
|
||
<Code>{'requests{path="/checkout"}'}</Code>{' '}
|
||
— label matchers filter a query down to one series; here, just the checkout path's share of its own
|
||
occurrences over the range (mostly useful once more matchers are added to compare paths against each
|
||
other).
|
||
</li>
|
||
</ul>
|
||
<p>
|
||
Other query shapes worth knowing: <Code>{'sum(requests)'}</Code> collapses every path into one combined
|
||
count-over-time series, and <Code>{'rate(requests[5m])'}</Code> 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 <Code>histogram()</Code> missing its{' '}
|
||
<Code>[width]</Code>) 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."
|
||
</p>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: 'settings',
|
||
title: 'Settings',
|
||
body: (
|
||
<div className="flex flex-col gap-2">
|
||
<p>
|
||
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.
|
||
</p>
|
||
<p className="font-semibold text-text">Worked example</p>
|
||
<p>
|
||
Ingesting a very chatty process and only care about the last hour of activity: set{' '}
|
||
<em>max logs in memory</em> to something like <Code>50000</Code> 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.
|
||
</p>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: 'presets',
|
||
title: 'Presets',
|
||
body: (
|
||
<div className="flex flex-col gap-2">
|
||
<p>
|
||
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 <Code>+</Code> button prompts for a
|
||
name and creates a new profile as a copy of the active one, then switches to it. The <Code>×</Code> button
|
||
deletes the active profile; it's disabled for <Code>Default</Code>, which can't be deleted. See{' '}
|
||
<em>General idea</em> above for what a profile stores.
|
||
</p>
|
||
</div>
|
||
),
|
||
},
|
||
]
|
||
|
||
const FLASH_DURATION_MS = 1200
|
||
|
||
export function DocsPanel({ target }: { target?: DocsTarget | null }) {
|
||
const [openId, setOpenId] = useState<string | null>(SECTIONS[0].id)
|
||
const [flashId, setFlashId] = useState<string | null>(null)
|
||
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||
|
||
// 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 (
|
||
<div className="flex flex-col gap-1 text-sm text-text-secondary">
|
||
{SECTIONS.map((section) => {
|
||
const isOpen = openId === section.id
|
||
const isFlashing = flashId === section.id
|
||
return (
|
||
<div
|
||
key={section.id}
|
||
ref={(el) => {
|
||
sectionRefs.current[section.id] = el
|
||
}}
|
||
className={`rounded border transition-colors duration-500 ${
|
||
isFlashing ? 'border-accent bg-accent-soft/25' : '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>
|
||
)
|
||
}
|