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
+14 -74
View File
@@ -1,21 +1,6 @@
//! Alert rules: definitions (ticket 017), trigger evaluation (ticket 018), //! Alert rules: definitions, trigger evaluation, and ntfy delivery.
//! and ntfy delivery (ticket 019).
//! //!
//! ## Design: why alert queries only support regex/sequence/timestamp //! Alert queries are restricted to regex/sequence/timestamp — a metric[param] comparison would require a full bucket rescan per line, breaking the proportional-cost guarantee.
//!
//! `Immediate`/`Threshold`/`Ratio` conditions reuse FilterBar query strings
//! (see `crate::filter_parser`), evaluated against a *single newly-ingested
//! line* on `EngineMessage::Ingest` (see `evaluate_alerts`, called from
//! `engine::run`). `engine.rs`'s own `compile_filter_query` resolves
//! `metric[param]` comparisons by scanning that metric's *entire* bucket
//! history up front (`compile_metric_comparison`) — reusing that per alert
//! check, per ingested line, would make alert evaluation cost proportional to
//! total log volume rather than to new logs ingested, which this ticket
//! requires. So alert queries here only support the subset of FilterBar that
//! evaluates against a single line's own fields — `regex(...)`, `sequence`,
//! `timestamp`, and `and`/`or` — via `eval_alert_expr` below. A `metric[param]`
//! comparison in an alert query is rejected with a clear error (logged, the
//! rule just never fires) rather than silently misbehaving.
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet, VecDeque}; use std::collections::{HashMap, HashSet, VecDeque};
@@ -34,7 +19,6 @@ use crate::filter_parser::{self, CompareOp, FilterExpr, FilterValue};
use crate::settings::Settings; use crate::settings::Settings;
use crate::timestamp::Timestamp; use crate::timestamp::Timestamp;
/// A single alert rule definition (ticket 017).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AlertRule { pub struct AlertRule {
pub id: String, pub id: String,
@@ -44,36 +28,23 @@ pub struct AlertRule {
pub sink: AlertSink, pub sink: AlertSink,
} }
/// What triggers an alert rule (ticket 018).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")] #[serde(tag = "kind", rename_all = "lowercase")]
pub enum AlertCondition { pub enum AlertCondition {
/// Fires once per log line matching `query`.
Immediate { query: String }, Immediate { query: String },
/// Fires when `query`'s match count within a rolling `window_ms` crosses /// Fires when `query`'s match count within a rolling `window_ms` crosses `threshold` (via `op`).
/// `threshold` (via `op`).
Threshold { Threshold {
query: String, query: String,
op: CompareOp, op: CompareOp,
threshold: f64, threshold: f64,
window_ms: i64, window_ms: i64,
}, },
/// Fires the first time `field_regex` matches a line and `capture_group` /// Seen values live only in memory — a restart forgets them, so previously-seen values can fire again; a deliberate simplicity tradeoff, not an oversight.
/// captures a value not seen before *for this rule*. Seen values live
/// only in memory (`AlertRuntimeState::seen_values`) for the process's
/// lifetime — a restart forgets them, so previously-seen values can fire
/// again. This is a deliberate simplicity tradeoff (matches the
/// codebase's general "no restart persistence for in-memory engine
/// state" pattern), not an oversight.
NewValue { NewValue {
field_regex: String, field_regex: String,
capture_group: String, capture_group: String,
}, },
/// Fires when `count(query_a) / count(query_b)` (rolling over /// A zero `count_b` skips evaluation for that line rather than treating the ratio as infinite.
/// `window_ms`) crosses `ratio` (via `op`). A zero `count_b` skips
/// evaluation entirely for that line (rather than treating the ratio as
/// infinite) — there's no meaningful "actual vs threshold" comparison to
/// report yet.
Ratio { Ratio {
query_a: String, query_a: String,
query_b: String, query_b: String,
@@ -83,9 +54,7 @@ pub enum AlertCondition {
}, },
} }
/// Where a fired alert is delivered (ticket 019). Modeled as an enum (rather /// Modeled as an enum (rather than bare fields on `AlertRule`) so more sink kinds can be added later without reshaping it.
/// than `AlertRule` holding a bare topic/server) so more sink kinds can be
/// added later without reshaping `AlertRule`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")] #[serde(tag = "kind", rename_all = "lowercase")]
pub enum AlertSink { pub enum AlertSink {
@@ -96,7 +65,6 @@ pub enum AlertSink {
}, },
} }
/// Error returned by alert-rule mutations/lookups. Mirrors `CssError`'s shape.
#[derive(Debug)] #[derive(Debug)]
pub enum AlertError { pub enum AlertError {
NotFound, NotFound,
@@ -120,9 +88,7 @@ impl From<io::Error> for AlertError {
} }
} }
/// Generates an id for a newly-created alert rule. Same approach as /// Hashes the name and current time rather than pulling in a `uuid` dependency.
/// `settings::generate_imported_css_id`: hash the name and current time
/// rather than pulling in a `uuid` dependency.
fn generate_alert_rule_id(name: &str) -> String { fn generate_alert_rule_id(name: &str) -> String {
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
name.hash(&mut hasher); name.hash(&mut hasher);
@@ -135,7 +101,6 @@ fn generate_alert_rule_id(name: &str) -> String {
} }
impl Settings { impl Settings {
/// Creates a new alert rule, persisting settings to `settings_path`.
pub fn create_alert_rule( pub fn create_alert_rule(
&mut self, &mut self,
settings_path: &Path, settings_path: &Path,
@@ -150,9 +115,6 @@ impl Settings {
Ok(rule) Ok(rule)
} }
/// Replaces an existing alert rule's fields (id stays fixed), persisting
/// settings to `settings_path`. Returns `AlertError::NotFound` if no rule
/// with `id` exists.
pub fn update_alert_rule( pub fn update_alert_rule(
&mut self, &mut self,
settings_path: &Path, settings_path: &Path,
@@ -176,8 +138,6 @@ impl Settings {
Ok(updated) Ok(updated)
} }
/// Removes an alert rule by id, persisting settings to `settings_path`.
/// Returns `AlertError::NotFound` if no rule with `id` exists.
pub fn remove_alert_rule(&mut self, settings_path: &Path, id: &str) -> Result<(), AlertError> { pub fn remove_alert_rule(&mut self, settings_path: &Path, id: &str) -> Result<(), AlertError> {
let index = self let index = self
.alert_rules .alert_rules
@@ -190,11 +150,7 @@ impl Settings {
} }
} }
/// Per-rule in-memory evaluation state (ticket 018): rolling match-timestamp /// Per-rule in-memory evaluation state; lives only for the process's lifetime — not persisted, not backfilled against already-ingested logs.
/// windows for `Threshold`/`Ratio`, and seen-values for `NewValue`. Lives
/// only for the engine process's lifetime (see `AlertCondition::NewValue`'s
/// doc comment) — not persisted, not backfilled against already-ingested
/// logs.
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct AlertRuntimeState { pub struct AlertRuntimeState {
threshold_matches: VecDeque<i64>, threshold_matches: VecDeque<i64>,
@@ -203,9 +159,7 @@ pub struct AlertRuntimeState {
seen_values: HashSet<String>, seen_values: HashSet<String>,
} }
/// Drops every entry older than `window_ms` relative to `now_ms` from the /// `timestamps` is push_back-ordered, so once the front entry is within the window, every entry behind it is too.
/// front of a rolling window. `timestamps` is push_back-ordered, so once a
/// front entry is within the window every entry behind it is too.
fn prune_window(timestamps: &mut VecDeque<i64>, now_ms: i64, window_ms: i64) { fn prune_window(timestamps: &mut VecDeque<i64>, now_ms: i64, window_ms: i64) {
while let Some(&front) = timestamps.front() { while let Some(&front) = timestamps.front() {
if now_ms - front > window_ms { if now_ms - front > window_ms {
@@ -216,9 +170,7 @@ fn prune_window(timestamps: &mut VecDeque<i64>, now_ms: i64, window_ms: i64) {
} }
} }
/// Evaluates a FilterBar query string against a single log line's own fields /// Evaluates a FilterBar query against a single line's own fields only (metric[param] comparisons aren't supported — see module doc).
/// only — see this module's doc comment for why `metric[param]` comparisons
/// aren't supported here.
fn eval_alert_query(query: &str, sequence: u64, timestamp: Timestamp, text: &str) -> Result<bool, String> { fn eval_alert_query(query: &str, sequence: u64, timestamp: Timestamp, text: &str) -> Result<bool, String> {
let expr = filter_parser::parse(query).map_err(|err| err.to_string())?; let expr = filter_parser::parse(query).map_err(|err| err.to_string())?;
eval_alert_expr(&expr, sequence, timestamp, text) eval_alert_expr(&expr, sequence, timestamp, text)
@@ -261,12 +213,7 @@ fn eval_alert_expr(expr: &FilterExpr, sequence: u64, timestamp: Timestamp, text:
} }
} }
/// Evaluates every enabled rule's condition against one newly-ingested line, /// A malformed query (bad regex, unsupported metric comparison) is logged and simply never matches, rather than panicking.
/// mutating each rule's runtime state (rolling windows / seen-values) along
/// the way. Returns `(rule, message)` for every rule that fired — the caller
/// (`engine::run`) hands each off to `deliver_for_rule`. A malformed query
/// (bad regex, unsupported metric comparison) is logged to stderr and simply
/// never matches, rather than panicking or propagating.
pub fn evaluate_alerts( pub fn evaluate_alerts(
rules: &[AlertRule], rules: &[AlertRule],
states: &mut HashMap<String, AlertRuntimeState>, states: &mut HashMap<String, AlertRuntimeState>,
@@ -362,9 +309,7 @@ fn evaluate_condition(
let count_a = state.ratio_a_matches.len() as f64; let count_a = state.ratio_a_matches.len() as f64;
let count_b = state.ratio_b_matches.len() as f64; let count_b = state.ratio_b_matches.len() as f64;
if count_b == 0.0 { if count_b == 0.0 {
// Zero denominator: skip evaluation rather than treat the // Zero denominator: skip rather than treat the ratio as infinite.
// ratio as infinite — there's nothing meaningful to compare
// against `ratio` yet.
return None; return None;
} }
let observed = count_a / count_b; let observed = count_a / count_b;
@@ -377,10 +322,7 @@ fn evaluate_condition(
} }
} }
/// Delivers one fired alert to its rule's configured sink. Errors are logged /// Errors are only logged (no retry queue); spawned off the ingest hot path so a slow/unreachable sink never blocks ingestion.
/// to stderr and dropped — no retry queue, matching `remove_css`'s
/// best-effort style. Called via `tokio::spawn` from the ingest hot path
/// (`engine::run`) so a slow/unreachable sink never blocks ingestion.
pub async fn deliver_for_rule(rule: AlertRule, message: String) { pub async fn deliver_for_rule(rule: AlertRule, message: String) {
match rule.sink { match rule.sink {
AlertSink::Ntfy { topic, server } => { AlertSink::Ntfy { topic, server } => {
@@ -392,9 +334,7 @@ pub async fn deliver_for_rule(rule: AlertRule, message: String) {
} }
} }
/// Publishes `message` to `{server}/{topic}` via ntfy's plain-text publish /// Standalone and `AlertRule`-free so a "send test notification" endpoint can call it directly.
/// API (no auth, raw body). Standalone and `AlertRule`-free so a future
/// "send test notification" endpoint can call it directly.
pub async fn deliver_ntfy(server: &str, topic: &str, message: &str) -> Result<(), String> { pub async fn deliver_ntfy(server: &str, topic: &str, message: &str) -> Result<(), String> {
let url = format!("{}/{}", server.trim_end_matches('/'), topic); let url = format!("{}/{}", server.trim_end_matches('/'), topic);
let response = reqwest::Client::new() let response = reqwest::Client::new()
+17 -171
View File
@@ -29,8 +29,7 @@ static WEBAPP_DIST: EmbedDir = fs_embed::fs_embed!("webapp/dist");
struct ApiState { struct ApiState {
engine: EngineHandle, engine: EngineHandle,
settings: SettingsHandle, settings: SettingsHandle,
/// Path to the settings JSON file on disk. Imported CSS files are stored /// Imported CSS files are stored alongside this path's parent directory (see `ApiState::css_dir`).
/// alongside it, in its parent directory (see `ApiState::css_dir`).
settings_path: Arc<PathBuf>, settings_path: Arc<PathBuf>,
} }
@@ -42,13 +41,7 @@ impl ApiState {
.unwrap_or_else(|| PathBuf::from(".")) .unwrap_or_else(|| PathBuf::from("."))
} }
/// Persists a newly-declared param into the active profile, if any (see /// Best-effort: a no-op if no profile is active, and I/O failures are only logged, since the engine declaration is already the source of truth.
/// `ProfileStore::record_param_declaration`), so it survives a restart.
/// A no-op (not an error) when no profile is currently active — the
/// engine declaration above has already succeeded regardless. Failures
/// (e.g. I/O) are logged to stderr rather than surfaced to the caller: the
/// engine declaration is the source of truth for the running process, and
/// this is a best-effort persistence step layered on top.
fn persist_param_declaration(&self, request: &DeclareParamRequest) { fn persist_param_declaration(&self, request: &DeclareParamRequest) {
let Some((store_path, profile_name)) = self.active_profile_location() else { let Some((store_path, profile_name)) = self.active_profile_location() else {
return; return;
@@ -76,7 +69,6 @@ impl ApiState {
} }
} }
/// The active profile's store path and name, if a profile is currently active.
fn active_profile_location(&self) -> Option<(PathBuf, String)> { fn active_profile_location(&self) -> Option<(PathBuf, String)> {
let settings = self.settings.lock().unwrap(); let settings = self.settings.lock().unwrap();
let profile_name = settings.profiles.active_profile.clone()?; let profile_name = settings.profiles.active_profile.clone()?;
@@ -126,8 +118,7 @@ pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: Pat
.fallback(serve_webapp) .fallback(serve_webapp)
} }
/// Serves the webapp embedded in the binary (see `WEBAPP_DIST`), falling /// Falls back to `index.html` for unmatched paths so client-side routing works.
/// back to `index.html` for any unmatched path so client-side routing works.
async fn serve_webapp(uri: axum::http::Uri) -> Response { async fn serve_webapp(uri: axum::http::Uri) -> Response {
let path = uri.path().trim_start_matches('/'); let path = uri.path().trim_start_matches('/');
let file = WEBAPP_DIST let file = WEBAPP_DIST
@@ -167,11 +158,6 @@ async fn ask(engine: &EngineHandle, query: EngineQuery) -> Result<EngineResponse
response.await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE) response.await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)
} }
/// `GET /api/logs` — also accepts a `filter` query param (ticket 007): a
/// FilterBar query string, parsed and evaluated inside the engine. A
/// malformed filter string comes back as `EngineResponse::Error` and is
/// surfaced here as `400 Bad Request` with `{ "error": "..." }`, matching
/// ticket 004's `declare_param`/`declare_metric` error-mapping pattern.
async fn get_logs( async fn get_logs(
State(state): State<ApiState>, State(state): State<ApiState>,
Query(params): Query<LogsParams>, Query(params): Query<LogsParams>,
@@ -186,8 +172,6 @@ async fn get_logs(
} }
} }
/// `GET /api/timeline` — same `filter` query param and error mapping as
/// `get_logs` above.
async fn get_timeline( async fn get_timeline(
State(state): State<ApiState>, State(state): State<ApiState>,
Query(params): Query<TimelineParams>, Query(params): Query<TimelineParams>,
@@ -210,10 +194,6 @@ async fn get_stats(State(state): State<ApiState>) -> impl IntoResponse {
} }
} }
/// `GET /api/params` — lists all declared params (name, regex, capture
/// group, kind) as `Vec<ParamDeclarationDto>`. Mirrors `list_metrics` below
/// (ticket 015 needed this for LogPreview's column picker; ticket 004 only
/// exposed the equivalent for metrics).
async fn list_params(State(state): State<ApiState>) -> impl IntoResponse { async fn list_params(State(state): State<ApiState>) -> impl IntoResponse {
match ask(&state.engine, EngineQuery::ParamDeclarations).await { match ask(&state.engine, EngineQuery::ParamDeclarations).await {
Ok(EngineResponse::ParamDeclarations(declarations)) => Json(declarations).into_response(), Ok(EngineResponse::ParamDeclarations(declarations)) => Json(declarations).into_response(),
@@ -222,14 +202,6 @@ async fn list_params(State(state): State<ApiState>) -> impl IntoResponse {
} }
} }
/// `POST /api/params` — declares a param. Body mirrors `DeclareParamRequest`:
/// `{ "name": "...", "message_match_regex": "...", "capture_group": "...",
/// "kind": "string" | "number" | "timestamp" }`. On success, also persists
/// the declaration into the active profile (if any — see
/// `ApiState::persist_param_declaration`) and returns `201 Created` with the
/// declaration as `ParamDeclarationDto`. On a validation failure (bad regex,
/// unknown capture group), returns `400 Bad Request` with
/// `{ "error": "..." }`.
async fn declare_param( async fn declare_param(
State(state): State<ApiState>, State(state): State<ApiState>,
Json(body): Json<DeclareParamRequest>, Json(body): Json<DeclareParamRequest>,
@@ -260,8 +232,6 @@ async fn declare_param(
} }
} }
/// `GET /api/metrics` — lists all declared metrics (name, regex, params) as
/// `Vec<MetricDeclarationDto>`.
async fn list_metrics(State(state): State<ApiState>) -> impl IntoResponse { async fn list_metrics(State(state): State<ApiState>) -> impl IntoResponse {
match ask(&state.engine, EngineQuery::MetricDeclarations).await { match ask(&state.engine, EngineQuery::MetricDeclarations).await {
Ok(EngineResponse::MetricDeclarations(declarations)) => Json(declarations).into_response(), Ok(EngineResponse::MetricDeclarations(declarations)) => Json(declarations).into_response(),
@@ -270,11 +240,6 @@ async fn list_metrics(State(state): State<ApiState>) -> impl IntoResponse {
} }
} }
/// `POST /api/metrics` — declares a metric. Body mirrors
/// `DeclareMetricRequest`: `{ "name": "...", "message_match_regex": "...",
/// "params": [{ "param_name": "...", "capture_group": "...", "kind": "..." }] }`.
/// Same success/failure shape as `declare_param` above, persisting into the
/// active profile via `ApiState::persist_metric_declaration`.
async fn declare_metric( async fn declare_metric(
State(state): State<ApiState>, State(state): State<ApiState>,
Json(body): Json<DeclareMetricRequest>, Json(body): Json<DeclareMetricRequest>,
@@ -306,9 +271,7 @@ async fn declare_metric(
} }
} }
/// Parses the `labels` query param (`"foo=bar,baz=qux"`) into a /// Malformed pairs (no `=`) are skipped rather than rejected.
/// `key -> expected value` map. Malformed pairs (no `=`) are skipped rather
/// than rejected — see `BucketValuesParams` doc comment for the format.
fn parse_labels_filter(raw: &str) -> BTreeMap<String, String> { fn parse_labels_filter(raw: &str) -> BTreeMap<String, String> {
raw.split(',') raw.split(',')
.filter_map(|pair| pair.split_once('=')) .filter_map(|pair| pair.split_once('='))
@@ -316,9 +279,6 @@ fn parse_labels_filter(raw: &str) -> BTreeMap<String, String> {
.collect() .collect()
} }
/// `GET /api/metrics/:name` — queries bucket values for one metric. Query
/// params: `labels` (see `parse_labels_filter`), `start_ms`, `end_ms`,
/// `since_sequence`, `limit` (mirroring `/api/logs`/`/api/timeline`).
async fn get_metric_bucket_values( async fn get_metric_bucket_values(
State(state): State<ApiState>, State(state): State<ApiState>,
AxumPath(metric_name): AxumPath<String>, AxumPath(metric_name): AxumPath<String>,
@@ -340,12 +300,6 @@ async fn get_metric_bucket_values(
} }
} }
/// `GET /api/query` — evaluates a PromQL query (ticket 013; see `crate::promql` for
/// the supported grammar) against declared metrics. Query params: `promql` (the
/// query string), `start_ms`, `end_ms` (bound which points are included, mirroring
/// `/api/logs`/`/api/metrics/:name`). Returns `Vec<PromqlSeriesDto>` — chart-ready
/// `labels` + `(timestamp_ms, value)` series — on success (`200 OK`), or
/// `400 Bad Request` with `{ "error": "..." }` for a malformed query.
async fn run_promql_query( async fn run_promql_query(
State(state): State<ApiState>, State(state): State<ApiState>,
Query(params): Query<PromqlParams>, Query(params): Query<PromqlParams>,
@@ -365,10 +319,6 @@ async fn run_promql_query(
} }
} }
/// Maps a `CssError` from a settings mutation/lookup to a response status
/// and message: `NotFound` -> 404, `NotImported` -> 400 (the id is valid but
/// refers to a linked entry, which this operation doesn't apply to), `Io` ->
/// 500.
fn css_error_response(err: CssError) -> axum::response::Response { fn css_error_response(err: CssError) -> axum::response::Response {
match err { match err {
CssError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(), CssError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(),
@@ -377,18 +327,12 @@ fn css_error_response(err: CssError) -> axum::response::Response {
} }
} }
/// `GET /api/settings/css` — lists all configured CSS entries (imported and
/// linked) as `Vec<CssEntryDto>`.
async fn list_css(State(state): State<ApiState>) -> impl IntoResponse { async fn list_css(State(state): State<ApiState>) -> impl IntoResponse {
let settings = state.settings.lock().unwrap(); let settings = state.settings.lock().unwrap();
let entries: Vec<CssEntryDto> = settings.css_entries.iter().map(CssEntryDto::from).collect(); let entries: Vec<CssEntryDto> = settings.css_entries.iter().map(CssEntryDto::from).collect();
Json(entries).into_response() Json(entries).into_response()
} }
/// `POST /api/settings/css` — imports raw CSS content. Body:
/// `{ "filename": "theme.css", "content": "body { ... }" }`. Stores the
/// content as a file alongside the settings file, adds a `CssEntry::Imported`
/// to settings, and returns the new entry as `CssEntryDto` (201 Created).
async fn import_css( async fn import_css(
State(state): State<ApiState>, State(state): State<ApiState>,
Json(body): Json<ImportCssRequest>, Json(body): Json<ImportCssRequest>,
@@ -414,11 +358,7 @@ async fn import_css(
} }
} }
/// `POST /api/settings/css/link` — registers a reference to an external CSS /// Content is never copied, and the entry can never be deleted via `DELETE /api/settings/css/:id`.
/// path/URL. Body: `{ "path_or_url": "https://example.com/style.css" }`.
/// Content is never copied; the entry is never deletable via
/// `DELETE /api/settings/css/:id`. Returns the new entry as `CssEntryDto`
/// (201 Created).
async fn link_css( async fn link_css(
State(state): State<ApiState>, State(state): State<ApiState>,
Json(body): Json<LinkCssRequest>, Json(body): Json<LinkCssRequest>,
@@ -436,10 +376,6 @@ async fn link_css(
} }
} }
/// `DELETE /api/settings/css/:id` — removes an imported CSS entry (file +
/// settings reference). Rejects with 400 if `id` refers to a `Linked` entry
/// (never deletable this way) and 404 if `id` is unknown. On success returns
/// 204 No Content.
async fn remove_css( async fn remove_css(
State(state): State<ApiState>, State(state): State<ApiState>,
AxumPath(id): AxumPath<String>, AxumPath(id): AxumPath<String>,
@@ -452,10 +388,7 @@ async fn remove_css(
} }
} }
/// `GET /api/settings/css/:id/content` — serves back the raw CSS content of /// Rejects a linked entry with 400 — there's no local content; the frontend should use `path_or_url` directly.
/// an imported entry (`content-type: text/css`). Rejects with 400 for a
/// linked entry (there's no local content to serve — the frontend should use
/// `path_or_url` directly) and 404 for an unknown id.
async fn get_css_content( async fn get_css_content(
State(state): State<ApiState>, State(state): State<ApiState>,
AxumPath(id): AxumPath<String>, AxumPath(id): AxumPath<String>,
@@ -473,8 +406,6 @@ async fn get_css_content(
} }
} }
/// Maps an `AlertError` to a response status/message: `NotFound` -> 404,
/// `Io` -> 500. Mirrors `css_error_response` above.
fn alert_error_response(err: AlertError) -> axum::response::Response { fn alert_error_response(err: AlertError) -> axum::response::Response {
match err { match err {
AlertError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(), AlertError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(),
@@ -482,18 +413,11 @@ fn alert_error_response(err: AlertError) -> axum::response::Response {
} }
} }
/// `GET /api/settings/alerts` — lists all configured alert rules as
/// `Vec<AlertRule>`.
async fn list_alert_rules(State(state): State<ApiState>) -> impl IntoResponse { async fn list_alert_rules(State(state): State<ApiState>) -> impl IntoResponse {
let settings = state.settings.lock().unwrap(); let settings = state.settings.lock().unwrap();
Json(settings.alert_rules.clone()).into_response() Json(settings.alert_rules.clone()).into_response()
} }
/// `POST /api/settings/alerts` — creates an alert rule. Body:
/// `{ "name": "...", "enabled": true, "condition": { "kind": "immediate" | \
/// "threshold" | "newvalue" | "ratio", ... }, "sink": { "kind": "ntfy", \
/// "topic": "...", "server": "..." | null } }`. Returns `201 Created` with
/// the created rule (including its generated `id`).
async fn create_alert_rule( async fn create_alert_rule(
State(state): State<ApiState>, State(state): State<ApiState>,
Json(body): Json<CreateAlertRuleRequest>, Json(body): Json<CreateAlertRuleRequest>,
@@ -505,9 +429,6 @@ async fn create_alert_rule(
} }
} }
/// `PUT /api/settings/alerts/:id` — replaces an alert rule's fields (id in
/// the URL is authoritative). Returns the updated rule, or `404 Not Found`
/// if no rule with `id` exists.
async fn update_alert_rule( async fn update_alert_rule(
State(state): State<ApiState>, State(state): State<ApiState>,
AxumPath(id): AxumPath<String>, AxumPath(id): AxumPath<String>,
@@ -520,8 +441,6 @@ async fn update_alert_rule(
} }
} }
/// `DELETE /api/settings/alerts/:id` — removes an alert rule. Returns
/// `204 No Content`, or `404 Not Found` if no rule with `id` exists.
async fn delete_alert_rule(State(state): State<ApiState>, AxumPath(id): AxumPath<String>) -> impl IntoResponse { async fn delete_alert_rule(State(state): State<ApiState>, AxumPath(id): AxumPath<String>) -> impl IntoResponse {
let mut settings = state.settings.lock().unwrap(); let mut settings = state.settings.lock().unwrap();
match settings.remove_alert_rule(&state.settings_path, &id) { match settings.remove_alert_rule(&state.settings_path, &id) {
@@ -530,11 +449,7 @@ async fn delete_alert_rule(State(state): State<ApiState>, AxumPath(id): AxumPath
} }
} }
/// `POST /api/settings/alerts/test` — delivers a test notification to an /// Delivers a test notification without a saved rule so the sink can be verified before saving.
/// ntfy topic/server without requiring a saved rule, so the sink can be
/// verified before saving. Body: `{ "topic": "...", "server": "..." | null }`.
/// Returns `204 No Content` on success, `502 Bad Gateway` if ntfy rejects
/// or is unreachable.
async fn test_alert_notification(Json(body): Json<TestAlertNotificationRequest>) -> impl IntoResponse { async fn test_alert_notification(Json(body): Json<TestAlertNotificationRequest>) -> impl IntoResponse {
let server = body.server.unwrap_or_else(|| "https://ntfy.sh".to_string()); let server = body.server.unwrap_or_else(|| "https://ntfy.sh".to_string());
match deliver_ntfy(&server, &body.topic, "Test notification from ottyl").await { match deliver_ntfy(&server, &body.topic, "Test notification from ottyl").await {
@@ -543,20 +458,13 @@ async fn test_alert_notification(Json(body): Json<TestAlertNotificationRequest>)
} }
} }
/// `GET /api/settings` — returns the subset of `Settings` the NavMenu UI /// CSS entries have their own endpoint (`GET /api/settings/css`).
/// needs (colour scheme, active profile name) as `SettingsDto`. CSS entries
/// have their own dedicated list endpoint (`GET /api/settings/css`).
async fn get_settings(State(state): State<ApiState>) -> impl IntoResponse { async fn get_settings(State(state): State<ApiState>) -> impl IntoResponse {
let settings = state.settings.lock().unwrap(); let settings = state.settings.lock().unwrap();
Json(SettingsDto::from(&*settings)).into_response() Json(SettingsDto::from(&*settings)).into_response()
} }
/// `PATCH /api/settings` — updates process-wide settings. Body: /// `max_logs: null` clears the cap; a changed value is pushed to the engine so it applies without a restart.
/// `{ "color_scheme": "light" | "dark" | "system", "max_logs": number |
/// null }` (omit a field to leave it unchanged; `max_logs: null` clears the
/// cap back to unlimited). Persists to `settings_path`, pushes a changed
/// `max_logs` to the engine so it takes effect on the next ingest without a
/// restart, and returns the updated settings as `SettingsDto`.
async fn update_settings( async fn update_settings(
State(state): State<ApiState>, State(state): State<ApiState>,
Json(body): Json<UpdateSettingsRequest>, Json(body): Json<UpdateSettingsRequest>,
@@ -588,9 +496,6 @@ async fn update_settings(
Json(new_max_logs.0).into_response() Json(new_max_logs.0).into_response()
} }
/// Maps a `ProfileError` to a response status/message: `NotFound` -> 404,
/// `DuplicateName`/`CannotDeleteDefault` -> 400, `Io` -> 500. Mirrors
/// `css_error_response` above.
fn profile_error_response(err: ProfileError) -> axum::response::Response { fn profile_error_response(err: ProfileError) -> axum::response::Response {
match err { match err {
ProfileError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(), ProfileError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(),
@@ -600,10 +505,6 @@ fn profile_error_response(err: ProfileError) -> axum::response::Response {
} }
} }
/// `GET /api/settings/profiles` — lists all known profiles with their
/// param/metric declaration and dashboard counts, as `Vec<ProfileSummaryDto>`.
/// Use `GET /api/settings`'s `active_profile` field to know which one (if
/// any) is currently active.
async fn list_profiles(State(state): State<ApiState>) -> impl IntoResponse { async fn list_profiles(State(state): State<ApiState>) -> impl IntoResponse {
let settings = state.settings.lock().unwrap(); let settings = state.settings.lock().unwrap();
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path); let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
@@ -612,10 +513,6 @@ async fn list_profiles(State(state): State<ApiState>) -> impl IntoResponse {
Json(summaries).into_response() Json(summaries).into_response()
} }
/// `POST /api/settings/profiles` — creates a new, empty profile. Body:
/// `{ "name": "..." }`. Returns `201 Created` with the new profile as
/// `ProfileSummaryDto`, or `400 Bad Request` if a profile with that name
/// already exists.
async fn create_profile( async fn create_profile(
State(state): State<ApiState>, State(state): State<ApiState>,
Json(body): Json<CreateProfileRequest>, Json(body): Json<CreateProfileRequest>,
@@ -633,11 +530,7 @@ async fn create_profile(
} }
} }
/// `DELETE /api/settings/profiles/:name` — deletes a profile. If it was the /// If it was the active profile, `active_profile` is cleared, but the engine's already-declared params/metrics keep running until the next restart.
/// active profile, `active_profile` is cleared (but the engine's currently
/// declared params/metrics are left as-is — they simply won't be reloaded on
/// the next restart). Returns `204 No Content`, or `404 Not Found` if no
/// profile with that name exists.
async fn delete_profile( async fn delete_profile(
State(state): State<ApiState>, State(state): State<ApiState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
@@ -652,12 +545,7 @@ async fn delete_profile(
} }
} }
/// Removes every currently-declared param/metric from the running engine /// Removes every currently-declared param/metric from the engine, then replays `profile`'s declarations into it.
/// (via `EngineMessage::RemoveParamDeclaration`/`RemoveMetricDeclaration`),
/// then replays `profile`'s stored declarations into it (see
/// `profile::load_into_engine`). Used by `activate_profile` so switching the
/// active profile takes effect on the running engine immediately, without
/// requiring a process restart.
async fn reload_engine_declarations(engine: &EngineHandle, profile: &Profile) { async fn reload_engine_declarations(engine: &EngineHandle, profile: &Profile) {
if let Ok(EngineResponse::ParamDeclarations(params)) = if let Ok(EngineResponse::ParamDeclarations(params)) =
ask(engine, EngineQuery::ParamDeclarations).await ask(engine, EngineQuery::ParamDeclarations).await
@@ -690,12 +578,6 @@ async fn reload_engine_declarations(engine: &EngineHandle, profile: &Profile) {
profile::load_into_engine(engine, profile).await; profile::load_into_engine(engine, profile).await;
} }
/// `POST /api/settings/profiles/:name/activate` — sets `name` as the active
/// profile (persisted, see `ProfileStore::set_active`) and hot-swaps the
/// running engine's declared params/metrics to match it (see
/// `reload_engine_declarations`), so the switch is visible without a
/// restart. Returns the updated settings as `SettingsDto`, or `404 Not
/// Found` if no profile with that name exists.
async fn activate_profile( async fn activate_profile(
State(state): State<ApiState>, State(state): State<ApiState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
@@ -723,9 +605,6 @@ async fn activate_profile(
} }
} }
/// `GET /api/settings/profiles/:name/dashboards` — lists all dashboards saved
/// on the profile `name`, as `Vec<DashboardDto>`. Returns `404 Not Found` if
/// no profile with that name exists.
async fn list_dashboards( async fn list_dashboards(
State(state): State<ApiState>, State(state): State<ApiState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
@@ -742,10 +621,6 @@ async fn list_dashboards(
} }
} }
/// `POST /api/settings/profiles/:name/dashboards` — creates a new, empty
/// dashboard on profile `name`. Body: `{ "name": "..." }`. Returns
/// `201 Created` with the new dashboard as `DashboardDto`, or `404 Not
/// Found` if no profile with that name exists.
async fn create_dashboard( async fn create_dashboard(
State(state): State<ApiState>, State(state): State<ApiState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
@@ -761,9 +636,6 @@ async fn create_dashboard(
} }
} }
/// `GET /api/settings/profiles/:name/dashboards/:dashboard_id` — fetches one
/// dashboard by id. Returns `404 Not Found` if the profile or dashboard
/// doesn't exist.
async fn get_dashboard( async fn get_dashboard(
State(state): State<ApiState>, State(state): State<ApiState>,
AxumPath((name, dashboard_id)): AxumPath<(String, String)>, AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
@@ -780,11 +652,6 @@ async fn get_dashboard(
} }
} }
/// `PUT /api/settings/profiles/:name/dashboards/:dashboard_id` — replaces the
/// dashboard's name/panels. Body: `{ "name": "...", "panels": [{ "id": "...",
/// "title": "...", "promql": "...", "chart_type": "bar" | "line" | "pie" }] }`.
/// Returns the updated dashboard as `DashboardDto`, or `404 Not Found` if the
/// profile or dashboard doesn't exist.
async fn save_dashboard( async fn save_dashboard(
State(state): State<ApiState>, State(state): State<ApiState>,
AxumPath((name, dashboard_id)): AxumPath<(String, String)>, AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
@@ -805,9 +672,6 @@ async fn save_dashboard(
} }
} }
/// `DELETE /api/settings/profiles/:name/dashboards/:dashboard_id` — deletes a
/// dashboard. Returns `204 No Content`, or `404 Not Found` if the profile or
/// dashboard doesn't exist.
async fn delete_dashboard( async fn delete_dashboard(
State(state): State<ApiState>, State(state): State<ApiState>,
AxumPath((name, dashboard_id)): AxumPath<(String, String)>, AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
@@ -836,10 +700,7 @@ mod tests {
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tower::ServiceExt; use tower::ServiceExt;
/// Spins up a fresh engine + router pair, backed by a temp settings file, /// Spins up a fresh engine + router pair backed by a temp settings file.
/// for one test. Returns the router, the engine handle (so a test can
/// ingest lines directly without an HTTP route for it), and the
/// `TempDir` guard (kept alive for the test's duration).
fn test_app() -> (Router, EngineHandle, SettingsHandle, tempfile::TempDir) { fn test_app() -> (Router, EngineHandle, SettingsHandle, tempfile::TempDir) {
let (engine_tx, engine_rx) = mpsc::channel(128); let (engine_tx, engine_rx) = mpsc::channel(128);
@@ -937,9 +798,7 @@ mod tests {
let status = delete_request(&app, "/api/settings/alerts/does-not-exist").await; let status = delete_request(&app, "/api/settings/alerts/does-not-exist").await;
assert_eq!(status, StatusCode::NOT_FOUND); assert_eq!(status, StatusCode::NOT_FOUND);
// `update_alert_rule`'s `AlertError::NotFound` maps to a plain-text // AlertError::NotFound maps to a plain-text (not JSON) 404 body, so check status directly instead of via send_json.
// 404 body (mirrors `css_error_response`), not JSON, so this checks
// the status directly rather than via `send_json`'s JSON parsing.
let request = Request::builder() let request = Request::builder()
.method("PUT") .method("PUT")
.uri("/api/settings/alerts/does-not-exist") .uri("/api/settings/alerts/does-not-exist")
@@ -1085,16 +944,11 @@ mod tests {
let values = body.as_array().unwrap(); let values = body.as_array().unwrap();
assert_eq!(values.len(), 2); assert_eq!(values.len(), 2);
for value in values { for value in values {
// `MetricPrimitive` is a Rust enum with derived `Serialize`, so its // MetricPrimitive derives Serialize as an externally-tagged enum, so JSON is {"String": "/a"}.
// JSON form is externally tagged: `{"String": "/a"}`.
assert_eq!(value["labels"]["path"]["String"], "/a"); assert_eq!(value["labels"]["path"]["String"], "/a");
} }
// Bucket values are grouped by label combination (ordered by the // Bucket values group by label (BTreeMap-ordered "/a" before "/b"), not by sequence, so limit=1 picks sequence 2 from "/a" ahead of sequence 1 from "/b".
// labels' `BTreeMap` key, i.e. "/a" before "/b"), not globally by
// sequence — so filtering to sequence > 0 leaves sequence 2 (from the
// "/a" bucket) ahead of sequence 1 (from the "/b" bucket), and a
// limit of 1 picks the former.
let (status, body) = get_json(&app, "/api/metrics/requests?since_sequence=0&limit=1").await; let (status, body) = get_json(&app, "/api/metrics/requests?since_sequence=0&limit=1").await;
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
let values = body.as_array().unwrap(); let values = body.as_array().unwrap();
@@ -1159,10 +1013,7 @@ mod tests {
async fn declaring_param_persists_into_active_profile() { async fn declaring_param_persists_into_active_profile() {
let (app, _engine, settings_handle, dir) = test_app(); let (app, _engine, settings_handle, dir) = test_app();
// Set up an active profile against the *same* shared `settings` // Use the same shared settings handle the router was built with — a separately-loaded Settings wouldn't be visible to the running app.
// handle the router was built with — mutating a separate `Settings`
// loaded fresh from disk wouldn't be visible to the running app's
// in-memory settings.
let settings_path = dir.path().join("settings.json"); let settings_path = dir.path().join("settings.json");
let profiles_dir = { let profiles_dir = {
let settings = settings_handle.lock().unwrap(); let settings = settings_handle.lock().unwrap();
@@ -1229,12 +1080,10 @@ mod tests {
assert!(body["panels"].as_array().unwrap().is_empty()); assert!(body["panels"].as_array().unwrap().is_empty());
let dashboard_id = body["id"].as_str().unwrap().to_string(); let dashboard_id = body["id"].as_str().unwrap().to_string();
// Listing shows the new (empty) dashboard.
let (status, body) = get_json(&app, "/api/settings/profiles/web/dashboards").await; let (status, body) = get_json(&app, "/api/settings/profiles/web/dashboards").await;
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
assert_eq!(body.as_array().unwrap().len(), 1); assert_eq!(body.as_array().unwrap().len(), 1);
// Save adds a panel.
let (status, body) = send_json( let (status, body) = send_json(
&app, &app,
"PUT", "PUT",
@@ -1253,13 +1102,11 @@ mod tests {
assert_eq!(panels.len(), 1); assert_eq!(panels.len(), 1);
assert_eq!(panels[0]["chart_type"], "line"); assert_eq!(panels[0]["chart_type"], "line");
// Get by id reflects the saved panel.
let (status, body) = let (status, body) =
get_json(&app, &format!("/api/settings/profiles/web/dashboards/{dashboard_id}")).await; get_json(&app, &format!("/api/settings/profiles/web/dashboards/{dashboard_id}")).await;
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
assert_eq!(body["panels"].as_array().unwrap().len(), 1); assert_eq!(body["panels"].as_array().unwrap().len(), 1);
// Delete removes it.
let status = delete_request(&app, &format!("/api/settings/profiles/web/dashboards/{dashboard_id}")).await; let status = delete_request(&app, &format!("/api/settings/profiles/web/dashboards/{dashboard_id}")).await;
assert_eq!(status, StatusCode::NO_CONTENT); assert_eq!(status, StatusCode::NO_CONTENT);
let (status, body) = get_json(&app, "/api/settings/profiles/web/dashboards").await; let (status, body) = get_json(&app, "/api/settings/profiles/web/dashboards").await;
@@ -1308,8 +1155,7 @@ mod tests {
) )
.await; .await;
// Simulate a restart: reload the profile store from disk directly, // Simulate a restart by reloading the profile store fresh from disk, independent of the running app/engine.
// independent of the running app/engine.
let settings_path = dir.path().join("settings.json"); let settings_path = dir.path().join("settings.json");
let profiles_dir = { let profiles_dir = {
let settings = settings_handle.lock().unwrap(); let settings = settings_handle.lock().unwrap();
+6 -20
View File
@@ -3,19 +3,13 @@ use crate::timestamp::Timestamp;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
/// A single parsed metric primitive extracted from a regex capture group.
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegexToMetricPrimitiveMatcher { pub struct RegexToMetricPrimitiveMatcher {
pub param_name: String, pub param_name: String,
pub capture_group: String, pub capture_group: String,
pub kind: MetricPrimitiveKind, pub kind: MetricPrimitiveKind,
/// Ticket 022: whether this param contributes to the bucket's `labels` /// Whether this param is a bucket label or its captured value (at most one Value-role param per metric); `#[serde(default)]` so profile files saved before this field existed still load as `Label`.
/// (the default) or is the bucket's per-event captured `value` (at most
/// one `Value`-role param per metric — `declare_metric` rejects more
/// than one). `#[serde(default)]` so profile files saved before this
/// field existed still load, with every param defaulting to `Label` —
/// i.e. unchanged behavior (the fixed occurrence-count-of-1 value).
#[serde(default)] #[serde(default)]
pub role: ParamRole, pub role: ParamRole,
} }
@@ -29,10 +23,7 @@ pub enum ParamRole {
Value, Value,
} }
/// `rename_all = "lowercase"` matches every other `kind`-style enum exposed /// `rename_all = "lowercase"` matches other `kind`-style enums, so JSON bodies use "string"/"number"/"timestamp".
/// over the API/settings JSON (`ColorScheme`, `CssEntryKind`,
/// `ProfileMetricPrimitiveKind`), so `POST /api/params`/`POST /api/metrics`
/// bodies use `"string"`/`"number"`/`"timestamp"`.
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -40,12 +31,7 @@ pub enum MetricPrimitiveKind {
String, String,
Number, Number,
Timestamp, Timestamp,
/// Ticket 023: milliseconds. On a metric's `Value`-role param, this /// On a Value-role param this is derived as elapsed time since the previous event (not parsed from text); otherwise it's a literal i64 millisecond count.
/// doesn't parse the captured text at all — the value is instead derived
/// as the elapsed time since the previous counter event with the same
/// label set (see `crate::engine::apply_metric_to_log`). On any other
/// param (a `Label`-role metric param, or a standalone `ParamDeclaration`),
/// it parses the captured text as a literal `i64` millisecond count.
Duration, Duration,
} }
@@ -65,16 +51,16 @@ pub enum DataValue {
MetricDeclaration { MetricDeclaration {
message_match_regex: String, message_match_regex: String,
params: Vec<RegexToMetricPrimitiveMatcher>, params: Vec<RegexToMetricPrimitiveMatcher>,
/// See `crate::engine::DeclareMetricRequest::matched_labels` (ticket 025). /// See `crate::engine::DeclareMetricRequest::matched_labels`.
matched_labels: Option<BTreeMap<String, String>>, matched_labels: Option<BTreeMap<String, String>>,
/// See `crate::engine::DeclareMetricRequest::unmatched_labels` (ticket 025). /// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
unmatched_labels: Option<BTreeMap<String, String>>, unmatched_labels: Option<BTreeMap<String, String>>,
}, },
/// Paired with `IndexKey::Bucket`. /// Paired with `IndexKey::Bucket`.
BucketValue { BucketValue {
values: Vec<(u64, Timestamp, MetricPrimitive)>, values: Vec<(u64, Timestamp, MetricPrimitive)>,
}, },
/// Paired with `IndexKey::LastMetricEvent` (ticket 023). /// Paired with `IndexKey::LastMetricEvent`.
LastEventTimestamp { LastEventTimestamp {
timestamp: Timestamp, timestamp: Timestamp,
}, },
+21 -96
View File
@@ -11,12 +11,7 @@ pub struct LogEntryDto {
pub sequence: u64, pub sequence: u64,
pub timestamp_ms: i64, pub timestamp_ms: i64,
pub text: String, pub text: String,
/// Derived param values (ticket 001's `ParamDeclaration`s) for this line, /// Only params whose regex matched (and parsed as the declared kind) are present — omitted rather than sent as null, so the frontend can render a blank for anything missing.
/// keyed by param name. Only params whose regex actually matched this
/// line's text (and whose captured group parsed as the declared `kind`)
/// are present — a non-matching param is omitted rather than sent as
/// null, so the frontend can render a blank/dash for anything missing
/// from the map.
pub params: BTreeMap<String, MetricPrimitive>, pub params: BTreeMap<String, MetricPrimitive>,
} }
@@ -33,9 +28,9 @@ pub struct MetricDeclarationDto {
pub name: String, pub name: String,
pub message_match_regex: String, pub message_match_regex: String,
pub params: Vec<RegexToMetricPrimitiveMatcher>, pub params: Vec<RegexToMetricPrimitiveMatcher>,
/// See `crate::engine::DeclareMetricRequest::matched_labels` (ticket 025). /// See `crate::engine::DeclareMetricRequest::matched_labels`.
pub matched_labels: Option<BTreeMap<String, String>>, pub matched_labels: Option<BTreeMap<String, String>>,
/// See `crate::engine::DeclareMetricRequest::unmatched_labels` (ticket 025). /// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
pub unmatched_labels: Option<BTreeMap<String, String>>, pub unmatched_labels: Option<BTreeMap<String, String>>,
} }
@@ -60,9 +55,7 @@ pub struct LogsParams {
pub start_ms: Option<i64>, pub start_ms: Option<i64>,
pub end_ms: Option<i64>, pub end_ms: Option<i64>,
pub limit: Option<usize>, pub limit: Option<usize>,
/// Raw FilterBar query string (ticket 007), e.g. `regex("error") and /// Raw FilterBar query string, e.g. `regex("error") and sequence<5`.
/// sequence<5`. Parsed with `filter_parser::parse` and evaluated in the
/// engine; a malformed string surfaces as `EngineResponse::Error`.
pub filter: Option<String>, pub filter: Option<String>,
} }
@@ -71,14 +64,11 @@ pub struct TimelineParams {
pub start_ms: Option<i64>, pub start_ms: Option<i64>,
pub end_ms: Option<i64>, pub end_ms: Option<i64>,
pub bucket_ms: Option<i64>, pub bucket_ms: Option<i64>,
/// Same FilterBar query string as `LogsParams::filter`: only logs /// Same FilterBar query string as `LogsParams::filter`; only matching logs are counted into timeline buckets.
/// matching it are counted into timeline buckets.
pub filter: Option<String>, pub filter: Option<String>,
} }
/// One entry from a metric's derived bucket timeseries: the label combination /// One (sequence, timestamp, value) occurrence within a metric's bucket for a given label combination.
/// that bucket represents, plus one `(sequence, timestamp, value)` occurrence
/// within it.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct BucketValueDto { pub struct BucketValueDto {
pub labels: BTreeMap<String, MetricPrimitive>, pub labels: BTreeMap<String, MetricPrimitive>,
@@ -87,13 +77,7 @@ pub struct BucketValueDto {
pub value: MetricPrimitive, pub value: MetricPrimitive,
} }
/// Query params for `GET /api/metrics/:name`. `labels` is a single /// `labels` is a comma-separated `key=value` list (not repeated params) since axum's `Query` extractor needs a fixed set of field names.
/// comma-separated `key=value` list (e.g. `?labels=path=/a,duration=5`)
/// rather than repeated `label.foo=bar` params, since axum's `Query`
/// extractor needs a fixed/known set of field names — an arbitrary label set
/// doesn't fit that without a custom deserializer. Every listed label must
/// match (compared against each label value's `Display` form, see
/// `MetricPrimitive`); unlisted labels on a bucket are ignored.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct BucketValuesParams { pub struct BucketValuesParams {
pub labels: Option<String>, pub labels: Option<String>,
@@ -103,24 +87,13 @@ pub struct BucketValuesParams {
pub limit: Option<usize>, pub limit: Option<usize>,
} }
/// One chart-ready time series returned by `GET /api/query` (ticket 013's PromQL /// `labels` uses plain `String` (already Display-formatted) since charts just need something to show; `value` is always `f64` for plottability.
/// endpoint): a label combination plus its `(timestamp_ms, value)` points, sorted by
/// timestamp. `labels` uses plain `String` values (not `MetricPrimitive`) since every
/// value has already been through `MetricPrimitive`'s `Display` form — chart code
/// doesn't need the typed value, just something to show in a legend/tooltip.
///
/// `value` is always `f64` (see `crate::engine::coerce_to_f64` for how a bucket's
/// `MetricPrimitive` is coerced into one), since a chart needs a plottable number
/// regardless of the underlying metric primitive's kind.
#[derive(Debug, Clone, Serialize, PartialEq)] #[derive(Debug, Clone, Serialize, PartialEq)]
pub struct PromqlSeriesDto { pub struct PromqlSeriesDto {
pub labels: BTreeMap<String, String>, pub labels: BTreeMap<String, String>,
pub points: Vec<(i64, f64)>, pub points: Vec<(i64, f64)>,
} }
/// Query params for `GET /api/query`. `promql` is the query string (see
/// `crate::promql`); `start_ms`/`end_ms` bound which points are included, mirroring
/// `/api/logs`/`/api/metrics/:name`.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct PromqlParams { pub struct PromqlParams {
pub promql: String, pub promql: String,
@@ -128,17 +101,13 @@ pub struct PromqlParams {
pub end_ms: Option<i64>, pub end_ms: Option<i64>,
} }
/// JSON error body returned by API handlers on a 4xx/5xx response, e.g. /// JSON error body returned by API handlers on a 4xx/5xx response.
/// `{ "error": "invalid regex: ..." }`.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct ErrorDto { pub struct ErrorDto {
pub error: String, pub error: String,
} }
/// Request body for `POST /api/settings/css`: raw CSS content to import and /// `is_theme` marks this as a selectable colour-scheme theme rather than arbitrary CSS; defaults to false.
/// store alongside the settings file. `is_theme` (ticket 033) marks this as
/// a selectable colour-scheme theme rather than arbitrary CSS; defaults to
/// `false` (arbitrary CSS) when omitted.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ImportCssRequest { pub struct ImportCssRequest {
pub filename: String, pub filename: String,
@@ -147,9 +116,7 @@ pub struct ImportCssRequest {
pub is_theme: bool, pub is_theme: bool,
} }
/// Request body for `POST /api/settings/css/link`: a reference to an /// A reference to an external CSS path/URL that ottyl doesn't copy or own.
/// external CSS path/URL that ottyl doesn't copy or own. `is_theme` (ticket
/// 033) marks this as a selectable theme rather than arbitrary CSS.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct LinkCssRequest { pub struct LinkCssRequest {
pub path_or_url: String, pub path_or_url: String,
@@ -157,14 +124,7 @@ pub struct LinkCssRequest {
pub is_theme: bool, pub is_theme: bool,
} }
/// Response shape for a single CSS entry, used both by the list endpoint and /// `id` is the real id for `Imported` entries or a stable id derived from the path/URL for `Linked` ones, so the frontend can key both kinds uniformly (only `Imported` entries are deletable/content-fetchable).
/// by mutation endpoints that return the affected entry. `id` is the entry's
/// real id for `Imported` entries, or a stable id derived from the
/// linked path/URL for `Linked` entries (see `settings::css_entry_id`) —
/// this lets the frontend key/reference both kinds of entries uniformly,
/// even though only `Imported` entries are deletable/content-fetchable.
/// `is_theme` (ticket 033) distinguishes a theme entry (selectable from the
/// theme picker) from arbitrary imported/linked CSS.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct CssEntryDto { pub struct CssEntryDto {
pub id: String, pub id: String,
@@ -202,19 +162,14 @@ impl From<&CssEntry> for CssEntryDto {
} }
} }
/// Response shape for `GET /api/settings` and `PATCH /api/settings` (ticket /// `css_entries` is omitted here since it has its own `GET /api/settings/css` endpoint.
/// 011): the subset of `Settings` the NavMenu UI needs to render a colour
/// scheme picker and know which profile is active. `css_entries` is
/// deliberately omitted here since it already has its own dedicated
/// `GET /api/settings/css` endpoint (ticket 010).
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct SettingsDto { pub struct SettingsDto {
pub color_scheme: ColorScheme, pub color_scheme: ColorScheme,
pub active_profile: Option<String>, pub active_profile: Option<String>,
/// `None` means unlimited (ticket 026). /// `None` means unlimited.
pub max_logs: Option<usize>, pub max_logs: Option<usize>,
/// Id of the selected custom theme entry, if any (ticket 033). `None` /// Id of the selected custom theme entry, if any. `None` means `color_scheme` alone determines the active palette.
/// means `color_scheme` alone determines the active palette.
pub custom_theme_id: Option<String>, pub custom_theme_id: Option<String>,
} }
@@ -229,11 +184,7 @@ impl From<&Settings> for SettingsDto {
} }
} }
/// Request body for `PATCH /api/settings`. `color_scheme`: `None` leaves the /// `max_logs`/`custom_theme_id` distinguish "omitted" (leave unchanged) from "present" (null clears, value sets), via `deserialize_double_option`.
/// current value untouched. `max_logs`/`custom_theme_id`: each distinguishes
/// "field omitted" (leave unchanged, outer `None`) from "field present"
/// (inner `Option` applied verbatim — `null` clears it, a value sets it), via
/// `deserialize_double_option`.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateSettingsRequest { pub struct UpdateSettingsRequest {
pub color_scheme: Option<ColorScheme>, pub color_scheme: Option<ColorScheme>,
@@ -243,10 +194,7 @@ pub struct UpdateSettingsRequest {
pub custom_theme_id: Option<Option<String>>, pub custom_theme_id: Option<Option<String>>,
} }
/// Distinguishes a missing JSON field (outer `None`, via `#[serde(default)]` /// Distinguishes a missing field (outer `None`) from present `null` (`Some(None)`) or present value (`Some(Some(value))`), which plain `Option<Option<T>>` can't do alone.
/// short-circuiting before this runs) from a present `null` (`Some(None)`)
/// or present value (`Some(Some(value))`) — plain `Option<Option<T>>` can't
/// tell "absent" and "explicit null" apart on its own.
fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error> fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where where
D: serde::Deserializer<'de>, D: serde::Deserializer<'de>,
@@ -255,10 +203,6 @@ where
Option::<T>::deserialize(deserializer).map(Some) Option::<T>::deserialize(deserializer).map(Some)
} }
/// Request body for `POST /api/settings/alerts` (ticket 017): creates a new
/// alert rule (see `crate::alerts::Settings::create_alert_rule`). `AlertRule`
/// itself is returned directly as the response body (it already derives
/// `Serialize`), so there's no separate response DTO here.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateAlertRuleRequest { pub struct CreateAlertRuleRequest {
pub name: String, pub name: String,
@@ -267,8 +211,6 @@ pub struct CreateAlertRuleRequest {
pub sink: AlertSink, pub sink: AlertSink,
} }
/// Request body for `PUT /api/settings/alerts/:id`: replaces every field
/// except `id` (taken from the URL, see `Settings::update_alert_rule`).
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateAlertRuleRequest { pub struct UpdateAlertRuleRequest {
pub name: String, pub name: String,
@@ -277,19 +219,13 @@ pub struct UpdateAlertRuleRequest {
pub sink: AlertSink, pub sink: AlertSink,
} }
/// Request body for `POST /api/settings/alerts/test` (ticket 020): delivers /// `server` defaults to `https://ntfy.sh` when absent.
/// a test notification to an ntfy topic/server without requiring a saved
/// rule. `server` defaults to `https://ntfy.sh` when absent, same as
/// `AlertSink::Ntfy`.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct TestAlertNotificationRequest { pub struct TestAlertNotificationRequest {
pub topic: String, pub topic: String,
pub server: Option<String>, pub server: Option<String>,
} }
/// Summary of one profile for `GET /api/settings/profiles` (ticket 011):
/// just the name and declaration/dashboard counts the Presets panel needs to
/// render a list, rather than the full param/metric declaration bodies.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct ProfileSummaryDto { pub struct ProfileSummaryDto {
pub name: String, pub name: String,
@@ -309,15 +245,12 @@ impl From<&Profile> for ProfileSummaryDto {
} }
} }
/// Request body for `POST /api/settings/profiles`: creates a new, empty
/// profile with this name (see `ProfileStore::create`).
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateProfileRequest { pub struct CreateProfileRequest {
pub name: String, pub name: String,
} }
/// One panel within a dashboard (ticket 014): a PromQL query plus how to /// Mirrors `profile::Panel`.
/// chart its result. Mirrors `profile::Panel`.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PanelDto { pub struct PanelDto {
pub id: String, pub id: String,
@@ -348,9 +281,7 @@ impl From<&PanelDto> for Panel {
} }
} }
/// A saved dashboard (ticket 014), as returned by /// Mirrors `profile::Dashboard`.
/// `GET /api/settings/profiles/:name/dashboards` and friends. Mirrors
/// `profile::Dashboard`.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardDto { pub struct DashboardDto {
pub id: String, pub id: String,
@@ -378,18 +309,12 @@ impl From<&DashboardDto> for Dashboard {
} }
} }
/// Request body for `POST /api/settings/profiles/:name/dashboards`: creates
/// a new, empty dashboard with this name (see `ProfileStore::create_dashboard`).
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateDashboardRequest { pub struct CreateDashboardRequest {
pub name: String, pub name: String,
} }
/// Request body for /// The dashboard `id` in the URL is authoritative; any `id` in the body is ignored.
/// `PUT /api/settings/profiles/:name/dashboards/:dashboard_id`: replaces the
/// dashboard's `name`/`panels` (see `ProfileStore::save_dashboard`). The
/// dashboard `id` in the URL is authoritative — any `id` in the body is
/// ignored.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct SaveDashboardRequest { pub struct SaveDashboardRequest {
pub name: String, pub name: String,
+43 -290
View File
@@ -23,9 +23,7 @@ pub struct IngestedLine {
pub text: String, pub text: String,
} }
/// Also the request body for `POST /api/params` (ticket 004): `Deserialize` is /// Also the request body for `POST /api/params` — `Deserialize` is derived directly here rather than adding a separate DTO, since the shape already matches.
/// derived directly on this engine type rather than adding a separate DTO,
/// since its shape is already exactly what the API needs.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct DeclareParamRequest { pub struct DeclareParamRequest {
pub name: String, pub name: String,
@@ -34,43 +32,16 @@ pub struct DeclareParamRequest {
pub kind: MetricPrimitiveKind, pub kind: MetricPrimitiveKind,
} }
/// v1 mechanism: a metric declaration's params are derived purely from inline named /// Params are derived purely from inline named capture groups on this metric's own regex — referencing an already-declared `ParamDeclaration` by name isn't supported yet. Also the request body for `POST /api/metrics` (hence `Deserialize`).
/// capture groups on the metric's own `message_match_regex` (each
/// `RegexToMetricPrimitiveMatcher::capture_group` must name a capture group on that
/// regex). Referencing an already-declared `ParamDeclaration` (001) by name is not
/// supported yet — that's left for a future ticket.
///
/// Also the request body for `POST /api/metrics` (ticket 004) — see
/// `DeclareParamRequest`'s doc comment for why `Deserialize` lives here rather
/// than on a separate DTO.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct DeclareMetricRequest { pub struct DeclareMetricRequest {
pub name: String, pub name: String,
pub message_match_regex: String, pub message_match_regex: String,
pub params: Vec<RegexToMetricPrimitiveMatcher>, pub params: Vec<RegexToMetricPrimitiveMatcher>,
/// Ticket 025: fixed labels added on top of whatever `params` captured /// Fixed labels layered on top of what `params` captured, so e.g. a case-insensitive match doesn't fragment into "ERROR"/"Error" series; `#[serde(default)]` so existing declarations are unaffected.
/// when the line *does* match `message_match_regex`. Together with
/// `unmatched_labels` below, this is what lets a single metric+query
/// produce a "matched X" vs "didn't match X" pair of series (e.g. the
/// seeded default dashboard's error/non-error split) with clean,
/// consistent label values, rather than whatever case/substring the
/// regex happened to capture (a case-insensitive `error` match could
/// capture `"ERROR"`, `"Error"`, etc., fragmenting what should be one
/// series into several). `#[serde(default)]` so existing declarations
/// without this field are unaffected.
#[serde(default)] #[serde(default)]
pub matched_labels: Option<BTreeMap<String, String>>, pub matched_labels: Option<BTreeMap<String, String>>,
/// When a line does *not* match `message_match_regex`, and this is /// When a line doesn't match, registers one occurrence labeled with these fixed values instead of being invisible to this metric — plain regex alternation can't express "didn't match" without lookaround; `#[serde(default)]` so existing declarations keep matching-only behavior.
/// `Some`, the metric still registers one occurrence — labeled with
/// these fixed values rather than anything captured (there's nothing to
/// capture on a non-match) — instead of the line being invisible to this
/// metric. This is the other half of the "matched X vs didn't match X"
/// pair described above: plain regex alternation can't express the
/// "didn't match" side without lookaround (unsupported by the `regex`
/// crate), since an alternation branch that's a catch-all always wins at
/// the search's leftmost position, so it can never tell "contains X
/// later in the line" apart from "X is absent entirely". `#[serde(default)]`
/// so existing declarations without this field keep matching only.
#[serde(default)] #[serde(default)]
pub unmatched_labels: Option<BTreeMap<String, String>>, pub unmatched_labels: Option<BTreeMap<String, String>>,
} }
@@ -82,11 +53,7 @@ pub enum EngineQuery {
Stats, Stats,
ParamDeclarations, ParamDeclarations,
MetricDeclarations, MetricDeclarations,
/// Bucket values for one metric (ticket 004's `GET /api/metrics/:name`). /// `labels_filter` compares against each label's `Display` form (not a typed value), since the filter arrives as plain strings off the query string.
/// `labels_filter`, when present, keeps only buckets where every listed
/// label matches — compared against each label value's `Display` form
/// (see `MetricPrimitive`) rather than a typed value, since the filter
/// arrives as plain strings off the query string.
BucketValues { BucketValues {
metric_name: String, metric_name: String,
labels_filter: Option<BTreeMap<String, String>>, labels_filter: Option<BTreeMap<String, String>>,
@@ -95,12 +62,7 @@ pub enum EngineQuery {
since_sequence: Option<u64>, since_sequence: Option<u64>,
limit: Option<usize>, limit: Option<usize>,
}, },
/// PromQL query (ticket 013's `GET /api/query`). `query` is parsed here (rather /// `query` is parsed inside the engine (not the API handler) so it stays in the single-owner loop with no copying; a malformed query is only discovered here, hence `EngineResponse::Promql` carries a `Result`.
/// than by the API handler) so parsing and evaluation both happen inside the
/// single-owner engine loop, matching every other `EngineQuery` variant's
/// pattern of evaluating in place against `index` with no copying. A malformed
/// query is therefore only discovered once this reaches `handle_query`, hence
/// `EngineResponse::Promql` carrying a `Result` rather than a bare `Vec`.
Promql { query: String, start_ms: Option<i64>, end_ms: Option<i64> }, Promql { query: String, start_ms: Option<i64>, end_ms: Option<i64> },
} }
@@ -123,10 +85,7 @@ pub enum EngineMessage {
name: String, name: String,
respond_to: oneshot::Sender<bool>, respond_to: oneshot::Sender<bool>,
}, },
/// Sets the cap on ingested logs kept in memory (ticket 026). `None` /// `None` means unlimited; sent on startup and whenever the setting changes so the cap applies without a restart.
/// means unlimited. Sent on startup (from `Settings::max_logs`) and
/// whenever the setting changes at runtime, so the cap takes effect on
/// the next ingest without a restart.
SetMaxLogs(Option<usize>), SetMaxLogs(Option<usize>),
Query { Query {
query: EngineQuery, query: EngineQuery,
@@ -142,30 +101,20 @@ pub enum EngineResponse {
ParamDeclarations(Vec<ParamDeclarationDto>), ParamDeclarations(Vec<ParamDeclarationDto>),
MetricDeclarations(Vec<MetricDeclarationDto>), MetricDeclarations(Vec<MetricDeclarationDto>),
BucketValues(Vec<BucketValueDto>), BucketValues(Vec<BucketValueDto>),
/// `Err` holds a human-readable message for a malformed query (see /// `Err` holds a human-readable message for a malformed query, which the API layer maps to a 400 response.
/// `crate::promql::parse_query`), which the API layer maps to a 400 response.
Promql(Result<Vec<PromqlSeriesDto>, String>), Promql(Result<Vec<PromqlSeriesDto>, String>),
/// A query-level error, e.g. a malformed `filter` string on /// Kept as a variant (rather than making `handle_query` return a `Result`) so every query path still funnels through the same `oneshot::Sender<EngineResponse>`.
/// `EngineQuery::Logs`/`Timeline` (ticket 007). Kept as a variant on the
/// existing response enum, rather than making `handle_query` return a
/// `Result`, so every query path still funnels through the same
/// `oneshot::Sender<EngineResponse>` — callers (`api.rs`) just add one
/// more match arm to turn it into a 4xx.
Error(String), Error(String),
} }
pub type EngineHandle = mpsc::Sender<EngineMessage>; pub type EngineHandle = mpsc::Sender<EngineMessage>;
/// Sole owner of the primary index. Runs single-threaded so the map never needs a /// Sole owner of the primary index; runs single-threaded so the map never needs a lock, and builds DTOs itself so callers never clone the index.
/// lock, and does its own response serialization/DTO construction so callers never
/// have to clone the index to answer a query.
pub async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle) { pub async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle) {
let mut index: BTreeMap<IndexKey, DataValue> = BTreeMap::new(); let mut index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
let mut max_logs: Option<usize> = None; let mut max_logs: Option<usize> = None;
let mut log_count: usize = 0; let mut log_count: usize = 0;
// Ticket 018: per-rule rolling windows / seen-value sets, keyed by rule // Per-rule rolling windows/seen-value sets, keyed by rule id; lives only as long as this loop (no restart persistence).
// id. Lives only as long as this loop (no restart persistence — see
// `alerts::AlertCondition::NewValue`'s doc comment).
let mut alert_states: HashMap<String, AlertRuntimeState> = HashMap::new(); let mut alert_states: HashMap<String, AlertRuntimeState> = HashMap::new();
while let Some(message) = rx.recv().await { while let Some(message) = rx.recv().await {
@@ -180,9 +129,7 @@ pub async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle
apply_all_metrics(&mut index, sequence, timestamp, &text); apply_all_metrics(&mut index, sequence, timestamp, &text);
evict_excess_logs(&mut index, &mut log_count, max_logs); evict_excess_logs(&mut index, &mut log_count, max_logs);
// Ticket 018: evaluate alert rules against just this line // Evaluate alert rules against just this line, then hand any fired rule off to delivery via tokio::spawn so it stays off the hot path.
// (not the whole log range), then hand any fired rule off to
// ticket 019's delivery, off the hot path via `tokio::spawn`.
let rules = settings.lock().unwrap().alert_rules.clone(); let rules = settings.lock().unwrap().alert_rules.clone();
let fired = alerts::evaluate_alerts(&rules, &mut alert_states, sequence, timestamp, &text); let fired = alerts::evaluate_alerts(&rules, &mut alert_states, sequence, timestamp, &text);
for (rule, message) in fired { for (rule, message) in fired {
@@ -217,9 +164,7 @@ pub async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle
} }
} }
/// Validates the regex and capture group before storing, so a bad declaration is /// Validates the regex and capture group before storing, so a bad declaration is rejected up front rather than discovered later.
/// rejected up front rather than silently stored (or discovered later at derivation
/// time).
fn declare_param( fn declare_param(
index: &mut BTreeMap<IndexKey, DataValue>, index: &mut BTreeMap<IndexKey, DataValue>,
request: DeclareParamRequest, request: DeclareParamRequest,
@@ -244,9 +189,7 @@ fn declare_param(
Ok(()) Ok(())
} }
/// Validates the regex and every param matcher's capture-group reference before /// Validates every param matcher's capture-group reference (not just the first bad one) so a rejection reports every problem at once.
/// storing. All matchers are checked (not just the first bad one) so a rejection can
/// report every problem at once.
fn declare_metric( fn declare_metric(
index: &mut BTreeMap<IndexKey, DataValue>, index: &mut BTreeMap<IndexKey, DataValue>,
request: DeclareMetricRequest, request: DeclareMetricRequest,
@@ -298,9 +241,7 @@ fn declare_metric(
Ok(()) Ok(())
} }
/// Parses a single captured substring into the `MetricPrimitive` its param declares. /// Returns `None` on a parse failure, which the caller treats as "this match doesn't apply".
/// Returns `None` on a parse failure (e.g. a `Number` param whose capture isn't
/// numeric), which the caller treats as "this match doesn't apply".
fn parse_metric_primitive(raw: &str, kind: MetricPrimitiveKind) -> Option<MetricPrimitive> { fn parse_metric_primitive(raw: &str, kind: MetricPrimitiveKind) -> Option<MetricPrimitive> {
match kind { match kind {
MetricPrimitiveKind::String => Some(MetricPrimitive::String(raw.to_string())), MetricPrimitiveKind::String => Some(MetricPrimitive::String(raw.to_string())),
@@ -315,52 +256,12 @@ fn parse_metric_primitive(raw: &str, kind: MetricPrimitiveKind) -> Option<Metric
.ok() .ok()
.map(MetricPrimitive::Timestamp) .map(MetricPrimitive::Timestamp)
} }
// A `Value`-role param never reaches this branch (see // A Value-role param never reaches this branch (see apply_metric_to_log); reached only for a Label-role param or standalone ParamDeclaration, parsed as a literal millisecond count.
// `apply_metric_to_log`, which derives its value from elapsed time
// instead of parsing captured text). Reached only for a `Label`-role
// metric param or a standalone `ParamDeclaration` of kind `Duration`,
// where the captured text is parsed as a literal millisecond count.
MetricPrimitiveKind::Duration => raw.parse::<i64>().ok().map(MetricPrimitive::Duration), MetricPrimitiveKind::Duration => raw.parse::<i64>().ok().map(MetricPrimitive::Duration),
} }
} }
/// Runs one metric's regex against a single log line and, on a match, appends a /// At most one param may be `Value`-role (its primitive becomes the event's value, otherwise a fixed `1` marker is used); any param failing to match/parse skips the whole match rather than storing a partial one. A `Value`-role `Duration` param is elapsed time since the previous same-labeled event, so the first occurrence pushes no bucket (nothing to diff against) rather than a bogus zero. When `regex` doesn't match and `unmatched_labels` is `Some`, an occurrence is still registered with those fixed labels.
/// bucket value entry: this is the "counter increment" (ticket 022) — a match is
/// an occurrence at a point in time, optionally labeled.
///
/// `Bucket.labels` holds every `Label`-role param (the default role — see
/// [`ParamRole`]); at most one param may instead be `Value`-role (enforced at
/// declare time), and its parsed primitive becomes the pushed event's value.
/// With no `Value`-role param, the pushed value is a fixed presence marker
/// (`MetricPrimitive::Number(Number::Int(1))`), matching the original v1
/// behavior where a bucket's `values` was purely the timeseries of *when that
/// label combination occurred* — histogram()/gauge() derived queries
/// (`crate::promql::AggOp`) read this same `value`, so a `Value`-role param is
/// what makes those meaningful (bucketing/reading an occurrence-count-of-1
/// series degenerates to "everything in one bucket"/"always 1"). If a param's
/// capture group doesn't match, or its captured text fails to parse as its
/// declared `kind`, the whole match is skipped for this line rather than
/// storing a partially-labeled bucket.
///
/// A `Value`-role param of kind `Duration` (ticket 023) is a special case: its
/// value isn't parsed from captured text at all. It's derived as the elapsed
/// time (using this event's ingestion `timestamp`) since the *previous*
/// counter event with this exact label set, tracked independently via
/// `IndexKey::LastMetricEvent` (not `Bucket.values` itself, since — see
/// below — a `Bucket` entry isn't always pushed). On the first occurrence of
/// a given label set there is no previous event to diff against, so — per the
/// ticket's "omit rather than emit a bogus zero" requirement — no `Bucket`
/// entry is pushed for that occurrence at all, rather than a zero/placeholder
/// duration; the occurrence is still recorded in `LastMetricEvent` so the
/// *next* occurrence of that label set has something to diff against.
///
/// When `regex` does *not* match and `unmatched_labels` is `Some` (ticket
/// 025), a fixed occurrence is registered anyway, labeled with those literal
/// values — see `DeclareMetricRequest::unmatched_labels` for why this exists
/// instead of a plain regex alternation. When it *does* match, `matched_labels`
/// (if `Some`) is layered on top of whatever `params` captured, so the
/// "matched" side can use the same clean literal values as the "unmatched"
/// side — see `DeclareMetricRequest::matched_labels`.
fn apply_metric_to_log( fn apply_metric_to_log(
index: &mut BTreeMap<IndexKey, DataValue>, index: &mut BTreeMap<IndexKey, DataValue>,
metric_name: &str, metric_name: &str,
@@ -413,12 +314,7 @@ fn apply_metric_to_log(
} }
let value = if is_duration { let value = if is_duration {
// Bookkeeping key is separate from the `Bucket` key below: a // Tracked separately from Bucket: a Duration's first occurrence pushes no Bucket entry, so the last-event timestamp can't live in Bucket.values.
// `Duration` value's first occurrence for this label set pushes no
// `Bucket` entry (nothing to diff against), so the "last event"
// timestamp can't live inside `Bucket.values` — it has to be tracked
// independently so the *next* occurrence still has something to
// diff against.
let last_event_key = let last_event_key =
IndexKey::LastMetricEvent { metric_name: metric_name.to_string(), labels: labels.clone() }; IndexKey::LastMetricEvent { metric_name: metric_name.to_string(), labels: labels.clone() };
let previous_timestamp = match index.get(&last_event_key) { let previous_timestamp = match index.get(&last_event_key) {
@@ -456,9 +352,7 @@ fn push_bucket_value(
} }
} }
/// Applies a newly-declared metric to every already-ingested log, so a metric /// Backfills buckets for a newly-declared metric against every already-ingested log, not just future ones.
/// declared after logs have already arrived still gets populated buckets for them
/// (backfill), rather than only reflecting logs ingested from that point on.
fn backfill_metric( fn backfill_metric(
index: &mut BTreeMap<IndexKey, DataValue>, index: &mut BTreeMap<IndexKey, DataValue>,
metric_name: &str, metric_name: &str,
@@ -485,9 +379,6 @@ fn backfill_metric(
} }
} }
/// Runs every currently-declared metric's regex against one newly-ingested log line,
/// updating whichever buckets match. A line matching zero metrics leaves the bucket
/// range untouched.
fn apply_all_metrics( fn apply_all_metrics(
index: &mut BTreeMap<IndexKey, DataValue>, index: &mut BTreeMap<IndexKey, DataValue>,
sequence: u64, sequence: u64,
@@ -529,11 +420,7 @@ fn apply_all_metrics(
} }
} }
/// Bucket values for one metric, in insertion order, optionally filtered to a single /// The metrics API layers its own textual label-filter and range filtering on top rather than using this function's `labels_filter` param (used directly by tests).
/// exact label combination. Used directly by tests (exact-match filtering) and, via
/// `EngineQuery::BucketValues` in `handle_query` below, by the metrics API (ticket
/// 004) — which layers its own textual label-filter and time/sequence-range
/// filtering on top rather than using this function's `labels_filter` parameter.
fn bucket_values<'a>( fn bucket_values<'a>(
index: &'a BTreeMap<IndexKey, DataValue>, index: &'a BTreeMap<IndexKey, DataValue>,
metric_name: &'a str, metric_name: &'a str,
@@ -553,42 +440,7 @@ fn bucket_values<'a>(
.flatten() .flatten()
} }
/// A [`FilterExpr`] compiled against a *snapshot* of the current index, ready /// A [`FilterExpr`] compiled against a snapshot of the index, so evaluating per log line touches the index only once: a `metric[param]` comparison is pre-resolved into a `HashSet` of matching sequences ([`CompiledFilter::MetricMatch`]) for O(1) per-line lookup instead of a regex re-run; a comparison with no `[param]` is rejected since v1 buckets have no single "the metric's value", only a per-label-combo occurrence marker.
/// to be evaluated per-log-line without touching the index again.
///
/// ## Design: how metric comparisons resolve against buckets
///
/// A FilterBar comparison like `my_metric[level]==5` can't be evaluated by
/// re-running `my_metric`'s regex against each log line at query time — that
/// would duplicate work the engine already did at ingest/backfill time
/// (ticket 003), and would require re-parsing the metric's declaration for
/// every query. Instead, since ticket 003 already derived, for every metric,
/// one `Bucket` per distinct label combination holding the `(sequence,
/// timestamp, _)` occurrences that produced it, resolving a metric
/// comparison is a **one-time scan of that metric's buckets per query**
/// (via [`bucket_values`]), rather than a per-log-line regex re-run:
///
/// 1. For `metric[param] <op> value`, scan every bucket for `metric` (this
/// reuses the same ticket-003 range-scan `bucket_values` already uses for
/// `GET /api/metrics/:name`), and keep the buckets whose `labels[param]`
/// satisfies `<op> value`.
/// 2. Every `(sequence, ..)` occurrence recorded in a kept bucket is a log
/// line the comparison matches. Those sequences are collected into a
/// `HashSet<u64>` once, up front — [`CompiledFilter::MetricMatch`].
/// 3. Evaluating the comparison for a given log line is then just an `O(1)`
/// `HashSet::contains(&sequence)` check, reusing the fact that a line's
/// bucket membership (which buckets a line matched) was already computed
/// when the line was ingested/backfilled, rather than recomputed here.
///
/// `sequence`/`timestamp` comparisons skip buckets entirely: they compare
/// straight against the log's own `IndexKey::IngestedLog` fields, so they're
/// compiled into their own lightweight variants ([`CompiledFilter::Sequence`]
/// / [`CompiledFilter::Timestamp`]) instead of [`CompiledFilter::MetricMatch`].
///
/// A metric comparison with no `[param]` isn't meaningful under this model —
/// v1's buckets have no single "the metric's value", only a per-label-combo
/// occurrence marker (see `apply_metric_to_log`'s doc comment) — so
/// `compile_filter` rejects it with a clear error instead of guessing.
enum CompiledFilter { enum CompiledFilter {
Regex(Regex), Regex(Regex),
Sequence { op: CompareOp, target: f64 }, Sequence { op: CompareOp, target: f64 },
@@ -598,11 +450,7 @@ enum CompiledFilter {
Or(Box<CompiledFilter>, Box<CompiledFilter>), Or(Box<CompiledFilter>, Box<CompiledFilter>),
} }
/// Parses and compiles a FilterBar query string against `index`, resolving /// Resolves every regex/metric-comparison/datetime-literal up front so per-line evaluation ([`eval_compiled`]) never touches the index; `now` anchors relative literals so every comparison in one query resolves against the same instant.
/// every regex/metric-comparison/datetime-literal up front so evaluating it
/// per log line (see [`eval_compiled`]) never touches the index or re-parses
/// anything. `now` anchors relative datetime literals (e.g. `-5m`) so every
/// comparison in one query is resolved against the same instant.
fn compile_filter_query( fn compile_filter_query(
index: &BTreeMap<IndexKey, DataValue>, index: &BTreeMap<IndexKey, DataValue>,
filter: &str, filter: &str,
@@ -637,12 +485,7 @@ fn compile_filter(
} }
} }
/// `sequence <op> value` — compares straight against the log's own sequence /// A `Raw` value is accepted defensively (parsed as a number) even though the grammar shouldn't produce one for `sequence`.
/// number. `value` is normally `FilterValue::Number` (the grammar parses a
/// bare integer as a number); a `Raw` value is accepted too as long as it
/// still parses as a number (defensive - the grammar shouldn't produce this
/// for `sequence`, but nothing stops a caller from writing `sequence<"5"`-ish
/// input that the value token still captures as text).
fn compile_sequence_comparison(op: CompareOp, value: &FilterValue) -> Result<CompiledFilter, String> { fn compile_sequence_comparison(op: CompareOp, value: &FilterValue) -> Result<CompiledFilter, String> {
let target = match value { let target = match value {
FilterValue::Number(n) => *n, FilterValue::Number(n) => *n,
@@ -653,11 +496,7 @@ fn compile_sequence_comparison(op: CompareOp, value: &FilterValue) -> Result<Com
Ok(CompiledFilter::Sequence { op, target }) Ok(CompiledFilter::Sequence { op, target })
} }
/// `timestamp <op> value` — compares straight against the log's own /// A `Number` is treated as raw milliseconds; a `Raw` token is resolved via `filter_datetime::parse_datetime` against `now`.
/// timestamp. A `FilterValue::Number` is treated as raw milliseconds (no
/// datetime parsing needed); a `FilterValue::Raw` token (e.g. `12:00`,
/// `-5m`, `2024-01-15`) is resolved via `filter_datetime::parse_datetime`
/// against `now`.
fn compile_timestamp_comparison( fn compile_timestamp_comparison(
op: CompareOp, op: CompareOp,
value: &FilterValue, value: &FilterValue,
@@ -672,9 +511,7 @@ fn compile_timestamp_comparison(
Ok(CompiledFilter::Timestamp { op, target_ms }) Ok(CompiledFilter::Timestamp { op, target_ms })
} }
/// `metric[param] <op> value` for any metric name other than the reserved /// `metric[param] <op> value` for any metric name other than `sequence`/`timestamp` — see [`CompiledFilter`]'s doc comment.
/// `sequence`/`timestamp` — see [`CompiledFilter`]'s doc comment for the
/// overall approach.
fn compile_metric_comparison( fn compile_metric_comparison(
index: &BTreeMap<IndexKey, DataValue>, index: &BTreeMap<IndexKey, DataValue>,
metric: &str, metric: &str,
@@ -710,14 +547,7 @@ fn compile_metric_comparison(
Ok(CompiledFilter::MetricMatch(matching)) Ok(CompiledFilter::MetricMatch(matching))
} }
/// Compares one bucket label's `MetricPrimitive` against a filter's /// Mismatched kinds that can't be meaningfully compared (e.g. a string label against a bare number) simply never match, rather than erroring.
/// right-hand side. Numbers compare numerically (a `Raw` value is parsed as
/// a number); timestamps compare against a resolved datetime (a `Number`
/// value is treated as raw milliseconds, a `Raw` value goes through
/// `filter_datetime::parse_datetime`); strings compare lexicographically
/// against the raw token (or a number's textual form). Mismatched kinds that
/// can't be meaningfully compared (e.g. a string label against a bare
/// number) simply never match, rather than erroring.
fn compare_metric_primitive( fn compare_metric_primitive(
primitive: &MetricPrimitive, primitive: &MetricPrimitive,
op: CompareOp, op: CompareOp,
@@ -754,8 +584,7 @@ fn compare_metric_primitive(
Ok(ordering.is_some_and(|ordering| apply_compare_op(ordering, op))) Ok(ordering.is_some_and(|ordering| apply_compare_op(ordering, op)))
} }
/// `pub(crate)` so `crate::alerts` can reuse it for `Threshold`/`Ratio` /// `pub(crate)` so `crate::alerts` can reuse it for `Threshold`/`Ratio` comparisons instead of duplicating the match.
/// comparisons instead of duplicating the match.
pub(crate) fn apply_compare_op(ordering: Ordering, op: CompareOp) -> bool { pub(crate) fn apply_compare_op(ordering: Ordering, op: CompareOp) -> bool {
match op { match op {
CompareOp::Lt => ordering == Ordering::Less, CompareOp::Lt => ordering == Ordering::Less,
@@ -767,10 +596,7 @@ pub(crate) fn apply_compare_op(ordering: Ordering, op: CompareOp) -> bool {
} }
} }
/// Evaluates a compiled filter against one log line. Cheap: every regex, /// Cheap: everything was already resolved by [`compile_filter_query`], so this is just pattern matching and `HashSet` lookups — no index access.
/// datetime literal, and bucket scan was already resolved by
/// [`compile_filter_query`], so this is just pattern matching, numeric
/// comparisons, and `HashSet` lookups — no index access.
fn eval_compiled(compiled: &CompiledFilter, sequence: u64, timestamp: Timestamp, text: &str) -> bool { fn eval_compiled(compiled: &CompiledFilter, sequence: u64, timestamp: Timestamp, text: &str) -> bool {
match compiled { match compiled {
CompiledFilter::Regex(re) => re.is_match(text), CompiledFilter::Regex(re) => re.is_match(text),
@@ -832,11 +658,7 @@ fn param_declarations_range(
}) })
} }
/// Compiles every currently-declared param's regex once, so `derive_log_params` /// A param whose stored regex fails to compile (shouldn't happen — `declare_param` validates it) is skipped rather than panicking.
/// can be applied to many lines without recompiling each param's regex per
/// line. A param whose stored regex somehow fails to compile (shouldn't
/// happen — `declare_param` validates it before storing) is skipped rather
/// than panicking.
fn compiled_param_declarations( fn compiled_param_declarations(
index: &BTreeMap<IndexKey, DataValue>, index: &BTreeMap<IndexKey, DataValue>,
) -> Vec<(String, Regex, String, MetricPrimitiveKind)> { ) -> Vec<(String, Regex, String, MetricPrimitiveKind)> {
@@ -849,12 +671,7 @@ fn compiled_param_declarations(
.collect() .collect()
} }
/// Runs every declared param's regex against one log line's text, returning /// A param is omitted (not included as null/empty) when its regex/capture group doesn't match or the captured text fails to parse — see `LogEntryDto::params`.
/// the derived `MetricPrimitive` values keyed by param name. A param is
/// omitted from the result (rather than included as null/empty) when its
/// regex doesn't match the line, its capture group doesn't match, or the
/// captured text fails to parse as the param's declared `kind` — see
/// `LogEntryDto::params`.
fn derive_log_params( fn derive_log_params(
text: &str, text: &str,
declarations: &[(String, Regex, String, MetricPrimitiveKind)], declarations: &[(String, Regex, String, MetricPrimitiveKind)],
@@ -875,11 +692,7 @@ fn derive_log_params(
params params
} }
/// Evicts the oldest (lowest-`sequence`) ingested logs until `log_count` is /// Bucket values already derived from an evicted log are left in place — buckets are aggregate data, not raw logs, so they're not retroactively pruned.
/// back at or under `max_logs`. `sequence` numbering is not renumbered on
/// eviction, and any `Bucket` values already derived from an evicted log are
/// left in place — buckets are aggregate data, not raw logs, so they're not
/// retroactively pruned.
fn evict_excess_logs( fn evict_excess_logs(
index: &mut BTreeMap<IndexKey, DataValue>, index: &mut BTreeMap<IndexKey, DataValue>,
log_count: &mut usize, log_count: &mut usize,
@@ -921,16 +734,7 @@ fn handle_query(index: &BTreeMap<IndexKey, DataValue>, query: EngineQuery) -> En
None => None, None => None,
}; };
let limit = params.limit.unwrap_or(500).min(10_000); let limit = params.limit.unwrap_or(500).min(10_000);
// Compile each currently-declared param's regex once up front, then // O(lines_returned * declared_params) per query — fine at v1 scale; if volume/params grow, move to incremental derivation at ingest time like apply_all_metrics does for buckets.
// apply the compiled set to every *returned* line below (ticket 015).
// Perf note: this is a straightforward per-returned-line,
// per-declared-param regex scan — O(lines_returned * declared_params)
// — rather than precomputing/caching derived params for the whole log
// range. That's fine at this codebase's current scale (v1), but if
// either the log volume or number of declared params grows a lot,
// this should move to incremental derivation at ingest time (mirroring
// how `apply_all_metrics` already does for metric buckets) instead of
// recomputing on every `/api/logs` query.
let compiled_params = compiled_param_declarations(index); let compiled_params = compiled_param_declarations(index);
let entries: Vec<LogEntryDto> = logs_range(index) let entries: Vec<LogEntryDto> = logs_range(index)
.filter(|(sequence, timestamp, _)| { .filter(|(sequence, timestamp, _)| {
@@ -1068,18 +872,7 @@ fn handle_query(index: &BTreeMap<IndexKey, DataValue>, query: EngineQuery) -> En
} }
} }
/// Coerces a bucket's stored `MetricPrimitive` into a plottable `f64` for /// Coerces a bucket's `MetricPrimitive` into a plottable `f64`: Number/Timestamp/Duration use their natural numeric value; `String` has no numeric reading, so it falls back to `1.0` (matching the presence/occurrence marker used for every bucket) rather than being skipped, keeping every declared metric queryable.
/// `PromqlSeriesDto`:
/// - `Number` uses its underlying numeric value.
/// - `Timestamp` uses its millisecond value (arbitrary but at least meaningful/
/// orderable, since there's no other sensible single number for a point in time).
/// - `String` has no numeric reading at all, so it falls back to `1.0` — i.e. it is
/// treated the same as the presence/occurrence marker `apply_metric_to_log` already
/// stores for every bucket value today (ticket 003), meaning a PromQL series over a
/// `String`-valued bucket becomes an occurrence-count series rather than being
/// skipped. This keeps every declared metric queryable (matching the ticket's
/// "documented fallback" requirement) rather than silently omitting non-numeric
/// series.
fn coerce_to_f64(value: &MetricPrimitive) -> f64 { fn coerce_to_f64(value: &MetricPrimitive) -> f64 {
match value { match value {
MetricPrimitive::Number(n) => match n { MetricPrimitive::Number(n) => match n {
@@ -1092,10 +885,7 @@ fn coerce_to_f64(value: &MetricPrimitive) -> f64 {
} }
} }
/// True when every matcher in `matchers` is satisfied by `labels` (compared against /// Compared against each label's `Display` form; extra labels on the bucket are ignored — the matcher list only needs to be a subset.
/// each label value's `Display` form, same convention as `EngineQuery::BucketValues`'s
/// `labels_filter`). Unmatched/extra labels on the bucket are ignored — a matcher list
/// only needs to be a subset.
fn matches_label_matchers( fn matches_label_matchers(
labels: &BTreeMap<String, MetricPrimitive>, labels: &BTreeMap<String, MetricPrimitive>,
matchers: &[LabelMatcher], matchers: &[LabelMatcher],
@@ -1105,26 +895,7 @@ fn matches_label_matchers(
}) })
} }
/// Evaluates a parsed PromQL query against `index`, producing chart-ready series. /// Evaluates a parsed PromQL query against `index`, producing chart-ready series: baseline is one series per label combination; `sum()`/`avg()` merge series by timestamp into one label-less series; `rate()` is simplified to a single point over `(last_ts - range, last_ts]` (an occurrence-count-per-second reading, not PromQL's true sliding-window-per-step) since bucket values are occurrence markers, not cumulative counters.
///
/// Baseline (no aggregation wrapper): one output series per distinct label
/// combination that matches `parsed.metric_name` + `parsed.matchers`, with that
/// combination's bucket values as `(timestamp_ms, value)` points (see
/// `coerce_to_f64`), sorted by timestamp.
///
/// `sum()`/`avg()`: merges every matching series' points by exact millisecond
/// timestamp (summing, or averaging, the values that land on the same timestamp
/// across series) into a single output series with empty labels — the aggregation
/// collapses the label dimension, same as PromQL's `sum by ()`/`avg by ()` default.
///
/// `rate()`: requires (and, per `promql::parse_query`, is only ever given) a range
/// selector. This is a simplified, single-point-per-series rate rather than PromQL's
/// true sliding-window-per-evaluation-step semantics: for each matching series, it
/// takes the window `(last_point_ts - range, last_point_ts]` and divides the number
/// of points falling in that window by the range's duration in seconds, producing one
/// `(last_point_ts, rate)` point per series. Since bucket values are occurrence
/// markers rather than cumulative counters (ticket 003), an occurrence-count-per-
/// second reading is the meaningful analogue of PromQL's counter-increase rate here.
fn evaluate_promql( fn evaluate_promql(
index: &BTreeMap<IndexKey, DataValue>, index: &BTreeMap<IndexKey, DataValue>,
parsed: &PromqlQuery, parsed: &PromqlQuery,
@@ -1190,10 +961,7 @@ fn evaluate_promql(
.collect() .collect()
} }
Some(AggOp::Gauge) => { Some(AggOp::Gauge) => {
// Ticket 022: collapse each label combination's counter events // Collapse each label combination's events down to their most-recent value in range — "value at time T", not a distinct stored gauge kind.
// down to their most-recent value in the queried time range —
// "value at time T" derived from the last matching event, rather
// than a distinct stored gauge kind.
series_points series_points
.into_iter() .into_iter()
.filter_map(|(labels, points)| { .filter_map(|(labels, points)| {
@@ -1212,12 +980,7 @@ fn evaluate_promql(
.flat_map(|points| points.iter().map(|(ts, _)| *ts)) .flat_map(|points| points.iter().map(|(ts, _)| *ts))
.max() .max()
.unwrap_or(0); .unwrap_or(0);
// Ticket 022: bucket every matching counter event's value // Buckets every value (across all label combinations, collapsing the label dimension like sum()/avg()) into fixed-width buckets, labeled `le` (Prometheus's less-than-or-equal convention).
// (across all label combinations — histogram collapses the
// label dimension the same way sum()/avg() do) into fixed-width
// buckets, one output series per bucket labeled `le` (Prometheus's
// "less-than-or-equal" convention) holding a single
// `(last_ts, count)` point.
let mut counts: BTreeMap<i64, u64> = BTreeMap::new(); let mut counts: BTreeMap<i64, u64> = BTreeMap::new();
for points in series_points.values() { for points in series_points.values() {
for (_, value) in points { for (_, value) in points {
@@ -1667,8 +1430,7 @@ mod tests {
assert_eq!(series.len(), 1); assert_eq!(series.len(), 1);
assert_eq!(series[0].labels.get("path").map(String::as_str), Some("/a")); assert_eq!(series[0].labels.get("path").map(String::as_str), Some("/a"));
// Occurrence markers coerce to 1.0 (see `coerce_to_f64`); timestamps come from // Occurrence markers coerce to 1.0 (see coerce_to_f64); timestamps come from ingest_log's sequence-as-millis helper.
// `ingest_log`'s `sequence as millis` helper.
assert_eq!(series[0].points, vec![(0, 1.0), (2, 1.0)]); assert_eq!(series[0].points, vec![(0, 1.0), (2, 1.0)]);
} }
@@ -1835,8 +1597,7 @@ mod tests {
let mut series = evaluate_promql(&index, &parsed, None, None); let mut series = evaluate_promql(&index, &parsed, None, None);
series.sort_by(|a, b| a.labels.get("le").cmp(&b.labels.get("le"))); series.sort_by(|a, b| a.labels.get("le").cmp(&b.labels.get("le")));
// durations 2,4 -> bucket [0,10) le=10 (count 2); 12,15 -> [10,20) le=20 (count 2); // durations 2,4 -> le=10 (count 2); 12,15 -> le=20 (count 2); 21 -> le=30 (count 1).
// 21 -> [20,30) le=30 (count 1).
assert_eq!(series.len(), 3); assert_eq!(series.len(), 3);
assert_eq!(series[0].labels.get("le").map(String::as_str), Some("10")); assert_eq!(series[0].labels.get("le").map(String::as_str), Some("10"));
assert_eq!(series[0].points[0].1, 2.0); assert_eq!(series[0].points[0].1, 2.0);
@@ -1871,9 +1632,7 @@ mod tests {
ingest_log(&mut index, 2, "user=alice logged in"); ingest_log(&mut index, 2, "user=alice logged in");
let values: Vec<_> = bucket_values(&index, "login_gap", None).collect(); let values: Vec<_> = bucket_values(&index, "login_gap", None).collect();
// First occurrences (alice@0, bob@1) produce no duration value at all — // First occurrences (alice@0, bob@1) produce no duration value — only alice's second occurrence (seq 2) has something to diff against.
// only alice's second occurrence (sequence 2) has a previous event to
// diff against.
assert_eq!(values.len(), 1); assert_eq!(values.len(), 1);
let (labels, (sequence, timestamp, value)) = &values[0]; let (labels, (sequence, timestamp, value)) = &values[0];
assert_eq!(labels.get("user").map(|v| v.to_string()), Some("alice".to_string())); assert_eq!(labels.get("user").map(|v| v.to_string()), Some("alice".to_string()));
@@ -1912,10 +1671,7 @@ mod tests {
assert_eq!(series[0].points, vec![(10, 10.0)]); assert_eq!(series[0].points, vec![(10, 10.0)]);
} }
// Fixed matched_labels/unmatched_labels (rather than a captured "category" // Fixed labels (not a captured param) so a case-insensitive match collapses into one clean "error" series regardless of source-text case.
// param) so a case-insensitive match like "ERROR" vs "Error" still
// collapses into one clean "error" series instead of fragmenting by
// whatever case the source text happened to use.
fn declare_error_category_metric(index: &mut BTreeMap<IndexKey, DataValue>) { fn declare_error_category_metric(index: &mut BTreeMap<IndexKey, DataValue>) {
declare_metric( declare_metric(
index, index,
@@ -2016,7 +1772,7 @@ mod tests {
} }
} }
// -- ticket 007: FilterBar query integration ----------------------- // -- FilterBar query integration --
fn logs_query(filter: Option<&str>) -> EngineQuery { fn logs_query(filter: Option<&str>) -> EngineQuery {
EngineQuery::Logs(LogsParams { EngineQuery::Logs(LogsParams {
@@ -2107,10 +1863,7 @@ mod tests {
ingest_log(&mut index, 1, "level=info duration=2 boom"); ingest_log(&mut index, 1, "level=info duration=2 boom");
ingest_log(&mut index, 2, "level=error duration=3 all good"); ingest_log(&mut index, 2, "level=error duration=3 all good");
// Only sequence 0 has both the "boom" regex match *and* level=error. // Only sequence 0 has both; note comparison values are bare tokens (level==error), not quoted string literals like regex()'s argument.
// Note: comparison values are bare tokens (see `value` in the
// grammar), not quoted string literals like `regex(...)`'s argument
// — so `level==error`, not `level=="error"`.
match handle_query( match handle_query(
&index, &index,
logs_query(Some(r#"regex("boom") and requests[level]==error"#)), logs_query(Some(r#"regex("boom") and requests[level]==error"#)),
+14 -45
View File
@@ -1,24 +1,9 @@
//! Datetime parsing for the filter query language's timestamp comparisons. //! Datetime parsing for the filter query language's timestamp comparisons: relative
//! //! humantime, time-of-day (resolved against the UTC calendar day of `now`), or a
//! This module is intentionally standalone: it has no dependency on the engine/index //! full date/datetime literal.
//! types or the filter grammar module, so it can be developed, tested, and reused
//! independently of the rest of the filter machinery.
//!
//! Three input forms are supported, all normalized to a single [`Timestamp`]:
//!
//! 1. Relative humantime, e.g. `5h`, `-3m` — resolved against a supplied "now" at
//! parse/eval time (parsed with the `humantime` crate).
//! 2. Absolute time-of-day, e.g. `12:00` — resolves to that time on the current day.
//! **This module treats "current day" as the UTC calendar day of `now`, and resolves
//! the time-of-day in UTC.** (We use `chrono` for date/time parsing since no other
//! datetime crate was already a dependency.)
//! 3. Full date/datetime literals, e.g. `2024-01-15` or `2024-01-15T12:00:00Z`
//! (RFC3339-ish). A bare date is interpreted as midnight UTC on that date.
// `engine.rs` (ticket 007) calls `parse_datetime` to resolve `FilterValue::Raw` // `#![allow(dead_code)]` stays since most of this module's surface is still only
// tokens (e.g. `-5m`, `12:00`, a full date) in `timestamp`/metric-comparison // exercised by its own unit tests below.
// filter clauses; `#![allow(dead_code)]` stays since most of this module's
// surface is still only exercised by its own unit tests below.
#![allow(dead_code)] #![allow(dead_code)]
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc}; use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
@@ -28,9 +13,7 @@ use crate::timestamp::Timestamp;
/// Error returned when a datetime filter literal can't be resolved to a [`Timestamp`]. /// Error returned when a datetime filter literal can't be resolved to a [`Timestamp`].
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum DateTimeParseError { pub enum DateTimeParseError {
/// The input matched the shape of a supported format (relative duration, /// The input matched a supported format's shape but its value was invalid, e.g. `25:99`.
/// time-of-day, or date/datetime) but its value was invalid or ambiguous,
/// e.g. an out-of-range time like `25:99`.
Invalid { input: String, reason: String }, Invalid { input: String, reason: String },
/// The input didn't match any supported format at all. /// The input didn't match any supported format at all.
Unparseable(String), Unparseable(String),
@@ -54,11 +37,8 @@ impl std::fmt::Display for DateTimeParseError {
impl std::error::Error for DateTimeParseError {} impl std::error::Error for DateTimeParseError {}
/// Parses a filter datetime literal into an absolute [`Timestamp`], resolving any /// Parses a filter datetime literal into an absolute [`Timestamp`]; `now` is a parameter
/// relative or day-relative forms against `now`. /// (rather than calling `Timestamp::now()` internally) so callers can pass a fixed value in tests.
///
/// `now` is taken as a parameter (rather than this function calling `Timestamp::now()`
/// internally) so callers can pass a fixed value in tests.
pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTimeParseError> { pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTimeParseError> {
let trimmed = input.trim(); let trimmed = input.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@@ -80,11 +60,8 @@ pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTime
Err(DateTimeParseError::Unparseable(input.to_string())) Err(DateTimeParseError::Unparseable(input.to_string()))
} }
/// Tries to interpret `input` as a signed relative humantime duration (`5h`, `-3m`). /// Tries to interpret `input` as a signed relative humantime duration (`5h`, `-3m`);
/// /// returns `None` if it doesn't look like one at all, so the caller can try other formats.
/// Returns `None` if `input` doesn't look like a relative duration at all (so the
/// caller can try the other formats); returns `Some(Err(..))` if it looks like one but
/// the duration itself is malformed.
fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTimeParseError>> { fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTimeParseError>> {
let (sign, rest) = match input.strip_prefix('-') { let (sign, rest) = match input.strip_prefix('-') {
Some(rest) => (-1i128, rest), Some(rest) => (-1i128, rest),
@@ -94,11 +71,7 @@ fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTim
}, },
}; };
// Only treat this as an attempted relative duration if it starts with a digit // Require a digit followed by an alphabetic unit so date-like inputs (e.g. "2024-01-15") fall through to the date parser instead of erroring here as an invalid duration.
// followed by (eventually) an alphabetic unit -- this keeps dates like
// "2024-01-15" (which start with digits but aren't durations) from being
// mis-attempted here and reported as "invalid duration" instead of falling
// through to the date parser.
let looks_like_duration = rest let looks_like_duration = rest
.chars() .chars()
.next() .next()
@@ -126,8 +99,7 @@ fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTim
/// Tries to interpret `input` as a full date or RFC3339-ish datetime literal. /// Tries to interpret `input` as a full date or RFC3339-ish datetime literal.
fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError>> { fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError>> {
// Only attempt this path for inputs shaped like a date (contain a '-' separating // Only attempt this path for inputs shaped like a date, e.g. "2024-01-15".
// digits, e.g. "2024-01-15"); otherwise let other parsers have a shot.
let looks_like_date = input.len() >= 8 && input.as_bytes()[4] == b'-'; let looks_like_date = input.len() >= 8 && input.as_bytes()[4] == b'-';
if !looks_like_date { if !looks_like_date {
return None; return None;
@@ -151,8 +123,7 @@ fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError
} }
} }
/// Tries to interpret `input` as a bare time-of-day (`12:00`, `00:00`, `23:59:59`), /// Tries to interpret `input` as a bare time-of-day, resolved against the UTC calendar day of `now`.
/// resolved against the UTC calendar day of `now`.
fn try_time_of_day( fn try_time_of_day(
input: &str, input: &str,
now: Timestamp, now: Timestamp,
@@ -176,8 +147,7 @@ fn try_time_of_day(
let now_millis = now.as_millis(); let now_millis = now.as_millis();
let now_dt = Utc.timestamp_millis_opt(now_millis).single().unwrap_or_else(|| { let now_dt = Utc.timestamp_millis_opt(now_millis).single().unwrap_or_else(|| {
// Fall back to the epoch date if `now` is somehow out of range; this should // Fall back to the epoch date; shouldn't happen since `Timestamp` is always a valid instant.
// not happen in practice since `Timestamp` always comes from a valid instant.
Utc.timestamp_millis_opt(0).single().expect("epoch is valid") Utc.timestamp_millis_opt(0).single().expect("epoch is valid")
}); });
let today = now_dt.date_naive(); let today = now_dt.date_naive();
@@ -193,7 +163,6 @@ fn timestamp_from_datetime(dt: DateTime<Utc>) -> Timestamp {
mod tests { mod tests {
use super::*; use super::*;
/// A fixed "now": 2024-06-15T12:34:56Z.
fn fixed_now() -> Timestamp { fn fixed_now() -> Timestamp {
let dt = Utc.with_ymd_and_hms(2024, 6, 15, 12, 34, 56).unwrap(); let dt = Utc.with_ymd_and_hms(2024, 6, 15, 12, 34, 56).unwrap();
Timestamp::from_millis(dt.timestamp_millis()) Timestamp::from_millis(dt.timestamp_millis())
+10 -41
View File
@@ -1,26 +1,5 @@
//! FilterBar query language: grammar, AST and parser. //! FilterBar query language: grammar (see `src/filter_parser.pest`), AST and parser.
//! //! Evaluating the AST against real log data is left to `engine.rs`.
//! This module is intentionally standalone and has no dependency on the
//! engine/index types (`data_value`, `index_key`, `engine`, ...). It only
//! knows how to turn a filter string into an [`FilterExpr`] AST. Evaluating
//! that AST against real log data, and resolving [`FilterValue::Raw`] tokens
//! into concrete datetimes, is left to later integration tickets.
//!
//! Grammar (see `src/filter_parser.pest`):
//!
//! - `regex("...")` — raw-text regex search over log lines.
//! - `metric_name[param] <op> value` — metric comparisons, with `<op>` one
//! of `<`, `>`, `<=`, `>=`, `==`, `!=`.
//! - `sequence <op> value` and `timestamp <op> value` — reserved metric
//! names usable the same way, without a `[param]`.
//! - `and` / `or` boolean combinators, with `and` binding tighter than `or`.
//! - Parenthesized sub-expressions.
//!
//! `engine.rs` (ticket 007) is what actually calls into this module: it
//! compiles the returned [`FilterExpr`] against the index (resolving
//! [`FilterValue::Raw`] tokens via `filter_datetime` and metric comparisons
//! via bucket data) and evaluates it per log line while answering
//! `EngineQuery::Logs`/`Timeline`.
#![allow(dead_code)] #![allow(dead_code)]
use pest::Parser; use pest::Parser;
@@ -48,9 +27,8 @@ pub enum FilterExpr {
Or(Box<FilterExpr>, Box<FilterExpr>), Or(Box<FilterExpr>, Box<FilterExpr>),
} }
/// Comparison operators supported by metric comparisons. Also reused by /// Comparison operators for metric comparisons; also reused by `crate::alerts::AlertCondition`
/// `crate::alerts::AlertCondition::Threshold`/`Ratio` for their own /// for threshold/ratio comparisons (hence `Serialize`/`Deserialize`).
/// threshold/ratio comparisons (hence `Serialize`/`Deserialize`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum CompareOp { pub enum CompareOp {
@@ -62,10 +40,8 @@ pub enum CompareOp {
Ne, Ne,
} }
/// The right-hand side of a comparison. Values that parse as a number /// The right-hand side of a comparison: values that parse as a number become
/// become [`FilterValue::Number`]; everything else (e.g. datetime-like /// [`FilterValue::Number`], everything else is kept as an opaque [`FilterValue::Raw`] string.
/// tokens such as `12:00`) is kept as an opaque [`FilterValue::Raw`] string
/// to be resolved by a later ticket.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum FilterValue { pub enum FilterValue {
Number(f64), Number(f64),
@@ -96,9 +72,7 @@ impl std::error::Error for FilterParseError {}
impl From<pest::error::Error<Rule>> for FilterParseError { impl From<pest::error::Error<Rule>> for FilterParseError {
fn from(err: pest::error::Error<Rule>) -> Self { fn from(err: pest::error::Error<Rule>) -> Self {
// pest's own Display already renders a located, human-readable // pest's Display already renders a located message, so use it and add a short prefix.
// message (line/column + a pointer into the offending input), so we
// use it as the basis and add a short prefix for context.
FilterParseError::new(format!("failed to parse filter expression: {err}")) FilterParseError::new(format!("failed to parse filter expression: {err}"))
} }
} }
@@ -121,9 +95,7 @@ pub fn parse(input: &str) -> Result<FilterExpr, FilterParseError> {
fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> { fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
debug_assert_eq!(pair.as_rule(), Rule::or_expr); debug_assert_eq!(pair.as_rule(), Rule::or_expr);
// `or_kw` is atomic (see the grammar file) so it is *not* silent: it // `or_kw` is atomic so it's not silent, showing up as a sibling pair between `and_expr` operands — skip it rather than assuming a fixed alternating structure.
// shows up as a sibling pair between the `and_expr` operands. Skip it
// rather than assuming a fixed alternating structure.
let mut and_exprs = pair let mut and_exprs = pair
.into_inner() .into_inner()
.filter(|p| p.as_rule() != Rule::or_kw); .filter(|p| p.as_rule() != Rule::or_kw);
@@ -140,7 +112,7 @@ fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
fn build_and_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> { fn build_and_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
debug_assert_eq!(pair.as_rule(), Rule::and_expr); debug_assert_eq!(pair.as_rule(), Rule::and_expr);
// Same reasoning as above for `and_kw`. // Same reasoning as `build_or_expr` for `and_kw`.
let mut terms = pair let mut terms = pair
.into_inner() .into_inner()
.filter(|p| p.as_rule() != Rule::and_kw); .filter(|p| p.as_rule() != Rule::and_kw);
@@ -236,8 +208,7 @@ fn parse_value(s: &str) -> FilterValue {
} }
} }
/// Unescape a regex string literal's inner contents (`\"` -> `"`, `\\` -> `\`, /// Unescape a regex string literal's inner contents (`\"` -> `"`, `\\` -> `\`, `\x` -> `x`).
/// any other `\x` -> `x`).
fn unescape(s: &str) -> String { fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len()); let mut out = String::with_capacity(s.len());
let mut chars = s.chars(); let mut chars = s.chars();
@@ -368,8 +339,6 @@ mod tests {
let result = parse("regex(\"unterminated and sequence<"); let result = parse("regex(\"unterminated and sequence<");
assert!(result.is_err()); assert!(result.is_err());
let err = result.unwrap_err(); let err = result.unwrap_err();
// Ensure the error message is human-readable and carries location
// info (pest errors always mention line/column context).
let msg = err.to_string(); let msg = err.to_string();
assert!(!msg.is_empty()); assert!(!msg.is_empty());
} }
+2 -12
View File
@@ -3,11 +3,7 @@ use crate::timestamp::Timestamp;
use serde::Serialize; use serde::Serialize;
use std::collections::BTreeMap; use std::collections::BTreeMap;
/// Key type for the engine's primary index. /// Key type for the engine's primary index. Variant order matters: derived `Ord` compares the discriminant first, carving the map into contiguous ranges in declaration order (logs, params, metric declarations, buckets).
///
/// Variants are ordered top to bottom as declared (derived `Ord` compares the
/// discriminant first), which is what carves the map into contiguous ranges:
/// logs, then params, then metric declarations, then buckets.
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub enum IndexKey { pub enum IndexKey {
@@ -33,13 +29,7 @@ pub enum IndexKey {
metric_name: String, metric_name: String,
labels: BTreeMap<String, MetricPrimitive>, labels: BTreeMap<String, MetricPrimitive>,
}, },
/// Ticket 023: internal bookkeeping (never returned by a query) tracking /// Internal bookkeeping tracking the last event timestamp per metric+label, so a `Duration` param's first occurrence (which pushes no `Bucket`) still lets the next occurrence compute elapsed time.
/// the timestamp of the most recent counter event for one metric+label
/// combination, independent of whether that event actually pushed a
/// `Bucket` value — a `Duration`-kind `Value` param's *first* occurrence
/// of a label set has no previous event to diff against and pushes no
/// `Bucket` entry, but must still be recorded here so the *next*
/// occurrence can compute its elapsed time.
LastMetricEvent { LastMetricEvent {
metric_name: String, metric_name: String,
labels: BTreeMap<String, MetricPrimitive>, labels: BTreeMap<String, MetricPrimitive>,
+7 -18
View File
@@ -30,9 +30,7 @@ pub async fn run(engine: mpsc::Sender<EngineMessage>) {
} }
} }
/// Identifies a specific file instance on disk, so a recreated path (e.g. a /// Identifies a specific file instance on disk, so a recreated path can be told apart from the same file simply growing.
/// tailed process restarting and reopening its log file) can be told apart from
/// the same file simply growing.
#[derive(PartialEq, Eq, Clone, Copy)] #[derive(PartialEq, Eq, Clone, Copy)]
struct FileId(#[cfg(unix)] u64, #[cfg(not(unix))] ()); struct FileId(#[cfg(unix)] u64, #[cfg(not(unix))] ());
@@ -55,13 +53,9 @@ async fn open(path: &Path) -> Option<(File, FileId)> {
Some((file, file_id(&meta))) Some((file, file_id(&meta)))
} }
/// Tails `path` like `tail -f`: reads existing content, then keeps reading as /// Tails `path` like `tail -f`, resuming from the start if the file is deleted/recreated or
/// new lines are appended. If the file is deleted and recreated at the same /// truncated in place rather than exiting or getting stuck; `sequence` keeps incrementing
/// path (or truncated in place) — e.g. the tailed process restarted and /// across a recreation since it's ottyl's own line counter, not tied to the source file.
/// reopened its log — tailing resumes against the new file from its start,
/// rather than exiting or getting stuck. `sequence` keeps incrementing across
/// a recreation event; it's ottyl's own line counter, not tied to the source
/// file.
pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) { pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
let mut sequence: u64 = 0; let mut sequence: u64 = 0;
@@ -81,9 +75,7 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
let mut chunk = String::new(); let mut chunk = String::new();
match reader.read_line(&mut chunk).await { match reader.read_line(&mut chunk).await {
Ok(0) => { Ok(0) => {
// EOF: decide whether the file was truncated/recreated // EOF: decide whether the file was truncated/recreated or just hasn't grown yet.
// (tail the new instance from scratch) or just hasn't
// grown yet (keep polling).
match tokio::fs::metadata(&path).await { match tokio::fs::metadata(&path).await {
Ok(meta) => { Ok(meta) => {
let new_id = file_id(&meta); let new_id = file_id(&meta);
@@ -93,8 +85,7 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
id = new_id; id = new_id;
} }
Err(_) => { Err(_) => {
// Path gone (mid-recreation); wait for it to // Path gone (mid-recreation); wait for it to reappear and reopen fresh.
// reappear and reopen fresh.
} }
} }
tokio::time::sleep(POLL_INTERVAL).await; tokio::time::sleep(POLL_INTERVAL).await;
@@ -119,8 +110,7 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
return; return;
} }
} }
// else: partial line at EOF (write in progress) — keep // else: partial line at EOF (write in progress) — keep buffering.
// buffering and read again without treating it as final.
} }
Err(_) => continue 'reopen, Err(_) => continue 'reopen,
} }
@@ -189,7 +179,6 @@ mod tests {
assert_eq!(recv_text(&mut rx).await, "long line before truncation"); assert_eq!(recv_text(&mut rx).await, "long line before truncation");
// Truncate in place (same inode, shorter content) then write new data.
let mut f = std::fs::OpenOptions::new() let mut f = std::fs::OpenOptions::new()
.write(true) .write(true)
.truncate(true) .truncate(true)
+4 -12
View File
@@ -31,14 +31,11 @@ struct Args {
#[arg(long, default_value_t = 0)] #[arg(long, default_value_t = 0)]
port: u16, port: u16,
/// Path to the settings file. Defaults to a per-OS config location /// Path to the settings file. Defaults to a per-OS config location.
/// (e.g. `~/.config/ottyl/settings.json` on Linux).
#[arg(long)] #[arg(long)]
settings_path: Option<PathBuf>, settings_path: Option<PathBuf>,
/// File to tail for log lines, instead of reading from stdin. Behaves like /// File to tail for log lines, instead of reading from stdin. Behaves like `tail -f`.
/// `tail -f`: reads existing content then keeps following appended lines,
/// and keeps tailing if the file is deleted/recreated at the same path.
#[arg(long)] #[arg(long)]
file: Option<PathBuf>, file: Option<PathBuf>,
} }
@@ -60,13 +57,8 @@ async fn main() {
let _ = engine_tx.send(engine::EngineMessage::SetMaxLogs(max_logs)).await; let _ = engine_tx.send(engine::EngineMessage::SetMaxLogs(max_logs)).await;
} }
// Ticket 009: reload the active profile's param/metric declarations into the // Reload the active profile's declarations before ingest starts: there's no way to
// engine BEFORE starting ingest below. Ordering matters: declarations must be // reprocess early lines later, so declaring after ingest starts could miss lines that raced ahead.
// in place before any line is ingested so that backfill/live-derivation
// semantics (ticket 003) apply uniformly to every line, including ones that
// arrive immediately once ingest starts (there's no "declare after the fact
// and go back to reprocess early lines" step, so doing it after ingest starts
// could silently miss lines that raced ahead of the declarations).
{ {
let (active_profile_name, profiles_dir) = { let (active_profile_name, profiles_dir) = {
let mut settings_guard = settings.lock().unwrap(); let mut settings_guard = settings.lock().unwrap();
+3 -11
View File
@@ -10,9 +10,7 @@ pub enum MetricPrimitive {
String(String), String(String),
Number(Number), Number(Number),
Timestamp(Timestamp), Timestamp(Timestamp),
/// Milliseconds elapsed (ticket 023). Distinct from `Number` so a /// Milliseconds elapsed; distinct from `Number` so it reads unambiguously in JSON/label form.
/// duration reads unambiguously in JSON/label form, even though it's
/// stored the same way (a plain `i64`).
Duration(i64), Duration(i64),
} }
@@ -49,10 +47,7 @@ impl Ord for MetricPrimitive {
} }
} }
/// Textual form used for label-filter comparisons over the metrics API /// Textual form used to compare label filters (query-string values) against a formatted `MetricPrimitive` rather than parsing them into one.
/// (`GET /api/metrics/:name?labels=foo=bar`): query-string values are plain
/// strings, so a label filter is compared against this formatted form rather
/// than trying to parse the filter value into a typed `MetricPrimitive`.
impl fmt::Display for MetricPrimitive { impl fmt::Display for MetricPrimitive {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
@@ -74,10 +69,7 @@ pub enum Number {
} }
impl Number { impl Number {
/// Widens to `f64` for cross-kind numeric comparisons (e.g. a filter /// Widens to `f64` for cross-kind numeric comparisons; `pub(crate)` since the filter evaluator in `engine.rs` needs it.
/// query's `FilterValue::Number` against a declared metric's captured
/// `Number`). `pub(crate)` rather than private since ticket 007's filter
/// evaluator (in `engine.rs`) needs it and lives in a different module.
pub(crate) fn as_f64(&self) -> f64 { pub(crate) fn as_f64(&self) -> f64 {
match self { match self {
Number::Int(v) => *v as f64, Number::Int(v) => *v as f64,
+41 -212
View File
@@ -1,33 +1,5 @@
//! Named, persistable bundles of parsing/indexing rules ("profiles" / "presets"), //! Named, persistable bundles of parsing/indexing rules and dashboards for a CLI app, persisted as a single `profiles.json`.
//! ticket 009. //! `Settings.profiles` only tracks lightweight name/active pointers; this module owns the actual content.
//!
//! A `Profile` bundles the param declarations (ticket 001) and metric
//! declarations (ticket 002) needed to derive a given CLI app's structure from
//! its raw log output, plus any saved dashboards for that app. Profiles are
//! collected in a `ProfileStore`, persisted as a single `profiles.json` file
//! under `Settings.profiles.profiles_dir` (resolved relative to the settings
//! file's directory — the same convention ticket 010 uses for CSS storage). A
//! single file (rather than one file per profile) is simplest and avoids
//! filename-sanitization concerns for profile names.
//!
//! `Settings.profiles` (see `settings.rs`) only tracks lightweight pointers —
//! known profile names and which one is active — while this module owns the
//! actual declaration content. That mirrors how `Settings.css_entries` holds
//! lightweight `CssEntry` records while the CSS content itself lives in
//! separate files (ticket 010).
//!
//! On startup, `main.rs` loads the active profile's declarations into a fresh
//! engine by replaying `EngineMessage::DeclareParam`/`DeclareMetric` for each
//! stored declaration (see `load_into_engine` below) — this reproduces the
//! same parsing/indexing rules the profile had when last saved, without
//! requiring the user to re-declare anything by hand after a restart.
//!
//! Several public methods here (e.g. `ProfileStore::delete`,
//! `record_param_declaration`) have no caller yet within this worktree — CRUD
//! over profiles isn't exposed over HTTP until ticket 004 lands the API layer
//! and calls into this module. `#![allow(dead_code)]` mirrors how
//! `engine.rs`/`data_value.rs` handle the same not-yet-wired situation for
//! tickets 001/002.
#![allow(dead_code)] #![allow(dead_code)]
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
@@ -46,22 +18,17 @@ use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, Eng
use crate::settings::Settings; use crate::settings::Settings;
use tokio::sync::oneshot; use tokio::sync::oneshot;
/// Name of the profile that always exists and can't be deleted (see /// Name of the profile that always exists and can't be deleted (see `ProfileStore::ensure_default`).
/// `ProfileStore::ensure_default`).
pub const DEFAULT_PROFILE_NAME: &str = "Default"; pub const DEFAULT_PROFILE_NAME: &str = "Default";
/// Mirrors `data_value::MetricPrimitiveKind` with `Serialize`/`Deserialize` so /// Mirrors `data_value::MetricPrimitiveKind` with `Serialize`/`Deserialize` so profiles can round-trip through JSON, since the engine-internal type only derives `Serialize`.
/// profiles can round-trip through JSON. `MetricPrimitiveKind` itself only
/// derives `Serialize` (it's an engine-internal type owned by tickets
/// 001/002/003, out of scope for this ticket to modify), so this module keeps
/// its own mirror and converts at the boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ProfileMetricPrimitiveKind { pub enum ProfileMetricPrimitiveKind {
String, String,
Number, Number,
Timestamp, Timestamp,
/// Mirrors `MetricPrimitiveKind::Duration` (ticket 023). /// Mirrors `MetricPrimitiveKind::Duration`.
Duration, Duration,
} }
@@ -87,7 +54,7 @@ impl From<ProfileMetricPrimitiveKind> for MetricPrimitiveKind {
} }
} }
/// A stored param declaration, matching `DeclareParamRequest`'s shape (ticket 001). /// A stored param declaration, matching `DeclareParamRequest`'s shape.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ParamDeclarationRecord { pub struct ParamDeclarationRecord {
pub name: String, pub name: String,
@@ -124,26 +91,21 @@ pub struct MetricParamRecord {
pub param_name: String, pub param_name: String,
pub capture_group: String, pub capture_group: String,
pub kind: ProfileMetricPrimitiveKind, pub kind: ProfileMetricPrimitiveKind,
/// See `ParamRole`. `#[serde(default)]` so profiles saved before ticket /// See `ParamRole`; `#[serde(default)]` so profiles saved before this field existed still load, defaulting to `Label`.
/// 022 still load, with every param defaulting to `Label`.
#[serde(default)] #[serde(default)]
pub role: ParamRole, pub role: ParamRole,
} }
/// A stored metric declaration, matching `DeclareMetricRequest`'s shape (ticket 002). /// A stored metric declaration, matching `DeclareMetricRequest`'s shape.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MetricDeclarationRecord { pub struct MetricDeclarationRecord {
pub name: String, pub name: String,
pub message_match_regex: String, pub message_match_regex: String,
pub params: Vec<MetricParamRecord>, pub params: Vec<MetricParamRecord>,
/// See `crate::engine::DeclareMetricRequest::matched_labels` (ticket /// See `crate::engine::DeclareMetricRequest::matched_labels`; `#[serde(default)]` so older profiles still load.
/// 025). `#[serde(default)]` so profiles saved before that ticket still
/// load.
#[serde(default)] #[serde(default)]
pub matched_labels: Option<std::collections::BTreeMap<String, String>>, pub matched_labels: Option<std::collections::BTreeMap<String, String>>,
/// See `crate::engine::DeclareMetricRequest::unmatched_labels` (ticket /// See `crate::engine::DeclareMetricRequest::unmatched_labels`; `#[serde(default)]` so older profiles still load.
/// 025). `#[serde(default)]` so profiles saved before that ticket still
/// load.
#[serde(default)] #[serde(default)]
pub unmatched_labels: Option<std::collections::BTreeMap<String, String>>, pub unmatched_labels: Option<std::collections::BTreeMap<String, String>>,
} }
@@ -190,8 +152,7 @@ impl From<&MetricDeclarationRecord> for DeclareMetricRequest {
} }
} }
/// A chart type a dashboard panel can render (ticket 014). Kept in sync with /// A chart type a dashboard panel can render; kept in sync with the frontend's `'bar' | 'line' | 'pie'` union (see `webapp/src/lib/api.ts`).
/// the frontend's `'bar' | 'line' | 'pie'` union (see `webapp/src/lib/api.ts`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ChartType { pub enum ChartType {
@@ -200,8 +161,7 @@ pub enum ChartType {
Pie, Pie,
} }
/// One panel within a dashboard: a PromQL query (ticket 013) plus how to /// One panel within a dashboard: a PromQL query plus how to chart its result.
/// chart its result.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Panel { pub struct Panel {
pub id: String, pub id: String,
@@ -210,7 +170,7 @@ pub struct Panel {
pub chart_type: ChartType, pub chart_type: ChartType,
} }
/// A named set of panels, saved as part of a profile (ticket 014). /// A named set of panels, saved as part of a profile.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Dashboard { pub struct Dashboard {
pub id: String, pub id: String,
@@ -219,10 +179,7 @@ pub struct Dashboard {
pub panels: Vec<Panel>, pub panels: Vec<Panel>,
} }
/// Generates an id for a newly-created dashboard/panel. Mirrors /// Generates an id by hashing a seed (dashboard/panel name) with the current time, avoiding a `uuid` dependency for what's otherwise an opaque string.
/// `settings::generate_imported_css_id`: hashes a discriminating seed
/// (dashboard/panel name) together with the current time, avoiding a new
/// dependency (e.g. `uuid`) for what's otherwise a random-ish opaque string.
fn generate_id(seed: &str) -> String { fn generate_id(seed: &str) -> String {
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
seed.hash(&mut hasher); seed.hash(&mut hasher);
@@ -235,28 +192,17 @@ fn generate_id(seed: &str) -> String {
} }
impl Dashboard { impl Dashboard {
/// A new dashboard named `name`, with no panels yet.
pub fn new(name: String) -> Dashboard { pub fn new(name: String) -> Dashboard {
let id = generate_id(&name); let id = generate_id(&name);
Dashboard { id, name, panels: Vec::new() } Dashboard { id, name, panels: Vec::new() }
} }
} }
/// Ticket 025: name of the metric seeded into a brand-new `Default` profile, /// Name of the metric seeded into a brand-new `Default` profile, used to drive its starter dashboard's two panels.
/// used to drive its starter dashboard's two panels.
const SEEDED_METRIC_NAME: &str = "log_category"; const SEEDED_METRIC_NAME: &str = "log_category";
const SEEDED_METRIC_LABEL: &str = "category"; const SEEDED_METRIC_LABEL: &str = "category";
/// A counter (ticket 022) that classifies every ingested line into exactly /// A counter that splits every line into `category="error"`/`"other"` via `matched_labels`/`unmatched_labels` rather than a captured param, so case variants like "ERROR" collapse into one series; the `regex` crate lacks the lookaround needed to express "didn't match" any other way.
/// two buckets — `category="error"` or `category="other"` — without
/// capturing any text: `matched_labels`/`unmatched_labels` (ticket 025) apply
/// those fixed values directly, rather than a param capturing the literal
/// matched substring, so a case-insensitive match like `"ERROR"` vs `"Error"`
/// collapses into one clean series instead of fragmenting by source casing.
/// A plain regex alternation can't express the "didn't match" half of this
/// without lookaround, which the `regex` crate doesn't support (see
/// `crate::engine::DeclareMetricRequest::unmatched_labels`) — hence going
/// through the metric-declaration mechanism instead of the PromQL surface.
fn seeded_error_split_metric() -> MetricDeclarationRecord { fn seeded_error_split_metric() -> MetricDeclarationRecord {
MetricDeclarationRecord { MetricDeclarationRecord {
name: SEEDED_METRIC_NAME.to_string(), name: SEEDED_METRIC_NAME.to_string(),
@@ -267,10 +213,7 @@ fn seeded_error_split_metric() -> MetricDeclarationRecord {
} }
} }
/// The starter dashboard seeded into a brand-new `Default` profile (ticket /// The starter dashboard seeded into a brand-new `Default` profile: a line chart and a pie chart, both querying the seeded error-split counter.
/// 025): a line chart and a pie chart, both querying the bare
/// `SEEDED_METRIC_NAME` counter so each renders both the "error" and "other"
/// series/slices from a single PromQL query, same as any user-created panel.
fn seeded_default_dashboard() -> Dashboard { fn seeded_default_dashboard() -> Dashboard {
let mut dashboard = Dashboard::new("Overview".to_string()); let mut dashboard = Dashboard::new("Overview".to_string());
dashboard.panels = vec![ dashboard.panels = vec![
@@ -290,9 +233,7 @@ fn seeded_default_dashboard() -> Dashboard {
dashboard dashboard
} }
/// A named bundle of parsing/indexing rules for a particular CLI app: which /// A named bundle of parsing/indexing rules for a particular CLI app, plus any saved dashboards.
/// params and metrics to derive from its log output, plus any saved
/// dashboards.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Profile { pub struct Profile {
pub name: String, pub name: String,
@@ -300,13 +241,11 @@ pub struct Profile {
pub params: Vec<ParamDeclarationRecord>, pub params: Vec<ParamDeclarationRecord>,
#[serde(default)] #[serde(default)]
pub metrics: Vec<MetricDeclarationRecord>, pub metrics: Vec<MetricDeclarationRecord>,
/// Saved dashboards (ticket 014), each a named set of PromQL-backed panels.
#[serde(default)] #[serde(default)]
pub dashboards: Vec<Dashboard>, pub dashboards: Vec<Dashboard>,
} }
impl Profile { impl Profile {
/// A new, empty profile with the given name.
pub fn new(name: String) -> Profile { pub fn new(name: String) -> Profile {
Profile { Profile {
name, name,
@@ -317,18 +256,7 @@ impl Profile {
} }
} }
/// Sends every param/metric declaration stored in `profile` to the engine at /// Replays every param/metric declaration stored in `profile` into `engine` (used on startup and in tests); any rejected declaration or send/recv failure is logged to stderr and skipped rather than panicking.
/// `engine`, as `EngineMessage::DeclareParam`/`DeclareMetric`. Used on startup
/// to reload the active profile's rules into a fresh engine (see `main.rs`),
/// and in tests to simulate that reload.
///
/// A stored declaration should generally already be valid — it round-tripped
/// through the engine's own validation when originally declared (see
/// `record_param_declaration`/`record_metric_declaration` below) — but this
/// doesn't assume that: any declaration the engine rejects (or any send/recv
/// failure, e.g. the engine channel closing) is logged to stderr and skipped
/// rather than panicking, so one bad stored declaration can't take the whole
/// process down.
pub async fn load_into_engine(engine: &EngineHandle, profile: &Profile) { pub async fn load_into_engine(engine: &EngineHandle, profile: &Profile) {
for record in &profile.params { for record in &profile.params {
let (respond_to, response) = oneshot::channel(); let (respond_to, response) = oneshot::channel();
@@ -388,8 +316,7 @@ pub enum ProfileError {
NotFound, NotFound,
/// A profile with the given name already exists. /// A profile with the given name already exists.
DuplicateName, DuplicateName,
/// The `Default` profile can't be deleted — there must always be at /// The `Default` profile can't be deleted — there must always be at least one profile to fall back to.
/// least one profile to fall back to.
CannotDeleteDefault, CannotDeleteDefault,
/// Underlying I/O failure reading/writing the profile store or settings file. /// Underlying I/O failure reading/writing the profile store or settings file.
Io(io::Error), Io(io::Error),
@@ -414,9 +341,7 @@ impl From<io::Error> for ProfileError {
} }
} }
/// Resolves `settings.profiles.profiles_dir` relative to `settings_path`'s /// Resolves `settings.profiles.profiles_dir` relative to `settings_path`'s parent directory (or as absolute if it already is one).
/// parent directory (or treats it as absolute if it already is one) — the
/// same directory convention ticket 010 uses for CSS storage.
pub fn resolve_profiles_dir(settings: &Settings, settings_path: &Path) -> PathBuf { pub fn resolve_profiles_dir(settings: &Settings, settings_path: &Path) -> PathBuf {
let profiles_dir = Path::new(&settings.profiles.profiles_dir); let profiles_dir = Path::new(&settings.profiles.profiles_dir);
if profiles_dir.is_absolute() { if profiles_dir.is_absolute() {
@@ -429,25 +354,19 @@ pub fn resolve_profiles_dir(settings: &Settings, settings_path: &Path) -> PathBu
} }
} }
/// Path to the single file holding all profiles, within `profiles_dir` (see /// Path to the single file holding all profiles, within `profiles_dir`.
/// `resolve_profiles_dir`).
pub fn profiles_store_path(profiles_dir: &Path) -> PathBuf { pub fn profiles_store_path(profiles_dir: &Path) -> PathBuf {
profiles_dir.join("profiles.json") profiles_dir.join("profiles.json")
} }
/// On-disk collection of all known profiles, persisted as a single JSON file /// On-disk collection of all known profiles, persisted as a single JSON file.
/// (see module docs for why one file rather than one-per-profile).
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct ProfileStore { pub struct ProfileStore {
profiles: Vec<Profile>, profiles: Vec<Profile>,
} }
impl ProfileStore { impl ProfileStore {
/// Loads the store from `store_path`. A missing file is treated as an /// Loads the store from `store_path`; a missing or unreadable/unparsable file is treated as an empty store rather than failing startup.
/// empty store (not an error) — there's no reason to fail startup just
/// because no profiles have been created yet. Any I/O or parse error
/// while reading an existing file is logged to stderr and treated as an
/// empty store, mirroring `Settings::load`'s non-fatal-error handling.
pub fn load(store_path: &Path) -> ProfileStore { pub fn load(store_path: &Path) -> ProfileStore {
match fs::read_to_string(store_path) { match fs::read_to_string(store_path) {
Ok(contents) => match serde_json::from_str::<Vec<Profile>>(&contents) { Ok(contents) => match serde_json::from_str::<Vec<Profile>>(&contents) {
@@ -471,8 +390,7 @@ impl ProfileStore {
} }
} }
/// Persists the store to `store_path` as pretty-printed JSON, creating /// Persists the store to `store_path` as pretty-printed JSON, creating any missing parent directories first.
/// any missing parent directories first.
pub fn save(&self, store_path: &Path) -> io::Result<()> { pub fn save(&self, store_path: &Path) -> io::Result<()> {
if let Some(parent) = store_path.parent() { if let Some(parent) = store_path.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
@@ -482,26 +400,15 @@ impl ProfileStore {
fs::write(store_path, contents) fs::write(store_path, contents)
} }
/// All known profiles.
pub fn list(&self) -> &[Profile] { pub fn list(&self) -> &[Profile] {
&self.profiles &self.profiles
} }
/// Looks up a profile by name.
pub fn get(&self, name: &str) -> Option<&Profile> { pub fn get(&self, name: &str) -> Option<&Profile> {
self.profiles.iter().find(|p| p.name == name) self.profiles.iter().find(|p| p.name == name)
} }
/// Creates a new profile named `name`, persists the store to /// Creates a new profile named `name` (persisting store and settings), copying the active profile's params/metrics/dashboards as a starting point rather than starting blank. Fails with `DuplicateName` if the name exists.
/// `store_path`, and records the name in `settings.profiles.names`
/// (persisting `settings` to `settings_path` too, keeping the two files in
/// sync). Fails with `ProfileError::DuplicateName` if a profile with that
/// name already exists.
///
/// The new profile starts as a copy of the currently active profile's
/// params/metrics/dashboards (or empty, if none is active) — a preset is
/// most useful as a variation on the one you were just using, not a
/// blank slate.
pub fn create( pub fn create(
&mut self, &mut self,
store_path: &Path, store_path: &Path,
@@ -528,13 +435,7 @@ impl ProfileStore {
Ok(()) Ok(())
} }
/// Deletes the profile named `name`, persists the store to `store_path`, /// Deletes the profile named `name` (persisting store and settings), clearing `active_profile` if it was active. Fails with `NotFound`, or `CannotDeleteDefault` for the `Default` profile.
/// and drops it from `settings.profiles.names` (persisting `settings` to
/// `settings_path` too). If the deleted profile was active, clears
/// `settings.profiles.active_profile`. Fails with `ProfileError::NotFound`
/// if no such profile exists, or `ProfileError::CannotDeleteDefault` for
/// the `Default` profile — there must always be at least one profile to
/// fall back to.
pub fn delete( pub fn delete(
&mut self, &mut self,
store_path: &Path, store_path: &Path,
@@ -557,21 +458,7 @@ impl ProfileStore {
Ok(()) Ok(())
} }
/// Guarantees a `Default` profile exists in this store, that it's tracked /// Ensures a `Default` profile exists (seeding a starter dashboard/metric only on first creation), is tracked in `settings.profiles.names`, and falls back to active if nothing else is. Called once on startup before anything else touches profiles.
/// in `settings.profiles.names`, and that `settings.profiles.active_profile`
/// falls back to it when nothing else is active — so the app always has
/// at least one usable profile, even on a first run with no settings file
/// yet. Persists both the store and `settings` if either changed.
///
/// A newly-created `Default` profile (not one that already existed) is
/// seeded with a starter dashboard (ticket 025): a line chart and a pie
/// chart of "error" vs. non-"error" log volume, both querying
/// `seeded_error_split_metric`'s counter — editable/deletable afterward
/// like any other dashboard/metric, and never re-seeded or duplicated on
/// a later call once the profile exists.
///
/// Called once on startup (see `main.rs`) before anything else touches
/// profiles.
pub fn ensure_default( pub fn ensure_default(
&mut self, &mut self,
store_path: &Path, store_path: &Path,
@@ -595,14 +482,7 @@ impl ProfileStore {
Ok(()) Ok(())
} }
/// Sets (or clears, with `name: None`) the active profile: updates /// Sets (or clears, with `None`) the active profile in `settings`. Fails with `NotFound` if `name` is `Some` and no such profile exists. Doesn't itself reload declarations into a running engine — that happens on next startup via `load_into_engine`.
/// `settings.profiles.active_profile` and persists `settings` to
/// `settings_path`. Fails with `ProfileError::NotFound` if `name` is
/// `Some` and no such profile exists in this store.
///
/// This only updates `Settings`; it does not itself reload the newly
/// active profile's declarations into a running engine (that happens on
/// the next startup — see `load_into_engine` and `main.rs`).
pub fn set_active( pub fn set_active(
&self, &self,
settings: &mut Settings, settings: &mut Settings,
@@ -619,22 +499,7 @@ impl ProfileStore {
Ok(()) Ok(())
} }
/// Records a newly-declared param into `profile_name`'s stored /// Records/updates a declared param into `profile_name`'s stored declarations (matched by param name), persisting the store. Intended to be called by the HTTP API alongside declaring against the engine, so declarations survive a restart.
/// declarations (replacing any existing record with the same param name,
/// so re-declaring updates it in place), persisting the store to
/// `store_path`.
///
/// **Integration hook for ticket 004 (HTTP API)**: when the HTTP API
/// declares a param against the engine (sending
/// `EngineMessage::DeclareParam`), it should also call this — with the
/// same `DeclareParamRequest` — against the currently-active profile, so
/// the declaration survives a restart (see `load_into_engine`). This
/// ticket (009) cannot wire that call itself: there's no declare-param
/// HTTP route yet for it to hook into. Call shape:
///
/// ```ignore
/// profile_store.record_param_declaration(&store_path, &active_profile_name, &request)?;
/// ```
pub fn record_param_declaration( pub fn record_param_declaration(
&mut self, &mut self,
store_path: &Path, store_path: &Path,
@@ -655,13 +520,7 @@ impl ProfileStore {
Ok(()) Ok(())
} }
/// Records a newly-declared metric into `profile_name`'s stored /// Records/updates a declared metric into `profile_name`'s stored declarations (matched by metric name), persisting the store; same integration intent as `record_param_declaration`.
/// declarations (replacing any existing record with the same metric name),
/// persisting the store to `store_path`.
///
/// **Integration hook for ticket 004 (HTTP API)**: same as
/// `record_param_declaration` above, but for
/// `EngineMessage::DeclareMetric`/`DeclareMetricRequest`.
pub fn record_metric_declaration( pub fn record_metric_declaration(
&mut self, &mut self,
store_path: &Path, store_path: &Path,
@@ -682,16 +541,13 @@ impl ProfileStore {
Ok(()) Ok(())
} }
/// Lists the dashboards saved on `profile_name`. Fails with /// Lists the dashboards saved on `profile_name`; fails with `NotFound` if it doesn't exist.
/// `ProfileError::NotFound` if no such profile exists.
pub fn list_dashboards(&self, profile_name: &str) -> Result<&[Dashboard], ProfileError> { pub fn list_dashboards(&self, profile_name: &str) -> Result<&[Dashboard], ProfileError> {
let profile = self.get(profile_name).ok_or(ProfileError::NotFound)?; let profile = self.get(profile_name).ok_or(ProfileError::NotFound)?;
Ok(&profile.dashboards) Ok(&profile.dashboards)
} }
/// Creates a new, empty dashboard named `name` on `profile_name`, /// Creates a new, empty dashboard named `name` on `profile_name`, persisting the store; fails with `NotFound` if the profile doesn't exist.
/// persisting the store to `store_path`. Returns the new dashboard.
/// Fails with `ProfileError::NotFound` if no such profile exists.
pub fn create_dashboard( pub fn create_dashboard(
&mut self, &mut self,
store_path: &Path, store_path: &Path,
@@ -709,12 +565,7 @@ impl ProfileStore {
Ok(dashboard) Ok(dashboard)
} }
/// Replaces the dashboard identified by `dashboard_id` on `profile_name` /// Replaces the dashboard identified by `dashboard_id` with `dashboard`, persisting the store; `dashboard.id` is forced to `dashboard_id` so a caller can't change a dashboard's identity through this call.
/// with `dashboard` (e.g. after adding/removing/editing panels),
/// persisting the store to `store_path`. `dashboard.id` is forced to
/// `dashboard_id` so a caller can't accidentally change a dashboard's
/// identity through this call. Fails with `ProfileError::NotFound` if
/// either the profile or the dashboard doesn't exist.
pub fn save_dashboard( pub fn save_dashboard(
&mut self, &mut self,
store_path: &Path, store_path: &Path,
@@ -738,10 +589,7 @@ impl ProfileStore {
Ok(dashboard) Ok(dashboard)
} }
/// Deletes the dashboard identified by `dashboard_id` from /// Deletes the dashboard identified by `dashboard_id` from `profile_name`, persisting the store; fails with `NotFound` if either doesn't exist.
/// `profile_name`, persisting the store to `store_path`. Fails with
/// `ProfileError::NotFound` if either the profile or the dashboard
/// doesn't exist.
pub fn delete_dashboard( pub fn delete_dashboard(
&mut self, &mut self,
store_path: &Path, store_path: &Path,
@@ -838,7 +686,6 @@ mod tests {
assert!(store.get("web").is_some()); assert!(store.get("web").is_some());
assert_eq!(settings.profiles.names, vec!["web".to_string(), "worker".to_string()]); assert_eq!(settings.profiles.names, vec!["web".to_string(), "worker".to_string()]);
// Duplicate creation is rejected.
let dup = store.create(&store_path, &mut settings, &settings_path, "web".to_string()); let dup = store.create(&store_path, &mut settings, &settings_path, "web".to_string());
assert!(matches!(dup, Err(ProfileError::DuplicateName))); assert!(matches!(dup, Err(ProfileError::DuplicateName)));
@@ -870,8 +717,7 @@ mod tests {
assert!(settings.profiles.names.contains(&DEFAULT_PROFILE_NAME.to_string())); assert!(settings.profiles.names.contains(&DEFAULT_PROFILE_NAME.to_string()));
assert_eq!(settings.profiles.active_profile, Some(DEFAULT_PROFILE_NAME.to_string())); assert_eq!(settings.profiles.active_profile, Some(DEFAULT_PROFILE_NAME.to_string()));
// Idempotent: doesn't duplicate the profile, and doesn't clobber an // Idempotent: doesn't duplicate the profile or clobber an already-active (non-Default) profile.
// already-active (non-Default) profile.
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap(); store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap(); store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap();
store.ensure_default(&store_path, &mut settings, &settings_path).unwrap(); store.ensure_default(&store_path, &mut settings, &settings_path).unwrap();
@@ -908,8 +754,7 @@ mod tests {
assert!(dashboard.panels.iter().any(|p| p.chart_type == ChartType::Line)); assert!(dashboard.panels.iter().any(|p| p.chart_type == ChartType::Line));
assert!(dashboard.panels.iter().any(|p| p.chart_type == ChartType::Pie)); assert!(dashboard.panels.iter().any(|p| p.chart_type == ChartType::Pie));
// Reload (simulating a restart) and ensure_default again: must not // Reload (simulating a restart) and ensure_default again: must not re-seed or duplicate.
// re-seed or duplicate the dashboard/metric.
let mut store = ProfileStore::load(&store_path); let mut store = ProfileStore::load(&store_path);
store.ensure_default(&store_path, &mut settings, &settings_path).unwrap(); store.ensure_default(&store_path, &mut settings, &settings_path).unwrap();
let default_profile = store.get(DEFAULT_PROFILE_NAME).unwrap(); let default_profile = store.get(DEFAULT_PROFILE_NAME).unwrap();
@@ -1076,19 +921,7 @@ mod tests {
assert_eq!(profile.metrics.len(), 1); assert_eq!(profile.metrics.len(), 1);
} }
/// Acceptance criterion: declaring params/metrics into a profile, "restarting" /// Acceptance criterion: declaring params/metrics, "restarting" (reloading the store from disk), and replaying into a fresh engine reproduces the same declarations and still ingests lines, without re-declaring by hand.
/// (dropping and reloading the store from disk, rather than a literal process
/// restart), and replaying the saved profile against a fresh engine reproduces
/// the same declarations and still ingests lines correctly — without
/// re-declaring anything by hand.
///
/// Note: ticket 003 (metric bucket derivation) isn't implemented yet in this
/// worktree, so there's no bucket index to compare directly. What this test
/// verifies instead is the part ticket 009 owns: that a profile's declarations
/// round-trip through disk byte-for-byte and, once replayed via
/// `load_into_engine`, produce identical engine-visible declarations (which is
/// exactly the state ticket 003's derivation logic will read from) — and that
/// the engine keeps ingesting lines normally afterwards.
#[tokio::test] #[tokio::test]
async fn profile_round_trip_reproduces_declarations_in_fresh_engine() { async fn profile_round_trip_reproduces_declarations_in_fresh_engine() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -1096,8 +929,7 @@ mod tests {
let settings_path = dir.path().join("settings.json"); let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default(); let mut settings = Settings::default();
// Declare directly into a profile (as ticket 004's HTTP API will do via the // Declare directly into a profile, as the HTTP API will do via the `record_*_declaration` hooks.
// `record_*_declaration` hooks once it exists).
let mut store = ProfileStore::load(&store_path); let mut store = ProfileStore::load(&store_path);
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap(); store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
store store
@@ -1135,8 +967,7 @@ mod tests {
tokio::spawn(crate::engine::run(engine_rx, settings_handle)); tokio::spawn(crate::engine::run(engine_rx, settings_handle));
load_into_engine(&engine_tx, &profile).await; load_into_engine(&engine_tx, &profile).await;
// Ingest a line and confirm the engine is functioning with the replayed // Ingest a line and confirm the engine works with the replayed declarations (no re-declaration needed).
// declarations in place (no re-declaration needed).
engine_tx engine_tx
.send(EngineMessage::Ingest(IngestedLine { .send(EngineMessage::Ingest(IngestedLine {
sequence: 0, sequence: 0,
@@ -1185,9 +1016,7 @@ mod tests {
assert_eq!(logs[0].text, "level=info request duration=42"); assert_eq!(logs[0].text, "level=info request duration=42");
} }
/// Acceptance criterion (ticket 014): creating a dashboard (with a panel), /// Acceptance criterion: a dashboard (with a panel) survives reloading the profile store from disk.
/// then reloading the profile store from disk (simulating a restart),
/// still has the dashboard and its panel.
#[test] #[test]
fn dashboard_create_save_delete_round_trip_and_persists_across_reload() { fn dashboard_create_save_delete_round_trip_and_persists_across_reload() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+11 -65
View File
@@ -1,47 +1,5 @@
//! Hand-rolled parser for a small PromQL subset (ticket 013). //! Hand-rolled parser for a small PromQL subset: metric name + label matchers, an optional `[5m]` range, and a top-level `sum()`/`avg()`/`rate()`/`histogram()`/`gauge()` wrapper.
//! //! Only exact-match (`=`) label matchers are supported; `rate()` requires a duration range selector, while `histogram(metric[width])` reuses that `[...]` slot for a bare bucket width instead.
//! Design decision: a crates.io PromQL parser (e.g. `promql-parser`) was considered
//! but rejected — the required subset here is tiny (metric name + label matchers,
//! plus an optional `[5m]` range and a single top-level `sum()`/`avg()`/`rate()`
//! wrapper), while a full PromQL parser brings a large, largely-unused AST plus an
//! extra maintenance dependency. Mirroring `filter_parser.rs`'s approach (a small,
//! engine-independent parser module) is simpler and easier to control/extend, and
//! keeps parsing fully decoupled from the engine's index — this module knows nothing
//! about `IndexKey`/`DataValue`, only `PromqlQuery`'s own types below.
//!
//! ## Supported grammar
//!
//! ```text
//! query := aggregation | vector_selector
//! aggregation := agg_op "(" vector_selector ")"
//! agg_op := "sum" | "avg" | "rate" | "histogram" | "gauge"
//! vector_selector := metric_name label_matchers? range_selector?
//! metric_name := [a-zA-Z_][a-zA-Z0-9_]*
//! label_matchers := "{" label_matcher ("," label_matcher)* "}"
//! label_matcher := label_name "=" quoted_string
//! label_name := [a-zA-Z_][a-zA-Z0-9_]*
//! quoted_string := '"' (any char except '"' or '\')* '"'
//! range_selector := "[" digits duration_unit? "]"
//! duration_unit := "s" | "m" | "h" | "d"
//! ```
//!
//! Only exact-match label matchers (`=`) are supported — PromQL's `!=`, `=~`, `!~`
//! are out of scope for this ticket's required subset. `rate()` requires its inner
//! vector selector to carry a range selector with a duration unit (matching real
//! PromQL), and is rejected at parse time otherwise.
//!
//! Ticket 022 (metrics-as-counters rethink) adds two derived forms on top of the
//! same grammar, both consumed as regular `PromqlSeriesDto` series like every
//! other query shape:
//! - `histogram(metric_name[width])`: buckets every matching counter event's
//! (coerced-to-f64) value into fixed-width buckets of size `width` (a bare
//! number, no duration unit — reusing the `[...]` range-selector slot for a
//! different purpose than `rate()`'s duration), and emits one series per
//! bucket, labeled `le` (Prometheus's "less-than-or-equal" convention) with
//! that bucket's upper bound, holding a single `(timestamp_ms, count)` point.
//! - `gauge(metric_name)`: takes no range selector; emits the most-recent
//! matching counter event's value per label combination as a single point,
//! i.e. the counter's series collapsed down to its last value.
use std::time::Duration; use std::time::Duration;
@@ -50,10 +8,9 @@ pub enum AggOp {
Sum, Sum,
Avg, Avg,
Rate, Rate,
/// Ticket 022: buckets counter values into fixed-width buckets. Requires /// Buckets counter values into fixed-width buckets; requires `bucket_width`.
/// `bucket_width`.
Histogram, Histogram,
/// Ticket 022: the most-recent counter value per label combination. /// The most-recent counter value per label combination.
Gauge, Gauge,
} }
@@ -68,23 +25,19 @@ pub struct PromqlQuery {
pub agg: Option<AggOp>, pub agg: Option<AggOp>,
pub metric_name: String, pub metric_name: String,
pub matchers: Vec<LabelMatcher>, pub matchers: Vec<LabelMatcher>,
/// Duration range selector, e.g. `[5m]`. Only meaningful for `rate()`. /// Duration range selector, e.g. `[5m]`; only meaningful for `rate()`.
pub range: Option<Duration>, pub range: Option<Duration>,
/// Bare-number range selector, e.g. `[10]`. Only meaningful for /// Bare-number range selector, e.g. `[10]`; only meaningful for `histogram()`, where it's the bucket width.
/// `histogram()`, where it's the bucket width.
pub bucket_width: Option<f64>, pub bucket_width: Option<f64>,
} }
/// What a `[...]` range selector turned out to hold, disambiguated by whether /// What a `[...]` range selector turned out to hold: a duration, or a bare width.
/// a duration unit followed the digits.
enum RangeSelector { enum RangeSelector {
Duration(Duration), Duration(Duration),
BareWidth(f64), BareWidth(f64),
} }
/// Parses a PromQL query string per the grammar documented above. Returns a /// Parses a PromQL query string, returning a human-readable error message on malformed input.
/// human-readable error message (suitable for surfacing directly to an API caller)
/// on any malformed input.
pub fn parse_query(input: &str) -> Result<PromqlQuery, String> { pub fn parse_query(input: &str) -> Result<PromqlQuery, String> {
let mut parser = Parser::new(input); let mut parser = Parser::new(input);
let query = parser.parse_query()?; let query = parser.parse_query()?;
@@ -143,7 +96,6 @@ impl<'a> Parser<'a> {
} }
} }
/// `[a-zA-Z_][a-zA-Z0-9_]*`, used for both metric names and label names.
fn parse_identifier(&mut self) -> Result<String, String> { fn parse_identifier(&mut self) -> Result<String, String> {
self.skip_whitespace(); self.skip_whitespace();
let rest = self.remaining(); let rest = self.remaining();
@@ -214,9 +166,7 @@ impl<'a> Parser<'a> {
Ok(matchers) Ok(matchers)
} }
/// `[<digits><unit>]` (e.g. `[5m]`, a duration — used by `rate()`) or a /// Parses `[<digits><unit>]` (a duration, e.g. `[5m]`) or a bare `[<digits>]` (a bucket width).
/// bare `[<digits>]` with no unit (a plain number — used by `histogram()`
/// as a bucket width).
fn parse_range_selector(&mut self) -> Result<Option<RangeSelector>, String> { fn parse_range_selector(&mut self) -> Result<Option<RangeSelector>, String> {
self.skip_whitespace(); self.skip_whitespace();
if self.peek() != Some('[') { if self.peek() != Some('[') {
@@ -290,10 +240,7 @@ impl<'a> Parser<'a> {
inner.agg = agg; inner.agg = agg;
Ok(inner) Ok(inner)
} else { } else {
// Not (or not followed by) an aggregation call — rewind and parse the // Rewind and parse as a plain vector selector, covering metric names that collide with an agg_op keyword.
// whole thing as a plain vector selector (covers metric names that
// happen to collide with an agg_op keyword, e.g. a metric named `rate`
// used without a call).
self.pos = checkpoint; self.pos = checkpoint;
self.parse_vector_selector() self.parse_vector_selector()
} }
@@ -387,8 +334,7 @@ mod tests {
#[test] #[test]
fn rejects_unknown_aggregation() { fn rejects_unknown_aggregation() {
// `count` isn't a supported agg_op, so this parses as a bare metric named // `count` isn't a supported agg_op, so this parses as a bare metric name and trips on the trailing `(...)`.
// `count`, then trips on the unexpected trailing `(...)`.
let err = parse_query("count(requests)").unwrap_err(); let err = parse_query("count(requests)").unwrap_err();
assert!(err.contains("unexpected trailing input")); assert!(err.contains("unexpected trailing input"));
} }
+23 -102
View File
@@ -1,9 +1,4 @@
//! Process-wide settings model: colour scheme, CSS entries, and profile //! Process-wide settings model: colour scheme, CSS entries, and profile pointers, persisted as JSON.
//! pointers. There is exactly one `Settings` instance at runtime, loaded once
//! on startup and persisted to disk whenever it changes (see `main.rs`).
//!
//! Persistence format is JSON (via `serde_json`, already a dependency) rather
//! than TOML, to keep the dependency list minimal.
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::fmt; use std::fmt;
@@ -19,8 +14,7 @@ use serde::{Deserialize, Serialize};
use crate::alerts::AlertRule; use crate::alerts::AlertRule;
/// Shared, process-wide settings handle. Cheap to clone; lock is only held /// Shared, process-wide settings handle. Cheap to clone.
/// briefly since settings changes are rare (unlike the engine's hot path).
pub type SettingsHandle = Arc<Mutex<Settings>>; pub type SettingsHandle = Arc<Mutex<Settings>>;
/// User's preferred colour scheme for the webapp. /// User's preferred colour scheme for the webapp.
@@ -40,17 +34,7 @@ pub enum ColorScheme {
System, System,
} }
/// A single CSS entry configured for the webapp: either content that ottyl /// A single CSS entry: content ottyl stores itself ("imported") or a reference to an external path/URL ("linked", not deletable via the API). `is_theme` marks it as a selectable colour-scheme theme rather than always-injected CSS.
/// stores itself ("imported") or a reference to an external path/URL that
/// the webapp links to directly ("linked", and therefore not deletable via
/// the settings API since ottyl doesn't own it).
///
/// Ticket 010 builds the CSS import/link HTTP API around this shape.
///
/// `is_theme` (ticket 033) marks an entry as a full colour-scheme theme (a
/// `:root { --c-*: ...; }` variable set, selectable from the theme picker)
/// rather than arbitrary CSS that's always injected. `#[serde(default)]` so
/// existing settings.json files without the field still load as `false`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")] #[serde(tag = "kind", rename_all = "lowercase")]
pub enum CssEntry { pub enum CssEntry {
@@ -67,14 +51,10 @@ pub enum CssEntry {
}, },
} }
/// Placeholder shape for profile management (ticket 009 builds CRUD on top /// Tracks known profile names and which one is active; the profile files themselves live elsewhere.
/// of this). Profiles are expected to live as individual files under a
/// dedicated directory; this struct only tracks known profile names and
/// which one is currently active.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Profiles { pub struct Profiles {
/// Names of known profiles. Ticket 009 owns creating/loading the actual /// Names of known profiles.
/// profile files.
pub names: Vec<String>, pub names: Vec<String>,
/// Name of the currently active profile, if any. /// Name of the currently active profile, if any.
pub active_profile: Option<String>, pub active_profile: Option<String>,
@@ -83,27 +63,19 @@ pub struct Profiles {
pub profiles_dir: String, pub profiles_dir: String,
} }
/// The single, process-wide settings instance. /// The single, process-wide settings instance. Only `PartialEq` (not `Eq`): `alert_rules` carries `f64` fields, which aren't `Eq`.
///
/// Only `PartialEq` (not `Eq`): `alert_rules` carries `f64` fields
/// (`AlertCondition::Threshold`/`Ratio`), which aren't `Eq`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Settings { pub struct Settings {
pub color_scheme: ColorScheme, pub color_scheme: ColorScheme,
pub css_entries: Vec<CssEntry>, pub css_entries: Vec<CssEntry>,
/// Id of the selected custom theme entry (a `css_entries` entry with /// Id of the selected custom theme entry, if any; `None` means `color_scheme` alone determines the active palette.
/// `is_theme: true`), if any (ticket 033). `None` means `color_scheme`
/// alone determines the active palette. `#[serde(default)]` so existing
/// settings.json files without this field still load.
#[serde(default)] #[serde(default)]
pub custom_theme_id: Option<String>, pub custom_theme_id: Option<String>,
pub profiles: Profiles, pub profiles: Profiles,
/// Maximum number of ingested logs kept in memory. `None` (the default) /// Maximum number of ingested logs kept in memory; `None` means unlimited.
/// means unlimited — matches today's unbounded retention.
#[serde(default)] #[serde(default)]
pub max_logs: Option<usize>, pub max_logs: Option<usize>,
/// Alert rule definitions (ticket 017). `#[serde(default)]` so existing /// Alert rule definitions.
/// settings.json files without this field still load.
#[serde(default)] #[serde(default)]
pub alert_rules: Vec<AlertRule>, pub alert_rules: Vec<AlertRule>,
} }
@@ -125,23 +97,13 @@ impl Default for Settings {
} }
} }
/// Computes the default settings file path using OS-conventional config /// Computes the default settings file path (`<config_dir>/settings.json`) from OS-conventional config directories; `None` if none could be determined.
/// directories: `<config_dir>/settings.json`, where `config_dir` comes from
/// `directories::ProjectDirs::from("", "", "ottyl")`.
///
/// Returns `None` if no valid config directory could be determined for the
/// current OS/user (this mirrors `ProjectDirs::from`'s own fallibility).
pub fn default_settings_path() -> Option<PathBuf> { pub fn default_settings_path() -> Option<PathBuf> {
ProjectDirs::from("", "", "ottyl").map(|dirs| dirs.config_dir().join("settings.json")) ProjectDirs::from("", "", "ottyl").map(|dirs| dirs.config_dir().join("settings.json"))
} }
impl Settings { impl Settings {
/// Loads settings from `path`. If the file doesn't exist yet, a default /// Loads settings from `path`, creating and persisting defaults if missing. Any I/O or parse error on an existing file is logged and treated as defaults, without overwriting the unreadable file.
/// `Settings` is created, persisted to `path`, and returned.
///
/// Any I/O or parse error while reading an existing file is treated as
/// non-fatal: it is logged to stderr and a default `Settings` is
/// returned (without overwriting the unreadable file).
pub fn load(path: &Path) -> Settings { pub fn load(path: &Path) -> Settings {
match fs::read_to_string(path) { match fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str(&contents) { Ok(contents) => match serde_json::from_str(&contents) {
@@ -174,8 +136,7 @@ impl Settings {
} }
} }
/// Persists these settings to `path` as pretty-printed JSON, creating /// Persists these settings to `path` as pretty-printed JSON, creating any missing parent directories first.
/// any missing parent directories first.
pub fn save(&self, path: &Path) -> io::Result<()> { pub fn save(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
@@ -186,18 +147,14 @@ impl Settings {
} }
} }
/// Error returned by CSS-entry mutations and lookups (ticket 010). /// Error returned by CSS-entry mutations and lookups.
#[derive(Debug)] #[derive(Debug)]
pub enum CssError { pub enum CssError {
/// No entry with the given id exists. /// No entry with the given id exists.
NotFound, NotFound,
/// The entry exists but is a `Linked` entry, not an `Imported` one, so /// The entry is a `Linked` entry, not `Imported` — ottyl doesn't own a linked entry's content, so there's nothing to delete or serve back.
/// the requested operation (delete / read-content) doesn't apply: ottyl
/// never owns a linked entry's content, so there's nothing to delete or
/// serve back.
NotImported, NotImported,
/// Underlying I/O failure: writing/reading/removing the CSS file on /// Underlying I/O failure: writing/reading/removing the CSS file, or persisting `settings.json`.
/// disk, or persisting `settings.json`.
Io(io::Error), Io(io::Error),
} }
@@ -219,14 +176,7 @@ impl From<io::Error> for CssError {
} }
} }
/// Computes the id used to reference a `CssEntry` over the API. /// Computes the id used to reference a `CssEntry` over the API: the stored id for `Imported`, or a hash of `path_or_url` for `Linked` (which has no stored id), so the frontend can key both the same way.
///
/// For `Imported` entries this is just the stored id. `Linked` entries have
/// no stored id (ottyl doesn't own them), so one is derived deterministically
/// by hashing the `path_or_url`; this lets the frontend key/reference linked
/// entries the same way as imported ones (e.g. for list rendering) without
/// ottyl needing to persist an id it has no other use for. Because it's
/// derived from content, the same `path_or_url` always maps to the same id.
pub fn css_entry_id(entry: &CssEntry) -> String { pub fn css_entry_id(entry: &CssEntry) -> String {
match entry { match entry {
CssEntry::Imported { id, .. } => id.clone(), CssEntry::Imported { id, .. } => id.clone(),
@@ -240,10 +190,7 @@ fn linked_entry_id(path_or_url: &str) -> String {
format!("linked-{:016x}", hasher.finish()) format!("linked-{:016x}", hasher.finish())
} }
/// Generates an id for a newly-imported CSS entry. Not cryptographically /// Generates an id for a newly-imported CSS entry by hashing filename, content, and time, avoiding a `uuid` dependency for what's otherwise an opaque string.
/// meaningful — just needs to be unique enough in practice: it hashes the
/// filename, content, and current time together, avoiding a new dependency
/// (e.g. `uuid`) for what's otherwise a random-ish opaque string.
fn generate_imported_css_id(filename: &str, content: &str) -> String { fn generate_imported_css_id(filename: &str, content: &str) -> String {
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
filename.hash(&mut hasher); filename.hash(&mut hasher);
@@ -261,15 +208,7 @@ fn imported_css_file_path(css_dir: &Path, id: &str) -> PathBuf {
} }
impl Settings { impl Settings {
/// Imports raw CSS `content`: writes it to `<css_dir>/<id>.css` (id /// Imports raw CSS `content`: writes it to `<css_dir>/<id>.css`, records a `CssEntry::Imported` in `css_entries`, and persists settings. Returns the generated id. `css_dir` is expected to be the settings file's parent directory.
/// generated from the filename/content/timestamp, see
/// `generate_imported_css_id`), records a `CssEntry::Imported { id,
/// filename, is_theme }` in `css_entries`, and persists settings to
/// `settings_path`. Returns the generated id.
///
/// `css_dir` is expected to be the settings file's parent directory
/// (i.e. CSS files live alongside `settings.json`). `is_theme` (ticket
/// 033) marks this entry as a selectable theme rather than arbitrary CSS.
pub fn import_css( pub fn import_css(
&mut self, &mut self,
settings_path: &Path, settings_path: &Path,
@@ -290,9 +229,7 @@ impl Settings {
Ok(id) Ok(id)
} }
/// Registers a reference to an external CSS path/URL without copying /// Registers a reference to an external CSS path/URL without copying its content, and persists settings.
/// its content. Persists settings to `settings_path`. `is_theme` (ticket
/// 033) marks this entry as a selectable theme rather than arbitrary CSS.
pub fn link_css( pub fn link_css(
&mut self, &mut self,
settings_path: &Path, settings_path: &Path,
@@ -307,14 +244,7 @@ impl Settings {
Ok(()) Ok(())
} }
/// Removes an imported CSS entry identified by `id` (see /// Removes an imported CSS entry identified by `id`: deletes its file under `css_dir`, drops it from `css_entries`, and persists settings. Fails with `NotFound` or `NotImported` (linked entries aren't deletable this way).
/// `css_entry_id`): deletes its file under `css_dir` and drops it from
/// `css_entries`, then persists settings to `settings_path`.
///
/// Returns `CssError::NotFound` if no entry matches `id`, and
/// `CssError::NotImported` if `id` refers to a `Linked` entry — linked
/// entries are never deletable this way since ottyl doesn't own their
/// content.
pub fn remove_css( pub fn remove_css(
&mut self, &mut self,
settings_path: &Path, settings_path: &Path,
@@ -331,8 +261,7 @@ impl Settings {
Some(CssEntry::Imported { .. }) => { Some(CssEntry::Imported { .. }) => {
let idx = index.unwrap(); let idx = index.unwrap();
self.css_entries.remove(idx); self.css_entries.remove(idx);
// Best-effort: if the file is already gone, the settings // Best-effort: the settings entry is already removed even if the file is gone.
// reference is still what matters and has been removed above.
let _ = fs::remove_file(imported_css_file_path(css_dir, id)); let _ = fs::remove_file(imported_css_file_path(css_dir, id));
self.save(settings_path)?; self.save(settings_path)?;
Ok(()) Ok(())
@@ -340,13 +269,7 @@ impl Settings {
} }
} }
/// Reads back the raw content of an imported CSS entry identified by /// Reads back the raw content of an imported CSS entry identified by `id`. Fails with `NotFound` or `NotImported` (linked entries have no local content — the frontend fetches `path_or_url` directly).
/// `id` (see `css_entry_id`).
///
/// Returns `CssError::NotFound` if no entry matches `id`, and
/// `CssError::NotImported` if `id` refers to a `Linked` entry (there's no
/// local content to serve for those — the frontend should link/fetch the
/// original `path_or_url` directly instead).
pub fn css_content(&self, css_dir: &Path, id: &str) -> Result<String, CssError> { pub fn css_content(&self, css_dir: &Path, id: &str) -> Result<String, CssError> {
match self.css_entries.iter().find(|entry| css_entry_id(entry) == id) { match self.css_entries.iter().find(|entry| css_entry_id(entry) == id) {
None => Err(CssError::NotFound), None => Err(CssError::NotFound),
@@ -364,9 +287,7 @@ mod tests {
#[test] #[test]
fn default_settings_path_ends_with_settings_json() { fn default_settings_path_ends_with_settings_json() {
// We can't assert the exact OS path here without touching real // Can't assert the exact OS path without touching real config dirs, so just check the shape.
// config dirs, but we can verify the path-computation logic shapes
// it as expected.
if let Some(path) = default_settings_path() { if let Some(path) = default_settings_path() {
assert_eq!(path.file_name().unwrap(), "settings.json"); assert_eq!(path.file_name().unwrap(), "settings.json");
assert!(path.parent().is_some()); assert!(path.parent().is_some());
+1 -5
View File
@@ -1,11 +1,7 @@
use serde::Serialize; use serde::Serialize;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
/// Wall-clock instant stored as nanoseconds since the Unix epoch. /// Wall-clock instant stored as nanoseconds since the Unix epoch (not `std::time::Instant`, which has no epoch and can't be serialized).
///
/// The README's data model sketches this field as `Instant`, but `Instant` has no
/// epoch and can't be serialized to the frontend, so wall-clock time is stored here
/// instead.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct Timestamp(i64); pub struct Timestamp(i64);
-35
View File
@@ -1,35 +0,0 @@
# 030 — Cut comments down to clear, direct one-liners
## Summary
Pass over the Rust codebase, the webapp source, and the webapp's in-app docs,
trimming every comment/doc-comment to a single clear, direct line where one is
needed at all, and removing it outright where it isn't.
## Scope
- `src/*.rs`: many modules currently carry multi-paragraph `///`/`//!` blocks
(e.g. `src/settings.rs`'s module doc and per-method docs). Keep only what
explains a non-obvious *why* (a constraint, an invariant, a workaround);
drop restatements of what the code already says via naming.
- `webapp/src/**`: same treatment for inline comments and any JSDoc-style
blocks.
- The webapp's in-app documentation content (surfaced via the "?" help icons
from [021](021-docs-contextual-help.md)) — reduce prose there the same way:
clear and direct, not verbose.
- This is a mechanical cleanup pass, not a refactor — don't change behavior or
restructure code while doing it; keep it reviewable as a comments-only diff
where possible.
## Acceptance criteria
- No multi-line comment blocks remain outside of license/generated headers
(if any).
- Every remaining comment states a non-obvious reason, not a restatement of
the adjacent code.
- `cargo test` and the webapp build/tests still pass (confirms nothing beyond
comments changed).
## Depends on
None.
+8 -28
View File
@@ -22,11 +22,7 @@ import { DocsNavProvider } from './lib/docsNav'
const POLL_MS = 2000 const POLL_MS = 2000
/// Reads the standalone-page routing params written by `PopOutButton` /// 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.
/// (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.
function parsePopOutQuery(): { section: PopOutSectionId | null; range: TimeRange | null } { function parsePopOutQuery(): { section: PopOutSectionId | null; range: TimeRange | null } {
const query = new URLSearchParams(window.location.search) const query = new URLSearchParams(window.location.search)
const section = query.get('section') as PopOutSectionId | null 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 /// Popped out on its own, Params has no lifted-state sibling to share `params`/`loadParams` with, so it owns a minimal fetch here instead.
/// when popped out on its own, so it owns a minimal fetch of its own here
/// rather than App's full `loadParams` machinery.
function StandaloneParams() { function StandaloneParams() {
const [params, setParams] = useState<ParamDeclaration[]>([]) const [params, setParams] = useState<ParamDeclaration[]>([])
@@ -71,15 +65,12 @@ function StandaloneParams() {
) )
} }
/// Metrics/Dashboards already own their own data fetching — they only need /// Metrics/Dashboards own their own data fetching — they only need the range carried over via query params.
/// the range that was active at pop-out time, carried over via query params.
function StandaloneRanged({ section, range }: { section: 'metrics' | 'dashboards'; range: TimeRange | null }) { function StandaloneRanged({ section, range }: { section: 'metrics' | 'dashboards'; range: TimeRange | null }) {
return <StandalonePage>{section === 'metrics' ? <Metrics range={range} /> : <Dashboards range={range} />}</StandalonePage> 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 /// 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.
/// 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.
function StandaloneLog() { function StandaloneLog() {
const [logs, setLogs] = useState<LogEntry[]>([]) const [logs, setLogs] = useState<LogEntry[]>([])
const [params, setParams] = useState<ParamDeclaration[]>([]) const [params, setParams] = useState<ParamDeclaration[]>([])
@@ -194,11 +185,7 @@ function FullApp() {
const [filterError, setFilterError] = useState<string | null>(null) const [filterError, setFilterError] = useState<string | null>(null)
const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer') const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer')
// Read from inside the poll loop below without making it a dependency — // Read via ref inside the poll loop instead of depending on `range` directly, which would restart the interval every rolling-preset tick.
// 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."
const rangeRef = useRef(range) const rangeRef = useRef(range)
useEffect(() => { useEffect(() => {
rangeRef.current = range rangeRef.current = range
@@ -221,9 +208,7 @@ function FullApp() {
setRange(computePresetRange(id)) setRange(computePresetRange(id))
} }
// A drag on LogTimeline always yields a fixed (non-rolling) range, // A drag always yields a fixed (non-rolling) range, overriding any active preset.
// overriding whatever preset was active — same as picking a preset
// overrides a previous drag.
function handleManualRangeSelect(next: TimeRange | null) { function handleManualRangeSelect(next: TimeRange | null) {
if (next === null) { if (next === null) {
selectPreset('all') selectPreset('all')
@@ -237,8 +222,7 @@ function FullApp() {
let cancelled = false let cancelled = false
async function poll() { async function poll() {
// Rolling presets "move with time": re-resolve on this same polling // Rolling presets re-resolve on this same polling cadence instead of freezing the window at selection time.
// cadence rather than freezing the window at selection time.
const effectiveRange = const effectiveRange =
activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current
if (activePreset && activePreset !== 'all') { if (activePreset && activePreset !== 'all') {
@@ -273,11 +257,7 @@ function FullApp() {
cancelled = true cancelled = true
clearInterval(id) clearInterval(id)
} }
// Intentionally excludes `range`: a manual drag (which does change // Intentionally excludes `range` to avoid restarting this interval on every rolling-preset tick.
// `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.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filter, activePreset]) }, [filter, activePreset])
+1 -5
View File
@@ -24,9 +24,7 @@ const OPS: { value: CompareOp; label: string }[] = [
{ value: 'ne', label: '!=' }, { value: 'ne', label: '!=' },
] ]
/// Parses a plain millisecond number or a humantime-style duration like /// 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.
/// `30s`/`5m`/`1h` into milliseconds. Mirrors the backend's `window_ms`
/// field without pulling in a full humantime parser for the webapp.
function parseWindowMs(input: string): number | null { function parseWindowMs(input: string): number | null {
const trimmed = input.trim() const trimmed = input.trim()
if (!trimmed) return null if (!trimmed) return null
@@ -119,8 +117,6 @@ function formFromRule(rule: AlertRule): FormState {
return base 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 } { function buildCondition(form: FormState): { error: string | null; condition: AlertCondition | null } {
switch (form.kind) { switch (form.kind) {
case 'immediate': case 'immediate':
+4 -14
View File
@@ -1,10 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import type { TimeRange } from './LogTimeline' import type { TimeRange } from './LogTimeline'
/// localStorage key for which of the four primary sections (log viewer, /// localStorage key holding a record of section id -> collapsed, mirroring LogPreview's VISIBLE_PARAM_COLUMNS_STORAGE_KEY pattern.
/// 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.
const COLLAPSED_SECTIONS_STORAGE_KEY = 'ottyl.collapsedSections' const COLLAPSED_SECTIONS_STORAGE_KEY = 'ottyl.collapsedSections'
function loadCollapsedSections(): Record<string, boolean> { 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, not a conditional-render helper — callers keep rendering their subtree and hide it with CSS (`hidden`), so collapsing never unmounts in-progress form state.
/// returns only the boolean + setter — callers keep rendering their
/// subtree, just hide it with CSS (`hidden`), so collapsing never unmounts
/// in-progress form state.
export function useSectionCollapsed(sectionId: string): [boolean, (next: boolean) => void] { export function useSectionCollapsed(sectionId: string): [boolean, (next: boolean) => void] {
const [collapsed, setCollapsedState] = useState(() => loadCollapsedSections()[sectionId] ?? false) 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 /// The four primary sections poppable into their own tab; matches the `?section=` query param a standalone page reads.
/// (ticket 036) — matches the `?section=` query param a standalone page reads.
export type PopOutSectionId = 'log' | 'params' | 'metrics' | 'dashboards' export type PopOutSectionId = 'log' | 'params' | 'metrics' | 'dashboards'
function popOutUrl(section: PopOutSectionId, range?: TimeRange | null): string { function popOutUrl(section: PopOutSectionId, range?: TimeRange | null): string {
@@ -72,10 +65,7 @@ function popOutUrl(section: PopOutSectionId, range?: TimeRange | null): string {
return `?${query.toString()}` return `?${query.toString()}`
} }
/// Opens this section, full-page and with no surrounding chrome, in a new /// `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.
/// 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).
export function PopOutButton({ section, range }: { section: PopOutSectionId; range?: TimeRange | null }) { export function PopOutButton({ section, range }: { section: PopOutSectionId; range?: TimeRange | null }) {
return ( return (
<button <button
+9 -30
View File
@@ -19,31 +19,22 @@ import { HelpIcon } from './HelpIcon'
const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie'] const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie']
/// How long to wait after the last edit before autosaving (ticket 027) — long /// Long enough that rapid typing doesn't spam the save endpoint, short enough that a pause reads as "done" and commits promptly.
/// 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.
const AUTOSAVE_DEBOUNCE_MS = 600 const AUTOSAVE_DEBOUNCE_MS = 600
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
/// What actually gets persisted for a dashboard — used both to snapshot "last /// What actually gets persisted for a dashboard — used both to snapshot "last saved" state and to build the `saveDashboard` request body.
/// saved" state and to build the `saveDashboard` request body.
function dashboardSaveShape(dashboard: Dashboard) { function dashboardSaveShape(dashboard: Dashboard) {
return { name: dashboard.name, panels: dashboard.panels } return { name: dashboard.name, panels: dashboard.panels }
} }
function newPanelId(): string { function newPanelId(): string {
// Client-side scratch id for a not-yet-saved panel; the backend treats // 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.
// panel ids as opaque strings, so this only needs to be unique within one
// dashboard's editing session.
return `panel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` return `panel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
} }
/// One panel's live preview: fetches its PromQL series whenever the query, /// 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.
/// 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).
function PanelPreview({ panel, range }: { panel: Panel; range: TimeRange | null }) { function PanelPreview({ panel, range }: { panel: Panel; range: TimeRange | null }) {
const [series, setSeries] = useState<PromqlSeries[]>([]) const [series, setSeries] = useState<PromqlSeries[]>([])
const [error, setError] = useState<string | null>(null) 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} /> 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 }) { function AutosaveStatus({ status, error }: { status: SaveStatus; error: string | null }) {
if (status === 'saving') return <span className="text-xs text-text-faint">Saving…</span> 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> 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 [error, setError] = useState<string | null>(null)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle') const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [saveError, setSaveError] = useState<string | null>(null) const [saveError, setSaveError] = useState<string | null>(null)
// JSON snapshot of the last-persisted { name, panels } shape for the // Last-persisted shape for the current draft, so the autosave effect can skip when nothing actually changed.
// 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).
const lastSavedRef = useRef<string | null>(null) const lastSavedRef = useRef<string | null>(null)
const [collapsed, setCollapsed] = useSectionCollapsed('dashboards') const [collapsed, setCollapsed] = useSectionCollapsed('dashboards')
@@ -138,9 +124,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
} }
}) })
.catch(() => setActiveProfile(null)) .catch(() => setActiveProfile(null))
// Only load once on mount; switching the active profile elsewhere in the // Only load once on mount; switching profiles elsewhere (NavMenu) requires a reload for now.
// app (NavMenu) requires a reload for now, same as other panels don't
// watch for profile changes mid-session.
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 lastSavedRef.current = found ? JSON.stringify(dashboardSaveShape(found)) : null
}, [selectedId, dashboards]) }, [selectedId, dashboards])
// Autosave (ticket 027): debounces edits so rapid typing doesn't spam the // 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.
// 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).
useEffect(() => { useEffect(() => {
if (!draft || !activeProfile) return if (!draft || !activeProfile) return
const snapshot = JSON.stringify(dashboardSaveShape(draft)) const snapshot = JSON.stringify(dashboardSaveShape(draft))
@@ -170,8 +151,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
setSaveStatus('saved') setSaveStatus('saved')
setSaveError(null) setSaveError(null)
} catch (err) { } catch (err) {
// Deliberately leave `draft` as-is on failure — the in-progress edit // Deliberately leave `draft` as-is on failure — the in-progress edit isn't lost, it just hasn't persisted yet.
// isn't lost, it just hasn't persisted yet.
setSaveStatus('error') setSaveStatus('error')
setSaveError(err instanceof ApiError ? err.message : 'Failed to save dashboard') setSaveError(err instanceof ApiError ? err.message : 'Failed to save dashboard')
} }
@@ -180,8 +160,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [draft, activeProfile]) }, [draft, activeProfile])
// Settles a transient "Saved" status back to idle after a moment, so it // Settles a transient "Saved" status back to idle so it reads as a confirmation, not a permanent label.
// reads as a confirmation rather than a permanent label.
useEffect(() => { useEffect(() => {
if (saveStatus !== 'saved') return if (saveStatus !== 'saved') return
const timeout = setTimeout(() => setSaveStatus('idle'), 1500) const timeout = setTimeout(() => setSaveStatus('idle'), 1500)
+65 -77
View File
@@ -1,9 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react' import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { DocsTarget } from '../lib/docsNav' import type { DocsTarget } from '../lib/docsNav'
/// One accordion section: a title plus its body, rendered as plain JSX so /// One accordion section: title plus body as plain JSX, avoiding a markdown renderer for these static pages.
/// each page can mix prose, lists, and inline code without pulling in a
/// markdown renderer for what is currently five static pages.
interface DocSection { interface DocSection {
id: string id: string
title: string title: string
@@ -21,16 +19,15 @@ const SECTIONS: DocSection[] = [
body: ( body: (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>
ottyl turns a CLI program's stdout into a Grafana-like dashboard: pipe a program's output into it, and it 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. indexes each line in memory as it arrives while serving a dashboard from the same process.
</p> </p>
<p> <p>
<Code>{'cli_command --verbose | ottyl'}</Code> <Code>{'cli_command --verbose | ottyl'}</Code>
</p> </p>
<p> <p>
Everything — ingested log lines, declared params, declared metrics, and derived buckets — lives in one Everything — log lines, params, metrics, and derived buckets — lives in one in-memory index for the
in-memory index for the life of the process. Nothing is written to disk unless you explicitly save a{' '} process's lifetime. Nothing touches disk unless you save a <em>profile</em> or change a <em>setting</em>.
<em>profile</em> or change a <em>setting</em>.
</p> </p>
<p className="font-semibold text-text">CLI arguments</p> <p className="font-semibold text-text">CLI arguments</p>
<ul className="list-disc pl-5"> <ul className="list-disc pl-5">
@@ -47,19 +44,17 @@ const SECTIONS: DocSection[] = [
</ul> </ul>
<p className="font-semibold text-text">Settings</p> <p className="font-semibold text-text">Settings</p>
<p> <p>
There is exactly one <em>settings</em> file per machine, holding the colour scheme, any imported/linked CSS, There is one <em>settings</em> file per machine, holding the colour scheme, any imported/linked CSS, and
and which profile is active. Settings "owns" profiles in that sense — it only tracks pointers (known which profile is active. Settings only tracks profile pointers — the profiles themselves (params, metrics,
profile names, which one is active); the profiles themselves (params, metrics, dashboards) are stored dashboards) are stored separately, next to the settings file by default.
separately, next to the settings file by default.
</p> </p>
<p className="font-semibold text-text">Profiles</p> <p className="font-semibold text-text">Profiles</p>
<p> <p>
A profile is a named bundle of parsing/indexing rules for a particular CLI app — which params and metrics A profile bundles the parsing/indexing rules for one CLI app — its params and metrics, plus any saved
to derive from its log output, plus any saved dashboards — so restarting ottyl against the same program dashboards — so restarting ottyl against the same program doesn't lose your setup. A <Code>Default</Code>{' '}
doesn't lose your setup. There is always a <Code>Default</Code> profile, and it can't be deleted. Creating profile always exists and can't be deleted. Creating a new profile copies the active one (or starts empty
a new profile copies the currently active one (or starts empty if none is active), so variations build on if none is active), so variations build on what you already have. Switching profiles hot-swaps the running
what you already have rather than starting from scratch. Switching profiles hot-swaps the running engine's engine's declared params/metrics immediately, with no restart needed.
declared params/metrics immediately, with no restart needed.
</p> </p>
</div> </div>
), ),
@@ -70,16 +65,16 @@ const SECTIONS: DocSection[] = [
body: ( body: (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>
The filter bar above the timeline takes a syntax-aware query combining regex search over raw log text with The filter bar takes a query combining regex search over raw log text with comparisons against declared
comparisons against declared metrics, joined by <Code>and</Code>/<Code>or</Code>: metrics, joined by <Code>and</Code>/<Code>or</Code>:
</p> </p>
<p> <p>
<Code>{'regex("error") and sequence<5 or timestamp>12:00'}</Code> <Code>{'regex("error") and sequence<5 or timestamp>12:00'}</Code>
</p> </p>
<p> <p>
<Code>{'metric_name[param]<5'}</Code> compares a metric's captured param value against a number, string, or <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 — every ingested log timestamp. <Code>sequence</Code> and <Code>timestamp</Code> are reserved metric names present on every log
line has both, so they're always available to filter on without declaring anything. line, so they're always filterable without declaring anything.
</p> </p>
<p className="font-semibold text-text">Date/time formats</p> <p className="font-semibold text-text">Date/time formats</p>
<p>Timestamp comparisons accept several forms:</p> <p>Timestamp comparisons accept several forms:</p>
@@ -91,9 +86,9 @@ const SECTIONS: DocSection[] = [
<li>full dates</li> <li>full dates</li>
</ul> </ul>
<p> <p>
The same filter string is sent to both <Code>GET /api/logs</Code> and <Code>GET /api/timeline</Code> as the{' '} 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 evaluated on the server; a parse error comes back from the server and <Code>filter</Code> query param and is evaluated on the server; a parse error surfaces as inline text under
surfaces as inline text under the filter bar rather than crashing the page. the filter bar rather than crashing the page.
</p> </p>
</div> </div>
), ),
@@ -104,21 +99,20 @@ const SECTIONS: DocSection[] = [
body: ( body: (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>
A parameter declaration tells ottyl how to derive a named value from a raw log line: a regex that must A parameter declaration tells ottyl how to derive a named value from a log line: a regex the message must
match the message, plus a capture group within it, plus a type (<Code>string</Code>, <Code>number</Code>, match, a capture group, and a type (<Code>string</Code>, <Code>number</Code>, or <Code>timestamp</Code>).
or <Code>timestamp</Code>). Once declared, every already-ingested line gets retroactively indexed for that Declaring one retroactively indexes every already-ingested line and checks every new line as it arrives.
param, and every new line is checked against it as it arrives.
</p> </p>
<p> <p>
Params are the building block metrics are made of (a metric declaration is really a message-match regex Params are the building blocks metrics are made of (a metric is really a message-match regex plus a set of
plus a set of param matchers), and they're also what drives filtering and the log preview's column picker — param matchers), and they drive filtering and the log preview's column picker — declaring a{' '}
declaring a <Code>duration</Code> param, for instance, makes <Code>duration</Code> available as a column in <Code>duration</Code> param, for instance, makes it available as both a log-table column and a comparable
the log table and as a comparable value in the filter bar. value in the filter bar.
</p> </p>
<p> <p>
The Params panel declares a bare param directly (<Code>POST /api/params</Code>) — the same request the 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 Metrics panel's declaration form makes per param matcher. Declaring one there updates the log preview's
preview's column picker, with no page reload needed. column picker immediately, with no page reload needed.
</p> </p>
</div> </div>
), ),
@@ -130,14 +124,14 @@ const SECTIONS: DocSection[] = [
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>
The timeline above the log table renders log volume per bucket over the active window. Drag across it to 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>
<p> <p>
The preset buttons (<Code>All</Code>, <Code>5m</Code>, <Code>15m</Code>, <Code>1h</Code>,{' '} 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 <Code>24h</Code>) are Grafana-style shortcuts: <Code>All</Code> clears the range entirely, and a relative
entirely, and a relative preset is <em>rolling</em> — its window keeps advancing to stay "last N minutes preset is <em>rolling</em> — its window keeps advancing to stay "last N minutes from now" instead of
from now" rather than freezing at the moment you picked it. Dragging on the timeline while a preset is freezing at selection time. Dragging while a preset is active switches to a fixed (non-rolling) range, same
active switches back to a fixed (non-rolling) range, same as dragging normally would. as a normal drag.
</p> </p>
</div> </div>
), ),
@@ -149,8 +143,8 @@ const SECTIONS: DocSection[] = [
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>
The log table lists ingested lines newest-last, each with its sequence number, timestamp, and raw text. The 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 column picker (top-right) adds a column per declared param, showing that line's derived value (blank if it
blank if it didn't match) — a quick way to eyeball params without switching to the Metrics panel. didn't match) — a quick way to eyeball params without switching to the Metrics panel.
</p> </p>
</div> </div>
), ),
@@ -167,20 +161,19 @@ const SECTIONS: DocSection[] = [
</p> </p>
<p> <p>
Each param is either a <strong>label</strong> (the default) or the metric's single <strong>value</strong>{' '} 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 param. Labels identify which bucket an occurrence belongs to; a value param's captured number becomes the
that occurrence's stored value instead of a label — e.g. a <Code>duration</Code> param marked{' '} occurrence's stored value instead of a label — e.g. a <Code>duration</Code> param marked <em>value</em>{' '}
<em>value</em> lets you query the distribution or latest reading of durations, not just how often they lets you query the distribution or latest reading, not just how often it occurred. A metric with only
occurred. A metric with only label params still works exactly as before: every occurrence's value defaults label params defaults every occurrence's value to a fixed <Code>1</Code> (a pure occurrence count).
to a fixed <Code>1</Code> (a pure occurrence count).
</p> </p>
<p> <p>
Declaring a metric backfills it against everything already ingested, then continues indexing new lines as Declaring a metric backfills it against everything already ingested, then keeps indexing new lines as they
they arrive — there's no need to restart ingestion or re-declare after the fact. arrive — no need to restart ingestion or re-declare after the fact.
</p> </p>
<p> <p>
The Metrics panel lets you declare a metric, pick one from the declared list, and see its bucket values 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 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: <em>derived</em> from a counter's value param rather than stored separately:
</p> </p>
<ul className="list-disc pl-5"> <ul className="list-disc pl-5">
@@ -188,7 +181,7 @@ const SECTIONS: DocSection[] = [
<span className="font-semibold text-text"> <span className="font-semibold text-text">
<Code>histogram(metric_name[width])</Code> <Code>histogram(metric_name[width])</Code>
</span>{' '} </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>. <Code>histogram(durations[10])</Code>.
</li> </li>
<li> <li>
@@ -208,9 +201,9 @@ const SECTIONS: DocSection[] = [
body: ( body: (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>
Dashboards work like Grafana: each one is a named set of panels, and each panel is a PromQL query paired Dashboards work like Grafana: each is a named set of panels, and each panel is a PromQL query paired with a
with a chart type — bar, line, or pie. Panels preview live as you edit the query, and a malformed query chart type — bar, line, or pie. Panels preview live as you edit the query, and a malformed query shows an
shows an inline error instead of breaking the page. inline error instead of breaking the page.
</p> </p>
<ul className="list-disc pl-5"> <ul className="list-disc pl-5">
<li> <li>
@@ -218,8 +211,7 @@ const SECTIONS: DocSection[] = [
x-axis. x-axis.
</li> </li>
<li> <li>
<span className="font-semibold text-text">Bar</span> — the latest value per series (a snapshot of "where <span className="font-semibold text-text">Bar</span> — the latest value per series (a snapshot of "now").
things are now").
</li> </li>
<li> <li>
<span className="font-semibold text-text">Pie</span> — each series' share of the summed total across the <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> </li>
</ul> </ul>
<p> <p>
Dashboards are saved per profile, so they travel with a profile when you switch or copy it, and require an Dashboards are saved per profile, so they travel with it when you switch or copy it — an active profile is
active profile to create or save — pick or create one from the settings menu first. required to create or save one; pick or create one from the settings menu first.
</p> </p>
</div> </div>
), ),
@@ -239,12 +231,12 @@ const SECTIONS: DocSection[] = [
body: ( body: (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>
An alert rule fires a notification when its condition matches a newly-ingested log line — evaluated per An alert rule fires a notification when its condition matches a newly-ingested log line, evaluated per line
line as it arrives, not backfilled against history. There are four condition types: as it arrives rather than backfilled against history. There are four condition types:
</p> </p>
<ul className="list-disc pl-5"> <ul className="list-disc pl-5">
<li> <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>
<li> <li>
<span className="font-semibold text-text">Threshold</span> — fires when a query's match count within a <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> </li>
</ul> </ul>
<p> <p>
Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's query syntax, but only the subset Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's syntax, but only the subset that
that evaluates against a single line's own fields — <Code>regex(...)</Code>, <Code>sequence</Code>,{' '} 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 <Code>timestamp</Code>, and <Code>and</Code>/<Code>or</Code>. A <Code>metric_name[param]</Code> comparison
isn't supported in an alert query. isn't supported in an alert query.
</p> </p>
@@ -270,10 +262,9 @@ const SECTIONS: DocSection[] = [
<Code>5m</Code>, <Code>1h</Code>. <Code>5m</Code>, <Code>1h</Code>.
</p> </p>
<p> <p>
Every rule delivers to an <em>ntfy</em> sink: a topic (required) and an optional server override (defaults Every rule delivers to an <em>ntfy</em> sink: a topic (required) and an optional server override (default{' '}
to <Code>https://ntfy.sh</Code>). "Send test notification" delivers to whatever topic/server is currently <Code>https://ntfy.sh</Code>). "Send test notification" delivers to the form's current topic/server without
entered in the form, without needing to save the rule first — handy for verifying the sink before needing to save the rule first.
committing to it.
</p> </p>
</div> </div>
), ),
@@ -284,9 +275,9 @@ const SECTIONS: DocSection[] = [
body: ( body: (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>
The Settings panel controls process-wide state: colour scheme, the maximum number of logs kept in memory The Settings panel controls process-wide state: colour scheme, the max number of logs kept in memory
(blank means unlimited), and imported/linked CSS. These apply immediately and persist to the single (blank means unlimited), and imported/linked CSS. Changes apply immediately and persist to the settings
settings file on disk — no save button needed. file on disk — no save button needed.
</p> </p>
</div> </div>
), ),
@@ -298,15 +289,14 @@ const SECTIONS: DocSection[] = [
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>
The Presets panel manages profiles: create, switch, and delete them. Switching activates a profile's 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>{' '} params/metrics in the running engine immediately, no restart needed — see <em>General idea</em> above for
section above for what a profile stores. what a profile stores.
</p> </p>
</div> </div>
), ),
}, },
] ]
/** How long a flashed section stays highlighted before settling back to normal. */
const FLASH_DURATION_MS = 1200 const FLASH_DURATION_MS = 1200
export function DocsPanel({ target }: { target?: DocsTarget | null }) { 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 [flashId, setFlashId] = useState<string | null>(null)
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({}) const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
// Keyed on `target?.nonce` (not `target?.sectionId`) so clicking the same // 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.
// "?" icon twice in a row re-triggers the flash rather than being a no-op
// because the panel is already open to that section.
useEffect(() => { useEffect(() => {
if (!target) return if (!target) return
setOpenId(target.sectionId) setOpenId(target.sectionId)
+2 -10
View File
@@ -2,19 +2,11 @@ import type { ReactNode } from 'react'
import { useState } from 'react' import { useState } from 'react'
import { HelpIcon } from './HelpIcon' import { HelpIcon } from './HelpIcon'
/// Shared styling for a single FilterBar-syntax query input, reused by // Reused by `Alerts.tsx`'s condition editor so alert conditions look like the filter bar without duplicating its draft/submit state.
/// `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.
export const queryInputClassName = 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' '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'
/** // 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.
* 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.
*/
export function FilterBar({ export function FilterBar({
value, value,
onChange, onChange,
+1 -5
View File
@@ -1,10 +1,6 @@
import { useDocsNav } from '../lib/docsNav' import { useDocsNav } from '../lib/docsNav'
/** // Opens the Docs panel scrolled to (and flashing) sectionId, instead of the user hunting for it in the Docs accordion (ticket 021).
* 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.
*/
export function HelpIcon({ sectionId, label }: { sectionId: string; label?: string }) { export function HelpIcon({ sectionId, label }: { sectionId: string; label?: string }) {
const { openSection } = useDocsNav() const { openSection } = useDocsNav()
return ( 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 { metricPrimitiveToDisplayString, type LogEntry, type ParamDeclaration } from '../lib/api'
import { HelpIcon } from './HelpIcon' import { HelpIcon } from './HelpIcon'
/// localStorage key for LogPreview's selected param columns. Namespaced // Namespaced under `ottyl.` so future per-viewer preferences elsewhere in the app won't collide with this key.
/// 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.
const VISIBLE_PARAM_COLUMNS_STORAGE_KEY = 'ottyl.logPreview.visibleParamColumns' const VISIBLE_PARAM_COLUMNS_STORAGE_KEY = 'ottyl.logPreview.visibleParamColumns'
function loadVisibleParamColumns(): string[] { function loadVisibleParamColumns(): string[] {
@@ -23,8 +20,7 @@ function saveVisibleParamColumns(columns: string[]) {
try { try {
localStorage.setItem(VISIBLE_PARAM_COLUMNS_STORAGE_KEY, JSON.stringify(columns)) localStorage.setItem(VISIBLE_PARAM_COLUMNS_STORAGE_KEY, JSON.stringify(columns))
} catch { } catch {
// best-effort — a private browsing mode or full storage just means the // best-effort — private browsing or full storage just means the selection won't persist across reloads
// 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 // Filter out stale localStorage entries for since-removed params, so they don't leave a dangling column.
// exist, so a stale localStorage entry for a since-removed param doesn't
// leave a dangling column.
const columns = visibleParamColumns.filter((name) => paramNames.includes(name)) const columns = visibleParamColumns.filter((name) => paramNames.includes(name))
return ( return (
+3 -10
View File
@@ -7,9 +7,7 @@ export interface TimeRange {
endMs: number endMs: number
} }
/// A Grafana-style relative time-range preset (ticket 024). `'all'` clears // `'all'` clears the range (the implicit default); the rest resolve to a window ending "now" at the moment they're (re-)computed — see `computePresetRange`.
/// the range (today's 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 type TimeRangePresetId = 'all' | '5m' | '15m' | '1h' | '24h'
export const TIME_RANGE_PRESETS: { id: TimeRangePresetId; label: string; ms: number | null }[] = [ 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 }, { 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 their existing poll cadence (see `App.tsx`) to keep a rolling preset's window moving with time, rather than freezing it at selection time.
/// 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.
export function computePresetRange(id: TimeRangePresetId): TimeRange | null { export function computePresetRange(id: TimeRangePresetId): TimeRange | null {
const preset = TIME_RANGE_PRESETS.find((p) => p.id === id) const preset = TIME_RANGE_PRESETS.find((p) => p.id === id)
if (!preset || preset.ms == null) return null if (!preset || preset.ms == null) return null
@@ -131,9 +126,7 @@ export function LogTimeline({
</button> </button>
))} ))}
</div> </div>
{/* A dragged (custom, non-preset) selection — presets already show {/* Only shown for a dragged (non-preset) selection — presets already show their own active state above. */}
their own active state above, so this only appears for the
"fixed range from a drag" case. */}
{range && activePreset === null && ( {range && activePreset === null && (
<button className="underline hover:text-text-secondary" onClick={() => onSelectPreset('all')}> <button className="underline hover:text-text-secondary" onClick={() => onSelectPreset('all')}>
Clear selection Clear selection
+3 -11
View File
@@ -15,9 +15,7 @@ import { HelpIcon } from './HelpIcon'
const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' } const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' }
/// One row in the "labels -> occurrence count" table for the selected // One row in the labels -> occurrence count table; `values` keeps the raw points for the sparkline rendering.
/// metric's bucket values over the active range. `values` keeps the raw
/// points (used for the sparkline-ish rendering) alongside the count.
interface LabelGroup { interface LabelGroup {
key: string key: string
labels: Record<string, 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) 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 // A crude text sparkline using block characters — not a real chart, that's ticket 014's job.
/// slots across the visible range and renders relative bar heights using
/// block characters. Not a real chart — that's ticket 014's job.
const SPARK_CHARS = ' ▁▂▃▄▅▆▇█' const SPARK_CHARS = ' ▁▂▃▄▅▆▇█'
function sparkline(values: BucketValue[], range: TimeRange | null): string { function sparkline(values: BucketValue[], range: TimeRange | null): string {
if (values.length === 0) return '' if (values.length === 0) return ''
@@ -98,11 +94,7 @@ export function Metrics({ range }: { range: TimeRange | null }) {
loadMetrics() loadMetrics()
}, []) }, [])
// Refetch-on-change rather than continuous polling: bucket values only // 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.
// 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.
useEffect(() => { useEffect(() => {
if (!selected) { if (!selected) {
setBucketValues([]) setBucketValues([])
+2 -6
View File
@@ -41,18 +41,14 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
setOpenPanel((current) => (current === name ? null : name)) setOpenPanel((current) => (current === name ? null : name))
} }
// A "?" icon (ticket 021) always opens Docs to that section and flashes it, // 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.
// even if Docs is already open to a different section — hence forcing
// `openPanel` here rather than toggling.
useEffect(() => { useEffect(() => {
if (!docsNav.target) return if (!docsNav.target) return
setOpenPanel('docs') setOpenPanel('docs')
setDocsPanelTarget(docsNav.target) setDocsPanelTarget(docsNav.target)
}, [docsNav.target]) }, [docsNav.target])
// Opening Docs the normal way (this button, not a "?" icon) behaves as // Opening Docs via this button (not a "?" icon) has no forced section or flash, so clear any pending help-triggered target.
// before: no forced section, no flash — so any pending help-triggered
// target is cleared.
function handleDocsButtonClick() { function handleDocsButtonClick() {
togglePanel('docs') togglePanel('docs')
setDocsPanelTarget(null) setDocsPanelTarget(null)
+4 -19
View File
@@ -15,24 +15,17 @@ import {
} from 'recharts' } from 'recharts'
import type { ChartType, PromqlSeries } from '../lib/api' import type { ChartType, PromqlSeries } from '../lib/api'
/// Renders a label combination as a short, stable string for use as a series /// Renders a label combination as a series key/legend string, e.g. `{"path":"/a"}` -> `path=/a`; no labels renders as `value`.
/// 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`.
export function seriesLabel(labels: Record<string, string>): string { export function seriesLabel(labels: Record<string, string>): string {
const entries = Object.entries(labels) const entries = Object.entries(labels)
if (entries.length === 0) return 'value' if (entries.length === 0) return 'value'
return entries.map(([k, v]) => `${k}=${v}`).join(', ') return entries.map(([k, v]) => `${k}=${v}`).join(', ')
} }
// Fixed, small categorical palette (rather than pulling in a colour-scale // Fixed, small categorical palette to avoid a colour-scale dependency; cycles if there are more series than colours.
// dependency) — cycles if there are more series than colours.
const COLORS = ['#38bdf8', '#f472b6', '#a78bfa', '#4ade80', '#fbbf24', '#fb7185', '#2dd4bf', '#f97316'] 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 // 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.
/// 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.
function toLineData(series: PromqlSeries[]): { rows: Record<string, number | string>[]; keys: string[] } { function toLineData(series: PromqlSeries[]): { rows: Record<string, number | string>[]; keys: string[] } {
const keys = series.map((s) => seriesLabel(s.labels)) const keys = series.map((s) => seriesLabel(s.labels))
const byTimestamp = new Map<number, Record<string, number | string>>() 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' }) 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 // 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).
/// 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").
function latestValue(series: PromqlSeries): number { function latestValue(series: PromqlSeries): number {
if (series.points.length === 0) return 0 if (series.points.length === 0) return 0
return series.points[series.points.length - 1][1] 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 rows = series.map((s) => ({ name: seriesLabel(s.labels), value: sumValue(s) }))
const total = rows.reduce((sum, r) => sum + r.value, 0) const total = rows.reduce((sum, r) => sum + r.value, 0)
if (total === 0) { if (total === 0) {
+12 -34
View File
@@ -1,7 +1,4 @@
/// `MetricPrimitive` is a Rust enum with derived `Serialize`, so its JSON // Externally-tagged JSON matching the Rust `MetricPrimitive` enum's derived `Serialize`, e.g. `{ "Duration": 1500 }` (milliseconds).
/// form is externally tagged, e.g. `{ "String": "info" }`, `{ "Number":
/// { "Int": 5 } }`, `{ "Timestamp": 1710000000000 }`, `{ "Duration": 1500 }`
/// (milliseconds, ticket 023).
export type MetricPrimitive = export type MetricPrimitive =
| { String: string } | { String: string }
| { Number: { Int: number } | { Float: number } } | { Number: { Int: number } | { Float: number } }
@@ -20,9 +17,7 @@ export interface LogEntry {
sequence: number sequence: number
timestamp_ms: number timestamp_ms: number
text: string text: string
/// Derived param values (declared via `POST /api/params`) that matched // A param with no match on this line is simply absent from the map.
/// this line's text, keyed by param name. A param with no match on this
/// line is simply absent from the map.
params: Record<string, MetricPrimitive> params: Record<string, MetricPrimitive>
} }
@@ -46,7 +41,6 @@ export interface Stats {
last_timestamp_ms: number | null 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 type ParamRole = 'label' | 'value'
export interface MetricParamMatcher { export interface MetricParamMatcher {
@@ -61,7 +55,7 @@ export interface MetricDeclaration {
name: string name: string
message_match_regex: string message_match_regex: string
params: MetricParamMatcher[] 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 matched_labels?: Record<string, string> | null
unmatched_labels?: Record<string, string> | null unmatched_labels?: Record<string, string> | null
} }
@@ -73,8 +67,7 @@ export interface BucketValue {
value: MetricPrimitive value: MetricPrimitive
} }
/// Error shape returned by API handlers on a 4xx/5xx response: // Error shape returned by API handlers on a 4xx/5xx response (see `ErrorDto` on the backend).
/// `{ "error": "..." }` (see `ErrorDto` on the backend).
export interface ApiErrorBody { export interface ApiErrorBody {
error: string error: string
} }
@@ -103,7 +96,7 @@ export interface Settings {
active_profile: string | null active_profile: string | null
/** Maximum ingested logs kept in memory; `null` means unlimited. */ /** Maximum ingested logs kept in memory; `null` means unlimited. */
max_logs: number | null 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 custom_theme_id: string | null
} }
@@ -114,7 +107,7 @@ export interface CssEntry {
kind: CssEntryKind kind: CssEntryKind
filename: string | null filename: string | null
path_or_url: 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 is_theme: boolean
} }
@@ -125,9 +118,7 @@ export interface ProfileSummary {
dashboard_count: number dashboard_count: number
} }
/// One chart-ready time series returned by `GET /api/query` (ticket 013): // One chart-ready time series from `GET /api/query`: a label combination plus its `(timestamp_ms, value)` points, sorted by timestamp.
/// a label combination plus its `(timestamp_ms, value)` points, sorted by
/// timestamp. See `PromqlSeriesDto` on the backend.
export interface PromqlSeries { export interface PromqlSeries {
labels: Record<string, string> labels: Record<string, string>
points: [number, number][] points: [number, number][]
@@ -148,20 +139,16 @@ export interface Dashboard {
panels: Panel[] 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' export type CompareOp = 'lt' | 'gt' | 'le' | 'ge' | 'eq' | 'ne'
/// What triggers an alert rule (ticket 017/018). Externally tagged on // Externally tagged on `kind`, matching `AlertCondition`'s `#[serde(tag = "kind")]` on the backend.
/// `kind`, matching `AlertCondition`'s `#[serde(tag = "kind")]`.
export type AlertCondition = export type AlertCondition =
| { kind: 'immediate'; query: string } | { kind: 'immediate'; query: string }
| { kind: 'threshold'; query: string; op: CompareOp; threshold: number; window_ms: number } | { kind: 'threshold'; query: string; op: CompareOp; threshold: number; window_ms: number }
| { kind: 'newvalue'; field_regex: string; capture_group: string } | { kind: 'newvalue'; field_regex: string; capture_group: string }
| { kind: 'ratio'; query_a: string; query_b: string; op: CompareOp; ratio: number; window_ms: number } | { 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 // Only one sink kind exists today, but this stays a tagged union to match the backend.
/// today, but this stays a tagged union to match the backend.
export type AlertSink = { kind: 'ntfy'; topic: string; server: string | null } export type AlertSink = { kind: 'ntfy'; topic: string; server: string | null }
export interface AlertRule { export interface AlertRule {
@@ -189,11 +176,7 @@ async function getJson<T>(path: string): Promise<T> {
return res.json() as Promise<T> return res.json() as Promise<T>
} }
/// POSTs a JSON body to `path`. On a non-ok response, attempts to parse the // Throws `ApiError` (not `Error`) with the parsed `{ "error": "..." }` message so callers can surface it inline.
/// 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.
async function postJson<T>(path: string, body: unknown): Promise<T> { async function postJson<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, { const res = await fetch(path, {
method: 'POST', method: 'POST',
@@ -357,10 +340,7 @@ export function activateProfile(name: string): Promise<Settings> {
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST') return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST')
} }
/// Evaluates a PromQL query (ticket 013) via `GET /api/query`. Throws on a // Throws on a malformed query (400); callers should catch and surface the message inline rather than letting it crash the page.
/// 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.
export function fetchPromqlSeries(params: { export function fetchPromqlSeries(params: {
promql: string promql: string
startMs?: number startMs?: number
@@ -419,9 +399,7 @@ export function deleteAlertRule(id: string): Promise<void> {
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'DELETE') return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'DELETE')
} }
/// Delivers a test notification to `topic`/`server` (ticket 020) without // Delivers a test notification without requiring the rule to be saved first; throws `ApiError` on failure so callers can surface it inline.
/// requiring the rule to be saved first. Throws `ApiError` on failure (e.g.
/// ntfy unreachable) so callers can surface it inline.
export function sendTestAlertNotification(topic: string, server: string | null): Promise<void> { export function sendTestAlertNotification(topic: string, server: string | null): Promise<void> {
return sendJson('/api/settings/alerts/test', 'POST', { topic, server }) 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' 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 { export interface DocsTarget {
sectionId: string sectionId: string
/** Bumped on every request, even to the same section, so a repeat click re-triggers the flash. */ /** 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 @@
/** // 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.
* 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.
*/
/** Canonical list of every `--c-*` variable every theme in `index.css` defines. */ /** Canonical list of every `--c-*` variable every theme in `index.css` defines. */
export const THEME_CSS_VARS = [ export const THEME_CSS_VARS = [
@@ -42,16 +26,7 @@ export const THEME_CSS_VARS = [
export type ThemeVars = Record<(typeof THEME_CSS_VARS)[number], string> export type ThemeVars = Record<(typeof THEME_CSS_VARS)[number], string>
/** // 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.
* 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.
*/
export const BUILTIN_THEMES: Record<string, ThemeVars> = { export const BUILTIN_THEMES: Record<string, ThemeVars> = {
dark: { dark: {
'--c-surface-0': '#0a0a0a', '--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 { export function generateThemeCss(vars: ThemeVars): string {
const lines = THEME_CSS_VARS.map((name) => ` ${name}: ${vars[name]};`) const lines = THEME_CSS_VARS.map((name) => ` ${name}: ${vars[name]};`)
return `:root {\n${lines.join('\n')}\n}\n` return `:root {\n${lines.join('\n')}\n}\n`
@@ -264,14 +238,7 @@ export interface ParsedTheme {
missing: string[] missing: string[]
} }
/** // 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.
* 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.
*/
export function parseThemeCss(content: string): ParsedTheme { export function parseThemeCss(content: string): ParsedTheme {
const rootMatch = content.match(/:root\s*\{([^}]*)\}/) const rootMatch = content.match(/:root\s*\{([^}]*)\}/)
const body = rootMatch ? rootMatch[1] : content const body = rootMatch ? rootMatch[1] : content
@@ -286,7 +253,6 @@ export function parseThemeCss(content: string): ParsedTheme {
return { vars, missing } return { vars, missing }
} }
/** Triggers a browser download of `content` as `filename` (a `.css` file). */
export function downloadThemeFile(filename: string, content: string) { export function downloadThemeFile(filename: string, content: string) {
const blob = new Blob([content], { type: 'text/css' }) const blob = new Blob([content], { type: 'text/css' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
+9 -45
View File
@@ -17,29 +17,16 @@ import {
type Settings, type Settings,
} from './api' } 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' 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' 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 const THEME_CLASSES = ['light', 'dark', 'grey', 'red', 'green', 'blue', 'yellow', 'pink', 'orange'] as const
/** // 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.
* 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.
*/
function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean) { function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean) {
const root = document.documentElement const root = document.documentElement
root.classList.remove(...THEME_CLASSES) root.classList.remove(...THEME_CLASSES)
@@ -53,17 +40,7 @@ function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean)
root.dataset.colorScheme = colorScheme root.dataset.colorScheme = colorScheme
} }
/** // 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`.
* 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`.
*/
function applyCssEntries(entries: CssEntry[]) { function applyCssEntries(entries: CssEntry[]) {
document.querySelectorAll(`link[${INJECTED_CSS_ATTR}]`).forEach((el) => el.remove()) document.querySelectorAll(`link[${INJECTED_CSS_ATTR}]`).forEach((el) => el.remove())
for (const entry of entries) { for (const entry of entries) {
@@ -77,13 +54,7 @@ function applyCssEntries(entries: CssEntry[]) {
} }
} }
/** // 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.
* 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.
*/
function applyCustomThemeCss(entry: CssEntry | null) { function applyCustomThemeCss(entry: CssEntry | null) {
document.querySelectorAll(`link[${INJECTED_THEME_ATTR}]`).forEach((el) => el.remove()) document.querySelectorAll(`link[${INJECTED_THEME_ATTR}]`).forEach((el) => el.remove())
if (!entry) return if (!entry) return
@@ -96,12 +67,7 @@ function applyCustomThemeCss(entry: CssEntry | null) {
document.head.appendChild(link) document.head.appendChild(link)
} }
/** // 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.
* 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.
*/
export function useSettingsPanel() { export function useSettingsPanel() {
const [settings, setSettings] = useState<Settings | null>(null) const [settings, setSettings] = useState<Settings | null>(null)
const [cssEntries, setCssEntries] = useState<CssEntry[]>([]) const [cssEntries, setCssEntries] = useState<CssEntry[]>([])
@@ -162,8 +128,7 @@ export function useSettingsPanel() {
const setColorScheme = useCallback( const setColorScheme = useCallback(
(colorScheme: ColorScheme) => (colorScheme: ColorScheme) =>
guarded(async () => { guarded(async () => {
// Selecting a built-in scheme always deselects any custom theme — // Selecting a built-in scheme always deselects any custom theme — the two are mutually exclusive (see `applyColorScheme`).
// the two are mutually exclusive (see `applyColorScheme`).
const next = await updateSettings({ colorScheme, customThemeId: null }) const next = await updateSettings({ colorScheme, customThemeId: null })
setSettings(next) setSettings(next)
})(), })(),
@@ -220,8 +185,7 @@ export function useSettingsPanel() {
guarded(async () => { guarded(async () => {
await deleteCssEntry(id) await deleteCssEntry(id)
await refreshCss() await refreshCss()
// The removed entry may have been the active custom theme — fall // The removed entry may have been the active custom theme — fall back to `color_scheme` rather than leaving a dangling selection.
// back to `color_scheme` rather than leaving a dangling selection.
if (settings?.custom_theme_id === id) { if (settings?.custom_theme_id === id) {
const next = await updateSettings({ customThemeId: null }) const next = await updateSettings({ customThemeId: null })
setSettings(next) setSettings(next)