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:
co-authored by
Claude Sonnet 5
parent
3cef6dc8ae
commit
00c21c8032
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user