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

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

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