Trim comments across Rust and webapp source to direct one-liners (ticket 030)

Pass over src/*.rs, webapp/src/**, and the in-app "?" docs prose:
condensed multi-paragraph doc comments to single lines that state the
non-obvious why, deleted comments that only restated adjacent code, and
tightened the Docs.tsx help-panel wording. No logic, structure, or code
formatting changed — verified via cargo test (143 passed) and
tsc -b && vite build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 17:02:33 +01:00
co-authored by Claude Sonnet 5
parent 3cef6dc8ae
commit 00c21c8032
32 changed files with 347 additions and 1550 deletions
+1 -5
View File
@@ -24,9 +24,7 @@ const OPS: { value: CompareOp; label: string }[] = [
{ value: 'ne', label: '!=' },
]
/// Parses a plain millisecond number or a humantime-style duration like
/// `30s`/`5m`/`1h` into milliseconds. Mirrors the backend's `window_ms`
/// field without pulling in a full humantime parser for the webapp.
/// Parses a plain millisecond number or a humantime-style duration (`30s`/`5m`/`1h`) to mirror the backend's `window_ms` without a full humantime parser here.
function parseWindowMs(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) return null
@@ -119,8 +117,6 @@ function formFromRule(rule: AlertRule): FormState {
return base
}
/// Validates the form's condition-specific fields, returning an error
/// message (or null if valid) plus the built `AlertCondition` when valid.
function buildCondition(form: FormState): { error: string | null; condition: AlertCondition | null } {
switch (form.kind) {
case 'immediate':
+4 -14
View File
@@ -1,10 +1,7 @@
import { useState } from 'react'
import type { TimeRange } from './LogTimeline'
/// localStorage key for which of the four primary sections (log viewer,
/// params, metrics, dashboards) are collapsed (ticket 035). Mirrors
/// LogPreview's VISIBLE_PARAM_COLUMNS_STORAGE_KEY pattern — one key holding
/// a record of section id -> collapsed.
/// localStorage key holding a record of section id -> collapsed, mirroring LogPreview's VISIBLE_PARAM_COLUMNS_STORAGE_KEY pattern.
const COLLAPSED_SECTIONS_STORAGE_KEY = 'ottyl.collapsedSections'
function loadCollapsedSections(): Record<string, boolean> {
@@ -27,10 +24,7 @@ function saveCollapsedSections(sections: Record<string, boolean>) {
}
}
/// Persists a section's collapsed state across reloads. Deliberately
/// returns only the boolean + setter — callers keep rendering their
/// subtree, just hide it with CSS (`hidden`), so collapsing never unmounts
/// in-progress form state.
/// Returns only the boolean + setter, not a conditional-render helper — callers keep rendering their subtree and hide it with CSS (`hidden`), so collapsing never unmounts in-progress form state.
export function useSectionCollapsed(sectionId: string): [boolean, (next: boolean) => void] {
const [collapsed, setCollapsedState] = useState(() => loadCollapsedSections()[sectionId] ?? false)
@@ -59,8 +53,7 @@ export function CollapseToggle({ collapsed, onToggle }: { collapsed: boolean; on
)
}
/// The four primary sections that can be popped out into their own tab
/// (ticket 036) — matches the `?section=` query param a standalone page reads.
/// The four primary sections poppable into their own tab; matches the `?section=` query param a standalone page reads.
export type PopOutSectionId = 'log' | 'params' | 'metrics' | 'dashboards'
function popOutUrl(section: PopOutSectionId, range?: TimeRange | null): string {
@@ -72,10 +65,7 @@ function popOutUrl(section: PopOutSectionId, range?: TimeRange | null): string {
return `?${query.toString()}`
}
/// Opens this section, full-page and with no surrounding chrome, in a new
/// tab. `range` is only needed for sections without their own LogTimeline —
/// the popped-out page has no way to pick a range itself, so the current one
/// is carried over via query params (see `App.tsx`'s standalone rendering).
/// `range` is only needed for sections without their own LogTimeline — the popped-out page can't pick a range itself, so the current one is carried over via query params.
export function PopOutButton({ section, range }: { section: PopOutSectionId; range?: TimeRange | null }) {
return (
<button
+9 -30
View File
@@ -19,31 +19,22 @@ import { HelpIcon } from './HelpIcon'
const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie']
/// How long to wait after the last edit before autosaving (ticket 027) — long
/// enough that rapid typing in a title/query field doesn't spam the save
/// endpoint on every keystroke, short enough that a pause reads as "done for
/// now" and commits promptly.
/// Long enough that rapid typing doesn't spam the save endpoint, short enough that a pause reads as "done" and commits promptly.
const AUTOSAVE_DEBOUNCE_MS = 600
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
/// What actually gets persisted for a dashboard — used both to snapshot "last
/// saved" state and to build the `saveDashboard` request body.
/// What actually gets persisted for a dashboard — used both to snapshot "last saved" state and to build the `saveDashboard` request body.
function dashboardSaveShape(dashboard: Dashboard) {
return { name: dashboard.name, panels: dashboard.panels }
}
function newPanelId(): string {
// Client-side scratch id for a not-yet-saved panel; the backend treats
// panel ids as opaque strings, so this only needs to be unique within one
// dashboard's editing session.
// Client-side scratch id for a not-yet-saved panel; only needs to be unique within one editing session since the backend treats panel ids as opaque strings.
return `panel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
/// One panel's live preview: fetches its PromQL series whenever the query,
/// chart type, or active time range changes, and renders either the chart, a
/// loading state, or an inline error (a malformed query must not crash the
/// page — see ticket 014's acceptance criteria).
/// Fetches the panel's PromQL series on query/chart-type/range change; a malformed query must render as an inline error rather than crash the page.
function PanelPreview({ panel, range }: { panel: Panel; range: TimeRange | null }) {
const [series, setSeries] = useState<PromqlSeries[]>([])
const [error, setError] = useState<string | null>(null)
@@ -85,8 +76,6 @@ function PanelPreview({ panel, range }: { panel: Panel; range: TimeRange | null
return <PanelChart series={series} chartType={panel.chart_type} />
}
/// Unobtrusive inline autosave indicator (ticket 027) — no blocking dialog,
/// just small text near the dashboard header.
function AutosaveStatus({ status, error }: { status: SaveStatus; error: string | null }) {
if (status === 'saving') return <span className="text-xs text-text-faint">Saving…</span>
if (status === 'saved') return <span className="text-xs text-text-faint">Saved</span>
@@ -109,10 +98,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
const [error, setError] = useState<string | null>(null)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [saveError, setSaveError] = useState<string | null>(null)
// JSON snapshot of the last-persisted { name, panels } shape for the
// current draft — lets the autosave effect skip scheduling a save when
// nothing actually changed (e.g. re-selecting the same dashboard, or the
// re-render that follows a successful save itself).
// Last-persisted shape for the current draft, so the autosave effect can skip when nothing actually changed.
const lastSavedRef = useRef<string | null>(null)
const [collapsed, setCollapsed] = useSectionCollapsed('dashboards')
@@ -138,9 +124,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
}
})
.catch(() => setActiveProfile(null))
// Only load once on mount; switching the active profile elsewhere in the
// app (NavMenu) requires a reload for now, same as other panels don't
// watch for profile changes mid-session.
// Only load once on mount; switching profiles elsewhere (NavMenu) requires a reload for now.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -150,10 +134,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
lastSavedRef.current = found ? JSON.stringify(dashboardSaveShape(found)) : null
}, [selectedId, dashboards])
// Autosave (ticket 027): debounces edits so rapid typing doesn't spam the
// save endpoint, and skips entirely once the draft matches what's already
// persisted (covers both re-selecting an unchanged dashboard and the
// re-render that follows this effect's own successful save).
// Skips entirely once the draft matches what's already persisted — covers both re-selecting an unchanged dashboard and this effect's own post-save re-render.
useEffect(() => {
if (!draft || !activeProfile) return
const snapshot = JSON.stringify(dashboardSaveShape(draft))
@@ -170,8 +151,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
setSaveStatus('saved')
setSaveError(null)
} catch (err) {
// Deliberately leave `draft` as-is on failure — the in-progress edit
// isn't lost, it just hasn't persisted yet.
// Deliberately leave `draft` as-is on failure — the in-progress edit isn't lost, it just hasn't persisted yet.
setSaveStatus('error')
setSaveError(err instanceof ApiError ? err.message : 'Failed to save dashboard')
}
@@ -180,8 +160,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
return () => clearTimeout(timeout)
}, [draft, activeProfile])
// Settles a transient "Saved" status back to idle after a moment, so it
// reads as a confirmation rather than a permanent label.
// Settles a transient "Saved" status back to idle so it reads as a confirmation, not a permanent label.
useEffect(() => {
if (saveStatus !== 'saved') return
const timeout = setTimeout(() => setSaveStatus('idle'), 1500)
+65 -77
View File
@@ -1,9 +1,7 @@
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.
/// One accordion section: title plus body as plain JSX, avoiding a markdown renderer for these static pages.
interface DocSection {
id: string
title: string
@@ -21,16 +19,15 @@ const SECTIONS: DocSection[] = [
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.
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 while serving a dashboard from 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>.
Everything — log lines, params, metrics, and derived buckets — lives in one 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">
@@ -47,19 +44,17 @@ const SECTIONS: DocSection[] = [
</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.
There is one <em>settings</em> file per machine, holding the colour scheme, any imported/linked CSS, 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 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.
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 doesn't lose your setup. A <Code>Default</Code>{' '}
profile always exists and can't be deleted. Creating a new profile copies the active one (or starts empty
if none is active), so variations build on what you already have. Switching profiles hot-swaps the running
engine's declared params/metrics immediately, with no restart needed.
</p>
</div>
),
@@ -70,16 +65,16 @@ const SECTIONS: DocSection[] = [
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>:
The filter bar takes a 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.
<Code>{'metric_name[param]<5'}</Code> compares a metric's captured param against a number, string, or
timestamp. <Code>sequence</Code> and <Code>timestamp</Code> are reserved metric names present on every log
line, so they're always filterable without declaring anything.
</p>
<p className="font-semibold text-text">Date/time formats</p>
<p>Timestamp comparisons accept several forms:</p>
@@ -91,9 +86,9 @@ const SECTIONS: DocSection[] = [
<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.
The same filter string goes to both <Code>GET /api/logs</Code> and <Code>GET /api/timeline</Code> as the{' '}
<Code>filter</Code> query param and is evaluated on the server; a parse error surfaces as inline text under
the filter bar rather than crashing the page.
</p>
</div>
),
@@ -104,21 +99,20 @@ const SECTIONS: DocSection[] = [
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.
A parameter declaration tells ottyl how to derive 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>).
Declaring one retroactively indexes every already-ingested line and checks every new line 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.
Params are the building blocks metrics are made of (a metric is really a message-match regex plus a set of
param matchers), and they drive filtering and the log preview's column picker — declaring a{' '}
<Code>duration</Code> param, for instance, makes it available as both a log-table column and a comparable
value in the filter bar.
</p>
<p>
The Params panel declares a bare param directly (<Code>POST /api/params</Code>) — the same request the
Metrics panel's declaration form makes per param matcher. Declaring one there immediately updates the log
preview's column picker, with no page reload needed.
Metrics panel's declaration form makes per param matcher. Declaring one there updates the log preview's
column picker immediately, with no page reload needed.
</p>
</div>
),
@@ -130,14 +124,14 @@ const SECTIONS: DocSection[] = [
<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 time range — it narrows the log table, metrics, and dashboards below to that window.
select a fixed range, narrowing the log table, metrics, and dashboards below to that window.
</p>
<p>
The preset buttons (<Code>All</Code>, <Code>5m</Code>, <Code>15m</Code>, <Code>1h</Code>,{' '}
<Code>24h</Code>) are Grafana-style shortcuts for the same thing: <Code>All</Code> clears the range
entirely, and a relative preset is <em>rolling</em> — 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.
<Code>24h</Code>) are Grafana-style shortcuts: <Code>All</Code> clears the range entirely, and a relative
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 (non-rolling) range, same
as a normal drag.
</p>
</div>
),
@@ -149,8 +143,8 @@ const SECTIONS: DocSection[] = [
<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 for it (or a
blank if it didn't match) — a quick way to eyeball params without switching to the Metrics panel.
column picker (top-right) adds a column per declared param, showing that line's derived value (blank if it
didn't match) — a quick way to eyeball params without switching to the Metrics panel.
</p>
</div>
),
@@ -167,20 +161,19 @@ const SECTIONS: DocSection[] = [
</p>
<p>
Each param is either a <strong>label</strong> (the default) or the metric's single <strong>value</strong>{' '}
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 <Code>duration</Code> param marked{' '}
<em>value</em> 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 <Code>1</Code> (a pure occurrence count).
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 — e.g. a <Code>duration</Code> param marked <em>value</em>{' '}
lets you query the distribution or latest reading, not just how often it 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 continues indexing new lines as
they arrive — there's no need to restart ingestion or re-declare after the fact.
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. That same data is what dashboard panels query via PromQL (see below), including two forms{' '}
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">
@@ -188,7 +181,7 @@ const SECTIONS: DocSection[] = [
<span className="font-semibold text-text">
<Code>histogram(metric_name[width])</Code>
</span>{' '}
— buckets every matching event's value into fixed-width buckets of size <Code>width</Code>, e.g.{' '}
— buckets each matching event's value into fixed-width buckets of size <Code>width</Code>, e.g.{' '}
<Code>histogram(durations[10])</Code>.
</li>
<li>
@@ -208,9 +201,9 @@ const SECTIONS: DocSection[] = [
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.
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>
@@ -218,8 +211,7 @@ const SECTIONS: DocSection[] = [
x-axis.
</li>
<li>
<span className="font-semibold text-text">Bar</span> — the latest value per series (a snapshot of "where
things are now").
<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
@@ -227,8 +219,8 @@ const SECTIONS: DocSection[] = [
</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.
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>
</div>
),
@@ -239,12 +231,12 @@ const SECTIONS: DocSection[] = [
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, not backfilled against history. There are four condition types:
An alert rule fires a notification when its condition matches a newly-ingested log line, evaluated per line
as it arrives rather than backfilled against history. There are four condition types:
</p>
<ul className="list-disc pl-5">
<li>
<span className="font-semibold text-text">Immediate</span> — fires once per line matching a query.
<span className="font-semibold text-text">Immediate</span> — fires once per matching line.
</li>
<li>
<span className="font-semibold text-text">Threshold</span> — fires when a query's match count within a
@@ -260,8 +252,8 @@ const SECTIONS: DocSection[] = [
</li>
</ul>
<p>
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 — <Code>regex(...)</Code>, <Code>sequence</Code>,{' '}
Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's syntax, but only the subset that
evaluates a single line's own fields — <Code>regex(...)</Code>, <Code>sequence</Code>,{' '}
<Code>timestamp</Code>, and <Code>and</Code>/<Code>or</Code>. A <Code>metric_name[param]</Code> comparison
isn't supported in an alert query.
</p>
@@ -270,10 +262,9 @@ const SECTIONS: DocSection[] = [
<Code>5m</Code>, <Code>1h</Code>.
</p>
<p>
Every rule delivers to an <em>ntfy</em> sink: a topic (required) and an optional server override (defaults
to <Code>https://ntfy.sh</Code>). "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.
Every rule delivers to an <em>ntfy</em> sink: a topic (required) and an optional server override (default{' '}
<Code>https://ntfy.sh</Code>). "Send test notification" delivers to the form's current topic/server without
needing to save the rule first.
</p>
</div>
),
@@ -284,9 +275,9 @@ const SECTIONS: DocSection[] = [
body: (
<div className="flex flex-col gap-2">
<p>
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.
The Settings panel controls process-wide state: colour scheme, the max number of logs kept in memory
(blank means unlimited), and imported/linked CSS. Changes apply immediately and persist to the settings
file on disk — no save button needed.
</p>
</div>
),
@@ -298,15 +289,14 @@ const SECTIONS: DocSection[] = [
<div className="flex flex-col gap-2">
<p>
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 <em>General idea</em>{' '}
section above for what a profile stores.
params/metrics in the running engine immediately, no restart needed — see <em>General idea</em> above for
what a profile stores.
</p>
</div>
),
},
]
/** How long a flashed section stays highlighted before settling back to normal. */
const FLASH_DURATION_MS = 1200
export function DocsPanel({ target }: { target?: DocsTarget | null }) {
@@ -314,9 +304,7 @@ export function DocsPanel({ target }: { target?: DocsTarget | null }) {
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 in a row re-triggers the flash rather than being a no-op
// because the panel is already open to that section.
// 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)
+2 -10
View File
@@ -2,19 +2,11 @@ import type { ReactNode } from 'react'
import { useState } from 'react'
import { HelpIcon } from './HelpIcon'
/// Shared styling for a single FilterBar-syntax query input, reused by
/// `Alerts.tsx`'s condition editor so query-based alert conditions look like
/// the top-level filter bar without dragging in its own draft/submit state.
// Reused by `Alerts.tsx`'s condition editor so alert conditions look like the filter bar without duplicating its draft/submit state.
export const queryInputClassName =
'flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none'
/**
* Input for the syntax-aware pest-parsed filter language described in the
* README (regex + metric comparisons combined with `and`/`or`). The raw
* filter string is sent to the server (`GET /api/logs` / `GET /api/timeline`
* `filter` param) and evaluated there; this component just manages the
* draft text and surfaces any parse error the server reports back.
*/
// The filter string is parsed and evaluated server-side (`filter` param on `GET /api/logs`/`GET /api/timeline`); this just manages draft text and surfaces the server's parse error.
export function FilterBar({
value,
onChange,
+1 -5
View File
@@ -1,10 +1,6 @@
import { useDocsNav } from '../lib/docsNav'
/**
* A small "?" affordance that opens the Docs panel scrolled to (and
* flashing) a specific section (ticket 021), instead of the user having to
* find it themselves in the Docs accordion.
*/
// Opens the Docs panel scrolled to (and flashing) sectionId, instead of the user hunting for it in the Docs accordion (ticket 021).
export function HelpIcon({ sectionId, label }: { sectionId: string; label?: string }) {
const { openSection } = useDocsNav()
return (
+3 -9
View File
@@ -2,10 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { metricPrimitiveToDisplayString, type LogEntry, type ParamDeclaration } from '../lib/api'
import { HelpIcon } from './HelpIcon'
/// localStorage key for LogPreview's selected param columns. Namespaced
/// under `ottyl.` since this is the app's first localStorage usage — kept
/// distinct enough that future per-viewer preferences elsewhere in the app
/// won't collide with it.
// Namespaced under `ottyl.` so future per-viewer preferences elsewhere in the app won't collide with this key.
const VISIBLE_PARAM_COLUMNS_STORAGE_KEY = 'ottyl.logPreview.visibleParamColumns'
function loadVisibleParamColumns(): string[] {
@@ -23,8 +20,7 @@ function saveVisibleParamColumns(columns: string[]) {
try {
localStorage.setItem(VISIBLE_PARAM_COLUMNS_STORAGE_KEY, JSON.stringify(columns))
} catch {
// best-effort — a private browsing mode or full storage just means the
// selection won't persist across reloads.
// best-effort — private browsing or full storage just means the selection won't persist across reloads
}
}
@@ -101,9 +97,7 @@ export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDe
})
}
// Only render columns for params that are both selected and still known to
// exist, so a stale localStorage entry for a since-removed param doesn't
// leave a dangling column.
// Filter out stale localStorage entries for since-removed params, so they don't leave a dangling column.
const columns = visibleParamColumns.filter((name) => paramNames.includes(name))
return (
+3 -10
View File
@@ -7,9 +7,7 @@ export interface TimeRange {
endMs: number
}
/// A Grafana-style relative time-range preset (ticket 024). `'all'` clears
/// the range (today's implicit default); the rest resolve to a window
/// ending "now" at the moment they're (re-)computed — see `computePresetRange`.
// `'all'` clears the range (the implicit default); the rest resolve to a window ending "now" at the moment they're (re-)computed — see `computePresetRange`.
export type TimeRangePresetId = 'all' | '5m' | '15m' | '1h' | '24h'
export const TIME_RANGE_PRESETS: { id: TimeRangePresetId; label: string; ms: number | null }[] = [
@@ -20,10 +18,7 @@ export const TIME_RANGE_PRESETS: { id: TimeRangePresetId; label: string; ms: num
{ id: '24h', label: '24h', ms: 24 * 60 * 60 * 1000 },
]
/// Resolves a preset to a concrete `{startMs, endMs}` window as of *now* —
/// callers re-invoke this on the same cadence they already poll on to keep a
/// rolling preset's window moving with time (see `App.tsx`), rather than
/// freezing it at selection time.
// Callers re-invoke this on their existing poll cadence (see `App.tsx`) to keep a rolling preset's window moving with time, rather than freezing it at selection time.
export function computePresetRange(id: TimeRangePresetId): TimeRange | null {
const preset = TIME_RANGE_PRESETS.find((p) => p.id === id)
if (!preset || preset.ms == null) return null
@@ -131,9 +126,7 @@ export function LogTimeline({
</button>
))}
</div>
{/* A dragged (custom, non-preset) selection — presets already show
their own active state above, so this only appears for the
"fixed range from a drag" case. */}
{/* Only shown for a dragged (non-preset) selection — presets already show their own active state above. */}
{range && activePreset === null && (
<button className="underline hover:text-text-secondary" onClick={() => onSelectPreset('all')}>
Clear selection
+3 -11
View File
@@ -15,9 +15,7 @@ import { HelpIcon } from './HelpIcon'
const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' }
/// One row in the "labels -> occurrence count" table for the selected
/// metric's bucket values over the active range. `values` keeps the raw
/// points (used for the sparkline-ish rendering) alongside the count.
// One row in the labels -> occurrence count table; `values` keeps the raw points for the sparkline rendering.
interface LabelGroup {
key: string
labels: Record<string, string>
@@ -52,9 +50,7 @@ function groupByLabels(values: BucketValue[]): LabelGroup[] {
return Array.from(groups.values()).sort((a, b) => b.count - a.count)
}
/// A crude text sparkline: buckets a group's values into a fixed number of
/// slots across the visible range and renders relative bar heights using
/// block characters. Not a real chart — that's ticket 014's job.
// A crude text sparkline using block characters — not a real chart, that's ticket 014's job.
const SPARK_CHARS = ' ▁▂▃▄▅▆▇█'
function sparkline(values: BucketValue[], range: TimeRange | null): string {
if (values.length === 0) return ''
@@ -98,11 +94,7 @@ export function Metrics({ range }: { range: TimeRange | null }) {
loadMetrics()
}, [])
// Refetch-on-change rather than continuous polling: bucket values only
// need to move when the selected metric or the active time range
// changes, and a declaration's backfill (ticket 003) is already reflected
// by the time `declareMetric`'s promise resolves, so there's no need for
// an interval here the way `App.tsx`'s log/timeline polling has.
// Refetch-on-change rather than continuous polling: bucket values only move when the selected metric or range changes, so no interval is needed here like `App.tsx`'s log/timeline polling.
useEffect(() => {
if (!selected) {
setBucketValues([])
+2 -6
View File
@@ -41,18 +41,14 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
setOpenPanel((current) => (current === name ? null : name))
}
// A "?" icon (ticket 021) always opens Docs to that section and flashes it,
// even if Docs is already open to a different section — hence forcing
// `openPanel` here rather than toggling.
// A "?" icon always opens Docs to its section and flashes it, even if Docs is already open elsewhere — hence forcing `openPanel` here rather than toggling.
useEffect(() => {
if (!docsNav.target) return
setOpenPanel('docs')
setDocsPanelTarget(docsNav.target)
}, [docsNav.target])
// Opening Docs the normal way (this button, not a "?" icon) behaves as
// before: no forced section, no flash — so any pending help-triggered
// target is cleared.
// Opening Docs via this button (not a "?" icon) has no forced section or flash, so clear any pending help-triggered target.
function handleDocsButtonClick() {
togglePanel('docs')
setDocsPanelTarget(null)
+4 -19
View File
@@ -15,24 +15,17 @@ import {
} from 'recharts'
import type { ChartType, PromqlSeries } from '../lib/api'
/// Renders a label combination as a short, stable string for use as a series
/// key/legend entry — e.g. `{"path":"/a"}` -> `path=/a`. A series with no
/// labels at all (a bare metric name with no matchers) renders as `value`.
/// Renders a label combination as a series key/legend string, e.g. `{"path":"/a"}` -> `path=/a`; no labels renders as `value`.
export function seriesLabel(labels: Record<string, string>): string {
const entries = Object.entries(labels)
if (entries.length === 0) return 'value'
return entries.map(([k, v]) => `${k}=${v}`).join(', ')
}
// Fixed, small categorical palette (rather than pulling in a colour-scale
// dependency) — cycles if there are more series than colours.
// Fixed, small categorical palette to avoid a colour-scale dependency; cycles if there are more series than colours.
const COLORS = ['#38bdf8', '#f472b6', '#a78bfa', '#4ade80', '#fbbf24', '#fb7185', '#2dd4bf', '#f97316']
/// A line chart with time on the x-axis, value on the y-axis, and one line
/// per label combination (series). Points across series are merged into one
/// row-per-timestamp dataset (recharts wants a single array of objects keyed
/// by series name) — timestamps that only one series has are left `undefined`
/// for the others, which recharts simply skips when drawing that line.
// Merges series into one row-per-timestamp dataset (recharts wants a single array keyed by series name); a timestamp missing from a series is left undefined there, which recharts skips when drawing that line.
function toLineData(series: PromqlSeries[]): { rows: Record<string, number | string>[]; keys: string[] } {
const keys = series.map((s) => seriesLabel(s.labels))
const byTimestamp = new Map<number, Record<string, number | string>>()
@@ -58,14 +51,7 @@ function formatTimestamp(ts: number): string {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
/// Bar/pie charts need a single scalar per series, but the input is a time
/// series — inherently lossy. Aggregation choices (documented here rather
/// than hidden in the code):
/// - bar: the *latest* point per series (a snapshot of "where things are
/// now" — the common Grafana single-stat-per-series bar use case).
/// - pie: the *sum* of all points per series, shown as a share of the total
/// across all series (proportion of total volume, the common Grafana pie
/// use case — e.g. "which path got the most requests over this range").
// Bar/pie need a single scalar per series from a time series: bar uses the latest point (current snapshot), pie uses the sum (share of total volume).
function latestValue(series: PromqlSeries): number {
if (series.points.length === 0) return 0
return series.points[series.points.length - 1][1]
@@ -133,7 +119,6 @@ export function PanelChart({ series, chartType }: { series: PromqlSeries[]; char
)
}
// Pie: share of total across label combinations (see aggregation note above).
const rows = series.map((s) => ({ name: seriesLabel(s.labels), value: sumValue(s) }))
const total = rows.reduce((sum, r) => sum + r.value, 0)
if (total === 0) {