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
+8
-28
@@ -22,11 +22,7 @@ import { DocsNavProvider } from './lib/docsNav'
|
||||
|
||||
const POLL_MS = 2000
|
||||
|
||||
/// Reads the standalone-page routing params written by `PopOutButton`
|
||||
/// (ticket 036) — a `section` id selects one of the four primary sections to
|
||||
/// render full-page with no nav/sibling chrome, and `startMs`/`endMs` carry
|
||||
/// over the range that was active at pop-out time for sections that have no
|
||||
/// `LogTimeline` of their own to pick one.
|
||||
/// Reads the routing params `PopOutButton` writes: `section` picks which primary section to render full-page, and `startMs`/`endMs` carry over the range for sections with no `LogTimeline` of their own to pick one.
|
||||
function parsePopOutQuery(): { section: PopOutSectionId | null; range: TimeRange | null } {
|
||||
const query = new URLSearchParams(window.location.search)
|
||||
const section = query.get('section') as PopOutSectionId | null
|
||||
@@ -46,9 +42,7 @@ function StandalonePage({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
/// Params has no lifted-state sibling to share `params`/`loadParams` with
|
||||
/// when popped out on its own, so it owns a minimal fetch of its own here
|
||||
/// rather than App's full `loadParams` machinery.
|
||||
/// Popped out on its own, Params has no lifted-state sibling to share `params`/`loadParams` with, so it owns a minimal fetch here instead.
|
||||
function StandaloneParams() {
|
||||
const [params, setParams] = useState<ParamDeclaration[]>([])
|
||||
|
||||
@@ -71,15 +65,12 @@ function StandaloneParams() {
|
||||
)
|
||||
}
|
||||
|
||||
/// Metrics/Dashboards already own their own data fetching — they only need
|
||||
/// the range that was active at pop-out time, carried over via query params.
|
||||
/// Metrics/Dashboards own their own data fetching — they only need the range carried over via query params.
|
||||
function StandaloneRanged({ section, range }: { section: 'metrics' | 'dashboards'; range: TimeRange | null }) {
|
||||
return <StandalonePage>{section === 'metrics' ? <Metrics range={range} /> : <Dashboards range={range} />}</StandalonePage>
|
||||
}
|
||||
|
||||
/// The log viewer has its own `LogTimeline` to pick a range, so it doesn't
|
||||
/// need one carried over — it just needs its own copy of App's log/timeline
|
||||
/// polling state, since none of that is shared with any sibling here.
|
||||
/// The log viewer has its own `LogTimeline`, so it needs its own copy of App's log/timeline polling state instead of a carried-over range.
|
||||
function StandaloneLog() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [params, setParams] = useState<ParamDeclaration[]>([])
|
||||
@@ -194,11 +185,7 @@ function FullApp() {
|
||||
const [filterError, setFilterError] = useState<string | null>(null)
|
||||
const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer')
|
||||
|
||||
// Read from inside the poll loop below without making it a dependency —
|
||||
// a rolling preset's own re-resolution (ticket 024) updates `range`, and
|
||||
// depending on `range` directly would restart the effect (and its
|
||||
// interval) every tick, turning "poll every POLL_MS" into "poll as fast as
|
||||
// React can re-render."
|
||||
// Read via ref inside the poll loop instead of depending on `range` directly, which would restart the interval every rolling-preset tick.
|
||||
const rangeRef = useRef(range)
|
||||
useEffect(() => {
|
||||
rangeRef.current = range
|
||||
@@ -221,9 +208,7 @@ function FullApp() {
|
||||
setRange(computePresetRange(id))
|
||||
}
|
||||
|
||||
// A drag on LogTimeline always yields a fixed (non-rolling) range,
|
||||
// overriding whatever preset was active — same as picking a preset
|
||||
// overrides a previous drag.
|
||||
// A drag always yields a fixed (non-rolling) range, overriding any active preset.
|
||||
function handleManualRangeSelect(next: TimeRange | null) {
|
||||
if (next === null) {
|
||||
selectPreset('all')
|
||||
@@ -237,8 +222,7 @@ function FullApp() {
|
||||
let cancelled = false
|
||||
|
||||
async function poll() {
|
||||
// Rolling presets "move with time": re-resolve on this same polling
|
||||
// cadence rather than freezing the window at selection time.
|
||||
// Rolling presets re-resolve on this same polling cadence instead of freezing the window at selection time.
|
||||
const effectiveRange =
|
||||
activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current
|
||||
if (activePreset && activePreset !== 'all') {
|
||||
@@ -273,11 +257,7 @@ function FullApp() {
|
||||
cancelled = true
|
||||
clearInterval(id)
|
||||
}
|
||||
// Intentionally excludes `range`: a manual drag (which does change
|
||||
// `activePreset` to null) still gets an immediate poll; only a second
|
||||
// drag made while already in fixed-range mode waits for the next
|
||||
// interval tick, which is an acceptable trade-off for not restarting
|
||||
// this effect (and its interval) every rolling-preset tick.
|
||||
// Intentionally excludes `range` to avoid restarting this interval on every rolling-preset tick.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filter, activePreset])
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+12
-34
@@ -1,7 +1,4 @@
|
||||
/// `MetricPrimitive` is a Rust enum with derived `Serialize`, so its JSON
|
||||
/// form is externally tagged, e.g. `{ "String": "info" }`, `{ "Number":
|
||||
/// { "Int": 5 } }`, `{ "Timestamp": 1710000000000 }`, `{ "Duration": 1500 }`
|
||||
/// (milliseconds, ticket 023).
|
||||
// Externally-tagged JSON matching the Rust `MetricPrimitive` enum's derived `Serialize`, e.g. `{ "Duration": 1500 }` (milliseconds).
|
||||
export type MetricPrimitive =
|
||||
| { String: string }
|
||||
| { Number: { Int: number } | { Float: number } }
|
||||
@@ -20,9 +17,7 @@ export interface LogEntry {
|
||||
sequence: number
|
||||
timestamp_ms: number
|
||||
text: string
|
||||
/// Derived param values (declared via `POST /api/params`) that matched
|
||||
/// this line's text, keyed by param name. A param with no match on this
|
||||
/// line is simply absent from the map.
|
||||
// A param with no match on this line is simply absent from the map.
|
||||
params: Record<string, MetricPrimitive>
|
||||
}
|
||||
|
||||
@@ -46,7 +41,6 @@ export interface Stats {
|
||||
last_timestamp_ms: number | null
|
||||
}
|
||||
|
||||
/** Whether a param contributes to a bucket's labels or is its captured value (ticket 022). */
|
||||
export type ParamRole = 'label' | 'value'
|
||||
|
||||
export interface MetricParamMatcher {
|
||||
@@ -61,7 +55,7 @@ export interface MetricDeclaration {
|
||||
name: string
|
||||
message_match_regex: string
|
||||
params: MetricParamMatcher[]
|
||||
/** Fixed labels applied on a match/non-match, on top of any captured params (ticket 025). */
|
||||
/** Fixed labels applied on a match/non-match, on top of any captured params. */
|
||||
matched_labels?: Record<string, string> | null
|
||||
unmatched_labels?: Record<string, string> | null
|
||||
}
|
||||
@@ -73,8 +67,7 @@ export interface BucketValue {
|
||||
value: MetricPrimitive
|
||||
}
|
||||
|
||||
/// Error shape returned by API handlers on a 4xx/5xx response:
|
||||
/// `{ "error": "..." }` (see `ErrorDto` on the backend).
|
||||
// Error shape returned by API handlers on a 4xx/5xx response (see `ErrorDto` on the backend).
|
||||
export interface ApiErrorBody {
|
||||
error: string
|
||||
}
|
||||
@@ -103,7 +96,7 @@ export interface Settings {
|
||||
active_profile: string | null
|
||||
/** Maximum ingested logs kept in memory; `null` means unlimited. */
|
||||
max_logs: number | null
|
||||
/** Id of the selected custom theme entry (ticket 033), or `null` if `color_scheme` alone applies. */
|
||||
/** Id of the selected custom theme entry, or `null` if `color_scheme` alone applies. */
|
||||
custom_theme_id: string | null
|
||||
}
|
||||
|
||||
@@ -114,7 +107,7 @@ export interface CssEntry {
|
||||
kind: CssEntryKind
|
||||
filename: string | null
|
||||
path_or_url: string | null
|
||||
/** Whether this entry is a selectable colour-scheme theme (ticket 033) rather than arbitrary CSS. */
|
||||
/** Whether this entry is a selectable colour-scheme theme rather than arbitrary CSS. */
|
||||
is_theme: boolean
|
||||
}
|
||||
|
||||
@@ -125,9 +118,7 @@ export interface ProfileSummary {
|
||||
dashboard_count: number
|
||||
}
|
||||
|
||||
/// One chart-ready time series returned by `GET /api/query` (ticket 013):
|
||||
/// a label combination plus its `(timestamp_ms, value)` points, sorted by
|
||||
/// timestamp. See `PromqlSeriesDto` on the backend.
|
||||
// One chart-ready time series from `GET /api/query`: a label combination plus its `(timestamp_ms, value)` points, sorted by timestamp.
|
||||
export interface PromqlSeries {
|
||||
labels: Record<string, string>
|
||||
points: [number, number][]
|
||||
@@ -148,20 +139,16 @@ export interface Dashboard {
|
||||
panels: Panel[]
|
||||
}
|
||||
|
||||
/// Comparison operator shared by `AlertCondition::Threshold`/`Ratio` and
|
||||
/// filter-query metric comparisons (see `CompareOp` on the backend).
|
||||
export type CompareOp = 'lt' | 'gt' | 'le' | 'ge' | 'eq' | 'ne'
|
||||
|
||||
/// What triggers an alert rule (ticket 017/018). Externally tagged on
|
||||
/// `kind`, matching `AlertCondition`'s `#[serde(tag = "kind")]`.
|
||||
// Externally tagged on `kind`, matching `AlertCondition`'s `#[serde(tag = "kind")]` on the backend.
|
||||
export type AlertCondition =
|
||||
| { kind: 'immediate'; query: string }
|
||||
| { kind: 'threshold'; query: string; op: CompareOp; threshold: number; window_ms: number }
|
||||
| { kind: 'newvalue'; field_regex: string; capture_group: string }
|
||||
| { kind: 'ratio'; query_a: string; query_b: string; op: CompareOp; ratio: number; window_ms: number }
|
||||
|
||||
/// Where a fired alert is delivered (ticket 019). Only one sink kind exists
|
||||
/// today, but this stays a tagged union to match the backend.
|
||||
// Only one sink kind exists today, but this stays a tagged union to match the backend.
|
||||
export type AlertSink = { kind: 'ntfy'; topic: string; server: string | null }
|
||||
|
||||
export interface AlertRule {
|
||||
@@ -189,11 +176,7 @@ async function getJson<T>(path: string): Promise<T> {
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
/// POSTs a JSON body to `path`. On a non-ok response, attempts to parse the
|
||||
/// body as `{ "error": "..." }` (the shape every declaration endpoint uses
|
||||
/// for validation failures) and throws an `ApiError` with that message so
|
||||
/// callers can surface it inline; falls back to a generic message if the
|
||||
/// body isn't in that shape.
|
||||
// Throws `ApiError` (not `Error`) with the parsed `{ "error": "..." }` message so callers can surface it inline.
|
||||
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
@@ -357,10 +340,7 @@ export function activateProfile(name: string): Promise<Settings> {
|
||||
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST')
|
||||
}
|
||||
|
||||
/// Evaluates a PromQL query (ticket 013) via `GET /api/query`. Throws on a
|
||||
/// malformed query (`400` with `{ "error": "..." }`), so callers should catch
|
||||
/// and surface the message inline (e.g. per-panel) rather than letting it
|
||||
/// crash the page.
|
||||
// Throws on a malformed query (400); callers should catch and surface the message inline rather than letting it crash the page.
|
||||
export function fetchPromqlSeries(params: {
|
||||
promql: string
|
||||
startMs?: number
|
||||
@@ -419,9 +399,7 @@ export function deleteAlertRule(id: string): Promise<void> {
|
||||
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'DELETE')
|
||||
}
|
||||
|
||||
/// Delivers a test notification to `topic`/`server` (ticket 020) without
|
||||
/// requiring the rule to be saved first. Throws `ApiError` on failure (e.g.
|
||||
/// ntfy unreachable) so callers can surface it inline.
|
||||
// Delivers a test notification without requiring the rule to be saved first; throws `ApiError` on failure so callers can surface it inline.
|
||||
export function sendTestAlertNotification(topic: string, server: string | null): Promise<void> {
|
||||
return sendJson('/api/settings/alerts/test', 'POST', { topic, server })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
|
||||
/** One request to open the Docs panel scrolled to a specific section (ticket 021). */
|
||||
// One request to open the Docs panel scrolled to a specific section.
|
||||
export interface DocsTarget {
|
||||
sectionId: string
|
||||
/** Bumped on every request, even to the same section, so a repeat click re-triggers the flash. */
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
/**
|
||||
* Downloadable/re-uploadable custom themes (ticket 033).
|
||||
*
|
||||
* A theme file is a small CSS file containing exactly one `:root { --c-*:
|
||||
* ...; }` block defining every variable in `THEME_CSS_VARS` (the full set
|
||||
* `index.css`'s `:root`/`html.<scheme>` blocks use to skin the app). Using
|
||||
* `:root` (rather than a scoped selector) keeps the file trivially portable:
|
||||
* the same content works whether it's applied at `:root` or scoped, and
|
||||
* round-tripping a downloaded file back through upload reproduces it
|
||||
* byte-for-byte-equivalent (same variable values).
|
||||
*
|
||||
* Applying a selected custom theme is left to `useSettingsPanel`, which
|
||||
* injects the entry's CSS as an unscoped stylesheet *only* while no built-in
|
||||
* `html.<scheme>` class is present — since both forms target `:root` with
|
||||
* equal specificity, the later stylesheet in the cascade wins, so no
|
||||
* rewriting of the file's own `:root` selector is needed.
|
||||
*/
|
||||
// A theme file is a CSS file with one `:root { --c-*: ...; }` block defining every variable in `THEME_CSS_VARS`; `useSettingsPanel` applies the selected one as an unscoped stylesheet only while no built-in `html.<scheme>` class is present, so it wins the cascade without rewriting the selector.
|
||||
|
||||
/** Canonical list of every `--c-*` variable every theme in `index.css` defines. */
|
||||
export const THEME_CSS_VARS = [
|
||||
@@ -42,16 +26,7 @@ export const THEME_CSS_VARS = [
|
||||
|
||||
export type ThemeVars = Record<(typeof THEME_CSS_VARS)[number], string>
|
||||
|
||||
/**
|
||||
* Built-in themes' fully-resolved variable values, matching `index.css`'s
|
||||
* current cascade (a theme that doesn't override `--c-danger-*` there falls
|
||||
* back to `:root`'s dark values there, so those are inlined here too — a
|
||||
* downloaded built-in file is always complete, never relies on a `:root`
|
||||
* fallback the download mechanism doesn't have access to).
|
||||
*
|
||||
* Keep in sync with `webapp/src/index.css` by hand; there's no built step
|
||||
* that derives one from the other.
|
||||
*/
|
||||
// Fully-resolved variable values for each built-in theme, including inherited fallbacks, so a downloaded file is always complete; keep in sync with `index.css` by hand, there's no build step linking the two.
|
||||
export const BUILTIN_THEMES: Record<string, ThemeVars> = {
|
||||
dark: {
|
||||
'--c-surface-0': '#0a0a0a',
|
||||
@@ -253,7 +228,6 @@ export const BUILTIN_THEMES: Record<string, ThemeVars> = {
|
||||
},
|
||||
}
|
||||
|
||||
/** Generates a downloadable theme CSS file's content from a full variable map. */
|
||||
export function generateThemeCss(vars: ThemeVars): string {
|
||||
const lines = THEME_CSS_VARS.map((name) => ` ${name}: ${vars[name]};`)
|
||||
return `:root {\n${lines.join('\n')}\n}\n`
|
||||
@@ -264,14 +238,7 @@ export interface ParsedTheme {
|
||||
missing: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a theme CSS file's content, pulling `--c-*: value;` declarations
|
||||
* out of its first `:root { ... }` block (or, failing that, out of the
|
||||
* whole file — tolerant of a hand-edited file that dropped the selector).
|
||||
* Returns every recognized variable found plus the list of canonical
|
||||
* variables (see `THEME_CSS_VARS`) that were missing, so callers can reject
|
||||
* an incomplete upload with a clear error.
|
||||
*/
|
||||
// Parses `--c-*: value;` declarations from the first `:root { ... }` block (or the whole file, tolerant of a dropped selector); reports missing canonical variables so callers can reject an incomplete upload.
|
||||
export function parseThemeCss(content: string): ParsedTheme {
|
||||
const rootMatch = content.match(/:root\s*\{([^}]*)\}/)
|
||||
const body = rootMatch ? rootMatch[1] : content
|
||||
@@ -286,7 +253,6 @@ export function parseThemeCss(content: string): ParsedTheme {
|
||||
return { vars, missing }
|
||||
}
|
||||
|
||||
/** Triggers a browser download of `content` as `filename` (a `.css` file). */
|
||||
export function downloadThemeFile(filename: string, content: string) {
|
||||
const blob = new Blob([content], { type: 'text/css' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
@@ -17,29 +17,16 @@ import {
|
||||
type Settings,
|
||||
} from './api'
|
||||
|
||||
/** `data-*` marker used to find/remove previously-injected CSS tags on re-apply. */
|
||||
// `data-*` marker used to find/remove previously-injected CSS tags on re-apply.
|
||||
const INJECTED_CSS_ATTR = 'data-ottyl-css-id'
|
||||
|
||||
/** `data-*` marker for the single injected `<link>` for the active custom theme, if any (ticket 033). */
|
||||
// `data-*` marker for the single injected `<link>` for the active custom theme, if any.
|
||||
const INJECTED_THEME_ATTR = 'data-ottyl-theme-css-id'
|
||||
|
||||
/** Themes selectable independently of `system`, each backed by a `html.<name>` block in `index.css`. */
|
||||
// Themes selectable independently of `system`, each backed by a `html.<name>` block in `index.css`.
|
||||
const THEME_CLASSES = ['light', 'dark', 'grey', 'red', 'green', 'blue', 'yellow', 'pink', 'orange'] as const
|
||||
|
||||
/**
|
||||
* Resolves `colorScheme` to an effective theme (following the OS preference
|
||||
* for `system`, which only ever resolves to `light`/`dark`) and reflects it
|
||||
* onto `<html>` as both a class (see `THEME_CLASSES`) and a
|
||||
* `data-color-scheme` attribute. Every visual role the app uses is a CSS
|
||||
* custom property keyed off that class (see `index.css`), so switching it
|
||||
* re-skins the whole app, not just the base page background.
|
||||
*
|
||||
* `customThemeActive` (ticket 033): when a custom theme is selected, no
|
||||
* built-in `html.<scheme>` class is added at all — its own injected `:root`
|
||||
* override (see `applyCustomThemeCss`) is the only thing setting the
|
||||
* `--c-*` variables, and adding a built-in class alongside it would win the
|
||||
* cascade (a class selector is more specific than `:root`) and mask it.
|
||||
*/
|
||||
// Reflects `colorScheme` onto `<html>` as a class + `data-color-scheme`; when a custom theme is active, no built-in class is added at all, since a class selector would out-specificity its injected `:root` override (see `applyCustomThemeCss`) and mask it.
|
||||
function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean) {
|
||||
const root = document.documentElement
|
||||
root.classList.remove(...THEME_CLASSES)
|
||||
@@ -53,17 +40,7 @@ function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean)
|
||||
root.dataset.colorScheme = colorScheme
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects a `<link>` tag per CSS entry into `<head>` (imported entries point
|
||||
* at `GET /api/settings/css/:id/content`, linked entries point straight at
|
||||
* their `path_or_url`), after removing any tags this hook previously
|
||||
* injected — so the rendered app reflects the current entry list immediately
|
||||
* on every change, without a page reload.
|
||||
*
|
||||
* Only arbitrary (non-theme) entries are applied unconditionally this way;
|
||||
* theme entries (`is_theme: true`) are applied separately and only while
|
||||
* selected, see `applyCustomThemeCss`.
|
||||
*/
|
||||
// Injects a `<link>` per CSS entry, replacing any this hook previously injected. Only non-theme entries are applied unconditionally; theme entries are applied separately, only while selected — see `applyCustomThemeCss`.
|
||||
function applyCssEntries(entries: CssEntry[]) {
|
||||
document.querySelectorAll(`link[${INJECTED_CSS_ATTR}]`).forEach((el) => el.remove())
|
||||
for (const entry of entries) {
|
||||
@@ -77,13 +54,7 @@ function applyCssEntries(entries: CssEntry[]) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the single active custom theme's CSS (ticket 033) as a
|
||||
* `<link>` — same mechanism as `applyCssEntries`, kept separate since this
|
||||
* one is conditional on `settings.custom_theme_id`, not always-on. `entry`
|
||||
* is `null` when no custom theme is selected, which just removes any
|
||||
* previously-injected tag.
|
||||
*/
|
||||
// Same injection mechanism as `applyCssEntries`, kept separate since this one is conditional on `settings.custom_theme_id`; `entry` null just removes any previously-injected tag.
|
||||
function applyCustomThemeCss(entry: CssEntry | null) {
|
||||
document.querySelectorAll(`link[${INJECTED_THEME_ATTR}]`).forEach((el) => el.remove())
|
||||
if (!entry) return
|
||||
@@ -96,12 +67,7 @@ function applyCustomThemeCss(entry: CssEntry | null) {
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns fetching + mutating settings/CSS/profile state for the NavMenu panels
|
||||
* (ticket 011), and applies the colour scheme / imported CSS to the document
|
||||
* as a side effect whenever that state changes — so toggling either is
|
||||
* visible in the running app immediately, with no reload.
|
||||
*/
|
||||
// Owns fetching + mutating settings/CSS/profile state for the NavMenu panels, and applies colour scheme / imported CSS to the document as a side effect so changes are visible immediately, with no reload.
|
||||
export function useSettingsPanel() {
|
||||
const [settings, setSettings] = useState<Settings | null>(null)
|
||||
const [cssEntries, setCssEntries] = useState<CssEntry[]>([])
|
||||
@@ -162,8 +128,7 @@ export function useSettingsPanel() {
|
||||
const setColorScheme = useCallback(
|
||||
(colorScheme: ColorScheme) =>
|
||||
guarded(async () => {
|
||||
// Selecting a built-in scheme always deselects any custom theme —
|
||||
// the two are mutually exclusive (see `applyColorScheme`).
|
||||
// Selecting a built-in scheme always deselects any custom theme — the two are mutually exclusive (see `applyColorScheme`).
|
||||
const next = await updateSettings({ colorScheme, customThemeId: null })
|
||||
setSettings(next)
|
||||
})(),
|
||||
@@ -220,8 +185,7 @@ export function useSettingsPanel() {
|
||||
guarded(async () => {
|
||||
await deleteCssEntry(id)
|
||||
await refreshCss()
|
||||
// The removed entry may have been the active custom theme — fall
|
||||
// back to `color_scheme` rather than leaving a dangling selection.
|
||||
// The removed entry may have been the active custom theme — fall back to `color_scheme` rather than leaving a dangling selection.
|
||||
if (settings?.custom_theme_id === id) {
|
||||
const next = await updateSettings({ customThemeId: null })
|
||||
setSettings(next)
|
||||
|
||||
Reference in New Issue
Block a user