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),
//! and ntfy delivery (ticket 019).
//! Alert rules: definitions, trigger evaluation, and ntfy delivery.
//!
//! ## Design: why alert queries only support regex/sequence/timestamp
//!
//! `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.
//! 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.
use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet, VecDeque};
@@ -34,7 +19,6 @@ use crate::filter_parser::{self, CompareOp, FilterExpr, FilterValue};
use crate::settings::Settings;
use crate::timestamp::Timestamp;
/// A single alert rule definition (ticket 017).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AlertRule {
pub id: String,
@@ -44,36 +28,23 @@ pub struct AlertRule {
pub sink: AlertSink,
}
/// What triggers an alert rule (ticket 018).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum AlertCondition {
/// Fires once per log line matching `query`.
Immediate { query: String },
/// Fires when `query`'s match count within a rolling `window_ms` crosses
/// `threshold` (via `op`).
/// Fires when `query`'s match count within a rolling `window_ms` crosses `threshold` (via `op`).
Threshold {
query: String,
op: CompareOp,
threshold: f64,
window_ms: i64,
},
/// Fires the first time `field_regex` matches a line and `capture_group`
/// 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.
/// Seen values live only in memory — a restart forgets them, so previously-seen values can fire again; a deliberate simplicity tradeoff, not an oversight.
NewValue {
field_regex: String,
capture_group: String,
},
/// Fires when `count(query_a) / count(query_b)` (rolling over
/// `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.
/// A zero `count_b` skips evaluation for that line rather than treating the ratio as infinite.
Ratio {
query_a: String,
query_b: String,
@@ -83,9 +54,7 @@ pub enum AlertCondition {
},
}
/// Where a fired alert is delivered (ticket 019). Modeled as an enum (rather
/// than `AlertRule` holding a bare topic/server) so more sink kinds can be
/// added later without reshaping `AlertRule`.
/// Modeled as an enum (rather than bare fields on `AlertRule`) so more sink kinds can be added later without reshaping it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum AlertSink {
@@ -96,7 +65,6 @@ pub enum AlertSink {
},
}
/// Error returned by alert-rule mutations/lookups. Mirrors `CssError`'s shape.
#[derive(Debug)]
pub enum AlertError {
NotFound,
@@ -120,9 +88,7 @@ impl From<io::Error> for AlertError {
}
}
/// Generates an id for a newly-created alert rule. Same approach as
/// `settings::generate_imported_css_id`: hash the name and current time
/// rather than pulling in a `uuid` dependency.
/// Hashes the name and current time rather than pulling in a `uuid` dependency.
fn generate_alert_rule_id(name: &str) -> String {
let mut hasher = DefaultHasher::new();
name.hash(&mut hasher);
@@ -135,7 +101,6 @@ fn generate_alert_rule_id(name: &str) -> String {
}
impl Settings {
/// Creates a new alert rule, persisting settings to `settings_path`.
pub fn create_alert_rule(
&mut self,
settings_path: &Path,
@@ -150,9 +115,6 @@ impl Settings {
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(
&mut self,
settings_path: &Path,
@@ -176,8 +138,6 @@ impl Settings {
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> {
let index = self
.alert_rules
@@ -190,11 +150,7 @@ impl Settings {
}
}
/// Per-rule in-memory evaluation state (ticket 018): rolling match-timestamp
/// 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.
/// Per-rule in-memory evaluation state; lives only for the process's lifetime — not persisted, not backfilled against already-ingested logs.
#[derive(Debug, Default)]
pub struct AlertRuntimeState {
threshold_matches: VecDeque<i64>,
@@ -203,9 +159,7 @@ pub struct AlertRuntimeState {
seen_values: HashSet<String>,
}
/// Drops every entry older than `window_ms` relative to `now_ms` from the
/// 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.
/// `timestamps` is push_back-ordered, so once the 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) {
while let Some(&front) = timestamps.front() {
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
/// only — see this module's doc comment for why `metric[param]` comparisons
/// aren't supported here.
/// Evaluates a FilterBar query against a single line's own fields only (metric[param] comparisons aren't supported — see module doc).
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())?;
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,
/// 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.
/// A malformed query (bad regex, unsupported metric comparison) is logged and simply never matches, rather than panicking.
pub fn evaluate_alerts(
rules: &[AlertRule],
states: &mut HashMap<String, AlertRuntimeState>,
@@ -362,9 +309,7 @@ fn evaluate_condition(
let count_a = state.ratio_a_matches.len() as f64;
let count_b = state.ratio_b_matches.len() as f64;
if count_b == 0.0 {
// Zero denominator: skip evaluation rather than treat the
// ratio as infinite — there's nothing meaningful to compare
// against `ratio` yet.
// Zero denominator: skip rather than treat the ratio as infinite.
return None;
}
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
/// 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.
/// Errors are only logged (no retry queue); spawned off the ingest hot path so a slow/unreachable sink never blocks ingestion.
pub async fn deliver_for_rule(rule: AlertRule, message: String) {
match rule.sink {
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
/// API (no auth, raw body). Standalone and `AlertRule`-free so a future
/// "send test notification" endpoint can call it directly.
/// Standalone and `AlertRule`-free so a "send test notification" endpoint can call it directly.
pub async fn deliver_ntfy(server: &str, topic: &str, message: &str) -> Result<(), String> {
let url = format!("{}/{}", server.trim_end_matches('/'), topic);
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 {
engine: EngineHandle,
settings: SettingsHandle,
/// Path to the settings JSON file on disk. Imported CSS files are stored
/// alongside it, in its parent directory (see `ApiState::css_dir`).
/// Imported CSS files are stored alongside this path's parent directory (see `ApiState::css_dir`).
settings_path: Arc<PathBuf>,
}
@@ -42,13 +41,7 @@ impl ApiState {
.unwrap_or_else(|| PathBuf::from("."))
}
/// Persists a newly-declared param into the active profile, if any (see
/// `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.
/// 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.
fn persist_param_declaration(&self, request: &DeclareParamRequest) {
let Some((store_path, profile_name)) = self.active_profile_location() else {
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)> {
let settings = self.settings.lock().unwrap();
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)
}
/// Serves the webapp embedded in the binary (see `WEBAPP_DIST`), falling
/// back to `index.html` for any unmatched path so client-side routing works.
/// Falls back to `index.html` for unmatched paths so client-side routing works.
async fn serve_webapp(uri: axum::http::Uri) -> Response {
let path = uri.path().trim_start_matches('/');
let file = WEBAPP_DIST
@@ -167,11 +158,6 @@ async fn ask(engine: &EngineHandle, query: EngineQuery) -> Result<EngineResponse
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(
State(state): State<ApiState>,
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(
State(state): State<ApiState>,
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 {
match ask(&state.engine, EngineQuery::ParamDeclarations).await {
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(
State(state): State<ApiState>,
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 {
match ask(&state.engine, EngineQuery::MetricDeclarations).await {
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(
State(state): State<ApiState>,
Json(body): Json<DeclareMetricRequest>,
@@ -306,9 +271,7 @@ async fn declare_metric(
}
}
/// Parses the `labels` query param (`"foo=bar,baz=qux"`) into a
/// `key -> expected value` map. Malformed pairs (no `=`) are skipped rather
/// than rejected — see `BucketValuesParams` doc comment for the format.
/// Malformed pairs (no `=`) are skipped rather than rejected.
fn parse_labels_filter(raw: &str) -> BTreeMap<String, String> {
raw.split(',')
.filter_map(|pair| pair.split_once('='))
@@ -316,9 +279,6 @@ fn parse_labels_filter(raw: &str) -> BTreeMap<String, String> {
.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(
State(state): State<ApiState>,
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(
State(state): State<ApiState>,
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 {
match err {
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 {
let settings = state.settings.lock().unwrap();
let entries: Vec<CssEntryDto> = settings.css_entries.iter().map(CssEntryDto::from).collect();
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(
State(state): State<ApiState>,
Json(body): Json<ImportCssRequest>,
@@ -414,11 +358,7 @@ async fn import_css(
}
}
/// `POST /api/settings/css/link` — registers a reference to an external CSS
/// 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).
/// Content is never copied, and the entry can never be deleted via `DELETE /api/settings/css/:id`.
async fn link_css(
State(state): State<ApiState>,
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(
State(state): State<ApiState>,
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
/// 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.
/// Rejects a linked entry with 400 — there's no local content; the frontend should use `path_or_url` directly.
async fn get_css_content(
State(state): State<ApiState>,
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 {
match err {
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 {
let settings = state.settings.lock().unwrap();
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(
State(state): State<ApiState>,
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(
State(state): State<ApiState>,
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 {
let mut settings = state.settings.lock().unwrap();
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
/// 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.
/// Delivers a test notification without a saved rule so the sink can be verified before saving.
async fn test_alert_notification(Json(body): Json<TestAlertNotificationRequest>) -> impl IntoResponse {
let server = body.server.unwrap_or_else(|| "https://ntfy.sh".to_string());
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
/// needs (colour scheme, active profile name) as `SettingsDto`. CSS entries
/// have their own dedicated list endpoint (`GET /api/settings/css`).
/// CSS entries have their own endpoint (`GET /api/settings/css`).
async fn get_settings(State(state): State<ApiState>) -> impl IntoResponse {
let settings = state.settings.lock().unwrap();
Json(SettingsDto::from(&*settings)).into_response()
}
/// `PATCH /api/settings` — updates process-wide settings. Body:
/// `{ "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`.
/// `max_logs: null` clears the cap; a changed value is pushed to the engine so it applies without a restart.
async fn update_settings(
State(state): State<ApiState>,
Json(body): Json<UpdateSettingsRequest>,
@@ -588,9 +496,6 @@ async fn update_settings(
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 {
match err {
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 {
let settings = state.settings.lock().unwrap();
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()
}
/// `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(
State(state): State<ApiState>,
Json(body): Json<CreateProfileRequest>,
@@ -633,11 +530,7 @@ async fn create_profile(
}
}
/// `DELETE /api/settings/profiles/:name` — deletes a profile. If it was the
/// 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.
/// If it was the active profile, `active_profile` is cleared, but the engine's already-declared params/metrics keep running until the next restart.
async fn delete_profile(
State(state): State<ApiState>,
AxumPath(name): AxumPath<String>,
@@ -652,12 +545,7 @@ async fn delete_profile(
}
}
/// Removes every currently-declared param/metric from the running engine
/// (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.
/// Removes every currently-declared param/metric from the engine, then replays `profile`'s declarations into it.
async fn reload_engine_declarations(engine: &EngineHandle, profile: &Profile) {
if let Ok(EngineResponse::ParamDeclarations(params)) =
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;
}
/// `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(
State(state): State<ApiState>,
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(
State(state): State<ApiState>,
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(
State(state): State<ApiState>,
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(
State(state): State<ApiState>,
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(
State(state): State<ApiState>,
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(
State(state): State<ApiState>,
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
@@ -836,10 +700,7 @@ mod tests {
use tokio::sync::mpsc;
use tower::ServiceExt;
/// 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).
/// Spins up a fresh engine + router pair backed by a temp settings file.
fn test_app() -> (Router, EngineHandle, SettingsHandle, tempfile::TempDir) {
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;
assert_eq!(status, StatusCode::NOT_FOUND);
// `update_alert_rule`'s `AlertError::NotFound` maps to a plain-text
// 404 body (mirrors `css_error_response`), not JSON, so this checks
// the status directly rather than via `send_json`'s JSON parsing.
// AlertError::NotFound maps to a plain-text (not JSON) 404 body, so check status directly instead of via send_json.
let request = Request::builder()
.method("PUT")
.uri("/api/settings/alerts/does-not-exist")
@@ -1085,16 +944,11 @@ mod tests {
let values = body.as_array().unwrap();
assert_eq!(values.len(), 2);
for value in values {
// `MetricPrimitive` is a Rust enum with derived `Serialize`, so its
// JSON form is externally tagged: `{"String": "/a"}`.
// MetricPrimitive derives Serialize as an externally-tagged enum, so JSON is {"String": "/a"}.
assert_eq!(value["labels"]["path"]["String"], "/a");
}
// Bucket values are grouped by label combination (ordered by the
// 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.
// 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".
let (status, body) = get_json(&app, "/api/metrics/requests?since_sequence=0&limit=1").await;
assert_eq!(status, StatusCode::OK);
let values = body.as_array().unwrap();
@@ -1159,10 +1013,7 @@ mod tests {
async fn declaring_param_persists_into_active_profile() {
let (app, _engine, settings_handle, dir) = test_app();
// Set up an active profile against the *same* shared `settings`
// 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.
// Use the same shared settings handle the router was built with — a separately-loaded Settings wouldn't be visible to the running app.
let settings_path = dir.path().join("settings.json");
let profiles_dir = {
let settings = settings_handle.lock().unwrap();
@@ -1229,12 +1080,10 @@ mod tests {
assert!(body["panels"].as_array().unwrap().is_empty());
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;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.as_array().unwrap().len(), 1);
// Save adds a panel.
let (status, body) = send_json(
&app,
"PUT",
@@ -1253,13 +1102,11 @@ mod tests {
assert_eq!(panels.len(), 1);
assert_eq!(panels[0]["chart_type"], "line");
// Get by id reflects the saved panel.
let (status, body) =
get_json(&app, &format!("/api/settings/profiles/web/dashboards/{dashboard_id}")).await;
assert_eq!(status, StatusCode::OK);
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;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, body) = get_json(&app, "/api/settings/profiles/web/dashboards").await;
@@ -1308,8 +1155,7 @@ mod tests {
)
.await;
// Simulate a restart: reload the profile store from disk directly,
// independent of the running app/engine.
// Simulate a restart by reloading the profile store fresh from disk, independent of the running app/engine.
let settings_path = dir.path().join("settings.json");
let profiles_dir = {
let settings = settings_handle.lock().unwrap();
+6 -20
View File
@@ -3,19 +3,13 @@ use crate::timestamp::Timestamp;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// A single parsed metric primitive extracted from a regex capture group.
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegexToMetricPrimitiveMatcher {
pub param_name: String,
pub capture_group: String,
pub kind: MetricPrimitiveKind,
/// Ticket 022: whether this param contributes to the bucket's `labels`
/// (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).
/// 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`.
#[serde(default)]
pub role: ParamRole,
}
@@ -29,10 +23,7 @@ pub enum ParamRole {
Value,
}
/// `rename_all = "lowercase"` matches every other `kind`-style enum exposed
/// over the API/settings JSON (`ColorScheme`, `CssEntryKind`,
/// `ProfileMetricPrimitiveKind`), so `POST /api/params`/`POST /api/metrics`
/// bodies use `"string"`/`"number"`/`"timestamp"`.
/// `rename_all = "lowercase"` matches other `kind`-style enums, so JSON bodies use "string"/"number"/"timestamp".
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -40,12 +31,7 @@ pub enum MetricPrimitiveKind {
String,
Number,
Timestamp,
/// Ticket 023: milliseconds. On a metric's `Value`-role param, this
/// 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.
/// 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.
Duration,
}
@@ -65,16 +51,16 @@ pub enum DataValue {
MetricDeclaration {
message_match_regex: String,
params: Vec<RegexToMetricPrimitiveMatcher>,
/// See `crate::engine::DeclareMetricRequest::matched_labels` (ticket 025).
/// See `crate::engine::DeclareMetricRequest::matched_labels`.
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>>,
},
/// Paired with `IndexKey::Bucket`.
BucketValue {
values: Vec<(u64, Timestamp, MetricPrimitive)>,
},
/// Paired with `IndexKey::LastMetricEvent` (ticket 023).
/// Paired with `IndexKey::LastMetricEvent`.
LastEventTimestamp {
timestamp: Timestamp,
},
+21 -96
View File
@@ -11,12 +11,7 @@ pub struct LogEntryDto {
pub sequence: u64,
pub timestamp_ms: i64,
pub text: String,
/// Derived param values (ticket 001's `ParamDeclaration`s) for this line,
/// 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.
/// 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.
pub params: BTreeMap<String, MetricPrimitive>,
}
@@ -33,9 +28,9 @@ pub struct MetricDeclarationDto {
pub name: String,
pub message_match_regex: String,
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>>,
/// See `crate::engine::DeclareMetricRequest::unmatched_labels` (ticket 025).
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
pub unmatched_labels: Option<BTreeMap<String, String>>,
}
@@ -60,9 +55,7 @@ pub struct LogsParams {
pub start_ms: Option<i64>,
pub end_ms: Option<i64>,
pub limit: Option<usize>,
/// Raw FilterBar query string (ticket 007), e.g. `regex("error") and
/// sequence<5`. Parsed with `filter_parser::parse` and evaluated in the
/// engine; a malformed string surfaces as `EngineResponse::Error`.
/// Raw FilterBar query string, e.g. `regex("error") and sequence<5`.
pub filter: Option<String>,
}
@@ -71,14 +64,11 @@ pub struct TimelineParams {
pub start_ms: Option<i64>,
pub end_ms: Option<i64>,
pub bucket_ms: Option<i64>,
/// Same FilterBar query string as `LogsParams::filter`: only logs
/// matching it are counted into timeline buckets.
/// Same FilterBar query string as `LogsParams::filter`; only matching logs are counted into timeline buckets.
pub filter: Option<String>,
}
/// One entry from a metric's derived bucket timeseries: the label combination
/// that bucket represents, plus one `(sequence, timestamp, value)` occurrence
/// within it.
/// One (sequence, timestamp, value) occurrence within a metric's bucket for a given label combination.
#[derive(Debug, Clone, Serialize)]
pub struct BucketValueDto {
pub labels: BTreeMap<String, MetricPrimitive>,
@@ -87,13 +77,7 @@ pub struct BucketValueDto {
pub value: MetricPrimitive,
}
/// Query params for `GET /api/metrics/:name`. `labels` is a single
/// 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.
/// `labels` is a comma-separated `key=value` list (not repeated params) since axum's `Query` extractor needs a fixed set of field names.
#[derive(Debug, Deserialize)]
pub struct BucketValuesParams {
pub labels: Option<String>,
@@ -103,24 +87,13 @@ pub struct BucketValuesParams {
pub limit: Option<usize>,
}
/// One chart-ready time series returned by `GET /api/query` (ticket 013's PromQL
/// 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.
/// `labels` uses plain `String` (already Display-formatted) since charts just need something to show; `value` is always `f64` for plottability.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct PromqlSeriesDto {
pub labels: BTreeMap<String, String>,
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)]
pub struct PromqlParams {
pub promql: String,
@@ -128,17 +101,13 @@ pub struct PromqlParams {
pub end_ms: Option<i64>,
}
/// JSON error body returned by API handlers on a 4xx/5xx response, e.g.
/// `{ "error": "invalid regex: ..." }`.
/// JSON error body returned by API handlers on a 4xx/5xx response.
#[derive(Debug, Serialize)]
pub struct ErrorDto {
pub error: String,
}
/// Request body for `POST /api/settings/css`: raw CSS content to import and
/// 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.
/// `is_theme` marks this as a selectable colour-scheme theme rather than arbitrary CSS; defaults to false.
#[derive(Debug, Deserialize)]
pub struct ImportCssRequest {
pub filename: String,
@@ -147,9 +116,7 @@ pub struct ImportCssRequest {
pub is_theme: bool,
}
/// Request body for `POST /api/settings/css/link`: a reference to an
/// 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.
/// A reference to an external CSS path/URL that ottyl doesn't copy or own.
#[derive(Debug, Deserialize)]
pub struct LinkCssRequest {
pub path_or_url: String,
@@ -157,14 +124,7 @@ pub struct LinkCssRequest {
pub is_theme: bool,
}
/// Response shape for a single CSS entry, used both by the list endpoint and
/// 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.
/// `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).
#[derive(Debug, Clone, Serialize)]
pub struct CssEntryDto {
pub id: String,
@@ -202,19 +162,14 @@ impl From<&CssEntry> for CssEntryDto {
}
}
/// Response shape for `GET /api/settings` and `PATCH /api/settings` (ticket
/// 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).
/// `css_entries` is omitted here since it has its own `GET /api/settings/css` endpoint.
#[derive(Debug, Clone, Serialize)]
pub struct SettingsDto {
pub color_scheme: ColorScheme,
pub active_profile: Option<String>,
/// `None` means unlimited (ticket 026).
/// `None` means unlimited.
pub max_logs: Option<usize>,
/// Id of the selected custom theme entry, if any (ticket 033). `None`
/// means `color_scheme` alone determines the active palette.
/// Id of the selected custom theme entry, if any. `None` means `color_scheme` alone determines the active palette.
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
/// 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`.
/// `max_logs`/`custom_theme_id` distinguish "omitted" (leave unchanged) from "present" (null clears, value sets), via `deserialize_double_option`.
#[derive(Debug, Deserialize)]
pub struct UpdateSettingsRequest {
pub color_scheme: Option<ColorScheme>,
@@ -243,10 +194,7 @@ pub struct UpdateSettingsRequest {
pub custom_theme_id: Option<Option<String>>,
}
/// Distinguishes a missing JSON field (outer `None`, via `#[serde(default)]`
/// 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.
/// 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.
fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: serde::Deserializer<'de>,
@@ -255,10 +203,6 @@ where
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)]
pub struct CreateAlertRuleRequest {
pub name: String,
@@ -267,8 +211,6 @@ pub struct CreateAlertRuleRequest {
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)]
pub struct UpdateAlertRuleRequest {
pub name: String,
@@ -277,19 +219,13 @@ pub struct UpdateAlertRuleRequest {
pub sink: AlertSink,
}
/// Request body for `POST /api/settings/alerts/test` (ticket 020): delivers
/// 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`.
/// `server` defaults to `https://ntfy.sh` when absent.
#[derive(Debug, Deserialize)]
pub struct TestAlertNotificationRequest {
pub topic: 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)]
pub struct ProfileSummaryDto {
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)]
pub struct CreateProfileRequest {
pub name: String,
}
/// One panel within a dashboard (ticket 014): a PromQL query plus how to
/// chart its result. Mirrors `profile::Panel`.
/// Mirrors `profile::Panel`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PanelDto {
pub id: String,
@@ -348,9 +281,7 @@ impl From<&PanelDto> for Panel {
}
}
/// A saved dashboard (ticket 014), as returned by
/// `GET /api/settings/profiles/:name/dashboards` and friends. Mirrors
/// `profile::Dashboard`.
/// Mirrors `profile::Dashboard`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardDto {
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)]
pub struct CreateDashboardRequest {
pub name: String,
}
/// Request body for
/// `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.
/// The dashboard `id` in the URL is authoritative; any `id` in the body is ignored.
#[derive(Debug, Deserialize)]
pub struct SaveDashboardRequest {
pub name: String,
+43 -290
View File
@@ -23,9 +23,7 @@ pub struct IngestedLine {
pub text: String,
}
/// Also the request body for `POST /api/params` (ticket 004): `Deserialize` is
/// derived directly on this engine type rather than adding a separate DTO,
/// since its shape is already exactly what the API needs.
/// Also the request body for `POST /api/params` — `Deserialize` is derived directly here rather than adding a separate DTO, since the shape already matches.
#[derive(Debug, Clone, Deserialize)]
pub struct DeclareParamRequest {
pub name: String,
@@ -34,43 +32,16 @@ pub struct DeclareParamRequest {
pub kind: MetricPrimitiveKind,
}
/// v1 mechanism: a metric declaration's params are derived purely from inline named
/// 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.
/// 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`).
#[derive(Debug, Clone, Deserialize)]
pub struct DeclareMetricRequest {
pub name: String,
pub message_match_regex: String,
pub params: Vec<RegexToMetricPrimitiveMatcher>,
/// Ticket 025: fixed labels added on top of whatever `params` captured
/// 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.
/// 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.
#[serde(default)]
pub matched_labels: Option<BTreeMap<String, String>>,
/// When a line does *not* match `message_match_regex`, and this is
/// `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.
/// 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.
#[serde(default)]
pub unmatched_labels: Option<BTreeMap<String, String>>,
}
@@ -82,11 +53,7 @@ pub enum EngineQuery {
Stats,
ParamDeclarations,
MetricDeclarations,
/// Bucket values for one metric (ticket 004's `GET /api/metrics/:name`).
/// `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.
/// `labels_filter` compares against each label's `Display` form (not a typed value), since the filter arrives as plain strings off the query string.
BucketValues {
metric_name: String,
labels_filter: Option<BTreeMap<String, String>>,
@@ -95,12 +62,7 @@ pub enum EngineQuery {
since_sequence: Option<u64>,
limit: Option<usize>,
},
/// PromQL query (ticket 013's `GET /api/query`). `query` is parsed here (rather
/// 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`.
/// `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`.
Promql { query: String, start_ms: Option<i64>, end_ms: Option<i64> },
}
@@ -123,10 +85,7 @@ pub enum EngineMessage {
name: String,
respond_to: oneshot::Sender<bool>,
},
/// Sets the cap on ingested logs kept in memory (ticket 026). `None`
/// 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.
/// `None` means unlimited; sent on startup and whenever the setting changes so the cap applies without a restart.
SetMaxLogs(Option<usize>),
Query {
query: EngineQuery,
@@ -142,30 +101,20 @@ pub enum EngineResponse {
ParamDeclarations(Vec<ParamDeclarationDto>),
MetricDeclarations(Vec<MetricDeclarationDto>),
BucketValues(Vec<BucketValueDto>),
/// `Err` holds a human-readable message for a malformed query (see
/// `crate::promql::parse_query`), which the API layer maps to a 400 response.
/// `Err` holds a human-readable message for a malformed query, which the API layer maps to a 400 response.
Promql(Result<Vec<PromqlSeriesDto>, String>),
/// A query-level error, e.g. a malformed `filter` string on
/// `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.
/// Kept as a variant (rather than making `handle_query` return a `Result`) so every query path still funnels through the same `oneshot::Sender<EngineResponse>`.
Error(String),
}
pub type EngineHandle = mpsc::Sender<EngineMessage>;
/// Sole owner of the primary index. Runs single-threaded so the map never needs a
/// lock, and does its own response serialization/DTO construction so callers never
/// have to clone the index to answer a query.
/// 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.
pub async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle) {
let mut index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
let mut max_logs: Option<usize> = None;
let mut log_count: usize = 0;
// Ticket 018: per-rule rolling windows / seen-value sets, keyed by rule
// id. Lives only as long as this loop (no restart persistence — see
// `alerts::AlertCondition::NewValue`'s doc comment).
// Per-rule rolling windows/seen-value sets, keyed by rule id; lives only as long as this loop (no restart persistence).
let mut alert_states: HashMap<String, AlertRuntimeState> = HashMap::new();
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);
evict_excess_logs(&mut index, &mut log_count, max_logs);
// Ticket 018: evaluate alert rules against just this line
// (not the whole log range), then hand any fired rule off to
// ticket 019's delivery, off the hot path via `tokio::spawn`.
// 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.
let rules = settings.lock().unwrap().alert_rules.clone();
let fired = alerts::evaluate_alerts(&rules, &mut alert_states, sequence, timestamp, &text);
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
/// rejected up front rather than silently stored (or discovered later at derivation
/// time).
/// Validates the regex and capture group before storing, so a bad declaration is rejected up front rather than discovered later.
fn declare_param(
index: &mut BTreeMap<IndexKey, DataValue>,
request: DeclareParamRequest,
@@ -244,9 +189,7 @@ fn declare_param(
Ok(())
}
/// Validates the regex and every param matcher's capture-group reference before
/// storing. All matchers are checked (not just the first bad one) so a rejection can
/// report every problem at once.
/// Validates every param matcher's capture-group reference (not just the first bad one) so a rejection reports every problem at once.
fn declare_metric(
index: &mut BTreeMap<IndexKey, DataValue>,
request: DeclareMetricRequest,
@@ -298,9 +241,7 @@ fn declare_metric(
Ok(())
}
/// Parses a single captured substring into the `MetricPrimitive` its param declares.
/// 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".
/// Returns `None` on a parse failure, which the caller treats as "this match doesn't apply".
fn parse_metric_primitive(raw: &str, kind: MetricPrimitiveKind) -> Option<MetricPrimitive> {
match kind {
MetricPrimitiveKind::String => Some(MetricPrimitive::String(raw.to_string())),
@@ -315,52 +256,12 @@ fn parse_metric_primitive(raw: &str, kind: MetricPrimitiveKind) -> Option<Metric
.ok()
.map(MetricPrimitive::Timestamp)
}
// A `Value`-role param never reaches this branch (see
// `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.
// 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.
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
/// 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`.
/// 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.
fn apply_metric_to_log(
index: &mut BTreeMap<IndexKey, DataValue>,
metric_name: &str,
@@ -413,12 +314,7 @@ fn apply_metric_to_log(
}
let value = if is_duration {
// Bookkeeping key is separate from the `Bucket` key below: a
// `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.
// Tracked separately from Bucket: a Duration's first occurrence pushes no Bucket entry, so the last-event timestamp can't live in Bucket.values.
let last_event_key =
IndexKey::LastMetricEvent { metric_name: metric_name.to_string(), labels: labels.clone() };
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
/// declared after logs have already arrived still gets populated buckets for them
/// (backfill), rather than only reflecting logs ingested from that point on.
/// Backfills buckets for a newly-declared metric against every already-ingested log, not just future ones.
fn backfill_metric(
index: &mut BTreeMap<IndexKey, DataValue>,
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(
index: &mut BTreeMap<IndexKey, DataValue>,
sequence: u64,
@@ -529,11 +420,7 @@ fn apply_all_metrics(
}
}
/// Bucket values for one metric, in insertion order, optionally filtered to a single
/// 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.
/// 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).
fn bucket_values<'a>(
index: &'a BTreeMap<IndexKey, DataValue>,
metric_name: &'a str,
@@ -553,42 +440,7 @@ fn bucket_values<'a>(
.flatten()
}
/// A [`FilterExpr`] compiled against a *snapshot* of the current index, ready
/// 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.
/// 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.
enum CompiledFilter {
Regex(Regex),
Sequence { op: CompareOp, target: f64 },
@@ -598,11 +450,7 @@ enum CompiledFilter {
Or(Box<CompiledFilter>, Box<CompiledFilter>),
}
/// Parses and compiles a FilterBar query string against `index`, resolving
/// 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.
/// 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.
fn compile_filter_query(
index: &BTreeMap<IndexKey, DataValue>,
filter: &str,
@@ -637,12 +485,7 @@ fn compile_filter(
}
}
/// `sequence <op> value` — compares straight against the log's own 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).
/// A `Raw` value is accepted defensively (parsed as a number) even though the grammar shouldn't produce one for `sequence`.
fn compile_sequence_comparison(op: CompareOp, value: &FilterValue) -> Result<CompiledFilter, String> {
let target = match value {
FilterValue::Number(n) => *n,
@@ -653,11 +496,7 @@ fn compile_sequence_comparison(op: CompareOp, value: &FilterValue) -> Result<Com
Ok(CompiledFilter::Sequence { op, target })
}
/// `timestamp <op> value` — compares straight against the log's own
/// 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`.
/// A `Number` is treated as raw milliseconds; a `Raw` token is resolved via `filter_datetime::parse_datetime` against `now`.
fn compile_timestamp_comparison(
op: CompareOp,
value: &FilterValue,
@@ -672,9 +511,7 @@ fn compile_timestamp_comparison(
Ok(CompiledFilter::Timestamp { op, target_ms })
}
/// `metric[param] <op> value` for any metric name other than the reserved
/// `sequence`/`timestamp` — see [`CompiledFilter`]'s doc comment for the
/// overall approach.
/// `metric[param] <op> value` for any metric name other than `sequence`/`timestamp` — see [`CompiledFilter`]'s doc comment.
fn compile_metric_comparison(
index: &BTreeMap<IndexKey, DataValue>,
metric: &str,
@@ -710,14 +547,7 @@ fn compile_metric_comparison(
Ok(CompiledFilter::MetricMatch(matching))
}
/// Compares one bucket label's `MetricPrimitive` against a filter's
/// 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.
/// 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(
primitive: &MetricPrimitive,
op: CompareOp,
@@ -754,8 +584,7 @@ fn compare_metric_primitive(
Ok(ordering.is_some_and(|ordering| apply_compare_op(ordering, op)))
}
/// `pub(crate)` so `crate::alerts` can reuse it for `Threshold`/`Ratio`
/// comparisons instead of duplicating the match.
/// `pub(crate)` so `crate::alerts` can reuse it for `Threshold`/`Ratio` comparisons instead of duplicating the match.
pub(crate) fn apply_compare_op(ordering: Ordering, op: CompareOp) -> bool {
match op {
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,
/// 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.
/// Cheap: everything was already resolved by [`compile_filter_query`], so this is just pattern matching and `HashSet` lookups — no index access.
fn eval_compiled(compiled: &CompiledFilter, sequence: u64, timestamp: Timestamp, text: &str) -> bool {
match compiled {
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`
/// 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.
/// A param whose stored regex fails to compile (shouldn't happen — `declare_param` validates it) is skipped rather than panicking.
fn compiled_param_declarations(
index: &BTreeMap<IndexKey, DataValue>,
) -> Vec<(String, Regex, String, MetricPrimitiveKind)> {
@@ -849,12 +671,7 @@ fn compiled_param_declarations(
.collect()
}
/// Runs every declared param's regex against one log line's text, returning
/// 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`.
/// 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`.
fn derive_log_params(
text: &str,
declarations: &[(String, Regex, String, MetricPrimitiveKind)],
@@ -875,11 +692,7 @@ fn derive_log_params(
params
}
/// Evicts the oldest (lowest-`sequence`) ingested logs until `log_count` is
/// 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.
/// 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(
index: &mut BTreeMap<IndexKey, DataValue>,
log_count: &mut usize,
@@ -921,16 +734,7 @@ fn handle_query(index: &BTreeMap<IndexKey, DataValue>, query: EngineQuery) -> En
None => None,
};
let limit = params.limit.unwrap_or(500).min(10_000);
// Compile each currently-declared param's regex once up front, then
// 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.
// 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.
let compiled_params = compiled_param_declarations(index);
let entries: Vec<LogEntryDto> = logs_range(index)
.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
/// `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.
/// 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.
fn coerce_to_f64(value: &MetricPrimitive) -> f64 {
match value {
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
/// 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.
/// Compared against each label's `Display` form; extra labels on the bucket are ignored — the matcher list only needs to be a subset.
fn matches_label_matchers(
labels: &BTreeMap<String, MetricPrimitive>,
matchers: &[LabelMatcher],
@@ -1105,26 +895,7 @@ fn matches_label_matchers(
})
}
/// Evaluates a parsed PromQL query against `index`, producing chart-ready series.
///
/// 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.
/// 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.
fn evaluate_promql(
index: &BTreeMap<IndexKey, DataValue>,
parsed: &PromqlQuery,
@@ -1190,10 +961,7 @@ fn evaluate_promql(
.collect()
}
Some(AggOp::Gauge) => {
// Ticket 022: collapse each label combination's counter events
// 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.
// Collapse each label combination's events down to their most-recent value in range — "value at time T", not a distinct stored gauge kind.
series_points
.into_iter()
.filter_map(|(labels, points)| {
@@ -1212,12 +980,7 @@ fn evaluate_promql(
.flat_map(|points| points.iter().map(|(ts, _)| *ts))
.max()
.unwrap_or(0);
// Ticket 022: bucket every matching counter event's value
// (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.
// 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).
let mut counts: BTreeMap<i64, u64> = BTreeMap::new();
for points in series_points.values() {
for (_, value) in points {
@@ -1667,8 +1430,7 @@ mod tests {
assert_eq!(series.len(), 1);
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
// `ingest_log`'s `sequence as millis` helper.
// Occurrence markers coerce to 1.0 (see coerce_to_f64); timestamps come from ingest_log's sequence-as-millis helper.
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);
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);
// 21 -> [20,30) le=30 (count 1).
// durations 2,4 -> le=10 (count 2); 12,15 -> le=20 (count 2); 21 -> le=30 (count 1).
assert_eq!(series.len(), 3);
assert_eq!(series[0].labels.get("le").map(String::as_str), Some("10"));
assert_eq!(series[0].points[0].1, 2.0);
@@ -1871,9 +1632,7 @@ mod tests {
ingest_log(&mut index, 2, "user=alice logged in");
let values: Vec<_> = bucket_values(&index, "login_gap", None).collect();
// First occurrences (alice@0, bob@1) produce no duration value at all —
// only alice's second occurrence (sequence 2) has a previous event to
// diff against.
// First occurrences (alice@0, bob@1) produce no duration value — only alice's second occurrence (seq 2) has something to diff against.
assert_eq!(values.len(), 1);
let (labels, (sequence, timestamp, value)) = &values[0];
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)]);
}
// Fixed matched_labels/unmatched_labels (rather than a captured "category"
// 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.
// Fixed labels (not a captured param) so a case-insensitive match collapses into one clean "error" series regardless of source-text case.
fn declare_error_category_metric(index: &mut BTreeMap<IndexKey, DataValue>) {
declare_metric(
index,
@@ -2016,7 +1772,7 @@ mod tests {
}
}
// -- ticket 007: FilterBar query integration -----------------------
// -- FilterBar query integration --
fn logs_query(filter: Option<&str>) -> EngineQuery {
EngineQuery::Logs(LogsParams {
@@ -2107,10 +1863,7 @@ mod tests {
ingest_log(&mut index, 1, "level=info duration=2 boom");
ingest_log(&mut index, 2, "level=error duration=3 all good");
// Only sequence 0 has both the "boom" regex match *and* level=error.
// 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"`.
// Only sequence 0 has both; note comparison values are bare tokens (level==error), not quoted string literals like regex()'s argument.
match handle_query(
&index,
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.
//!
//! This module is intentionally standalone: it has no dependency on the engine/index
//! 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.
//! 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
//! full date/datetime literal.
// `engine.rs` (ticket 007) calls `parse_datetime` to resolve `FilterValue::Raw`
// tokens (e.g. `-5m`, `12:00`, a full date) in `timestamp`/metric-comparison
// 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)]` stays since most of this module's surface is still only
// exercised by its own unit tests below.
#![allow(dead_code)]
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`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DateTimeParseError {
/// The input matched the shape of a supported format (relative duration,
/// time-of-day, or date/datetime) but its value was invalid or ambiguous,
/// e.g. an out-of-range time like `25:99`.
/// The input matched a supported format's shape but its value was invalid, e.g. `25:99`.
Invalid { input: String, reason: String },
/// The input didn't match any supported format at all.
Unparseable(String),
@@ -54,11 +37,8 @@ impl std::fmt::Display for DateTimeParseError {
impl std::error::Error for DateTimeParseError {}
/// Parses a filter datetime literal into an absolute [`Timestamp`], resolving any
/// relative or day-relative forms against `now`.
///
/// `now` is taken as a parameter (rather than this function calling `Timestamp::now()`
/// internally) so callers can pass a fixed value in tests.
/// Parses a filter datetime literal into an absolute [`Timestamp`]; `now` is a parameter
/// (rather than calling `Timestamp::now()` internally) so callers can pass a fixed value in tests.
pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTimeParseError> {
let trimmed = input.trim();
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()))
}
/// Tries to interpret `input` as a signed relative humantime duration (`5h`, `-3m`).
///
/// 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.
/// 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.
fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTimeParseError>> {
let (sign, rest) = match input.strip_prefix('-') {
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
// 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.
// 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.
let looks_like_duration = rest
.chars()
.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.
fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError>> {
// Only attempt this path for inputs shaped like a date (contain a '-' separating
// digits, e.g. "2024-01-15"); otherwise let other parsers have a shot.
// Only attempt this path for inputs shaped like a date, e.g. "2024-01-15".
let looks_like_date = input.len() >= 8 && input.as_bytes()[4] == b'-';
if !looks_like_date {
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`),
/// resolved against the UTC calendar day of `now`.
/// Tries to interpret `input` as a bare time-of-day, resolved against the UTC calendar day of `now`.
fn try_time_of_day(
input: &str,
now: Timestamp,
@@ -176,8 +147,7 @@ fn try_time_of_day(
let now_millis = now.as_millis();
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
// not happen in practice since `Timestamp` always comes from a valid instant.
// Fall back to the epoch date; shouldn't happen since `Timestamp` is always a valid instant.
Utc.timestamp_millis_opt(0).single().expect("epoch is valid")
});
let today = now_dt.date_naive();
@@ -193,7 +163,6 @@ fn timestamp_from_datetime(dt: DateTime<Utc>) -> Timestamp {
mod tests {
use super::*;
/// A fixed "now": 2024-06-15T12:34:56Z.
fn fixed_now() -> Timestamp {
let dt = Utc.with_ymd_and_hms(2024, 6, 15, 12, 34, 56).unwrap();
Timestamp::from_millis(dt.timestamp_millis())
+10 -41
View File
@@ -1,26 +1,5 @@
//! FilterBar query language: grammar, AST and parser.
//!
//! 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`.
//! FilterBar query language: grammar (see `src/filter_parser.pest`), AST and parser.
//! Evaluating the AST against real log data is left to `engine.rs`.
#![allow(dead_code)]
use pest::Parser;
@@ -48,9 +27,8 @@ pub enum FilterExpr {
Or(Box<FilterExpr>, Box<FilterExpr>),
}
/// Comparison operators supported by metric comparisons. Also reused by
/// `crate::alerts::AlertCondition::Threshold`/`Ratio` for their own
/// threshold/ratio comparisons (hence `Serialize`/`Deserialize`).
/// Comparison operators for metric comparisons; also reused by `crate::alerts::AlertCondition`
/// for threshold/ratio comparisons (hence `Serialize`/`Deserialize`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CompareOp {
@@ -62,10 +40,8 @@ pub enum CompareOp {
Ne,
}
/// The right-hand side of a comparison. Values that parse as a number
/// become [`FilterValue::Number`]; everything else (e.g. datetime-like
/// tokens such as `12:00`) is kept as an opaque [`FilterValue::Raw`] string
/// to be resolved by a later ticket.
/// The right-hand side of a comparison: values that parse as a number become
/// [`FilterValue::Number`], everything else is kept as an opaque [`FilterValue::Raw`] string.
#[derive(Debug, Clone, PartialEq)]
pub enum FilterValue {
Number(f64),
@@ -96,9 +72,7 @@ impl std::error::Error for FilterParseError {}
impl From<pest::error::Error<Rule>> for FilterParseError {
fn from(err: pest::error::Error<Rule>) -> Self {
// pest's own Display already renders a located, human-readable
// message (line/column + a pointer into the offending input), so we
// use it as the basis and add a short prefix for context.
// pest's Display already renders a located message, so use it and add a short prefix.
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> {
debug_assert_eq!(pair.as_rule(), Rule::or_expr);
// `or_kw` is atomic (see the grammar file) so it is *not* silent: it
// shows up as a sibling pair between the `and_expr` operands. Skip it
// rather than assuming a fixed alternating structure.
// `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.
let mut and_exprs = pair
.into_inner()
.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> {
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
.into_inner()
.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 (`\"` -> `"`, `\\` -> `\`,
/// any other `\x` -> `x`).
/// Unescape a regex string literal's inner contents (`\"` -> `"`, `\\` -> `\`, `\x` -> `x`).
fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
@@ -368,8 +339,6 @@ mod tests {
let result = parse("regex(\"unterminated and sequence<");
assert!(result.is_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();
assert!(!msg.is_empty());
}
+2 -12
View File
@@ -3,11 +3,7 @@ use crate::timestamp::Timestamp;
use serde::Serialize;
use std::collections::BTreeMap;
/// Key type for the engine's primary index.
///
/// 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.
/// 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).
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub enum IndexKey {
@@ -33,13 +29,7 @@ pub enum IndexKey {
metric_name: String,
labels: BTreeMap<String, MetricPrimitive>,
},
/// Ticket 023: internal bookkeeping (never returned by a query) tracking
/// 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.
/// 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.
LastMetricEvent {
metric_name: String,
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
/// tailed process restarting and reopening its log file) can be told apart from
/// the same file simply growing.
/// Identifies a specific file instance on disk, so a recreated path can be told apart from the same file simply growing.
#[derive(PartialEq, Eq, Clone, Copy)]
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)))
}
/// Tails `path` like `tail -f`: reads existing content, then keeps reading as
/// new lines are appended. If the file is deleted and recreated at the same
/// path (or truncated in place) — e.g. the tailed process restarted and
/// 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.
/// Tails `path` like `tail -f`, resuming from the start if the file is deleted/recreated or
/// truncated in place rather than exiting or getting stuck; `sequence` keeps incrementing
/// across a recreation since it's ottyl's own line counter, not tied to the source file.
pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
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();
match reader.read_line(&mut chunk).await {
Ok(0) => {
// EOF: decide whether the file was truncated/recreated
// (tail the new instance from scratch) or just hasn't
// grown yet (keep polling).
// EOF: decide whether the file was truncated/recreated or just hasn't grown yet.
match tokio::fs::metadata(&path).await {
Ok(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;
}
Err(_) => {
// Path gone (mid-recreation); wait for it to
// reappear and reopen fresh.
// Path gone (mid-recreation); wait for it to reappear and reopen fresh.
}
}
tokio::time::sleep(POLL_INTERVAL).await;
@@ -119,8 +110,7 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
return;
}
}
// else: partial line at EOF (write in progress) — keep
// buffering and read again without treating it as final.
// else: partial line at EOF (write in progress) — keep buffering.
}
Err(_) => continue 'reopen,
}
@@ -189,7 +179,6 @@ mod tests {
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()
.write(true)
.truncate(true)
+4 -12
View File
@@ -31,14 +31,11 @@ struct Args {
#[arg(long, default_value_t = 0)]
port: u16,
/// Path to the settings file. Defaults to a per-OS config location
/// (e.g. `~/.config/ottyl/settings.json` on Linux).
/// Path to the settings file. Defaults to a per-OS config location.
#[arg(long)]
settings_path: Option<PathBuf>,
/// File to tail for log lines, instead of reading from stdin. Behaves like
/// `tail -f`: reads existing content then keeps following appended lines,
/// and keeps tailing if the file is deleted/recreated at the same path.
/// File to tail for log lines, instead of reading from stdin. Behaves like `tail -f`.
#[arg(long)]
file: Option<PathBuf>,
}
@@ -60,13 +57,8 @@ async fn main() {
let _ = engine_tx.send(engine::EngineMessage::SetMaxLogs(max_logs)).await;
}
// Ticket 009: reload the active profile's param/metric declarations into the
// engine BEFORE starting ingest below. Ordering matters: declarations must be
// 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).
// Reload the active profile's declarations before ingest starts: there's no way to
// reprocess early lines later, so declaring after ingest starts could miss lines that raced ahead.
{
let (active_profile_name, profiles_dir) = {
let mut settings_guard = settings.lock().unwrap();
+3 -11
View File
@@ -10,9 +10,7 @@ pub enum MetricPrimitive {
String(String),
Number(Number),
Timestamp(Timestamp),
/// Milliseconds elapsed (ticket 023). Distinct from `Number` so a
/// duration reads unambiguously in JSON/label form, even though it's
/// stored the same way (a plain `i64`).
/// Milliseconds elapsed; distinct from `Number` so it reads unambiguously in JSON/label form.
Duration(i64),
}
@@ -49,10 +47,7 @@ impl Ord for MetricPrimitive {
}
}
/// Textual form used for label-filter comparisons over the metrics API
/// (`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`.
/// Textual form used to compare label filters (query-string values) against a formatted `MetricPrimitive` rather than parsing them into one.
impl fmt::Display for MetricPrimitive {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -74,10 +69,7 @@ pub enum Number {
}
impl Number {
/// Widens to `f64` for cross-kind numeric comparisons (e.g. a filter
/// 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.
/// Widens to `f64` for cross-kind numeric comparisons; `pub(crate)` since the filter evaluator in `engine.rs` needs it.
pub(crate) fn as_f64(&self) -> f64 {
match self {
Number::Int(v) => *v as f64,
+41 -212
View File
@@ -1,33 +1,5 @@
//! Named, persistable bundles of parsing/indexing rules ("profiles" / "presets"),
//! ticket 009.
//!
//! 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.
//! Named, persistable bundles of parsing/indexing rules and dashboards for a CLI app, persisted as a single `profiles.json`.
//! `Settings.profiles` only tracks lightweight name/active pointers; this module owns the actual content.
#![allow(dead_code)]
use std::collections::hash_map::DefaultHasher;
@@ -46,22 +18,17 @@ use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, Eng
use crate::settings::Settings;
use tokio::sync::oneshot;
/// Name of the profile that always exists and can't be deleted (see
/// `ProfileStore::ensure_default`).
/// Name of the profile that always exists and can't be deleted (see `ProfileStore::ensure_default`).
pub const DEFAULT_PROFILE_NAME: &str = "Default";
/// Mirrors `data_value::MetricPrimitiveKind` with `Serialize`/`Deserialize` so
/// 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.
/// Mirrors `data_value::MetricPrimitiveKind` with `Serialize`/`Deserialize` so profiles can round-trip through JSON, since the engine-internal type only derives `Serialize`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProfileMetricPrimitiveKind {
String,
Number,
Timestamp,
/// Mirrors `MetricPrimitiveKind::Duration` (ticket 023).
/// Mirrors `MetricPrimitiveKind::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)]
pub struct ParamDeclarationRecord {
pub name: String,
@@ -124,26 +91,21 @@ pub struct MetricParamRecord {
pub param_name: String,
pub capture_group: String,
pub kind: ProfileMetricPrimitiveKind,
/// See `ParamRole`. `#[serde(default)]` so profiles saved before ticket
/// 022 still load, with every param defaulting to `Label`.
/// See `ParamRole`; `#[serde(default)]` so profiles saved before this field existed still load, defaulting to `Label`.
#[serde(default)]
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)]
pub struct MetricDeclarationRecord {
pub name: String,
pub message_match_regex: String,
pub params: Vec<MetricParamRecord>,
/// See `crate::engine::DeclareMetricRequest::matched_labels` (ticket
/// 025). `#[serde(default)]` so profiles saved before that ticket still
/// load.
/// See `crate::engine::DeclareMetricRequest::matched_labels`; `#[serde(default)]` so older profiles still load.
#[serde(default)]
pub matched_labels: Option<std::collections::BTreeMap<String, String>>,
/// See `crate::engine::DeclareMetricRequest::unmatched_labels` (ticket
/// 025). `#[serde(default)]` so profiles saved before that ticket still
/// load.
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`; `#[serde(default)]` so older profiles still load.
#[serde(default)]
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
/// the frontend's `'bar' | 'line' | 'pie'` union (see `webapp/src/lib/api.ts`).
/// 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`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChartType {
@@ -200,8 +161,7 @@ pub enum ChartType {
Pie,
}
/// One panel within a dashboard: a PromQL query (ticket 013) plus how to
/// chart its result.
/// One panel within a dashboard: a PromQL query plus how to chart its result.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Panel {
pub id: String,
@@ -210,7 +170,7 @@ pub struct Panel {
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)]
pub struct Dashboard {
pub id: String,
@@ -219,10 +179,7 @@ pub struct Dashboard {
pub panels: Vec<Panel>,
}
/// Generates an id for a newly-created dashboard/panel. Mirrors
/// `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.
/// 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.
fn generate_id(seed: &str) -> String {
let mut hasher = DefaultHasher::new();
seed.hash(&mut hasher);
@@ -235,28 +192,17 @@ fn generate_id(seed: &str) -> String {
}
impl Dashboard {
/// A new dashboard named `name`, with no panels yet.
pub fn new(name: String) -> Dashboard {
let id = generate_id(&name);
Dashboard { id, name, panels: Vec::new() }
}
}
/// Ticket 025: name of the metric seeded into a brand-new `Default` profile,
/// used to drive its starter dashboard's two panels.
/// Name of the metric seeded into a brand-new `Default` profile, used to drive its starter dashboard's two panels.
const SEEDED_METRIC_NAME: &str = "log_category";
const SEEDED_METRIC_LABEL: &str = "category";
/// A counter (ticket 022) that classifies every ingested line into exactly
/// 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.
/// 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.
fn seeded_error_split_metric() -> MetricDeclarationRecord {
MetricDeclarationRecord {
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
/// 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.
/// The starter dashboard seeded into a brand-new `Default` profile: a line chart and a pie chart, both querying the seeded error-split counter.
fn seeded_default_dashboard() -> Dashboard {
let mut dashboard = Dashboard::new("Overview".to_string());
dashboard.panels = vec![
@@ -290,9 +233,7 @@ fn seeded_default_dashboard() -> Dashboard {
dashboard
}
/// A named bundle of parsing/indexing rules for a particular CLI app: which
/// params and metrics to derive from its log output, plus any saved
/// dashboards.
/// A named bundle of parsing/indexing rules for a particular CLI app, plus any saved dashboards.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Profile {
pub name: String,
@@ -300,13 +241,11 @@ pub struct Profile {
pub params: Vec<ParamDeclarationRecord>,
#[serde(default)]
pub metrics: Vec<MetricDeclarationRecord>,
/// Saved dashboards (ticket 014), each a named set of PromQL-backed panels.
#[serde(default)]
pub dashboards: Vec<Dashboard>,
}
impl Profile {
/// A new, empty profile with the given name.
pub fn new(name: String) -> Profile {
Profile {
name,
@@ -317,18 +256,7 @@ impl Profile {
}
}
/// Sends every param/metric declaration stored in `profile` to the engine at
/// `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.
/// 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.
pub async fn load_into_engine(engine: &EngineHandle, profile: &Profile) {
for record in &profile.params {
let (respond_to, response) = oneshot::channel();
@@ -388,8 +316,7 @@ pub enum ProfileError {
NotFound,
/// A profile with the given name already exists.
DuplicateName,
/// The `Default` profile can't be deleted — there must always be at
/// least one profile to fall back to.
/// The `Default` profile can't be deleted — there must always be at least one profile to fall back to.
CannotDeleteDefault,
/// Underlying I/O failure reading/writing the profile store or settings file.
Io(io::Error),
@@ -414,9 +341,7 @@ impl From<io::Error> for ProfileError {
}
}
/// Resolves `settings.profiles.profiles_dir` relative to `settings_path`'s
/// parent directory (or treats it as absolute if it already is one) — the
/// same directory convention ticket 010 uses for CSS storage.
/// Resolves `settings.profiles.profiles_dir` relative to `settings_path`'s parent directory (or as absolute if it already is one).
pub fn resolve_profiles_dir(settings: &Settings, settings_path: &Path) -> PathBuf {
let profiles_dir = Path::new(&settings.profiles.profiles_dir);
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
/// `resolve_profiles_dir`).
/// Path to the single file holding all profiles, within `profiles_dir`.
pub fn profiles_store_path(profiles_dir: &Path) -> PathBuf {
profiles_dir.join("profiles.json")
}
/// 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).
/// On-disk collection of all known profiles, persisted as a single JSON file.
#[derive(Debug, Clone, Default)]
pub struct ProfileStore {
profiles: Vec<Profile>,
}
impl ProfileStore {
/// Loads the store from `store_path`. A missing file is treated as an
/// 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.
/// Loads the store from `store_path`; a missing or unreadable/unparsable file is treated as an empty store rather than failing startup.
pub fn load(store_path: &Path) -> ProfileStore {
match fs::read_to_string(store_path) {
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
/// any missing parent directories first.
/// Persists the store to `store_path` as pretty-printed JSON, creating any missing parent directories first.
pub fn save(&self, store_path: &Path) -> io::Result<()> {
if let Some(parent) = store_path.parent() {
fs::create_dir_all(parent)?;
@@ -482,26 +400,15 @@ impl ProfileStore {
fs::write(store_path, contents)
}
/// All known profiles.
pub fn list(&self) -> &[Profile] {
&self.profiles
}
/// Looks up a profile by name.
pub fn get(&self, name: &str) -> Option<&Profile> {
self.profiles.iter().find(|p| p.name == name)
}
/// Creates a new profile named `name`, persists the store to
/// `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.
/// 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.
pub fn create(
&mut self,
store_path: &Path,
@@ -528,13 +435,7 @@ impl ProfileStore {
Ok(())
}
/// Deletes the profile named `name`, persists the store to `store_path`,
/// 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.
/// 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.
pub fn delete(
&mut self,
store_path: &Path,
@@ -557,21 +458,7 @@ impl ProfileStore {
Ok(())
}
/// Guarantees a `Default` profile exists in this store, that it's tracked
/// 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.
/// 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.
pub fn ensure_default(
&mut self,
store_path: &Path,
@@ -595,14 +482,7 @@ impl ProfileStore {
Ok(())
}
/// Sets (or clears, with `name: None`) the active profile: updates
/// `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`).
/// 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`.
pub fn set_active(
&self,
settings: &mut Settings,
@@ -619,22 +499,7 @@ impl ProfileStore {
Ok(())
}
/// Records a newly-declared param into `profile_name`'s stored
/// 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)?;
/// ```
/// 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.
pub fn record_param_declaration(
&mut self,
store_path: &Path,
@@ -655,13 +520,7 @@ impl ProfileStore {
Ok(())
}
/// Records a newly-declared metric into `profile_name`'s stored
/// 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`.
/// 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`.
pub fn record_metric_declaration(
&mut self,
store_path: &Path,
@@ -682,16 +541,13 @@ impl ProfileStore {
Ok(())
}
/// Lists the dashboards saved on `profile_name`. Fails with
/// `ProfileError::NotFound` if no such profile exists.
/// Lists the dashboards saved on `profile_name`; fails with `NotFound` if it doesn't exist.
pub fn list_dashboards(&self, profile_name: &str) -> Result<&[Dashboard], ProfileError> {
let profile = self.get(profile_name).ok_or(ProfileError::NotFound)?;
Ok(&profile.dashboards)
}
/// Creates a new, empty dashboard named `name` on `profile_name`,
/// persisting the store to `store_path`. Returns the new dashboard.
/// Fails with `ProfileError::NotFound` if no such profile exists.
/// Creates a new, empty dashboard named `name` on `profile_name`, persisting the store; fails with `NotFound` if the profile doesn't exist.
pub fn create_dashboard(
&mut self,
store_path: &Path,
@@ -709,12 +565,7 @@ impl ProfileStore {
Ok(dashboard)
}
/// Replaces the dashboard identified by `dashboard_id` on `profile_name`
/// 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.
/// 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.
pub fn save_dashboard(
&mut self,
store_path: &Path,
@@ -738,10 +589,7 @@ impl ProfileStore {
Ok(dashboard)
}
/// Deletes the dashboard identified by `dashboard_id` from
/// `profile_name`, persisting the store to `store_path`. Fails with
/// `ProfileError::NotFound` if either the profile or the dashboard
/// doesn't exist.
/// Deletes the dashboard identified by `dashboard_id` from `profile_name`, persisting the store; fails with `NotFound` if either doesn't exist.
pub fn delete_dashboard(
&mut self,
store_path: &Path,
@@ -838,7 +686,6 @@ mod tests {
assert!(store.get("web").is_some());
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());
assert!(matches!(dup, Err(ProfileError::DuplicateName)));
@@ -870,8 +717,7 @@ mod tests {
assert!(settings.profiles.names.contains(&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
// already-active (non-Default) profile.
// Idempotent: doesn't duplicate the profile or clobber an already-active (non-Default) profile.
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.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::Pie));
// Reload (simulating a restart) and ensure_default again: must not
// re-seed or duplicate the dashboard/metric.
// Reload (simulating a restart) and ensure_default again: must not re-seed or duplicate.
let mut store = ProfileStore::load(&store_path);
store.ensure_default(&store_path, &mut settings, &settings_path).unwrap();
let default_profile = store.get(DEFAULT_PROFILE_NAME).unwrap();
@@ -1076,19 +921,7 @@ mod tests {
assert_eq!(profile.metrics.len(), 1);
}
/// Acceptance criterion: declaring params/metrics into a profile, "restarting"
/// (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.
/// 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.
#[tokio::test]
async fn profile_round_trip_reproduces_declarations_in_fresh_engine() {
let dir = tempfile::tempdir().unwrap();
@@ -1096,8 +929,7 @@ mod tests {
let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default();
// Declare directly into a profile (as ticket 004's HTTP API will do via the
// `record_*_declaration` hooks once it exists).
// Declare directly into a profile, as the HTTP API will do via the `record_*_declaration` hooks.
let mut store = ProfileStore::load(&store_path);
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
store
@@ -1135,8 +967,7 @@ mod tests {
tokio::spawn(crate::engine::run(engine_rx, settings_handle));
load_into_engine(&engine_tx, &profile).await;
// Ingest a line and confirm the engine is functioning with the replayed
// declarations in place (no re-declaration needed).
// Ingest a line and confirm the engine works with the replayed declarations (no re-declaration needed).
engine_tx
.send(EngineMessage::Ingest(IngestedLine {
sequence: 0,
@@ -1185,9 +1016,7 @@ mod tests {
assert_eq!(logs[0].text, "level=info request duration=42");
}
/// Acceptance criterion (ticket 014): creating a dashboard (with a panel),
/// then reloading the profile store from disk (simulating a restart),
/// still has the dashboard and its panel.
/// Acceptance criterion: a dashboard (with a panel) survives reloading the profile store from disk.
#[test]
fn dashboard_create_save_delete_round_trip_and_persists_across_reload() {
let dir = tempfile::tempdir().unwrap();
+11 -65
View File
@@ -1,47 +1,5 @@
//! Hand-rolled parser for a small PromQL subset (ticket 013).
//!
//! 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.
//! 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.
use std::time::Duration;
@@ -50,10 +8,9 @@ pub enum AggOp {
Sum,
Avg,
Rate,
/// Ticket 022: buckets counter values into fixed-width buckets. Requires
/// `bucket_width`.
/// Buckets counter values into fixed-width buckets; requires `bucket_width`.
Histogram,
/// Ticket 022: the most-recent counter value per label combination.
/// The most-recent counter value per label combination.
Gauge,
}
@@ -68,23 +25,19 @@ pub struct PromqlQuery {
pub agg: Option<AggOp>,
pub metric_name: String,
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>,
/// Bare-number range selector, e.g. `[10]`. Only meaningful for
/// `histogram()`, where it's the bucket width.
/// Bare-number range selector, e.g. `[10]`; only meaningful for `histogram()`, where it's the bucket width.
pub bucket_width: Option<f64>,
}
/// What a `[...]` range selector turned out to hold, disambiguated by whether
/// a duration unit followed the digits.
/// What a `[...]` range selector turned out to hold: a duration, or a bare width.
enum RangeSelector {
Duration(Duration),
BareWidth(f64),
}
/// Parses a PromQL query string per the grammar documented above. Returns a
/// human-readable error message (suitable for surfacing directly to an API caller)
/// on any malformed input.
/// Parses a PromQL query string, returning a human-readable error message on malformed input.
pub fn parse_query(input: &str) -> Result<PromqlQuery, String> {
let mut parser = Parser::new(input);
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> {
self.skip_whitespace();
let rest = self.remaining();
@@ -214,9 +166,7 @@ impl<'a> Parser<'a> {
Ok(matchers)
}
/// `[<digits><unit>]` (e.g. `[5m]`, a duration — used by `rate()`) or a
/// bare `[<digits>]` with no unit (a plain number — used by `histogram()`
/// as a bucket width).
/// Parses `[<digits><unit>]` (a duration, e.g. `[5m]`) or a bare `[<digits>]` (a bucket width).
fn parse_range_selector(&mut self) -> Result<Option<RangeSelector>, String> {
self.skip_whitespace();
if self.peek() != Some('[') {
@@ -290,10 +240,7 @@ impl<'a> Parser<'a> {
inner.agg = agg;
Ok(inner)
} else {
// Not (or not followed by) an aggregation call — rewind and parse the
// 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).
// Rewind and parse as a plain vector selector, covering metric names that collide with an agg_op keyword.
self.pos = checkpoint;
self.parse_vector_selector()
}
@@ -387,8 +334,7 @@ mod tests {
#[test]
fn rejects_unknown_aggregation() {
// `count` isn't a supported agg_op, so this parses as a bare metric named
// `count`, then trips on the unexpected trailing `(...)`.
// `count` isn't a supported agg_op, so this parses as a bare metric name and trips on the trailing `(...)`.
let err = parse_query("count(requests)").unwrap_err();
assert!(err.contains("unexpected trailing input"));
}
+23 -102
View File
@@ -1,9 +1,4 @@
//! Process-wide settings model: colour scheme, CSS entries, and profile
//! 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.
//! Process-wide settings model: colour scheme, CSS entries, and profile pointers, persisted as JSON.
use std::collections::hash_map::DefaultHasher;
use std::fmt;
@@ -19,8 +14,7 @@ use serde::{Deserialize, Serialize};
use crate::alerts::AlertRule;
/// Shared, process-wide settings handle. Cheap to clone; lock is only held
/// briefly since settings changes are rare (unlike the engine's hot path).
/// Shared, process-wide settings handle. Cheap to clone.
pub type SettingsHandle = Arc<Mutex<Settings>>;
/// User's preferred colour scheme for the webapp.
@@ -40,17 +34,7 @@ pub enum ColorScheme {
System,
}
/// A single CSS entry configured for the webapp: either content that ottyl
/// 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`.
/// 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.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum CssEntry {
@@ -67,14 +51,10 @@ pub enum CssEntry {
},
}
/// Placeholder shape for profile management (ticket 009 builds CRUD on top
/// 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.
/// Tracks known profile names and which one is active; the profile files themselves live elsewhere.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Profiles {
/// Names of known profiles. Ticket 009 owns creating/loading the actual
/// profile files.
/// Names of known profiles.
pub names: Vec<String>,
/// Name of the currently active profile, if any.
pub active_profile: Option<String>,
@@ -83,27 +63,19 @@ pub struct Profiles {
pub profiles_dir: String,
}
/// The single, process-wide settings instance.
///
/// Only `PartialEq` (not `Eq`): `alert_rules` carries `f64` fields
/// (`AlertCondition::Threshold`/`Ratio`), which aren't `Eq`.
/// The single, process-wide settings instance. Only `PartialEq` (not `Eq`): `alert_rules` carries `f64` fields, which aren't `Eq`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Settings {
pub color_scheme: ColorScheme,
pub css_entries: Vec<CssEntry>,
/// Id of the selected custom theme entry (a `css_entries` entry with
/// `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.
/// Id of the selected custom theme entry, if any; `None` means `color_scheme` alone determines the active palette.
#[serde(default)]
pub custom_theme_id: Option<String>,
pub profiles: Profiles,
/// Maximum number of ingested logs kept in memory. `None` (the default)
/// means unlimited — matches today's unbounded retention.
/// Maximum number of ingested logs kept in memory; `None` means unlimited.
#[serde(default)]
pub max_logs: Option<usize>,
/// Alert rule definitions (ticket 017). `#[serde(default)]` so existing
/// settings.json files without this field still load.
/// Alert rule definitions.
#[serde(default)]
pub alert_rules: Vec<AlertRule>,
}
@@ -125,23 +97,13 @@ impl Default for Settings {
}
}
/// Computes the default settings file path using OS-conventional config
/// 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).
/// Computes the default settings file path (`<config_dir>/settings.json`) from OS-conventional config directories; `None` if none could be determined.
pub fn default_settings_path() -> Option<PathBuf> {
ProjectDirs::from("", "", "ottyl").map(|dirs| dirs.config_dir().join("settings.json"))
}
impl Settings {
/// Loads settings from `path`. If the file doesn't exist yet, a default
/// `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).
/// 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.
pub fn load(path: &Path) -> Settings {
match fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str(&contents) {
@@ -174,8 +136,7 @@ impl Settings {
}
}
/// Persists these settings to `path` as pretty-printed JSON, creating
/// any missing parent directories first.
/// Persists these settings to `path` as pretty-printed JSON, creating any missing parent directories first.
pub fn save(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.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)]
pub enum CssError {
/// No entry with the given id exists.
NotFound,
/// The entry exists but is a `Linked` entry, not an `Imported` one, so
/// 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.
/// 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.
NotImported,
/// Underlying I/O failure: writing/reading/removing the CSS file on
/// disk, or persisting `settings.json`.
/// Underlying I/O failure: writing/reading/removing the CSS file, or persisting `settings.json`.
Io(io::Error),
}
@@ -219,14 +176,7 @@ impl From<io::Error> for CssError {
}
}
/// Computes the id used to reference a `CssEntry` over the API.
///
/// 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.
/// 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.
pub fn css_entry_id(entry: &CssEntry) -> String {
match entry {
CssEntry::Imported { id, .. } => id.clone(),
@@ -240,10 +190,7 @@ fn linked_entry_id(path_or_url: &str) -> String {
format!("linked-{:016x}", hasher.finish())
}
/// Generates an id for a newly-imported CSS entry. Not cryptographically
/// 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.
/// 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.
fn generate_imported_css_id(filename: &str, content: &str) -> String {
let mut hasher = DefaultHasher::new();
filename.hash(&mut hasher);
@@ -261,15 +208,7 @@ fn imported_css_file_path(css_dir: &Path, id: &str) -> PathBuf {
}
impl Settings {
/// Imports raw CSS `content`: writes it to `<css_dir>/<id>.css` (id
/// 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.
/// 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.
pub fn import_css(
&mut self,
settings_path: &Path,
@@ -290,9 +229,7 @@ impl Settings {
Ok(id)
}
/// Registers a reference to an external CSS path/URL without copying
/// its content. Persists settings to `settings_path`. `is_theme` (ticket
/// 033) marks this entry as a selectable theme rather than arbitrary CSS.
/// Registers a reference to an external CSS path/URL without copying its content, and persists settings.
pub fn link_css(
&mut self,
settings_path: &Path,
@@ -307,14 +244,7 @@ impl Settings {
Ok(())
}
/// Removes an imported CSS entry identified by `id` (see
/// `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.
/// 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).
pub fn remove_css(
&mut self,
settings_path: &Path,
@@ -331,8 +261,7 @@ impl Settings {
Some(CssEntry::Imported { .. }) => {
let idx = index.unwrap();
self.css_entries.remove(idx);
// Best-effort: if the file is already gone, the settings
// reference is still what matters and has been removed above.
// Best-effort: the settings entry is already removed even if the file is gone.
let _ = fs::remove_file(imported_css_file_path(css_dir, id));
self.save(settings_path)?;
Ok(())
@@ -340,13 +269,7 @@ impl Settings {
}
}
/// Reads back the raw content of an imported CSS entry identified by
/// `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).
/// 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).
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) {
None => Err(CssError::NotFound),
@@ -364,9 +287,7 @@ mod tests {
#[test]
fn default_settings_path_ends_with_settings_json() {
// We can't assert the exact OS path here without touching real
// config dirs, but we can verify the path-computation logic shapes
// it as expected.
// Can't assert the exact OS path without touching real config dirs, so just check the shape.
if let Some(path) = default_settings_path() {
assert_eq!(path.file_name().unwrap(), "settings.json");
assert!(path.parent().is_some());
+1 -5
View File
@@ -1,11 +1,7 @@
use serde::Serialize;
use std::time::{SystemTime, UNIX_EPOCH};
/// Wall-clock instant stored as nanoseconds since the Unix epoch.
///
/// 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.
/// Wall-clock instant stored as nanoseconds since the Unix epoch (not `std::time::Instant`, which has no epoch and can't be serialized).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct Timestamp(i64);