Trim comments across Rust and webapp source to direct one-liners (ticket 030)
Pass over src/*.rs, webapp/src/**, and the in-app "?" docs prose: condensed multi-paragraph doc comments to single lines that state the non-obvious why, deleted comments that only restated adjacent code, and tightened the Docs.tsx help-panel wording. No logic, structure, or code formatting changed — verified via cargo test (143 passed) and tsc -b && vite build. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3cef6dc8ae
commit
00c21c8032
+14
-74
@@ -1,21 +1,6 @@
|
|||||||
//! Alert rules: definitions (ticket 017), trigger evaluation (ticket 018),
|
//! Alert rules: definitions, trigger evaluation, and ntfy delivery.
|
||||||
//! and ntfy delivery (ticket 019).
|
|
||||||
//!
|
//!
|
||||||
//! ## Design: why alert queries only support regex/sequence/timestamp
|
//! Alert queries are restricted to regex/sequence/timestamp — a metric[param] comparison would require a full bucket rescan per line, breaking the proportional-cost guarantee.
|
||||||
//!
|
|
||||||
//! `Immediate`/`Threshold`/`Ratio` conditions reuse FilterBar query strings
|
|
||||||
//! (see `crate::filter_parser`), evaluated against a *single newly-ingested
|
|
||||||
//! line* on `EngineMessage::Ingest` (see `evaluate_alerts`, called from
|
|
||||||
//! `engine::run`). `engine.rs`'s own `compile_filter_query` resolves
|
|
||||||
//! `metric[param]` comparisons by scanning that metric's *entire* bucket
|
|
||||||
//! history up front (`compile_metric_comparison`) — reusing that per alert
|
|
||||||
//! check, per ingested line, would make alert evaluation cost proportional to
|
|
||||||
//! total log volume rather than to new logs ingested, which this ticket
|
|
||||||
//! requires. So alert queries here only support the subset of FilterBar that
|
|
||||||
//! evaluates against a single line's own fields — `regex(...)`, `sequence`,
|
|
||||||
//! `timestamp`, and `and`/`or` — via `eval_alert_expr` below. A `metric[param]`
|
|
||||||
//! comparison in an alert query is rejected with a clear error (logged, the
|
|
||||||
//! rule just never fires) rather than silently misbehaving.
|
|
||||||
|
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
@@ -34,7 +19,6 @@ use crate::filter_parser::{self, CompareOp, FilterExpr, FilterValue};
|
|||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use crate::timestamp::Timestamp;
|
use crate::timestamp::Timestamp;
|
||||||
|
|
||||||
/// A single alert rule definition (ticket 017).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct AlertRule {
|
pub struct AlertRule {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -44,36 +28,23 @@ pub struct AlertRule {
|
|||||||
pub sink: AlertSink,
|
pub sink: AlertSink,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What triggers an alert rule (ticket 018).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||||
pub enum AlertCondition {
|
pub enum AlertCondition {
|
||||||
/// Fires once per log line matching `query`.
|
|
||||||
Immediate { query: String },
|
Immediate { query: String },
|
||||||
/// Fires when `query`'s match count within a rolling `window_ms` crosses
|
/// Fires when `query`'s match count within a rolling `window_ms` crosses `threshold` (via `op`).
|
||||||
/// `threshold` (via `op`).
|
|
||||||
Threshold {
|
Threshold {
|
||||||
query: String,
|
query: String,
|
||||||
op: CompareOp,
|
op: CompareOp,
|
||||||
threshold: f64,
|
threshold: f64,
|
||||||
window_ms: i64,
|
window_ms: i64,
|
||||||
},
|
},
|
||||||
/// Fires the first time `field_regex` matches a line and `capture_group`
|
/// Seen values live only in memory — a restart forgets them, so previously-seen values can fire again; a deliberate simplicity tradeoff, not an oversight.
|
||||||
/// captures a value not seen before *for this rule*. Seen values live
|
|
||||||
/// only in memory (`AlertRuntimeState::seen_values`) for the process's
|
|
||||||
/// lifetime — a restart forgets them, so previously-seen values can fire
|
|
||||||
/// again. This is a deliberate simplicity tradeoff (matches the
|
|
||||||
/// codebase's general "no restart persistence for in-memory engine
|
|
||||||
/// state" pattern), not an oversight.
|
|
||||||
NewValue {
|
NewValue {
|
||||||
field_regex: String,
|
field_regex: String,
|
||||||
capture_group: String,
|
capture_group: String,
|
||||||
},
|
},
|
||||||
/// Fires when `count(query_a) / count(query_b)` (rolling over
|
/// A zero `count_b` skips evaluation for that line rather than treating the ratio as infinite.
|
||||||
/// `window_ms`) crosses `ratio` (via `op`). A zero `count_b` skips
|
|
||||||
/// evaluation entirely for that line (rather than treating the ratio as
|
|
||||||
/// infinite) — there's no meaningful "actual vs threshold" comparison to
|
|
||||||
/// report yet.
|
|
||||||
Ratio {
|
Ratio {
|
||||||
query_a: String,
|
query_a: String,
|
||||||
query_b: String,
|
query_b: String,
|
||||||
@@ -83,9 +54,7 @@ pub enum AlertCondition {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a fired alert is delivered (ticket 019). Modeled as an enum (rather
|
/// Modeled as an enum (rather than bare fields on `AlertRule`) so more sink kinds can be added later without reshaping it.
|
||||||
/// than `AlertRule` holding a bare topic/server) so more sink kinds can be
|
|
||||||
/// added later without reshaping `AlertRule`.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||||
pub enum AlertSink {
|
pub enum AlertSink {
|
||||||
@@ -96,7 +65,6 @@ pub enum AlertSink {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error returned by alert-rule mutations/lookups. Mirrors `CssError`'s shape.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum AlertError {
|
pub enum AlertError {
|
||||||
NotFound,
|
NotFound,
|
||||||
@@ -120,9 +88,7 @@ impl From<io::Error> for AlertError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates an id for a newly-created alert rule. Same approach as
|
/// Hashes the name and current time rather than pulling in a `uuid` dependency.
|
||||||
/// `settings::generate_imported_css_id`: hash the name and current time
|
|
||||||
/// rather than pulling in a `uuid` dependency.
|
|
||||||
fn generate_alert_rule_id(name: &str) -> String {
|
fn generate_alert_rule_id(name: &str) -> String {
|
||||||
let mut hasher = DefaultHasher::new();
|
let mut hasher = DefaultHasher::new();
|
||||||
name.hash(&mut hasher);
|
name.hash(&mut hasher);
|
||||||
@@ -135,7 +101,6 @@ fn generate_alert_rule_id(name: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
/// Creates a new alert rule, persisting settings to `settings_path`.
|
|
||||||
pub fn create_alert_rule(
|
pub fn create_alert_rule(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
@@ -150,9 +115,6 @@ impl Settings {
|
|||||||
Ok(rule)
|
Ok(rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces an existing alert rule's fields (id stays fixed), persisting
|
|
||||||
/// settings to `settings_path`. Returns `AlertError::NotFound` if no rule
|
|
||||||
/// with `id` exists.
|
|
||||||
pub fn update_alert_rule(
|
pub fn update_alert_rule(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
@@ -176,8 +138,6 @@ impl Settings {
|
|||||||
Ok(updated)
|
Ok(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes an alert rule by id, persisting settings to `settings_path`.
|
|
||||||
/// Returns `AlertError::NotFound` if no rule with `id` exists.
|
|
||||||
pub fn remove_alert_rule(&mut self, settings_path: &Path, id: &str) -> Result<(), AlertError> {
|
pub fn remove_alert_rule(&mut self, settings_path: &Path, id: &str) -> Result<(), AlertError> {
|
||||||
let index = self
|
let index = self
|
||||||
.alert_rules
|
.alert_rules
|
||||||
@@ -190,11 +150,7 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-rule in-memory evaluation state (ticket 018): rolling match-timestamp
|
/// Per-rule in-memory evaluation state; lives only for the process's lifetime — not persisted, not backfilled against already-ingested logs.
|
||||||
/// windows for `Threshold`/`Ratio`, and seen-values for `NewValue`. Lives
|
|
||||||
/// only for the engine process's lifetime (see `AlertCondition::NewValue`'s
|
|
||||||
/// doc comment) — not persisted, not backfilled against already-ingested
|
|
||||||
/// logs.
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct AlertRuntimeState {
|
pub struct AlertRuntimeState {
|
||||||
threshold_matches: VecDeque<i64>,
|
threshold_matches: VecDeque<i64>,
|
||||||
@@ -203,9 +159,7 @@ pub struct AlertRuntimeState {
|
|||||||
seen_values: HashSet<String>,
|
seen_values: HashSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops every entry older than `window_ms` relative to `now_ms` from the
|
/// `timestamps` is push_back-ordered, so once the front entry is within the window, every entry behind it is too.
|
||||||
/// front of a rolling window. `timestamps` is push_back-ordered, so once a
|
|
||||||
/// front entry is within the window every entry behind it is too.
|
|
||||||
fn prune_window(timestamps: &mut VecDeque<i64>, now_ms: i64, window_ms: i64) {
|
fn prune_window(timestamps: &mut VecDeque<i64>, now_ms: i64, window_ms: i64) {
|
||||||
while let Some(&front) = timestamps.front() {
|
while let Some(&front) = timestamps.front() {
|
||||||
if now_ms - front > window_ms {
|
if now_ms - front > window_ms {
|
||||||
@@ -216,9 +170,7 @@ fn prune_window(timestamps: &mut VecDeque<i64>, now_ms: i64, window_ms: i64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluates a FilterBar query string against a single log line's own fields
|
/// Evaluates a FilterBar query against a single line's own fields only (metric[param] comparisons aren't supported — see module doc).
|
||||||
/// only — see this module's doc comment for why `metric[param]` comparisons
|
|
||||||
/// aren't supported here.
|
|
||||||
fn eval_alert_query(query: &str, sequence: u64, timestamp: Timestamp, text: &str) -> Result<bool, String> {
|
fn eval_alert_query(query: &str, sequence: u64, timestamp: Timestamp, text: &str) -> Result<bool, String> {
|
||||||
let expr = filter_parser::parse(query).map_err(|err| err.to_string())?;
|
let expr = filter_parser::parse(query).map_err(|err| err.to_string())?;
|
||||||
eval_alert_expr(&expr, sequence, timestamp, text)
|
eval_alert_expr(&expr, sequence, timestamp, text)
|
||||||
@@ -261,12 +213,7 @@ fn eval_alert_expr(expr: &FilterExpr, sequence: u64, timestamp: Timestamp, text:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluates every enabled rule's condition against one newly-ingested line,
|
/// A malformed query (bad regex, unsupported metric comparison) is logged and simply never matches, rather than panicking.
|
||||||
/// mutating each rule's runtime state (rolling windows / seen-values) along
|
|
||||||
/// the way. Returns `(rule, message)` for every rule that fired — the caller
|
|
||||||
/// (`engine::run`) hands each off to `deliver_for_rule`. A malformed query
|
|
||||||
/// (bad regex, unsupported metric comparison) is logged to stderr and simply
|
|
||||||
/// never matches, rather than panicking or propagating.
|
|
||||||
pub fn evaluate_alerts(
|
pub fn evaluate_alerts(
|
||||||
rules: &[AlertRule],
|
rules: &[AlertRule],
|
||||||
states: &mut HashMap<String, AlertRuntimeState>,
|
states: &mut HashMap<String, AlertRuntimeState>,
|
||||||
@@ -362,9 +309,7 @@ fn evaluate_condition(
|
|||||||
let count_a = state.ratio_a_matches.len() as f64;
|
let count_a = state.ratio_a_matches.len() as f64;
|
||||||
let count_b = state.ratio_b_matches.len() as f64;
|
let count_b = state.ratio_b_matches.len() as f64;
|
||||||
if count_b == 0.0 {
|
if count_b == 0.0 {
|
||||||
// Zero denominator: skip evaluation rather than treat the
|
// Zero denominator: skip rather than treat the ratio as infinite.
|
||||||
// ratio as infinite — there's nothing meaningful to compare
|
|
||||||
// against `ratio` yet.
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let observed = count_a / count_b;
|
let observed = count_a / count_b;
|
||||||
@@ -377,10 +322,7 @@ fn evaluate_condition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delivers one fired alert to its rule's configured sink. Errors are logged
|
/// Errors are only logged (no retry queue); spawned off the ingest hot path so a slow/unreachable sink never blocks ingestion.
|
||||||
/// to stderr and dropped — no retry queue, matching `remove_css`'s
|
|
||||||
/// best-effort style. Called via `tokio::spawn` from the ingest hot path
|
|
||||||
/// (`engine::run`) so a slow/unreachable sink never blocks ingestion.
|
|
||||||
pub async fn deliver_for_rule(rule: AlertRule, message: String) {
|
pub async fn deliver_for_rule(rule: AlertRule, message: String) {
|
||||||
match rule.sink {
|
match rule.sink {
|
||||||
AlertSink::Ntfy { topic, server } => {
|
AlertSink::Ntfy { topic, server } => {
|
||||||
@@ -392,9 +334,7 @@ pub async fn deliver_for_rule(rule: AlertRule, message: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publishes `message` to `{server}/{topic}` via ntfy's plain-text publish
|
/// Standalone and `AlertRule`-free so a "send test notification" endpoint can call it directly.
|
||||||
/// API (no auth, raw body). Standalone and `AlertRule`-free so a future
|
|
||||||
/// "send test notification" endpoint can call it directly.
|
|
||||||
pub async fn deliver_ntfy(server: &str, topic: &str, message: &str) -> Result<(), String> {
|
pub async fn deliver_ntfy(server: &str, topic: &str, message: &str) -> Result<(), String> {
|
||||||
let url = format!("{}/{}", server.trim_end_matches('/'), topic);
|
let url = format!("{}/{}", server.trim_end_matches('/'), topic);
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
|
|||||||
+17
-171
@@ -29,8 +29,7 @@ static WEBAPP_DIST: EmbedDir = fs_embed::fs_embed!("webapp/dist");
|
|||||||
struct ApiState {
|
struct ApiState {
|
||||||
engine: EngineHandle,
|
engine: EngineHandle,
|
||||||
settings: SettingsHandle,
|
settings: SettingsHandle,
|
||||||
/// Path to the settings JSON file on disk. Imported CSS files are stored
|
/// Imported CSS files are stored alongside this path's parent directory (see `ApiState::css_dir`).
|
||||||
/// alongside it, in its parent directory (see `ApiState::css_dir`).
|
|
||||||
settings_path: Arc<PathBuf>,
|
settings_path: Arc<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,13 +41,7 @@ impl ApiState {
|
|||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persists a newly-declared param into the active profile, if any (see
|
/// Best-effort: a no-op if no profile is active, and I/O failures are only logged, since the engine declaration is already the source of truth.
|
||||||
/// `ProfileStore::record_param_declaration`), so it survives a restart.
|
|
||||||
/// A no-op (not an error) when no profile is currently active — the
|
|
||||||
/// engine declaration above has already succeeded regardless. Failures
|
|
||||||
/// (e.g. I/O) are logged to stderr rather than surfaced to the caller: the
|
|
||||||
/// engine declaration is the source of truth for the running process, and
|
|
||||||
/// this is a best-effort persistence step layered on top.
|
|
||||||
fn persist_param_declaration(&self, request: &DeclareParamRequest) {
|
fn persist_param_declaration(&self, request: &DeclareParamRequest) {
|
||||||
let Some((store_path, profile_name)) = self.active_profile_location() else {
|
let Some((store_path, profile_name)) = self.active_profile_location() else {
|
||||||
return;
|
return;
|
||||||
@@ -76,7 +69,6 @@ impl ApiState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The active profile's store path and name, if a profile is currently active.
|
|
||||||
fn active_profile_location(&self) -> Option<(PathBuf, String)> {
|
fn active_profile_location(&self) -> Option<(PathBuf, String)> {
|
||||||
let settings = self.settings.lock().unwrap();
|
let settings = self.settings.lock().unwrap();
|
||||||
let profile_name = settings.profiles.active_profile.clone()?;
|
let profile_name = settings.profiles.active_profile.clone()?;
|
||||||
@@ -126,8 +118,7 @@ pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: Pat
|
|||||||
.fallback(serve_webapp)
|
.fallback(serve_webapp)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serves the webapp embedded in the binary (see `WEBAPP_DIST`), falling
|
/// Falls back to `index.html` for unmatched paths so client-side routing works.
|
||||||
/// back to `index.html` for any unmatched path so client-side routing works.
|
|
||||||
async fn serve_webapp(uri: axum::http::Uri) -> Response {
|
async fn serve_webapp(uri: axum::http::Uri) -> Response {
|
||||||
let path = uri.path().trim_start_matches('/');
|
let path = uri.path().trim_start_matches('/');
|
||||||
let file = WEBAPP_DIST
|
let file = WEBAPP_DIST
|
||||||
@@ -167,11 +158,6 @@ async fn ask(engine: &EngineHandle, query: EngineQuery) -> Result<EngineResponse
|
|||||||
response.await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)
|
response.await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/logs` — also accepts a `filter` query param (ticket 007): a
|
|
||||||
/// FilterBar query string, parsed and evaluated inside the engine. A
|
|
||||||
/// malformed filter string comes back as `EngineResponse::Error` and is
|
|
||||||
/// surfaced here as `400 Bad Request` with `{ "error": "..." }`, matching
|
|
||||||
/// ticket 004's `declare_param`/`declare_metric` error-mapping pattern.
|
|
||||||
async fn get_logs(
|
async fn get_logs(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
Query(params): Query<LogsParams>,
|
Query(params): Query<LogsParams>,
|
||||||
@@ -186,8 +172,6 @@ async fn get_logs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/timeline` — same `filter` query param and error mapping as
|
|
||||||
/// `get_logs` above.
|
|
||||||
async fn get_timeline(
|
async fn get_timeline(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
Query(params): Query<TimelineParams>,
|
Query(params): Query<TimelineParams>,
|
||||||
@@ -210,10 +194,6 @@ async fn get_stats(State(state): State<ApiState>) -> impl IntoResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/params` — lists all declared params (name, regex, capture
|
|
||||||
/// group, kind) as `Vec<ParamDeclarationDto>`. Mirrors `list_metrics` below
|
|
||||||
/// (ticket 015 needed this for LogPreview's column picker; ticket 004 only
|
|
||||||
/// exposed the equivalent for metrics).
|
|
||||||
async fn list_params(State(state): State<ApiState>) -> impl IntoResponse {
|
async fn list_params(State(state): State<ApiState>) -> impl IntoResponse {
|
||||||
match ask(&state.engine, EngineQuery::ParamDeclarations).await {
|
match ask(&state.engine, EngineQuery::ParamDeclarations).await {
|
||||||
Ok(EngineResponse::ParamDeclarations(declarations)) => Json(declarations).into_response(),
|
Ok(EngineResponse::ParamDeclarations(declarations)) => Json(declarations).into_response(),
|
||||||
@@ -222,14 +202,6 @@ async fn list_params(State(state): State<ApiState>) -> impl IntoResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/params` — declares a param. Body mirrors `DeclareParamRequest`:
|
|
||||||
/// `{ "name": "...", "message_match_regex": "...", "capture_group": "...",
|
|
||||||
/// "kind": "string" | "number" | "timestamp" }`. On success, also persists
|
|
||||||
/// the declaration into the active profile (if any — see
|
|
||||||
/// `ApiState::persist_param_declaration`) and returns `201 Created` with the
|
|
||||||
/// declaration as `ParamDeclarationDto`. On a validation failure (bad regex,
|
|
||||||
/// unknown capture group), returns `400 Bad Request` with
|
|
||||||
/// `{ "error": "..." }`.
|
|
||||||
async fn declare_param(
|
async fn declare_param(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
Json(body): Json<DeclareParamRequest>,
|
Json(body): Json<DeclareParamRequest>,
|
||||||
@@ -260,8 +232,6 @@ async fn declare_param(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/metrics` — lists all declared metrics (name, regex, params) as
|
|
||||||
/// `Vec<MetricDeclarationDto>`.
|
|
||||||
async fn list_metrics(State(state): State<ApiState>) -> impl IntoResponse {
|
async fn list_metrics(State(state): State<ApiState>) -> impl IntoResponse {
|
||||||
match ask(&state.engine, EngineQuery::MetricDeclarations).await {
|
match ask(&state.engine, EngineQuery::MetricDeclarations).await {
|
||||||
Ok(EngineResponse::MetricDeclarations(declarations)) => Json(declarations).into_response(),
|
Ok(EngineResponse::MetricDeclarations(declarations)) => Json(declarations).into_response(),
|
||||||
@@ -270,11 +240,6 @@ async fn list_metrics(State(state): State<ApiState>) -> impl IntoResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/metrics` — declares a metric. Body mirrors
|
|
||||||
/// `DeclareMetricRequest`: `{ "name": "...", "message_match_regex": "...",
|
|
||||||
/// "params": [{ "param_name": "...", "capture_group": "...", "kind": "..." }] }`.
|
|
||||||
/// Same success/failure shape as `declare_param` above, persisting into the
|
|
||||||
/// active profile via `ApiState::persist_metric_declaration`.
|
|
||||||
async fn declare_metric(
|
async fn declare_metric(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
Json(body): Json<DeclareMetricRequest>,
|
Json(body): Json<DeclareMetricRequest>,
|
||||||
@@ -306,9 +271,7 @@ async fn declare_metric(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses the `labels` query param (`"foo=bar,baz=qux"`) into a
|
/// Malformed pairs (no `=`) are skipped rather than rejected.
|
||||||
/// `key -> expected value` map. Malformed pairs (no `=`) are skipped rather
|
|
||||||
/// than rejected — see `BucketValuesParams` doc comment for the format.
|
|
||||||
fn parse_labels_filter(raw: &str) -> BTreeMap<String, String> {
|
fn parse_labels_filter(raw: &str) -> BTreeMap<String, String> {
|
||||||
raw.split(',')
|
raw.split(',')
|
||||||
.filter_map(|pair| pair.split_once('='))
|
.filter_map(|pair| pair.split_once('='))
|
||||||
@@ -316,9 +279,6 @@ fn parse_labels_filter(raw: &str) -> BTreeMap<String, String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/metrics/:name` — queries bucket values for one metric. Query
|
|
||||||
/// params: `labels` (see `parse_labels_filter`), `start_ms`, `end_ms`,
|
|
||||||
/// `since_sequence`, `limit` (mirroring `/api/logs`/`/api/timeline`).
|
|
||||||
async fn get_metric_bucket_values(
|
async fn get_metric_bucket_values(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
AxumPath(metric_name): AxumPath<String>,
|
AxumPath(metric_name): AxumPath<String>,
|
||||||
@@ -340,12 +300,6 @@ async fn get_metric_bucket_values(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/query` — evaluates a PromQL query (ticket 013; see `crate::promql` for
|
|
||||||
/// the supported grammar) against declared metrics. Query params: `promql` (the
|
|
||||||
/// query string), `start_ms`, `end_ms` (bound which points are included, mirroring
|
|
||||||
/// `/api/logs`/`/api/metrics/:name`). Returns `Vec<PromqlSeriesDto>` — chart-ready
|
|
||||||
/// `labels` + `(timestamp_ms, value)` series — on success (`200 OK`), or
|
|
||||||
/// `400 Bad Request` with `{ "error": "..." }` for a malformed query.
|
|
||||||
async fn run_promql_query(
|
async fn run_promql_query(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
Query(params): Query<PromqlParams>,
|
Query(params): Query<PromqlParams>,
|
||||||
@@ -365,10 +319,6 @@ async fn run_promql_query(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps a `CssError` from a settings mutation/lookup to a response status
|
|
||||||
/// and message: `NotFound` -> 404, `NotImported` -> 400 (the id is valid but
|
|
||||||
/// refers to a linked entry, which this operation doesn't apply to), `Io` ->
|
|
||||||
/// 500.
|
|
||||||
fn css_error_response(err: CssError) -> axum::response::Response {
|
fn css_error_response(err: CssError) -> axum::response::Response {
|
||||||
match err {
|
match err {
|
||||||
CssError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(),
|
CssError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(),
|
||||||
@@ -377,18 +327,12 @@ fn css_error_response(err: CssError) -> axum::response::Response {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/settings/css` — lists all configured CSS entries (imported and
|
|
||||||
/// linked) as `Vec<CssEntryDto>`.
|
|
||||||
async fn list_css(State(state): State<ApiState>) -> impl IntoResponse {
|
async fn list_css(State(state): State<ApiState>) -> impl IntoResponse {
|
||||||
let settings = state.settings.lock().unwrap();
|
let settings = state.settings.lock().unwrap();
|
||||||
let entries: Vec<CssEntryDto> = settings.css_entries.iter().map(CssEntryDto::from).collect();
|
let entries: Vec<CssEntryDto> = settings.css_entries.iter().map(CssEntryDto::from).collect();
|
||||||
Json(entries).into_response()
|
Json(entries).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/settings/css` — imports raw CSS content. Body:
|
|
||||||
/// `{ "filename": "theme.css", "content": "body { ... }" }`. Stores the
|
|
||||||
/// content as a file alongside the settings file, adds a `CssEntry::Imported`
|
|
||||||
/// to settings, and returns the new entry as `CssEntryDto` (201 Created).
|
|
||||||
async fn import_css(
|
async fn import_css(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
Json(body): Json<ImportCssRequest>,
|
Json(body): Json<ImportCssRequest>,
|
||||||
@@ -414,11 +358,7 @@ async fn import_css(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/settings/css/link` — registers a reference to an external CSS
|
/// Content is never copied, and the entry can never be deleted via `DELETE /api/settings/css/:id`.
|
||||||
/// path/URL. Body: `{ "path_or_url": "https://example.com/style.css" }`.
|
|
||||||
/// Content is never copied; the entry is never deletable via
|
|
||||||
/// `DELETE /api/settings/css/:id`. Returns the new entry as `CssEntryDto`
|
|
||||||
/// (201 Created).
|
|
||||||
async fn link_css(
|
async fn link_css(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
Json(body): Json<LinkCssRequest>,
|
Json(body): Json<LinkCssRequest>,
|
||||||
@@ -436,10 +376,6 @@ async fn link_css(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/settings/css/:id` — removes an imported CSS entry (file +
|
|
||||||
/// settings reference). Rejects with 400 if `id` refers to a `Linked` entry
|
|
||||||
/// (never deletable this way) and 404 if `id` is unknown. On success returns
|
|
||||||
/// 204 No Content.
|
|
||||||
async fn remove_css(
|
async fn remove_css(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
AxumPath(id): AxumPath<String>,
|
AxumPath(id): AxumPath<String>,
|
||||||
@@ -452,10 +388,7 @@ async fn remove_css(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/settings/css/:id/content` — serves back the raw CSS content of
|
/// Rejects a linked entry with 400 — there's no local content; the frontend should use `path_or_url` directly.
|
||||||
/// an imported entry (`content-type: text/css`). Rejects with 400 for a
|
|
||||||
/// linked entry (there's no local content to serve — the frontend should use
|
|
||||||
/// `path_or_url` directly) and 404 for an unknown id.
|
|
||||||
async fn get_css_content(
|
async fn get_css_content(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
AxumPath(id): AxumPath<String>,
|
AxumPath(id): AxumPath<String>,
|
||||||
@@ -473,8 +406,6 @@ async fn get_css_content(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps an `AlertError` to a response status/message: `NotFound` -> 404,
|
|
||||||
/// `Io` -> 500. Mirrors `css_error_response` above.
|
|
||||||
fn alert_error_response(err: AlertError) -> axum::response::Response {
|
fn alert_error_response(err: AlertError) -> axum::response::Response {
|
||||||
match err {
|
match err {
|
||||||
AlertError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(),
|
AlertError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(),
|
||||||
@@ -482,18 +413,11 @@ fn alert_error_response(err: AlertError) -> axum::response::Response {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/settings/alerts` — lists all configured alert rules as
|
|
||||||
/// `Vec<AlertRule>`.
|
|
||||||
async fn list_alert_rules(State(state): State<ApiState>) -> impl IntoResponse {
|
async fn list_alert_rules(State(state): State<ApiState>) -> impl IntoResponse {
|
||||||
let settings = state.settings.lock().unwrap();
|
let settings = state.settings.lock().unwrap();
|
||||||
Json(settings.alert_rules.clone()).into_response()
|
Json(settings.alert_rules.clone()).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/settings/alerts` — creates an alert rule. Body:
|
|
||||||
/// `{ "name": "...", "enabled": true, "condition": { "kind": "immediate" | \
|
|
||||||
/// "threshold" | "newvalue" | "ratio", ... }, "sink": { "kind": "ntfy", \
|
|
||||||
/// "topic": "...", "server": "..." | null } }`. Returns `201 Created` with
|
|
||||||
/// the created rule (including its generated `id`).
|
|
||||||
async fn create_alert_rule(
|
async fn create_alert_rule(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
Json(body): Json<CreateAlertRuleRequest>,
|
Json(body): Json<CreateAlertRuleRequest>,
|
||||||
@@ -505,9 +429,6 @@ async fn create_alert_rule(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PUT /api/settings/alerts/:id` — replaces an alert rule's fields (id in
|
|
||||||
/// the URL is authoritative). Returns the updated rule, or `404 Not Found`
|
|
||||||
/// if no rule with `id` exists.
|
|
||||||
async fn update_alert_rule(
|
async fn update_alert_rule(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
AxumPath(id): AxumPath<String>,
|
AxumPath(id): AxumPath<String>,
|
||||||
@@ -520,8 +441,6 @@ async fn update_alert_rule(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/settings/alerts/:id` — removes an alert rule. Returns
|
|
||||||
/// `204 No Content`, or `404 Not Found` if no rule with `id` exists.
|
|
||||||
async fn delete_alert_rule(State(state): State<ApiState>, AxumPath(id): AxumPath<String>) -> impl IntoResponse {
|
async fn delete_alert_rule(State(state): State<ApiState>, AxumPath(id): AxumPath<String>) -> impl IntoResponse {
|
||||||
let mut settings = state.settings.lock().unwrap();
|
let mut settings = state.settings.lock().unwrap();
|
||||||
match settings.remove_alert_rule(&state.settings_path, &id) {
|
match settings.remove_alert_rule(&state.settings_path, &id) {
|
||||||
@@ -530,11 +449,7 @@ async fn delete_alert_rule(State(state): State<ApiState>, AxumPath(id): AxumPath
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/settings/alerts/test` — delivers a test notification to an
|
/// Delivers a test notification without a saved rule so the sink can be verified before saving.
|
||||||
/// ntfy topic/server without requiring a saved rule, so the sink can be
|
|
||||||
/// verified before saving. Body: `{ "topic": "...", "server": "..." | null }`.
|
|
||||||
/// Returns `204 No Content` on success, `502 Bad Gateway` if ntfy rejects
|
|
||||||
/// or is unreachable.
|
|
||||||
async fn test_alert_notification(Json(body): Json<TestAlertNotificationRequest>) -> impl IntoResponse {
|
async fn test_alert_notification(Json(body): Json<TestAlertNotificationRequest>) -> impl IntoResponse {
|
||||||
let server = body.server.unwrap_or_else(|| "https://ntfy.sh".to_string());
|
let server = body.server.unwrap_or_else(|| "https://ntfy.sh".to_string());
|
||||||
match deliver_ntfy(&server, &body.topic, "Test notification from ottyl").await {
|
match deliver_ntfy(&server, &body.topic, "Test notification from ottyl").await {
|
||||||
@@ -543,20 +458,13 @@ async fn test_alert_notification(Json(body): Json<TestAlertNotificationRequest>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/settings` — returns the subset of `Settings` the NavMenu UI
|
/// CSS entries have their own endpoint (`GET /api/settings/css`).
|
||||||
/// needs (colour scheme, active profile name) as `SettingsDto`. CSS entries
|
|
||||||
/// have their own dedicated list endpoint (`GET /api/settings/css`).
|
|
||||||
async fn get_settings(State(state): State<ApiState>) -> impl IntoResponse {
|
async fn get_settings(State(state): State<ApiState>) -> impl IntoResponse {
|
||||||
let settings = state.settings.lock().unwrap();
|
let settings = state.settings.lock().unwrap();
|
||||||
Json(SettingsDto::from(&*settings)).into_response()
|
Json(SettingsDto::from(&*settings)).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PATCH /api/settings` — updates process-wide settings. Body:
|
/// `max_logs: null` clears the cap; a changed value is pushed to the engine so it applies without a restart.
|
||||||
/// `{ "color_scheme": "light" | "dark" | "system", "max_logs": number |
|
|
||||||
/// null }` (omit a field to leave it unchanged; `max_logs: null` clears the
|
|
||||||
/// cap back to unlimited). Persists to `settings_path`, pushes a changed
|
|
||||||
/// `max_logs` to the engine so it takes effect on the next ingest without a
|
|
||||||
/// restart, and returns the updated settings as `SettingsDto`.
|
|
||||||
async fn update_settings(
|
async fn update_settings(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
Json(body): Json<UpdateSettingsRequest>,
|
Json(body): Json<UpdateSettingsRequest>,
|
||||||
@@ -588,9 +496,6 @@ async fn update_settings(
|
|||||||
Json(new_max_logs.0).into_response()
|
Json(new_max_logs.0).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps a `ProfileError` to a response status/message: `NotFound` -> 404,
|
|
||||||
/// `DuplicateName`/`CannotDeleteDefault` -> 400, `Io` -> 500. Mirrors
|
|
||||||
/// `css_error_response` above.
|
|
||||||
fn profile_error_response(err: ProfileError) -> axum::response::Response {
|
fn profile_error_response(err: ProfileError) -> axum::response::Response {
|
||||||
match err {
|
match err {
|
||||||
ProfileError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(),
|
ProfileError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(),
|
||||||
@@ -600,10 +505,6 @@ fn profile_error_response(err: ProfileError) -> axum::response::Response {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/settings/profiles` — lists all known profiles with their
|
|
||||||
/// param/metric declaration and dashboard counts, as `Vec<ProfileSummaryDto>`.
|
|
||||||
/// Use `GET /api/settings`'s `active_profile` field to know which one (if
|
|
||||||
/// any) is currently active.
|
|
||||||
async fn list_profiles(State(state): State<ApiState>) -> impl IntoResponse {
|
async fn list_profiles(State(state): State<ApiState>) -> impl IntoResponse {
|
||||||
let settings = state.settings.lock().unwrap();
|
let settings = state.settings.lock().unwrap();
|
||||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||||
@@ -612,10 +513,6 @@ async fn list_profiles(State(state): State<ApiState>) -> impl IntoResponse {
|
|||||||
Json(summaries).into_response()
|
Json(summaries).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/settings/profiles` — creates a new, empty profile. Body:
|
|
||||||
/// `{ "name": "..." }`. Returns `201 Created` with the new profile as
|
|
||||||
/// `ProfileSummaryDto`, or `400 Bad Request` if a profile with that name
|
|
||||||
/// already exists.
|
|
||||||
async fn create_profile(
|
async fn create_profile(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
Json(body): Json<CreateProfileRequest>,
|
Json(body): Json<CreateProfileRequest>,
|
||||||
@@ -633,11 +530,7 @@ async fn create_profile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/settings/profiles/:name` — deletes a profile. If it was the
|
/// If it was the active profile, `active_profile` is cleared, but the engine's already-declared params/metrics keep running until the next restart.
|
||||||
/// active profile, `active_profile` is cleared (but the engine's currently
|
|
||||||
/// declared params/metrics are left as-is — they simply won't be reloaded on
|
|
||||||
/// the next restart). Returns `204 No Content`, or `404 Not Found` if no
|
|
||||||
/// profile with that name exists.
|
|
||||||
async fn delete_profile(
|
async fn delete_profile(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
AxumPath(name): AxumPath<String>,
|
AxumPath(name): AxumPath<String>,
|
||||||
@@ -652,12 +545,7 @@ async fn delete_profile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes every currently-declared param/metric from the running engine
|
/// Removes every currently-declared param/metric from the engine, then replays `profile`'s declarations into it.
|
||||||
/// (via `EngineMessage::RemoveParamDeclaration`/`RemoveMetricDeclaration`),
|
|
||||||
/// then replays `profile`'s stored declarations into it (see
|
|
||||||
/// `profile::load_into_engine`). Used by `activate_profile` so switching the
|
|
||||||
/// active profile takes effect on the running engine immediately, without
|
|
||||||
/// requiring a process restart.
|
|
||||||
async fn reload_engine_declarations(engine: &EngineHandle, profile: &Profile) {
|
async fn reload_engine_declarations(engine: &EngineHandle, profile: &Profile) {
|
||||||
if let Ok(EngineResponse::ParamDeclarations(params)) =
|
if let Ok(EngineResponse::ParamDeclarations(params)) =
|
||||||
ask(engine, EngineQuery::ParamDeclarations).await
|
ask(engine, EngineQuery::ParamDeclarations).await
|
||||||
@@ -690,12 +578,6 @@ async fn reload_engine_declarations(engine: &EngineHandle, profile: &Profile) {
|
|||||||
profile::load_into_engine(engine, profile).await;
|
profile::load_into_engine(engine, profile).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/settings/profiles/:name/activate` — sets `name` as the active
|
|
||||||
/// profile (persisted, see `ProfileStore::set_active`) and hot-swaps the
|
|
||||||
/// running engine's declared params/metrics to match it (see
|
|
||||||
/// `reload_engine_declarations`), so the switch is visible without a
|
|
||||||
/// restart. Returns the updated settings as `SettingsDto`, or `404 Not
|
|
||||||
/// Found` if no profile with that name exists.
|
|
||||||
async fn activate_profile(
|
async fn activate_profile(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
AxumPath(name): AxumPath<String>,
|
AxumPath(name): AxumPath<String>,
|
||||||
@@ -723,9 +605,6 @@ async fn activate_profile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/settings/profiles/:name/dashboards` — lists all dashboards saved
|
|
||||||
/// on the profile `name`, as `Vec<DashboardDto>`. Returns `404 Not Found` if
|
|
||||||
/// no profile with that name exists.
|
|
||||||
async fn list_dashboards(
|
async fn list_dashboards(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
AxumPath(name): AxumPath<String>,
|
AxumPath(name): AxumPath<String>,
|
||||||
@@ -742,10 +621,6 @@ async fn list_dashboards(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/settings/profiles/:name/dashboards` — creates a new, empty
|
|
||||||
/// dashboard on profile `name`. Body: `{ "name": "..." }`. Returns
|
|
||||||
/// `201 Created` with the new dashboard as `DashboardDto`, or `404 Not
|
|
||||||
/// Found` if no profile with that name exists.
|
|
||||||
async fn create_dashboard(
|
async fn create_dashboard(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
AxumPath(name): AxumPath<String>,
|
AxumPath(name): AxumPath<String>,
|
||||||
@@ -761,9 +636,6 @@ async fn create_dashboard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/settings/profiles/:name/dashboards/:dashboard_id` — fetches one
|
|
||||||
/// dashboard by id. Returns `404 Not Found` if the profile or dashboard
|
|
||||||
/// doesn't exist.
|
|
||||||
async fn get_dashboard(
|
async fn get_dashboard(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
||||||
@@ -780,11 +652,6 @@ async fn get_dashboard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PUT /api/settings/profiles/:name/dashboards/:dashboard_id` — replaces the
|
|
||||||
/// dashboard's name/panels. Body: `{ "name": "...", "panels": [{ "id": "...",
|
|
||||||
/// "title": "...", "promql": "...", "chart_type": "bar" | "line" | "pie" }] }`.
|
|
||||||
/// Returns the updated dashboard as `DashboardDto`, or `404 Not Found` if the
|
|
||||||
/// profile or dashboard doesn't exist.
|
|
||||||
async fn save_dashboard(
|
async fn save_dashboard(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
||||||
@@ -805,9 +672,6 @@ async fn save_dashboard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/settings/profiles/:name/dashboards/:dashboard_id` — deletes a
|
|
||||||
/// dashboard. Returns `204 No Content`, or `404 Not Found` if the profile or
|
|
||||||
/// dashboard doesn't exist.
|
|
||||||
async fn delete_dashboard(
|
async fn delete_dashboard(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
||||||
@@ -836,10 +700,7 @@ mod tests {
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
/// Spins up a fresh engine + router pair, backed by a temp settings file,
|
/// Spins up a fresh engine + router pair backed by a temp settings file.
|
||||||
/// for one test. Returns the router, the engine handle (so a test can
|
|
||||||
/// ingest lines directly without an HTTP route for it), and the
|
|
||||||
/// `TempDir` guard (kept alive for the test's duration).
|
|
||||||
fn test_app() -> (Router, EngineHandle, SettingsHandle, tempfile::TempDir) {
|
fn test_app() -> (Router, EngineHandle, SettingsHandle, tempfile::TempDir) {
|
||||||
let (engine_tx, engine_rx) = mpsc::channel(128);
|
let (engine_tx, engine_rx) = mpsc::channel(128);
|
||||||
|
|
||||||
@@ -937,9 +798,7 @@ mod tests {
|
|||||||
let status = delete_request(&app, "/api/settings/alerts/does-not-exist").await;
|
let status = delete_request(&app, "/api/settings/alerts/does-not-exist").await;
|
||||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
// `update_alert_rule`'s `AlertError::NotFound` maps to a plain-text
|
// AlertError::NotFound maps to a plain-text (not JSON) 404 body, so check status directly instead of via send_json.
|
||||||
// 404 body (mirrors `css_error_response`), not JSON, so this checks
|
|
||||||
// the status directly rather than via `send_json`'s JSON parsing.
|
|
||||||
let request = Request::builder()
|
let request = Request::builder()
|
||||||
.method("PUT")
|
.method("PUT")
|
||||||
.uri("/api/settings/alerts/does-not-exist")
|
.uri("/api/settings/alerts/does-not-exist")
|
||||||
@@ -1085,16 +944,11 @@ mod tests {
|
|||||||
let values = body.as_array().unwrap();
|
let values = body.as_array().unwrap();
|
||||||
assert_eq!(values.len(), 2);
|
assert_eq!(values.len(), 2);
|
||||||
for value in values {
|
for value in values {
|
||||||
// `MetricPrimitive` is a Rust enum with derived `Serialize`, so its
|
// MetricPrimitive derives Serialize as an externally-tagged enum, so JSON is {"String": "/a"}.
|
||||||
// JSON form is externally tagged: `{"String": "/a"}`.
|
|
||||||
assert_eq!(value["labels"]["path"]["String"], "/a");
|
assert_eq!(value["labels"]["path"]["String"], "/a");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bucket values are grouped by label combination (ordered by the
|
// Bucket values group by label (BTreeMap-ordered "/a" before "/b"), not by sequence, so limit=1 picks sequence 2 from "/a" ahead of sequence 1 from "/b".
|
||||||
// labels' `BTreeMap` key, i.e. "/a" before "/b"), not globally by
|
|
||||||
// sequence — so filtering to sequence > 0 leaves sequence 2 (from the
|
|
||||||
// "/a" bucket) ahead of sequence 1 (from the "/b" bucket), and a
|
|
||||||
// limit of 1 picks the former.
|
|
||||||
let (status, body) = get_json(&app, "/api/metrics/requests?since_sequence=0&limit=1").await;
|
let (status, body) = get_json(&app, "/api/metrics/requests?since_sequence=0&limit=1").await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
let values = body.as_array().unwrap();
|
let values = body.as_array().unwrap();
|
||||||
@@ -1159,10 +1013,7 @@ mod tests {
|
|||||||
async fn declaring_param_persists_into_active_profile() {
|
async fn declaring_param_persists_into_active_profile() {
|
||||||
let (app, _engine, settings_handle, dir) = test_app();
|
let (app, _engine, settings_handle, dir) = test_app();
|
||||||
|
|
||||||
// Set up an active profile against the *same* shared `settings`
|
// Use the same shared settings handle the router was built with — a separately-loaded Settings wouldn't be visible to the running app.
|
||||||
// handle the router was built with — mutating a separate `Settings`
|
|
||||||
// loaded fresh from disk wouldn't be visible to the running app's
|
|
||||||
// in-memory settings.
|
|
||||||
let settings_path = dir.path().join("settings.json");
|
let settings_path = dir.path().join("settings.json");
|
||||||
let profiles_dir = {
|
let profiles_dir = {
|
||||||
let settings = settings_handle.lock().unwrap();
|
let settings = settings_handle.lock().unwrap();
|
||||||
@@ -1229,12 +1080,10 @@ mod tests {
|
|||||||
assert!(body["panels"].as_array().unwrap().is_empty());
|
assert!(body["panels"].as_array().unwrap().is_empty());
|
||||||
let dashboard_id = body["id"].as_str().unwrap().to_string();
|
let dashboard_id = body["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
// Listing shows the new (empty) dashboard.
|
|
||||||
let (status, body) = get_json(&app, "/api/settings/profiles/web/dashboards").await;
|
let (status, body) = get_json(&app, "/api/settings/profiles/web/dashboards").await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
assert_eq!(body.as_array().unwrap().len(), 1);
|
assert_eq!(body.as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
// Save adds a panel.
|
|
||||||
let (status, body) = send_json(
|
let (status, body) = send_json(
|
||||||
&app,
|
&app,
|
||||||
"PUT",
|
"PUT",
|
||||||
@@ -1253,13 +1102,11 @@ mod tests {
|
|||||||
assert_eq!(panels.len(), 1);
|
assert_eq!(panels.len(), 1);
|
||||||
assert_eq!(panels[0]["chart_type"], "line");
|
assert_eq!(panels[0]["chart_type"], "line");
|
||||||
|
|
||||||
// Get by id reflects the saved panel.
|
|
||||||
let (status, body) =
|
let (status, body) =
|
||||||
get_json(&app, &format!("/api/settings/profiles/web/dashboards/{dashboard_id}")).await;
|
get_json(&app, &format!("/api/settings/profiles/web/dashboards/{dashboard_id}")).await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
assert_eq!(body["panels"].as_array().unwrap().len(), 1);
|
assert_eq!(body["panels"].as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
// Delete removes it.
|
|
||||||
let status = delete_request(&app, &format!("/api/settings/profiles/web/dashboards/{dashboard_id}")).await;
|
let status = delete_request(&app, &format!("/api/settings/profiles/web/dashboards/{dashboard_id}")).await;
|
||||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||||
let (status, body) = get_json(&app, "/api/settings/profiles/web/dashboards").await;
|
let (status, body) = get_json(&app, "/api/settings/profiles/web/dashboards").await;
|
||||||
@@ -1308,8 +1155,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Simulate a restart: reload the profile store from disk directly,
|
// Simulate a restart by reloading the profile store fresh from disk, independent of the running app/engine.
|
||||||
// independent of the running app/engine.
|
|
||||||
let settings_path = dir.path().join("settings.json");
|
let settings_path = dir.path().join("settings.json");
|
||||||
let profiles_dir = {
|
let profiles_dir = {
|
||||||
let settings = settings_handle.lock().unwrap();
|
let settings = settings_handle.lock().unwrap();
|
||||||
|
|||||||
+6
-20
@@ -3,19 +3,13 @@ use crate::timestamp::Timestamp;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
/// A single parsed metric primitive extracted from a regex capture group.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RegexToMetricPrimitiveMatcher {
|
pub struct RegexToMetricPrimitiveMatcher {
|
||||||
pub param_name: String,
|
pub param_name: String,
|
||||||
pub capture_group: String,
|
pub capture_group: String,
|
||||||
pub kind: MetricPrimitiveKind,
|
pub kind: MetricPrimitiveKind,
|
||||||
/// Ticket 022: whether this param contributes to the bucket's `labels`
|
/// Whether this param is a bucket label or its captured value (at most one Value-role param per metric); `#[serde(default)]` so profile files saved before this field existed still load as `Label`.
|
||||||
/// (the default) or is the bucket's per-event captured `value` (at most
|
|
||||||
/// one `Value`-role param per metric — `declare_metric` rejects more
|
|
||||||
/// than one). `#[serde(default)]` so profile files saved before this
|
|
||||||
/// field existed still load, with every param defaulting to `Label` —
|
|
||||||
/// i.e. unchanged behavior (the fixed occurrence-count-of-1 value).
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub role: ParamRole,
|
pub role: ParamRole,
|
||||||
}
|
}
|
||||||
@@ -29,10 +23,7 @@ pub enum ParamRole {
|
|||||||
Value,
|
Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `rename_all = "lowercase"` matches every other `kind`-style enum exposed
|
/// `rename_all = "lowercase"` matches other `kind`-style enums, so JSON bodies use "string"/"number"/"timestamp".
|
||||||
/// over the API/settings JSON (`ColorScheme`, `CssEntryKind`,
|
|
||||||
/// `ProfileMetricPrimitiveKind`), so `POST /api/params`/`POST /api/metrics`
|
|
||||||
/// bodies use `"string"`/`"number"`/`"timestamp"`.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -40,12 +31,7 @@ pub enum MetricPrimitiveKind {
|
|||||||
String,
|
String,
|
||||||
Number,
|
Number,
|
||||||
Timestamp,
|
Timestamp,
|
||||||
/// Ticket 023: milliseconds. On a metric's `Value`-role param, this
|
/// On a Value-role param this is derived as elapsed time since the previous event (not parsed from text); otherwise it's a literal i64 millisecond count.
|
||||||
/// doesn't parse the captured text at all — the value is instead derived
|
|
||||||
/// as the elapsed time since the previous counter event with the same
|
|
||||||
/// label set (see `crate::engine::apply_metric_to_log`). On any other
|
|
||||||
/// param (a `Label`-role metric param, or a standalone `ParamDeclaration`),
|
|
||||||
/// it parses the captured text as a literal `i64` millisecond count.
|
|
||||||
Duration,
|
Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,16 +51,16 @@ pub enum DataValue {
|
|||||||
MetricDeclaration {
|
MetricDeclaration {
|
||||||
message_match_regex: String,
|
message_match_regex: String,
|
||||||
params: Vec<RegexToMetricPrimitiveMatcher>,
|
params: Vec<RegexToMetricPrimitiveMatcher>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::matched_labels` (ticket 025).
|
/// See `crate::engine::DeclareMetricRequest::matched_labels`.
|
||||||
matched_labels: Option<BTreeMap<String, String>>,
|
matched_labels: Option<BTreeMap<String, String>>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::unmatched_labels` (ticket 025).
|
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
|
||||||
unmatched_labels: Option<BTreeMap<String, String>>,
|
unmatched_labels: Option<BTreeMap<String, String>>,
|
||||||
},
|
},
|
||||||
/// Paired with `IndexKey::Bucket`.
|
/// Paired with `IndexKey::Bucket`.
|
||||||
BucketValue {
|
BucketValue {
|
||||||
values: Vec<(u64, Timestamp, MetricPrimitive)>,
|
values: Vec<(u64, Timestamp, MetricPrimitive)>,
|
||||||
},
|
},
|
||||||
/// Paired with `IndexKey::LastMetricEvent` (ticket 023).
|
/// Paired with `IndexKey::LastMetricEvent`.
|
||||||
LastEventTimestamp {
|
LastEventTimestamp {
|
||||||
timestamp: Timestamp,
|
timestamp: Timestamp,
|
||||||
},
|
},
|
||||||
|
|||||||
+21
-96
@@ -11,12 +11,7 @@ pub struct LogEntryDto {
|
|||||||
pub sequence: u64,
|
pub sequence: u64,
|
||||||
pub timestamp_ms: i64,
|
pub timestamp_ms: i64,
|
||||||
pub text: String,
|
pub text: String,
|
||||||
/// Derived param values (ticket 001's `ParamDeclaration`s) for this line,
|
/// Only params whose regex matched (and parsed as the declared kind) are present — omitted rather than sent as null, so the frontend can render a blank for anything missing.
|
||||||
/// keyed by param name. Only params whose regex actually matched this
|
|
||||||
/// line's text (and whose captured group parsed as the declared `kind`)
|
|
||||||
/// are present — a non-matching param is omitted rather than sent as
|
|
||||||
/// null, so the frontend can render a blank/dash for anything missing
|
|
||||||
/// from the map.
|
|
||||||
pub params: BTreeMap<String, MetricPrimitive>,
|
pub params: BTreeMap<String, MetricPrimitive>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,9 +28,9 @@ pub struct MetricDeclarationDto {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub message_match_regex: String,
|
pub message_match_regex: String,
|
||||||
pub params: Vec<RegexToMetricPrimitiveMatcher>,
|
pub params: Vec<RegexToMetricPrimitiveMatcher>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::matched_labels` (ticket 025).
|
/// See `crate::engine::DeclareMetricRequest::matched_labels`.
|
||||||
pub matched_labels: Option<BTreeMap<String, String>>,
|
pub matched_labels: Option<BTreeMap<String, String>>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::unmatched_labels` (ticket 025).
|
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
|
||||||
pub unmatched_labels: Option<BTreeMap<String, String>>,
|
pub unmatched_labels: Option<BTreeMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +55,7 @@ pub struct LogsParams {
|
|||||||
pub start_ms: Option<i64>,
|
pub start_ms: Option<i64>,
|
||||||
pub end_ms: Option<i64>,
|
pub end_ms: Option<i64>,
|
||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
/// Raw FilterBar query string (ticket 007), e.g. `regex("error") and
|
/// Raw FilterBar query string, e.g. `regex("error") and sequence<5`.
|
||||||
/// sequence<5`. Parsed with `filter_parser::parse` and evaluated in the
|
|
||||||
/// engine; a malformed string surfaces as `EngineResponse::Error`.
|
|
||||||
pub filter: Option<String>,
|
pub filter: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,14 +64,11 @@ pub struct TimelineParams {
|
|||||||
pub start_ms: Option<i64>,
|
pub start_ms: Option<i64>,
|
||||||
pub end_ms: Option<i64>,
|
pub end_ms: Option<i64>,
|
||||||
pub bucket_ms: Option<i64>,
|
pub bucket_ms: Option<i64>,
|
||||||
/// Same FilterBar query string as `LogsParams::filter`: only logs
|
/// Same FilterBar query string as `LogsParams::filter`; only matching logs are counted into timeline buckets.
|
||||||
/// matching it are counted into timeline buckets.
|
|
||||||
pub filter: Option<String>,
|
pub filter: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One entry from a metric's derived bucket timeseries: the label combination
|
/// One (sequence, timestamp, value) occurrence within a metric's bucket for a given label combination.
|
||||||
/// that bucket represents, plus one `(sequence, timestamp, value)` occurrence
|
|
||||||
/// within it.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct BucketValueDto {
|
pub struct BucketValueDto {
|
||||||
pub labels: BTreeMap<String, MetricPrimitive>,
|
pub labels: BTreeMap<String, MetricPrimitive>,
|
||||||
@@ -87,13 +77,7 @@ pub struct BucketValueDto {
|
|||||||
pub value: MetricPrimitive,
|
pub value: MetricPrimitive,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Query params for `GET /api/metrics/:name`. `labels` is a single
|
/// `labels` is a comma-separated `key=value` list (not repeated params) since axum's `Query` extractor needs a fixed set of field names.
|
||||||
/// comma-separated `key=value` list (e.g. `?labels=path=/a,duration=5`)
|
|
||||||
/// rather than repeated `label.foo=bar` params, since axum's `Query`
|
|
||||||
/// extractor needs a fixed/known set of field names — an arbitrary label set
|
|
||||||
/// doesn't fit that without a custom deserializer. Every listed label must
|
|
||||||
/// match (compared against each label value's `Display` form, see
|
|
||||||
/// `MetricPrimitive`); unlisted labels on a bucket are ignored.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct BucketValuesParams {
|
pub struct BucketValuesParams {
|
||||||
pub labels: Option<String>,
|
pub labels: Option<String>,
|
||||||
@@ -103,24 +87,13 @@ pub struct BucketValuesParams {
|
|||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One chart-ready time series returned by `GET /api/query` (ticket 013's PromQL
|
/// `labels` uses plain `String` (already Display-formatted) since charts just need something to show; `value` is always `f64` for plottability.
|
||||||
/// endpoint): a label combination plus its `(timestamp_ms, value)` points, sorted by
|
|
||||||
/// timestamp. `labels` uses plain `String` values (not `MetricPrimitive`) since every
|
|
||||||
/// value has already been through `MetricPrimitive`'s `Display` form — chart code
|
|
||||||
/// doesn't need the typed value, just something to show in a legend/tooltip.
|
|
||||||
///
|
|
||||||
/// `value` is always `f64` (see `crate::engine::coerce_to_f64` for how a bucket's
|
|
||||||
/// `MetricPrimitive` is coerced into one), since a chart needs a plottable number
|
|
||||||
/// regardless of the underlying metric primitive's kind.
|
|
||||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
pub struct PromqlSeriesDto {
|
pub struct PromqlSeriesDto {
|
||||||
pub labels: BTreeMap<String, String>,
|
pub labels: BTreeMap<String, String>,
|
||||||
pub points: Vec<(i64, f64)>,
|
pub points: Vec<(i64, f64)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Query params for `GET /api/query`. `promql` is the query string (see
|
|
||||||
/// `crate::promql`); `start_ms`/`end_ms` bound which points are included, mirroring
|
|
||||||
/// `/api/logs`/`/api/metrics/:name`.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct PromqlParams {
|
pub struct PromqlParams {
|
||||||
pub promql: String,
|
pub promql: String,
|
||||||
@@ -128,17 +101,13 @@ pub struct PromqlParams {
|
|||||||
pub end_ms: Option<i64>,
|
pub end_ms: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JSON error body returned by API handlers on a 4xx/5xx response, e.g.
|
/// JSON error body returned by API handlers on a 4xx/5xx response.
|
||||||
/// `{ "error": "invalid regex: ..." }`.
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ErrorDto {
|
pub struct ErrorDto {
|
||||||
pub error: String,
|
pub error: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body for `POST /api/settings/css`: raw CSS content to import and
|
/// `is_theme` marks this as a selectable colour-scheme theme rather than arbitrary CSS; defaults to false.
|
||||||
/// store alongside the settings file. `is_theme` (ticket 033) marks this as
|
|
||||||
/// a selectable colour-scheme theme rather than arbitrary CSS; defaults to
|
|
||||||
/// `false` (arbitrary CSS) when omitted.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ImportCssRequest {
|
pub struct ImportCssRequest {
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
@@ -147,9 +116,7 @@ pub struct ImportCssRequest {
|
|||||||
pub is_theme: bool,
|
pub is_theme: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body for `POST /api/settings/css/link`: a reference to an
|
/// A reference to an external CSS path/URL that ottyl doesn't copy or own.
|
||||||
/// external CSS path/URL that ottyl doesn't copy or own. `is_theme` (ticket
|
|
||||||
/// 033) marks this as a selectable theme rather than arbitrary CSS.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct LinkCssRequest {
|
pub struct LinkCssRequest {
|
||||||
pub path_or_url: String,
|
pub path_or_url: String,
|
||||||
@@ -157,14 +124,7 @@ pub struct LinkCssRequest {
|
|||||||
pub is_theme: bool,
|
pub is_theme: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response shape for a single CSS entry, used both by the list endpoint and
|
/// `id` is the real id for `Imported` entries or a stable id derived from the path/URL for `Linked` ones, so the frontend can key both kinds uniformly (only `Imported` entries are deletable/content-fetchable).
|
||||||
/// by mutation endpoints that return the affected entry. `id` is the entry's
|
|
||||||
/// real id for `Imported` entries, or a stable id derived from the
|
|
||||||
/// linked path/URL for `Linked` entries (see `settings::css_entry_id`) —
|
|
||||||
/// this lets the frontend key/reference both kinds of entries uniformly,
|
|
||||||
/// even though only `Imported` entries are deletable/content-fetchable.
|
|
||||||
/// `is_theme` (ticket 033) distinguishes a theme entry (selectable from the
|
|
||||||
/// theme picker) from arbitrary imported/linked CSS.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct CssEntryDto {
|
pub struct CssEntryDto {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -202,19 +162,14 @@ impl From<&CssEntry> for CssEntryDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response shape for `GET /api/settings` and `PATCH /api/settings` (ticket
|
/// `css_entries` is omitted here since it has its own `GET /api/settings/css` endpoint.
|
||||||
/// 011): the subset of `Settings` the NavMenu UI needs to render a colour
|
|
||||||
/// scheme picker and know which profile is active. `css_entries` is
|
|
||||||
/// deliberately omitted here since it already has its own dedicated
|
|
||||||
/// `GET /api/settings/css` endpoint (ticket 010).
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct SettingsDto {
|
pub struct SettingsDto {
|
||||||
pub color_scheme: ColorScheme,
|
pub color_scheme: ColorScheme,
|
||||||
pub active_profile: Option<String>,
|
pub active_profile: Option<String>,
|
||||||
/// `None` means unlimited (ticket 026).
|
/// `None` means unlimited.
|
||||||
pub max_logs: Option<usize>,
|
pub max_logs: Option<usize>,
|
||||||
/// Id of the selected custom theme entry, if any (ticket 033). `None`
|
/// Id of the selected custom theme entry, if any. `None` means `color_scheme` alone determines the active palette.
|
||||||
/// means `color_scheme` alone determines the active palette.
|
|
||||||
pub custom_theme_id: Option<String>,
|
pub custom_theme_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,11 +184,7 @@ impl From<&Settings> for SettingsDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body for `PATCH /api/settings`. `color_scheme`: `None` leaves the
|
/// `max_logs`/`custom_theme_id` distinguish "omitted" (leave unchanged) from "present" (null clears, value sets), via `deserialize_double_option`.
|
||||||
/// current value untouched. `max_logs`/`custom_theme_id`: each distinguishes
|
|
||||||
/// "field omitted" (leave unchanged, outer `None`) from "field present"
|
|
||||||
/// (inner `Option` applied verbatim — `null` clears it, a value sets it), via
|
|
||||||
/// `deserialize_double_option`.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateSettingsRequest {
|
pub struct UpdateSettingsRequest {
|
||||||
pub color_scheme: Option<ColorScheme>,
|
pub color_scheme: Option<ColorScheme>,
|
||||||
@@ -243,10 +194,7 @@ pub struct UpdateSettingsRequest {
|
|||||||
pub custom_theme_id: Option<Option<String>>,
|
pub custom_theme_id: Option<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Distinguishes a missing JSON field (outer `None`, via `#[serde(default)]`
|
/// Distinguishes a missing field (outer `None`) from present `null` (`Some(None)`) or present value (`Some(Some(value))`), which plain `Option<Option<T>>` can't do alone.
|
||||||
/// short-circuiting before this runs) from a present `null` (`Some(None)`)
|
|
||||||
/// or present value (`Some(Some(value))`) — plain `Option<Option<T>>` can't
|
|
||||||
/// tell "absent" and "explicit null" apart on its own.
|
|
||||||
fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
|
fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
|
||||||
where
|
where
|
||||||
D: serde::Deserializer<'de>,
|
D: serde::Deserializer<'de>,
|
||||||
@@ -255,10 +203,6 @@ where
|
|||||||
Option::<T>::deserialize(deserializer).map(Some)
|
Option::<T>::deserialize(deserializer).map(Some)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body for `POST /api/settings/alerts` (ticket 017): creates a new
|
|
||||||
/// alert rule (see `crate::alerts::Settings::create_alert_rule`). `AlertRule`
|
|
||||||
/// itself is returned directly as the response body (it already derives
|
|
||||||
/// `Serialize`), so there's no separate response DTO here.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateAlertRuleRequest {
|
pub struct CreateAlertRuleRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -267,8 +211,6 @@ pub struct CreateAlertRuleRequest {
|
|||||||
pub sink: AlertSink,
|
pub sink: AlertSink,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body for `PUT /api/settings/alerts/:id`: replaces every field
|
|
||||||
/// except `id` (taken from the URL, see `Settings::update_alert_rule`).
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateAlertRuleRequest {
|
pub struct UpdateAlertRuleRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -277,19 +219,13 @@ pub struct UpdateAlertRuleRequest {
|
|||||||
pub sink: AlertSink,
|
pub sink: AlertSink,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body for `POST /api/settings/alerts/test` (ticket 020): delivers
|
/// `server` defaults to `https://ntfy.sh` when absent.
|
||||||
/// a test notification to an ntfy topic/server without requiring a saved
|
|
||||||
/// rule. `server` defaults to `https://ntfy.sh` when absent, same as
|
|
||||||
/// `AlertSink::Ntfy`.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct TestAlertNotificationRequest {
|
pub struct TestAlertNotificationRequest {
|
||||||
pub topic: String,
|
pub topic: String,
|
||||||
pub server: Option<String>,
|
pub server: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Summary of one profile for `GET /api/settings/profiles` (ticket 011):
|
|
||||||
/// just the name and declaration/dashboard counts the Presets panel needs to
|
|
||||||
/// render a list, rather than the full param/metric declaration bodies.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct ProfileSummaryDto {
|
pub struct ProfileSummaryDto {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -309,15 +245,12 @@ impl From<&Profile> for ProfileSummaryDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body for `POST /api/settings/profiles`: creates a new, empty
|
|
||||||
/// profile with this name (see `ProfileStore::create`).
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateProfileRequest {
|
pub struct CreateProfileRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One panel within a dashboard (ticket 014): a PromQL query plus how to
|
/// Mirrors `profile::Panel`.
|
||||||
/// chart its result. Mirrors `profile::Panel`.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PanelDto {
|
pub struct PanelDto {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -348,9 +281,7 @@ impl From<&PanelDto> for Panel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A saved dashboard (ticket 014), as returned by
|
/// Mirrors `profile::Dashboard`.
|
||||||
/// `GET /api/settings/profiles/:name/dashboards` and friends. Mirrors
|
|
||||||
/// `profile::Dashboard`.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DashboardDto {
|
pub struct DashboardDto {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -378,18 +309,12 @@ impl From<&DashboardDto> for Dashboard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body for `POST /api/settings/profiles/:name/dashboards`: creates
|
|
||||||
/// a new, empty dashboard with this name (see `ProfileStore::create_dashboard`).
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateDashboardRequest {
|
pub struct CreateDashboardRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body for
|
/// The dashboard `id` in the URL is authoritative; any `id` in the body is ignored.
|
||||||
/// `PUT /api/settings/profiles/:name/dashboards/:dashboard_id`: replaces the
|
|
||||||
/// dashboard's `name`/`panels` (see `ProfileStore::save_dashboard`). The
|
|
||||||
/// dashboard `id` in the URL is authoritative — any `id` in the body is
|
|
||||||
/// ignored.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct SaveDashboardRequest {
|
pub struct SaveDashboardRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|||||||
+43
-290
@@ -23,9 +23,7 @@ pub struct IngestedLine {
|
|||||||
pub text: String,
|
pub text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Also the request body for `POST /api/params` (ticket 004): `Deserialize` is
|
/// Also the request body for `POST /api/params` — `Deserialize` is derived directly here rather than adding a separate DTO, since the shape already matches.
|
||||||
/// derived directly on this engine type rather than adding a separate DTO,
|
|
||||||
/// since its shape is already exactly what the API needs.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct DeclareParamRequest {
|
pub struct DeclareParamRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -34,43 +32,16 @@ pub struct DeclareParamRequest {
|
|||||||
pub kind: MetricPrimitiveKind,
|
pub kind: MetricPrimitiveKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v1 mechanism: a metric declaration's params are derived purely from inline named
|
/// Params are derived purely from inline named capture groups on this metric's own regex — referencing an already-declared `ParamDeclaration` by name isn't supported yet. Also the request body for `POST /api/metrics` (hence `Deserialize`).
|
||||||
/// capture groups on the metric's own `message_match_regex` (each
|
|
||||||
/// `RegexToMetricPrimitiveMatcher::capture_group` must name a capture group on that
|
|
||||||
/// regex). Referencing an already-declared `ParamDeclaration` (001) by name is not
|
|
||||||
/// supported yet — that's left for a future ticket.
|
|
||||||
///
|
|
||||||
/// Also the request body for `POST /api/metrics` (ticket 004) — see
|
|
||||||
/// `DeclareParamRequest`'s doc comment for why `Deserialize` lives here rather
|
|
||||||
/// than on a separate DTO.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct DeclareMetricRequest {
|
pub struct DeclareMetricRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub message_match_regex: String,
|
pub message_match_regex: String,
|
||||||
pub params: Vec<RegexToMetricPrimitiveMatcher>,
|
pub params: Vec<RegexToMetricPrimitiveMatcher>,
|
||||||
/// Ticket 025: fixed labels added on top of whatever `params` captured
|
/// Fixed labels layered on top of what `params` captured, so e.g. a case-insensitive match doesn't fragment into "ERROR"/"Error" series; `#[serde(default)]` so existing declarations are unaffected.
|
||||||
/// when the line *does* match `message_match_regex`. Together with
|
|
||||||
/// `unmatched_labels` below, this is what lets a single metric+query
|
|
||||||
/// produce a "matched X" vs "didn't match X" pair of series (e.g. the
|
|
||||||
/// seeded default dashboard's error/non-error split) with clean,
|
|
||||||
/// consistent label values, rather than whatever case/substring the
|
|
||||||
/// regex happened to capture (a case-insensitive `error` match could
|
|
||||||
/// capture `"ERROR"`, `"Error"`, etc., fragmenting what should be one
|
|
||||||
/// series into several). `#[serde(default)]` so existing declarations
|
|
||||||
/// without this field are unaffected.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub matched_labels: Option<BTreeMap<String, String>>,
|
pub matched_labels: Option<BTreeMap<String, String>>,
|
||||||
/// When a line does *not* match `message_match_regex`, and this is
|
/// When a line doesn't match, registers one occurrence labeled with these fixed values instead of being invisible to this metric — plain regex alternation can't express "didn't match" without lookaround; `#[serde(default)]` so existing declarations keep matching-only behavior.
|
||||||
/// `Some`, the metric still registers one occurrence — labeled with
|
|
||||||
/// these fixed values rather than anything captured (there's nothing to
|
|
||||||
/// capture on a non-match) — instead of the line being invisible to this
|
|
||||||
/// metric. This is the other half of the "matched X vs didn't match X"
|
|
||||||
/// pair described above: plain regex alternation can't express the
|
|
||||||
/// "didn't match" side without lookaround (unsupported by the `regex`
|
|
||||||
/// crate), since an alternation branch that's a catch-all always wins at
|
|
||||||
/// the search's leftmost position, so it can never tell "contains X
|
|
||||||
/// later in the line" apart from "X is absent entirely". `#[serde(default)]`
|
|
||||||
/// so existing declarations without this field keep matching only.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub unmatched_labels: Option<BTreeMap<String, String>>,
|
pub unmatched_labels: Option<BTreeMap<String, String>>,
|
||||||
}
|
}
|
||||||
@@ -82,11 +53,7 @@ pub enum EngineQuery {
|
|||||||
Stats,
|
Stats,
|
||||||
ParamDeclarations,
|
ParamDeclarations,
|
||||||
MetricDeclarations,
|
MetricDeclarations,
|
||||||
/// Bucket values for one metric (ticket 004's `GET /api/metrics/:name`).
|
/// `labels_filter` compares against each label's `Display` form (not a typed value), since the filter arrives as plain strings off the query string.
|
||||||
/// `labels_filter`, when present, keeps only buckets where every listed
|
|
||||||
/// label matches — compared against each label value's `Display` form
|
|
||||||
/// (see `MetricPrimitive`) rather than a typed value, since the filter
|
|
||||||
/// arrives as plain strings off the query string.
|
|
||||||
BucketValues {
|
BucketValues {
|
||||||
metric_name: String,
|
metric_name: String,
|
||||||
labels_filter: Option<BTreeMap<String, String>>,
|
labels_filter: Option<BTreeMap<String, String>>,
|
||||||
@@ -95,12 +62,7 @@ pub enum EngineQuery {
|
|||||||
since_sequence: Option<u64>,
|
since_sequence: Option<u64>,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
},
|
},
|
||||||
/// PromQL query (ticket 013's `GET /api/query`). `query` is parsed here (rather
|
/// `query` is parsed inside the engine (not the API handler) so it stays in the single-owner loop with no copying; a malformed query is only discovered here, hence `EngineResponse::Promql` carries a `Result`.
|
||||||
/// than by the API handler) so parsing and evaluation both happen inside the
|
|
||||||
/// single-owner engine loop, matching every other `EngineQuery` variant's
|
|
||||||
/// pattern of evaluating in place against `index` with no copying. A malformed
|
|
||||||
/// query is therefore only discovered once this reaches `handle_query`, hence
|
|
||||||
/// `EngineResponse::Promql` carrying a `Result` rather than a bare `Vec`.
|
|
||||||
Promql { query: String, start_ms: Option<i64>, end_ms: Option<i64> },
|
Promql { query: String, start_ms: Option<i64>, end_ms: Option<i64> },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,10 +85,7 @@ pub enum EngineMessage {
|
|||||||
name: String,
|
name: String,
|
||||||
respond_to: oneshot::Sender<bool>,
|
respond_to: oneshot::Sender<bool>,
|
||||||
},
|
},
|
||||||
/// Sets the cap on ingested logs kept in memory (ticket 026). `None`
|
/// `None` means unlimited; sent on startup and whenever the setting changes so the cap applies without a restart.
|
||||||
/// means unlimited. Sent on startup (from `Settings::max_logs`) and
|
|
||||||
/// whenever the setting changes at runtime, so the cap takes effect on
|
|
||||||
/// the next ingest without a restart.
|
|
||||||
SetMaxLogs(Option<usize>),
|
SetMaxLogs(Option<usize>),
|
||||||
Query {
|
Query {
|
||||||
query: EngineQuery,
|
query: EngineQuery,
|
||||||
@@ -142,30 +101,20 @@ pub enum EngineResponse {
|
|||||||
ParamDeclarations(Vec<ParamDeclarationDto>),
|
ParamDeclarations(Vec<ParamDeclarationDto>),
|
||||||
MetricDeclarations(Vec<MetricDeclarationDto>),
|
MetricDeclarations(Vec<MetricDeclarationDto>),
|
||||||
BucketValues(Vec<BucketValueDto>),
|
BucketValues(Vec<BucketValueDto>),
|
||||||
/// `Err` holds a human-readable message for a malformed query (see
|
/// `Err` holds a human-readable message for a malformed query, which the API layer maps to a 400 response.
|
||||||
/// `crate::promql::parse_query`), which the API layer maps to a 400 response.
|
|
||||||
Promql(Result<Vec<PromqlSeriesDto>, String>),
|
Promql(Result<Vec<PromqlSeriesDto>, String>),
|
||||||
/// A query-level error, e.g. a malformed `filter` string on
|
/// Kept as a variant (rather than making `handle_query` return a `Result`) so every query path still funnels through the same `oneshot::Sender<EngineResponse>`.
|
||||||
/// `EngineQuery::Logs`/`Timeline` (ticket 007). Kept as a variant on the
|
|
||||||
/// existing response enum, rather than making `handle_query` return a
|
|
||||||
/// `Result`, so every query path still funnels through the same
|
|
||||||
/// `oneshot::Sender<EngineResponse>` — callers (`api.rs`) just add one
|
|
||||||
/// more match arm to turn it into a 4xx.
|
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type EngineHandle = mpsc::Sender<EngineMessage>;
|
pub type EngineHandle = mpsc::Sender<EngineMessage>;
|
||||||
|
|
||||||
/// Sole owner of the primary index. Runs single-threaded so the map never needs a
|
/// Sole owner of the primary index; runs single-threaded so the map never needs a lock, and builds DTOs itself so callers never clone the index.
|
||||||
/// lock, and does its own response serialization/DTO construction so callers never
|
|
||||||
/// have to clone the index to answer a query.
|
|
||||||
pub async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle) {
|
pub async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle) {
|
||||||
let mut index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
let mut index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
||||||
let mut max_logs: Option<usize> = None;
|
let mut max_logs: Option<usize> = None;
|
||||||
let mut log_count: usize = 0;
|
let mut log_count: usize = 0;
|
||||||
// Ticket 018: per-rule rolling windows / seen-value sets, keyed by rule
|
// Per-rule rolling windows/seen-value sets, keyed by rule id; lives only as long as this loop (no restart persistence).
|
||||||
// id. Lives only as long as this loop (no restart persistence — see
|
|
||||||
// `alerts::AlertCondition::NewValue`'s doc comment).
|
|
||||||
let mut alert_states: HashMap<String, AlertRuntimeState> = HashMap::new();
|
let mut alert_states: HashMap<String, AlertRuntimeState> = HashMap::new();
|
||||||
|
|
||||||
while let Some(message) = rx.recv().await {
|
while let Some(message) = rx.recv().await {
|
||||||
@@ -180,9 +129,7 @@ pub async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle
|
|||||||
apply_all_metrics(&mut index, sequence, timestamp, &text);
|
apply_all_metrics(&mut index, sequence, timestamp, &text);
|
||||||
evict_excess_logs(&mut index, &mut log_count, max_logs);
|
evict_excess_logs(&mut index, &mut log_count, max_logs);
|
||||||
|
|
||||||
// Ticket 018: evaluate alert rules against just this line
|
// Evaluate alert rules against just this line, then hand any fired rule off to delivery via tokio::spawn so it stays off the hot path.
|
||||||
// (not the whole log range), then hand any fired rule off to
|
|
||||||
// ticket 019's delivery, off the hot path via `tokio::spawn`.
|
|
||||||
let rules = settings.lock().unwrap().alert_rules.clone();
|
let rules = settings.lock().unwrap().alert_rules.clone();
|
||||||
let fired = alerts::evaluate_alerts(&rules, &mut alert_states, sequence, timestamp, &text);
|
let fired = alerts::evaluate_alerts(&rules, &mut alert_states, sequence, timestamp, &text);
|
||||||
for (rule, message) in fired {
|
for (rule, message) in fired {
|
||||||
@@ -217,9 +164,7 @@ pub async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates the regex and capture group before storing, so a bad declaration is
|
/// Validates the regex and capture group before storing, so a bad declaration is rejected up front rather than discovered later.
|
||||||
/// rejected up front rather than silently stored (or discovered later at derivation
|
|
||||||
/// time).
|
|
||||||
fn declare_param(
|
fn declare_param(
|
||||||
index: &mut BTreeMap<IndexKey, DataValue>,
|
index: &mut BTreeMap<IndexKey, DataValue>,
|
||||||
request: DeclareParamRequest,
|
request: DeclareParamRequest,
|
||||||
@@ -244,9 +189,7 @@ fn declare_param(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates the regex and every param matcher's capture-group reference before
|
/// Validates every param matcher's capture-group reference (not just the first bad one) so a rejection reports every problem at once.
|
||||||
/// storing. All matchers are checked (not just the first bad one) so a rejection can
|
|
||||||
/// report every problem at once.
|
|
||||||
fn declare_metric(
|
fn declare_metric(
|
||||||
index: &mut BTreeMap<IndexKey, DataValue>,
|
index: &mut BTreeMap<IndexKey, DataValue>,
|
||||||
request: DeclareMetricRequest,
|
request: DeclareMetricRequest,
|
||||||
@@ -298,9 +241,7 @@ fn declare_metric(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses a single captured substring into the `MetricPrimitive` its param declares.
|
/// Returns `None` on a parse failure, which the caller treats as "this match doesn't apply".
|
||||||
/// Returns `None` on a parse failure (e.g. a `Number` param whose capture isn't
|
|
||||||
/// numeric), which the caller treats as "this match doesn't apply".
|
|
||||||
fn parse_metric_primitive(raw: &str, kind: MetricPrimitiveKind) -> Option<MetricPrimitive> {
|
fn parse_metric_primitive(raw: &str, kind: MetricPrimitiveKind) -> Option<MetricPrimitive> {
|
||||||
match kind {
|
match kind {
|
||||||
MetricPrimitiveKind::String => Some(MetricPrimitive::String(raw.to_string())),
|
MetricPrimitiveKind::String => Some(MetricPrimitive::String(raw.to_string())),
|
||||||
@@ -315,52 +256,12 @@ fn parse_metric_primitive(raw: &str, kind: MetricPrimitiveKind) -> Option<Metric
|
|||||||
.ok()
|
.ok()
|
||||||
.map(MetricPrimitive::Timestamp)
|
.map(MetricPrimitive::Timestamp)
|
||||||
}
|
}
|
||||||
// A `Value`-role param never reaches this branch (see
|
// A Value-role param never reaches this branch (see apply_metric_to_log); reached only for a Label-role param or standalone ParamDeclaration, parsed as a literal millisecond count.
|
||||||
// `apply_metric_to_log`, which derives its value from elapsed time
|
|
||||||
// instead of parsing captured text). Reached only for a `Label`-role
|
|
||||||
// metric param or a standalone `ParamDeclaration` of kind `Duration`,
|
|
||||||
// where the captured text is parsed as a literal millisecond count.
|
|
||||||
MetricPrimitiveKind::Duration => raw.parse::<i64>().ok().map(MetricPrimitive::Duration),
|
MetricPrimitiveKind::Duration => raw.parse::<i64>().ok().map(MetricPrimitive::Duration),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs one metric's regex against a single log line and, on a match, appends a
|
/// At most one param may be `Value`-role (its primitive becomes the event's value, otherwise a fixed `1` marker is used); any param failing to match/parse skips the whole match rather than storing a partial one. A `Value`-role `Duration` param is elapsed time since the previous same-labeled event, so the first occurrence pushes no bucket (nothing to diff against) rather than a bogus zero. When `regex` doesn't match and `unmatched_labels` is `Some`, an occurrence is still registered with those fixed labels.
|
||||||
/// bucket value entry: this is the "counter increment" (ticket 022) — a match is
|
|
||||||
/// an occurrence at a point in time, optionally labeled.
|
|
||||||
///
|
|
||||||
/// `Bucket.labels` holds every `Label`-role param (the default role — see
|
|
||||||
/// [`ParamRole`]); at most one param may instead be `Value`-role (enforced at
|
|
||||||
/// declare time), and its parsed primitive becomes the pushed event's value.
|
|
||||||
/// With no `Value`-role param, the pushed value is a fixed presence marker
|
|
||||||
/// (`MetricPrimitive::Number(Number::Int(1))`), matching the original v1
|
|
||||||
/// behavior where a bucket's `values` was purely the timeseries of *when that
|
|
||||||
/// label combination occurred* — histogram()/gauge() derived queries
|
|
||||||
/// (`crate::promql::AggOp`) read this same `value`, so a `Value`-role param is
|
|
||||||
/// what makes those meaningful (bucketing/reading an occurrence-count-of-1
|
|
||||||
/// series degenerates to "everything in one bucket"/"always 1"). If a param's
|
|
||||||
/// capture group doesn't match, or its captured text fails to parse as its
|
|
||||||
/// declared `kind`, the whole match is skipped for this line rather than
|
|
||||||
/// storing a partially-labeled bucket.
|
|
||||||
///
|
|
||||||
/// A `Value`-role param of kind `Duration` (ticket 023) is a special case: its
|
|
||||||
/// value isn't parsed from captured text at all. It's derived as the elapsed
|
|
||||||
/// time (using this event's ingestion `timestamp`) since the *previous*
|
|
||||||
/// counter event with this exact label set, tracked independently via
|
|
||||||
/// `IndexKey::LastMetricEvent` (not `Bucket.values` itself, since — see
|
|
||||||
/// below — a `Bucket` entry isn't always pushed). On the first occurrence of
|
|
||||||
/// a given label set there is no previous event to diff against, so — per the
|
|
||||||
/// ticket's "omit rather than emit a bogus zero" requirement — no `Bucket`
|
|
||||||
/// entry is pushed for that occurrence at all, rather than a zero/placeholder
|
|
||||||
/// duration; the occurrence is still recorded in `LastMetricEvent` so the
|
|
||||||
/// *next* occurrence of that label set has something to diff against.
|
|
||||||
///
|
|
||||||
/// When `regex` does *not* match and `unmatched_labels` is `Some` (ticket
|
|
||||||
/// 025), a fixed occurrence is registered anyway, labeled with those literal
|
|
||||||
/// values — see `DeclareMetricRequest::unmatched_labels` for why this exists
|
|
||||||
/// instead of a plain regex alternation. When it *does* match, `matched_labels`
|
|
||||||
/// (if `Some`) is layered on top of whatever `params` captured, so the
|
|
||||||
/// "matched" side can use the same clean literal values as the "unmatched"
|
|
||||||
/// side — see `DeclareMetricRequest::matched_labels`.
|
|
||||||
fn apply_metric_to_log(
|
fn apply_metric_to_log(
|
||||||
index: &mut BTreeMap<IndexKey, DataValue>,
|
index: &mut BTreeMap<IndexKey, DataValue>,
|
||||||
metric_name: &str,
|
metric_name: &str,
|
||||||
@@ -413,12 +314,7 @@ fn apply_metric_to_log(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let value = if is_duration {
|
let value = if is_duration {
|
||||||
// Bookkeeping key is separate from the `Bucket` key below: a
|
// Tracked separately from Bucket: a Duration's first occurrence pushes no Bucket entry, so the last-event timestamp can't live in Bucket.values.
|
||||||
// `Duration` value's first occurrence for this label set pushes no
|
|
||||||
// `Bucket` entry (nothing to diff against), so the "last event"
|
|
||||||
// timestamp can't live inside `Bucket.values` — it has to be tracked
|
|
||||||
// independently so the *next* occurrence still has something to
|
|
||||||
// diff against.
|
|
||||||
let last_event_key =
|
let last_event_key =
|
||||||
IndexKey::LastMetricEvent { metric_name: metric_name.to_string(), labels: labels.clone() };
|
IndexKey::LastMetricEvent { metric_name: metric_name.to_string(), labels: labels.clone() };
|
||||||
let previous_timestamp = match index.get(&last_event_key) {
|
let previous_timestamp = match index.get(&last_event_key) {
|
||||||
@@ -456,9 +352,7 @@ fn push_bucket_value(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies a newly-declared metric to every already-ingested log, so a metric
|
/// Backfills buckets for a newly-declared metric against every already-ingested log, not just future ones.
|
||||||
/// declared after logs have already arrived still gets populated buckets for them
|
|
||||||
/// (backfill), rather than only reflecting logs ingested from that point on.
|
|
||||||
fn backfill_metric(
|
fn backfill_metric(
|
||||||
index: &mut BTreeMap<IndexKey, DataValue>,
|
index: &mut BTreeMap<IndexKey, DataValue>,
|
||||||
metric_name: &str,
|
metric_name: &str,
|
||||||
@@ -485,9 +379,6 @@ fn backfill_metric(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs every currently-declared metric's regex against one newly-ingested log line,
|
|
||||||
/// updating whichever buckets match. A line matching zero metrics leaves the bucket
|
|
||||||
/// range untouched.
|
|
||||||
fn apply_all_metrics(
|
fn apply_all_metrics(
|
||||||
index: &mut BTreeMap<IndexKey, DataValue>,
|
index: &mut BTreeMap<IndexKey, DataValue>,
|
||||||
sequence: u64,
|
sequence: u64,
|
||||||
@@ -529,11 +420,7 @@ fn apply_all_metrics(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bucket values for one metric, in insertion order, optionally filtered to a single
|
/// The metrics API layers its own textual label-filter and range filtering on top rather than using this function's `labels_filter` param (used directly by tests).
|
||||||
/// exact label combination. Used directly by tests (exact-match filtering) and, via
|
|
||||||
/// `EngineQuery::BucketValues` in `handle_query` below, by the metrics API (ticket
|
|
||||||
/// 004) — which layers its own textual label-filter and time/sequence-range
|
|
||||||
/// filtering on top rather than using this function's `labels_filter` parameter.
|
|
||||||
fn bucket_values<'a>(
|
fn bucket_values<'a>(
|
||||||
index: &'a BTreeMap<IndexKey, DataValue>,
|
index: &'a BTreeMap<IndexKey, DataValue>,
|
||||||
metric_name: &'a str,
|
metric_name: &'a str,
|
||||||
@@ -553,42 +440,7 @@ fn bucket_values<'a>(
|
|||||||
.flatten()
|
.flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A [`FilterExpr`] compiled against a *snapshot* of the current index, ready
|
/// A [`FilterExpr`] compiled against a snapshot of the index, so evaluating per log line touches the index only once: a `metric[param]` comparison is pre-resolved into a `HashSet` of matching sequences ([`CompiledFilter::MetricMatch`]) for O(1) per-line lookup instead of a regex re-run; a comparison with no `[param]` is rejected since v1 buckets have no single "the metric's value", only a per-label-combo occurrence marker.
|
||||||
/// to be evaluated per-log-line without touching the index again.
|
|
||||||
///
|
|
||||||
/// ## Design: how metric comparisons resolve against buckets
|
|
||||||
///
|
|
||||||
/// A FilterBar comparison like `my_metric[level]==5` can't be evaluated by
|
|
||||||
/// re-running `my_metric`'s regex against each log line at query time — that
|
|
||||||
/// would duplicate work the engine already did at ingest/backfill time
|
|
||||||
/// (ticket 003), and would require re-parsing the metric's declaration for
|
|
||||||
/// every query. Instead, since ticket 003 already derived, for every metric,
|
|
||||||
/// one `Bucket` per distinct label combination holding the `(sequence,
|
|
||||||
/// timestamp, _)` occurrences that produced it, resolving a metric
|
|
||||||
/// comparison is a **one-time scan of that metric's buckets per query**
|
|
||||||
/// (via [`bucket_values`]), rather than a per-log-line regex re-run:
|
|
||||||
///
|
|
||||||
/// 1. For `metric[param] <op> value`, scan every bucket for `metric` (this
|
|
||||||
/// reuses the same ticket-003 range-scan `bucket_values` already uses for
|
|
||||||
/// `GET /api/metrics/:name`), and keep the buckets whose `labels[param]`
|
|
||||||
/// satisfies `<op> value`.
|
|
||||||
/// 2. Every `(sequence, ..)` occurrence recorded in a kept bucket is a log
|
|
||||||
/// line the comparison matches. Those sequences are collected into a
|
|
||||||
/// `HashSet<u64>` once, up front — [`CompiledFilter::MetricMatch`].
|
|
||||||
/// 3. Evaluating the comparison for a given log line is then just an `O(1)`
|
|
||||||
/// `HashSet::contains(&sequence)` check, reusing the fact that a line's
|
|
||||||
/// bucket membership (which buckets a line matched) was already computed
|
|
||||||
/// when the line was ingested/backfilled, rather than recomputed here.
|
|
||||||
///
|
|
||||||
/// `sequence`/`timestamp` comparisons skip buckets entirely: they compare
|
|
||||||
/// straight against the log's own `IndexKey::IngestedLog` fields, so they're
|
|
||||||
/// compiled into their own lightweight variants ([`CompiledFilter::Sequence`]
|
|
||||||
/// / [`CompiledFilter::Timestamp`]) instead of [`CompiledFilter::MetricMatch`].
|
|
||||||
///
|
|
||||||
/// A metric comparison with no `[param]` isn't meaningful under this model —
|
|
||||||
/// v1's buckets have no single "the metric's value", only a per-label-combo
|
|
||||||
/// occurrence marker (see `apply_metric_to_log`'s doc comment) — so
|
|
||||||
/// `compile_filter` rejects it with a clear error instead of guessing.
|
|
||||||
enum CompiledFilter {
|
enum CompiledFilter {
|
||||||
Regex(Regex),
|
Regex(Regex),
|
||||||
Sequence { op: CompareOp, target: f64 },
|
Sequence { op: CompareOp, target: f64 },
|
||||||
@@ -598,11 +450,7 @@ enum CompiledFilter {
|
|||||||
Or(Box<CompiledFilter>, Box<CompiledFilter>),
|
Or(Box<CompiledFilter>, Box<CompiledFilter>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses and compiles a FilterBar query string against `index`, resolving
|
/// Resolves every regex/metric-comparison/datetime-literal up front so per-line evaluation ([`eval_compiled`]) never touches the index; `now` anchors relative literals so every comparison in one query resolves against the same instant.
|
||||||
/// every regex/metric-comparison/datetime-literal up front so evaluating it
|
|
||||||
/// per log line (see [`eval_compiled`]) never touches the index or re-parses
|
|
||||||
/// anything. `now` anchors relative datetime literals (e.g. `-5m`) so every
|
|
||||||
/// comparison in one query is resolved against the same instant.
|
|
||||||
fn compile_filter_query(
|
fn compile_filter_query(
|
||||||
index: &BTreeMap<IndexKey, DataValue>,
|
index: &BTreeMap<IndexKey, DataValue>,
|
||||||
filter: &str,
|
filter: &str,
|
||||||
@@ -637,12 +485,7 @@ fn compile_filter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `sequence <op> value` — compares straight against the log's own sequence
|
/// A `Raw` value is accepted defensively (parsed as a number) even though the grammar shouldn't produce one for `sequence`.
|
||||||
/// number. `value` is normally `FilterValue::Number` (the grammar parses a
|
|
||||||
/// bare integer as a number); a `Raw` value is accepted too as long as it
|
|
||||||
/// still parses as a number (defensive - the grammar shouldn't produce this
|
|
||||||
/// for `sequence`, but nothing stops a caller from writing `sequence<"5"`-ish
|
|
||||||
/// input that the value token still captures as text).
|
|
||||||
fn compile_sequence_comparison(op: CompareOp, value: &FilterValue) -> Result<CompiledFilter, String> {
|
fn compile_sequence_comparison(op: CompareOp, value: &FilterValue) -> Result<CompiledFilter, String> {
|
||||||
let target = match value {
|
let target = match value {
|
||||||
FilterValue::Number(n) => *n,
|
FilterValue::Number(n) => *n,
|
||||||
@@ -653,11 +496,7 @@ fn compile_sequence_comparison(op: CompareOp, value: &FilterValue) -> Result<Com
|
|||||||
Ok(CompiledFilter::Sequence { op, target })
|
Ok(CompiledFilter::Sequence { op, target })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `timestamp <op> value` — compares straight against the log's own
|
/// A `Number` is treated as raw milliseconds; a `Raw` token is resolved via `filter_datetime::parse_datetime` against `now`.
|
||||||
/// timestamp. A `FilterValue::Number` is treated as raw milliseconds (no
|
|
||||||
/// datetime parsing needed); a `FilterValue::Raw` token (e.g. `12:00`,
|
|
||||||
/// `-5m`, `2024-01-15`) is resolved via `filter_datetime::parse_datetime`
|
|
||||||
/// against `now`.
|
|
||||||
fn compile_timestamp_comparison(
|
fn compile_timestamp_comparison(
|
||||||
op: CompareOp,
|
op: CompareOp,
|
||||||
value: &FilterValue,
|
value: &FilterValue,
|
||||||
@@ -672,9 +511,7 @@ fn compile_timestamp_comparison(
|
|||||||
Ok(CompiledFilter::Timestamp { op, target_ms })
|
Ok(CompiledFilter::Timestamp { op, target_ms })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `metric[param] <op> value` for any metric name other than the reserved
|
/// `metric[param] <op> value` for any metric name other than `sequence`/`timestamp` — see [`CompiledFilter`]'s doc comment.
|
||||||
/// `sequence`/`timestamp` — see [`CompiledFilter`]'s doc comment for the
|
|
||||||
/// overall approach.
|
|
||||||
fn compile_metric_comparison(
|
fn compile_metric_comparison(
|
||||||
index: &BTreeMap<IndexKey, DataValue>,
|
index: &BTreeMap<IndexKey, DataValue>,
|
||||||
metric: &str,
|
metric: &str,
|
||||||
@@ -710,14 +547,7 @@ fn compile_metric_comparison(
|
|||||||
Ok(CompiledFilter::MetricMatch(matching))
|
Ok(CompiledFilter::MetricMatch(matching))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compares one bucket label's `MetricPrimitive` against a filter's
|
/// Mismatched kinds that can't be meaningfully compared (e.g. a string label against a bare number) simply never match, rather than erroring.
|
||||||
/// right-hand side. Numbers compare numerically (a `Raw` value is parsed as
|
|
||||||
/// a number); timestamps compare against a resolved datetime (a `Number`
|
|
||||||
/// value is treated as raw milliseconds, a `Raw` value goes through
|
|
||||||
/// `filter_datetime::parse_datetime`); strings compare lexicographically
|
|
||||||
/// against the raw token (or a number's textual form). Mismatched kinds that
|
|
||||||
/// can't be meaningfully compared (e.g. a string label against a bare
|
|
||||||
/// number) simply never match, rather than erroring.
|
|
||||||
fn compare_metric_primitive(
|
fn compare_metric_primitive(
|
||||||
primitive: &MetricPrimitive,
|
primitive: &MetricPrimitive,
|
||||||
op: CompareOp,
|
op: CompareOp,
|
||||||
@@ -754,8 +584,7 @@ fn compare_metric_primitive(
|
|||||||
Ok(ordering.is_some_and(|ordering| apply_compare_op(ordering, op)))
|
Ok(ordering.is_some_and(|ordering| apply_compare_op(ordering, op)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `pub(crate)` so `crate::alerts` can reuse it for `Threshold`/`Ratio`
|
/// `pub(crate)` so `crate::alerts` can reuse it for `Threshold`/`Ratio` comparisons instead of duplicating the match.
|
||||||
/// comparisons instead of duplicating the match.
|
|
||||||
pub(crate) fn apply_compare_op(ordering: Ordering, op: CompareOp) -> bool {
|
pub(crate) fn apply_compare_op(ordering: Ordering, op: CompareOp) -> bool {
|
||||||
match op {
|
match op {
|
||||||
CompareOp::Lt => ordering == Ordering::Less,
|
CompareOp::Lt => ordering == Ordering::Less,
|
||||||
@@ -767,10 +596,7 @@ pub(crate) fn apply_compare_op(ordering: Ordering, op: CompareOp) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluates a compiled filter against one log line. Cheap: every regex,
|
/// Cheap: everything was already resolved by [`compile_filter_query`], so this is just pattern matching and `HashSet` lookups — no index access.
|
||||||
/// datetime literal, and bucket scan was already resolved by
|
|
||||||
/// [`compile_filter_query`], so this is just pattern matching, numeric
|
|
||||||
/// comparisons, and `HashSet` lookups — no index access.
|
|
||||||
fn eval_compiled(compiled: &CompiledFilter, sequence: u64, timestamp: Timestamp, text: &str) -> bool {
|
fn eval_compiled(compiled: &CompiledFilter, sequence: u64, timestamp: Timestamp, text: &str) -> bool {
|
||||||
match compiled {
|
match compiled {
|
||||||
CompiledFilter::Regex(re) => re.is_match(text),
|
CompiledFilter::Regex(re) => re.is_match(text),
|
||||||
@@ -832,11 +658,7 @@ fn param_declarations_range(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compiles every currently-declared param's regex once, so `derive_log_params`
|
/// A param whose stored regex fails to compile (shouldn't happen — `declare_param` validates it) is skipped rather than panicking.
|
||||||
/// can be applied to many lines without recompiling each param's regex per
|
|
||||||
/// line. A param whose stored regex somehow fails to compile (shouldn't
|
|
||||||
/// happen — `declare_param` validates it before storing) is skipped rather
|
|
||||||
/// than panicking.
|
|
||||||
fn compiled_param_declarations(
|
fn compiled_param_declarations(
|
||||||
index: &BTreeMap<IndexKey, DataValue>,
|
index: &BTreeMap<IndexKey, DataValue>,
|
||||||
) -> Vec<(String, Regex, String, MetricPrimitiveKind)> {
|
) -> Vec<(String, Regex, String, MetricPrimitiveKind)> {
|
||||||
@@ -849,12 +671,7 @@ fn compiled_param_declarations(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs every declared param's regex against one log line's text, returning
|
/// A param is omitted (not included as null/empty) when its regex/capture group doesn't match or the captured text fails to parse — see `LogEntryDto::params`.
|
||||||
/// the derived `MetricPrimitive` values keyed by param name. A param is
|
|
||||||
/// omitted from the result (rather than included as null/empty) when its
|
|
||||||
/// regex doesn't match the line, its capture group doesn't match, or the
|
|
||||||
/// captured text fails to parse as the param's declared `kind` — see
|
|
||||||
/// `LogEntryDto::params`.
|
|
||||||
fn derive_log_params(
|
fn derive_log_params(
|
||||||
text: &str,
|
text: &str,
|
||||||
declarations: &[(String, Regex, String, MetricPrimitiveKind)],
|
declarations: &[(String, Regex, String, MetricPrimitiveKind)],
|
||||||
@@ -875,11 +692,7 @@ fn derive_log_params(
|
|||||||
params
|
params
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evicts the oldest (lowest-`sequence`) ingested logs until `log_count` is
|
/// Bucket values already derived from an evicted log are left in place — buckets are aggregate data, not raw logs, so they're not retroactively pruned.
|
||||||
/// back at or under `max_logs`. `sequence` numbering is not renumbered on
|
|
||||||
/// eviction, and any `Bucket` values already derived from an evicted log are
|
|
||||||
/// left in place — buckets are aggregate data, not raw logs, so they're not
|
|
||||||
/// retroactively pruned.
|
|
||||||
fn evict_excess_logs(
|
fn evict_excess_logs(
|
||||||
index: &mut BTreeMap<IndexKey, DataValue>,
|
index: &mut BTreeMap<IndexKey, DataValue>,
|
||||||
log_count: &mut usize,
|
log_count: &mut usize,
|
||||||
@@ -921,16 +734,7 @@ fn handle_query(index: &BTreeMap<IndexKey, DataValue>, query: EngineQuery) -> En
|
|||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let limit = params.limit.unwrap_or(500).min(10_000);
|
let limit = params.limit.unwrap_or(500).min(10_000);
|
||||||
// Compile each currently-declared param's regex once up front, then
|
// O(lines_returned * declared_params) per query — fine at v1 scale; if volume/params grow, move to incremental derivation at ingest time like apply_all_metrics does for buckets.
|
||||||
// apply the compiled set to every *returned* line below (ticket 015).
|
|
||||||
// Perf note: this is a straightforward per-returned-line,
|
|
||||||
// per-declared-param regex scan — O(lines_returned * declared_params)
|
|
||||||
// — rather than precomputing/caching derived params for the whole log
|
|
||||||
// range. That's fine at this codebase's current scale (v1), but if
|
|
||||||
// either the log volume or number of declared params grows a lot,
|
|
||||||
// this should move to incremental derivation at ingest time (mirroring
|
|
||||||
// how `apply_all_metrics` already does for metric buckets) instead of
|
|
||||||
// recomputing on every `/api/logs` query.
|
|
||||||
let compiled_params = compiled_param_declarations(index);
|
let compiled_params = compiled_param_declarations(index);
|
||||||
let entries: Vec<LogEntryDto> = logs_range(index)
|
let entries: Vec<LogEntryDto> = logs_range(index)
|
||||||
.filter(|(sequence, timestamp, _)| {
|
.filter(|(sequence, timestamp, _)| {
|
||||||
@@ -1068,18 +872,7 @@ fn handle_query(index: &BTreeMap<IndexKey, DataValue>, query: EngineQuery) -> En
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Coerces a bucket's stored `MetricPrimitive` into a plottable `f64` for
|
/// Coerces a bucket's `MetricPrimitive` into a plottable `f64`: Number/Timestamp/Duration use their natural numeric value; `String` has no numeric reading, so it falls back to `1.0` (matching the presence/occurrence marker used for every bucket) rather than being skipped, keeping every declared metric queryable.
|
||||||
/// `PromqlSeriesDto`:
|
|
||||||
/// - `Number` uses its underlying numeric value.
|
|
||||||
/// - `Timestamp` uses its millisecond value (arbitrary but at least meaningful/
|
|
||||||
/// orderable, since there's no other sensible single number for a point in time).
|
|
||||||
/// - `String` has no numeric reading at all, so it falls back to `1.0` — i.e. it is
|
|
||||||
/// treated the same as the presence/occurrence marker `apply_metric_to_log` already
|
|
||||||
/// stores for every bucket value today (ticket 003), meaning a PromQL series over a
|
|
||||||
/// `String`-valued bucket becomes an occurrence-count series rather than being
|
|
||||||
/// skipped. This keeps every declared metric queryable (matching the ticket's
|
|
||||||
/// "documented fallback" requirement) rather than silently omitting non-numeric
|
|
||||||
/// series.
|
|
||||||
fn coerce_to_f64(value: &MetricPrimitive) -> f64 {
|
fn coerce_to_f64(value: &MetricPrimitive) -> f64 {
|
||||||
match value {
|
match value {
|
||||||
MetricPrimitive::Number(n) => match n {
|
MetricPrimitive::Number(n) => match n {
|
||||||
@@ -1092,10 +885,7 @@ fn coerce_to_f64(value: &MetricPrimitive) -> f64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when every matcher in `matchers` is satisfied by `labels` (compared against
|
/// Compared against each label's `Display` form; extra labels on the bucket are ignored — the matcher list only needs to be a subset.
|
||||||
/// each label value's `Display` form, same convention as `EngineQuery::BucketValues`'s
|
|
||||||
/// `labels_filter`). Unmatched/extra labels on the bucket are ignored — a matcher list
|
|
||||||
/// only needs to be a subset.
|
|
||||||
fn matches_label_matchers(
|
fn matches_label_matchers(
|
||||||
labels: &BTreeMap<String, MetricPrimitive>,
|
labels: &BTreeMap<String, MetricPrimitive>,
|
||||||
matchers: &[LabelMatcher],
|
matchers: &[LabelMatcher],
|
||||||
@@ -1105,26 +895,7 @@ fn matches_label_matchers(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluates a parsed PromQL query against `index`, producing chart-ready series.
|
/// Evaluates a parsed PromQL query against `index`, producing chart-ready series: baseline is one series per label combination; `sum()`/`avg()` merge series by timestamp into one label-less series; `rate()` is simplified to a single point over `(last_ts - range, last_ts]` (an occurrence-count-per-second reading, not PromQL's true sliding-window-per-step) since bucket values are occurrence markers, not cumulative counters.
|
||||||
///
|
|
||||||
/// Baseline (no aggregation wrapper): one output series per distinct label
|
|
||||||
/// combination that matches `parsed.metric_name` + `parsed.matchers`, with that
|
|
||||||
/// combination's bucket values as `(timestamp_ms, value)` points (see
|
|
||||||
/// `coerce_to_f64`), sorted by timestamp.
|
|
||||||
///
|
|
||||||
/// `sum()`/`avg()`: merges every matching series' points by exact millisecond
|
|
||||||
/// timestamp (summing, or averaging, the values that land on the same timestamp
|
|
||||||
/// across series) into a single output series with empty labels — the aggregation
|
|
||||||
/// collapses the label dimension, same as PromQL's `sum by ()`/`avg by ()` default.
|
|
||||||
///
|
|
||||||
/// `rate()`: requires (and, per `promql::parse_query`, is only ever given) a range
|
|
||||||
/// selector. This is a simplified, single-point-per-series rate rather than PromQL's
|
|
||||||
/// true sliding-window-per-evaluation-step semantics: for each matching series, it
|
|
||||||
/// takes the window `(last_point_ts - range, last_point_ts]` and divides the number
|
|
||||||
/// of points falling in that window by the range's duration in seconds, producing one
|
|
||||||
/// `(last_point_ts, rate)` point per series. Since bucket values are occurrence
|
|
||||||
/// markers rather than cumulative counters (ticket 003), an occurrence-count-per-
|
|
||||||
/// second reading is the meaningful analogue of PromQL's counter-increase rate here.
|
|
||||||
fn evaluate_promql(
|
fn evaluate_promql(
|
||||||
index: &BTreeMap<IndexKey, DataValue>,
|
index: &BTreeMap<IndexKey, DataValue>,
|
||||||
parsed: &PromqlQuery,
|
parsed: &PromqlQuery,
|
||||||
@@ -1190,10 +961,7 @@ fn evaluate_promql(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
Some(AggOp::Gauge) => {
|
Some(AggOp::Gauge) => {
|
||||||
// Ticket 022: collapse each label combination's counter events
|
// Collapse each label combination's events down to their most-recent value in range — "value at time T", not a distinct stored gauge kind.
|
||||||
// down to their most-recent value in the queried time range —
|
|
||||||
// "value at time T" derived from the last matching event, rather
|
|
||||||
// than a distinct stored gauge kind.
|
|
||||||
series_points
|
series_points
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|(labels, points)| {
|
.filter_map(|(labels, points)| {
|
||||||
@@ -1212,12 +980,7 @@ fn evaluate_promql(
|
|||||||
.flat_map(|points| points.iter().map(|(ts, _)| *ts))
|
.flat_map(|points| points.iter().map(|(ts, _)| *ts))
|
||||||
.max()
|
.max()
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
// Ticket 022: bucket every matching counter event's value
|
// Buckets every value (across all label combinations, collapsing the label dimension like sum()/avg()) into fixed-width buckets, labeled `le` (Prometheus's less-than-or-equal convention).
|
||||||
// (across all label combinations — histogram collapses the
|
|
||||||
// label dimension the same way sum()/avg() do) into fixed-width
|
|
||||||
// buckets, one output series per bucket labeled `le` (Prometheus's
|
|
||||||
// "less-than-or-equal" convention) holding a single
|
|
||||||
// `(last_ts, count)` point.
|
|
||||||
let mut counts: BTreeMap<i64, u64> = BTreeMap::new();
|
let mut counts: BTreeMap<i64, u64> = BTreeMap::new();
|
||||||
for points in series_points.values() {
|
for points in series_points.values() {
|
||||||
for (_, value) in points {
|
for (_, value) in points {
|
||||||
@@ -1667,8 +1430,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(series.len(), 1);
|
assert_eq!(series.len(), 1);
|
||||||
assert_eq!(series[0].labels.get("path").map(String::as_str), Some("/a"));
|
assert_eq!(series[0].labels.get("path").map(String::as_str), Some("/a"));
|
||||||
// Occurrence markers coerce to 1.0 (see `coerce_to_f64`); timestamps come from
|
// Occurrence markers coerce to 1.0 (see coerce_to_f64); timestamps come from ingest_log's sequence-as-millis helper.
|
||||||
// `ingest_log`'s `sequence as millis` helper.
|
|
||||||
assert_eq!(series[0].points, vec![(0, 1.0), (2, 1.0)]);
|
assert_eq!(series[0].points, vec![(0, 1.0), (2, 1.0)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1835,8 +1597,7 @@ mod tests {
|
|||||||
let mut series = evaluate_promql(&index, &parsed, None, None);
|
let mut series = evaluate_promql(&index, &parsed, None, None);
|
||||||
series.sort_by(|a, b| a.labels.get("le").cmp(&b.labels.get("le")));
|
series.sort_by(|a, b| a.labels.get("le").cmp(&b.labels.get("le")));
|
||||||
|
|
||||||
// durations 2,4 -> bucket [0,10) le=10 (count 2); 12,15 -> [10,20) le=20 (count 2);
|
// durations 2,4 -> le=10 (count 2); 12,15 -> le=20 (count 2); 21 -> le=30 (count 1).
|
||||||
// 21 -> [20,30) le=30 (count 1).
|
|
||||||
assert_eq!(series.len(), 3);
|
assert_eq!(series.len(), 3);
|
||||||
assert_eq!(series[0].labels.get("le").map(String::as_str), Some("10"));
|
assert_eq!(series[0].labels.get("le").map(String::as_str), Some("10"));
|
||||||
assert_eq!(series[0].points[0].1, 2.0);
|
assert_eq!(series[0].points[0].1, 2.0);
|
||||||
@@ -1871,9 +1632,7 @@ mod tests {
|
|||||||
ingest_log(&mut index, 2, "user=alice logged in");
|
ingest_log(&mut index, 2, "user=alice logged in");
|
||||||
|
|
||||||
let values: Vec<_> = bucket_values(&index, "login_gap", None).collect();
|
let values: Vec<_> = bucket_values(&index, "login_gap", None).collect();
|
||||||
// First occurrences (alice@0, bob@1) produce no duration value at all —
|
// First occurrences (alice@0, bob@1) produce no duration value — only alice's second occurrence (seq 2) has something to diff against.
|
||||||
// only alice's second occurrence (sequence 2) has a previous event to
|
|
||||||
// diff against.
|
|
||||||
assert_eq!(values.len(), 1);
|
assert_eq!(values.len(), 1);
|
||||||
let (labels, (sequence, timestamp, value)) = &values[0];
|
let (labels, (sequence, timestamp, value)) = &values[0];
|
||||||
assert_eq!(labels.get("user").map(|v| v.to_string()), Some("alice".to_string()));
|
assert_eq!(labels.get("user").map(|v| v.to_string()), Some("alice".to_string()));
|
||||||
@@ -1912,10 +1671,7 @@ mod tests {
|
|||||||
assert_eq!(series[0].points, vec![(10, 10.0)]);
|
assert_eq!(series[0].points, vec![(10, 10.0)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixed matched_labels/unmatched_labels (rather than a captured "category"
|
// Fixed labels (not a captured param) so a case-insensitive match collapses into one clean "error" series regardless of source-text case.
|
||||||
// param) so a case-insensitive match like "ERROR" vs "Error" still
|
|
||||||
// collapses into one clean "error" series instead of fragmenting by
|
|
||||||
// whatever case the source text happened to use.
|
|
||||||
fn declare_error_category_metric(index: &mut BTreeMap<IndexKey, DataValue>) {
|
fn declare_error_category_metric(index: &mut BTreeMap<IndexKey, DataValue>) {
|
||||||
declare_metric(
|
declare_metric(
|
||||||
index,
|
index,
|
||||||
@@ -2016,7 +1772,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- ticket 007: FilterBar query integration -----------------------
|
// -- FilterBar query integration --
|
||||||
|
|
||||||
fn logs_query(filter: Option<&str>) -> EngineQuery {
|
fn logs_query(filter: Option<&str>) -> EngineQuery {
|
||||||
EngineQuery::Logs(LogsParams {
|
EngineQuery::Logs(LogsParams {
|
||||||
@@ -2107,10 +1863,7 @@ mod tests {
|
|||||||
ingest_log(&mut index, 1, "level=info duration=2 boom");
|
ingest_log(&mut index, 1, "level=info duration=2 boom");
|
||||||
ingest_log(&mut index, 2, "level=error duration=3 all good");
|
ingest_log(&mut index, 2, "level=error duration=3 all good");
|
||||||
|
|
||||||
// Only sequence 0 has both the "boom" regex match *and* level=error.
|
// Only sequence 0 has both; note comparison values are bare tokens (level==error), not quoted string literals like regex()'s argument.
|
||||||
// Note: comparison values are bare tokens (see `value` in the
|
|
||||||
// grammar), not quoted string literals like `regex(...)`'s argument
|
|
||||||
// — so `level==error`, not `level=="error"`.
|
|
||||||
match handle_query(
|
match handle_query(
|
||||||
&index,
|
&index,
|
||||||
logs_query(Some(r#"regex("boom") and requests[level]==error"#)),
|
logs_query(Some(r#"regex("boom") and requests[level]==error"#)),
|
||||||
|
|||||||
+14
-45
@@ -1,24 +1,9 @@
|
|||||||
//! Datetime parsing for the filter query language's timestamp comparisons.
|
//! Datetime parsing for the filter query language's timestamp comparisons: relative
|
||||||
//!
|
//! humantime, time-of-day (resolved against the UTC calendar day of `now`), or a
|
||||||
//! This module is intentionally standalone: it has no dependency on the engine/index
|
//! full date/datetime literal.
|
||||||
//! types or the filter grammar module, so it can be developed, tested, and reused
|
|
||||||
//! independently of the rest of the filter machinery.
|
|
||||||
//!
|
|
||||||
//! Three input forms are supported, all normalized to a single [`Timestamp`]:
|
|
||||||
//!
|
|
||||||
//! 1. Relative humantime, e.g. `5h`, `-3m` — resolved against a supplied "now" at
|
|
||||||
//! parse/eval time (parsed with the `humantime` crate).
|
|
||||||
//! 2. Absolute time-of-day, e.g. `12:00` — resolves to that time on the current day.
|
|
||||||
//! **This module treats "current day" as the UTC calendar day of `now`, and resolves
|
|
||||||
//! the time-of-day in UTC.** (We use `chrono` for date/time parsing since no other
|
|
||||||
//! datetime crate was already a dependency.)
|
|
||||||
//! 3. Full date/datetime literals, e.g. `2024-01-15` or `2024-01-15T12:00:00Z`
|
|
||||||
//! (RFC3339-ish). A bare date is interpreted as midnight UTC on that date.
|
|
||||||
|
|
||||||
// `engine.rs` (ticket 007) calls `parse_datetime` to resolve `FilterValue::Raw`
|
// `#![allow(dead_code)]` stays since most of this module's surface is still only
|
||||||
// tokens (e.g. `-5m`, `12:00`, a full date) in `timestamp`/metric-comparison
|
// exercised by its own unit tests below.
|
||||||
// filter clauses; `#![allow(dead_code)]` stays since most of this module's
|
|
||||||
// surface is still only exercised by its own unit tests below.
|
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
|
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
|
||||||
@@ -28,9 +13,7 @@ use crate::timestamp::Timestamp;
|
|||||||
/// Error returned when a datetime filter literal can't be resolved to a [`Timestamp`].
|
/// Error returned when a datetime filter literal can't be resolved to a [`Timestamp`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum DateTimeParseError {
|
pub enum DateTimeParseError {
|
||||||
/// The input matched the shape of a supported format (relative duration,
|
/// The input matched a supported format's shape but its value was invalid, e.g. `25:99`.
|
||||||
/// time-of-day, or date/datetime) but its value was invalid or ambiguous,
|
|
||||||
/// e.g. an out-of-range time like `25:99`.
|
|
||||||
Invalid { input: String, reason: String },
|
Invalid { input: String, reason: String },
|
||||||
/// The input didn't match any supported format at all.
|
/// The input didn't match any supported format at all.
|
||||||
Unparseable(String),
|
Unparseable(String),
|
||||||
@@ -54,11 +37,8 @@ impl std::fmt::Display for DateTimeParseError {
|
|||||||
|
|
||||||
impl std::error::Error for DateTimeParseError {}
|
impl std::error::Error for DateTimeParseError {}
|
||||||
|
|
||||||
/// Parses a filter datetime literal into an absolute [`Timestamp`], resolving any
|
/// Parses a filter datetime literal into an absolute [`Timestamp`]; `now` is a parameter
|
||||||
/// relative or day-relative forms against `now`.
|
/// (rather than calling `Timestamp::now()` internally) so callers can pass a fixed value in tests.
|
||||||
///
|
|
||||||
/// `now` is taken as a parameter (rather than this function calling `Timestamp::now()`
|
|
||||||
/// internally) so callers can pass a fixed value in tests.
|
|
||||||
pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTimeParseError> {
|
pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTimeParseError> {
|
||||||
let trimmed = input.trim();
|
let trimmed = input.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -80,11 +60,8 @@ pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTime
|
|||||||
Err(DateTimeParseError::Unparseable(input.to_string()))
|
Err(DateTimeParseError::Unparseable(input.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tries to interpret `input` as a signed relative humantime duration (`5h`, `-3m`).
|
/// Tries to interpret `input` as a signed relative humantime duration (`5h`, `-3m`);
|
||||||
///
|
/// returns `None` if it doesn't look like one at all, so the caller can try other formats.
|
||||||
/// Returns `None` if `input` doesn't look like a relative duration at all (so the
|
|
||||||
/// caller can try the other formats); returns `Some(Err(..))` if it looks like one but
|
|
||||||
/// the duration itself is malformed.
|
|
||||||
fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTimeParseError>> {
|
fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTimeParseError>> {
|
||||||
let (sign, rest) = match input.strip_prefix('-') {
|
let (sign, rest) = match input.strip_prefix('-') {
|
||||||
Some(rest) => (-1i128, rest),
|
Some(rest) => (-1i128, rest),
|
||||||
@@ -94,11 +71,7 @@ fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTim
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only treat this as an attempted relative duration if it starts with a digit
|
// Require a digit followed by an alphabetic unit so date-like inputs (e.g. "2024-01-15") fall through to the date parser instead of erroring here as an invalid duration.
|
||||||
// followed by (eventually) an alphabetic unit -- this keeps dates like
|
|
||||||
// "2024-01-15" (which start with digits but aren't durations) from being
|
|
||||||
// mis-attempted here and reported as "invalid duration" instead of falling
|
|
||||||
// through to the date parser.
|
|
||||||
let looks_like_duration = rest
|
let looks_like_duration = rest
|
||||||
.chars()
|
.chars()
|
||||||
.next()
|
.next()
|
||||||
@@ -126,8 +99,7 @@ fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTim
|
|||||||
|
|
||||||
/// Tries to interpret `input` as a full date or RFC3339-ish datetime literal.
|
/// Tries to interpret `input` as a full date or RFC3339-ish datetime literal.
|
||||||
fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError>> {
|
fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError>> {
|
||||||
// Only attempt this path for inputs shaped like a date (contain a '-' separating
|
// Only attempt this path for inputs shaped like a date, e.g. "2024-01-15".
|
||||||
// digits, e.g. "2024-01-15"); otherwise let other parsers have a shot.
|
|
||||||
let looks_like_date = input.len() >= 8 && input.as_bytes()[4] == b'-';
|
let looks_like_date = input.len() >= 8 && input.as_bytes()[4] == b'-';
|
||||||
if !looks_like_date {
|
if !looks_like_date {
|
||||||
return None;
|
return None;
|
||||||
@@ -151,8 +123,7 @@ fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tries to interpret `input` as a bare time-of-day (`12:00`, `00:00`, `23:59:59`),
|
/// Tries to interpret `input` as a bare time-of-day, resolved against the UTC calendar day of `now`.
|
||||||
/// resolved against the UTC calendar day of `now`.
|
|
||||||
fn try_time_of_day(
|
fn try_time_of_day(
|
||||||
input: &str,
|
input: &str,
|
||||||
now: Timestamp,
|
now: Timestamp,
|
||||||
@@ -176,8 +147,7 @@ fn try_time_of_day(
|
|||||||
|
|
||||||
let now_millis = now.as_millis();
|
let now_millis = now.as_millis();
|
||||||
let now_dt = Utc.timestamp_millis_opt(now_millis).single().unwrap_or_else(|| {
|
let now_dt = Utc.timestamp_millis_opt(now_millis).single().unwrap_or_else(|| {
|
||||||
// Fall back to the epoch date if `now` is somehow out of range; this should
|
// Fall back to the epoch date; shouldn't happen since `Timestamp` is always a valid instant.
|
||||||
// not happen in practice since `Timestamp` always comes from a valid instant.
|
|
||||||
Utc.timestamp_millis_opt(0).single().expect("epoch is valid")
|
Utc.timestamp_millis_opt(0).single().expect("epoch is valid")
|
||||||
});
|
});
|
||||||
let today = now_dt.date_naive();
|
let today = now_dt.date_naive();
|
||||||
@@ -193,7 +163,6 @@ fn timestamp_from_datetime(dt: DateTime<Utc>) -> Timestamp {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// A fixed "now": 2024-06-15T12:34:56Z.
|
|
||||||
fn fixed_now() -> Timestamp {
|
fn fixed_now() -> Timestamp {
|
||||||
let dt = Utc.with_ymd_and_hms(2024, 6, 15, 12, 34, 56).unwrap();
|
let dt = Utc.with_ymd_and_hms(2024, 6, 15, 12, 34, 56).unwrap();
|
||||||
Timestamp::from_millis(dt.timestamp_millis())
|
Timestamp::from_millis(dt.timestamp_millis())
|
||||||
|
|||||||
+10
-41
@@ -1,26 +1,5 @@
|
|||||||
//! FilterBar query language: grammar, AST and parser.
|
//! FilterBar query language: grammar (see `src/filter_parser.pest`), AST and parser.
|
||||||
//!
|
//! Evaluating the AST against real log data is left to `engine.rs`.
|
||||||
//! This module is intentionally standalone and has no dependency on the
|
|
||||||
//! engine/index types (`data_value`, `index_key`, `engine`, ...). It only
|
|
||||||
//! knows how to turn a filter string into an [`FilterExpr`] AST. Evaluating
|
|
||||||
//! that AST against real log data, and resolving [`FilterValue::Raw`] tokens
|
|
||||||
//! into concrete datetimes, is left to later integration tickets.
|
|
||||||
//!
|
|
||||||
//! Grammar (see `src/filter_parser.pest`):
|
|
||||||
//!
|
|
||||||
//! - `regex("...")` — raw-text regex search over log lines.
|
|
||||||
//! - `metric_name[param] <op> value` — metric comparisons, with `<op>` one
|
|
||||||
//! of `<`, `>`, `<=`, `>=`, `==`, `!=`.
|
|
||||||
//! - `sequence <op> value` and `timestamp <op> value` — reserved metric
|
|
||||||
//! names usable the same way, without a `[param]`.
|
|
||||||
//! - `and` / `or` boolean combinators, with `and` binding tighter than `or`.
|
|
||||||
//! - Parenthesized sub-expressions.
|
|
||||||
//!
|
|
||||||
//! `engine.rs` (ticket 007) is what actually calls into this module: it
|
|
||||||
//! compiles the returned [`FilterExpr`] against the index (resolving
|
|
||||||
//! [`FilterValue::Raw`] tokens via `filter_datetime` and metric comparisons
|
|
||||||
//! via bucket data) and evaluates it per log line while answering
|
|
||||||
//! `EngineQuery::Logs`/`Timeline`.
|
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use pest::Parser;
|
use pest::Parser;
|
||||||
@@ -48,9 +27,8 @@ pub enum FilterExpr {
|
|||||||
Or(Box<FilterExpr>, Box<FilterExpr>),
|
Or(Box<FilterExpr>, Box<FilterExpr>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Comparison operators supported by metric comparisons. Also reused by
|
/// Comparison operators for metric comparisons; also reused by `crate::alerts::AlertCondition`
|
||||||
/// `crate::alerts::AlertCondition::Threshold`/`Ratio` for their own
|
/// for threshold/ratio comparisons (hence `Serialize`/`Deserialize`).
|
||||||
/// threshold/ratio comparisons (hence `Serialize`/`Deserialize`).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum CompareOp {
|
pub enum CompareOp {
|
||||||
@@ -62,10 +40,8 @@ pub enum CompareOp {
|
|||||||
Ne,
|
Ne,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The right-hand side of a comparison. Values that parse as a number
|
/// The right-hand side of a comparison: values that parse as a number become
|
||||||
/// become [`FilterValue::Number`]; everything else (e.g. datetime-like
|
/// [`FilterValue::Number`], everything else is kept as an opaque [`FilterValue::Raw`] string.
|
||||||
/// tokens such as `12:00`) is kept as an opaque [`FilterValue::Raw`] string
|
|
||||||
/// to be resolved by a later ticket.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum FilterValue {
|
pub enum FilterValue {
|
||||||
Number(f64),
|
Number(f64),
|
||||||
@@ -96,9 +72,7 @@ impl std::error::Error for FilterParseError {}
|
|||||||
|
|
||||||
impl From<pest::error::Error<Rule>> for FilterParseError {
|
impl From<pest::error::Error<Rule>> for FilterParseError {
|
||||||
fn from(err: pest::error::Error<Rule>) -> Self {
|
fn from(err: pest::error::Error<Rule>) -> Self {
|
||||||
// pest's own Display already renders a located, human-readable
|
// pest's Display already renders a located message, so use it and add a short prefix.
|
||||||
// message (line/column + a pointer into the offending input), so we
|
|
||||||
// use it as the basis and add a short prefix for context.
|
|
||||||
FilterParseError::new(format!("failed to parse filter expression: {err}"))
|
FilterParseError::new(format!("failed to parse filter expression: {err}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,9 +95,7 @@ pub fn parse(input: &str) -> Result<FilterExpr, FilterParseError> {
|
|||||||
|
|
||||||
fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
||||||
debug_assert_eq!(pair.as_rule(), Rule::or_expr);
|
debug_assert_eq!(pair.as_rule(), Rule::or_expr);
|
||||||
// `or_kw` is atomic (see the grammar file) so it is *not* silent: it
|
// `or_kw` is atomic so it's not silent, showing up as a sibling pair between `and_expr` operands — skip it rather than assuming a fixed alternating structure.
|
||||||
// shows up as a sibling pair between the `and_expr` operands. Skip it
|
|
||||||
// rather than assuming a fixed alternating structure.
|
|
||||||
let mut and_exprs = pair
|
let mut and_exprs = pair
|
||||||
.into_inner()
|
.into_inner()
|
||||||
.filter(|p| p.as_rule() != Rule::or_kw);
|
.filter(|p| p.as_rule() != Rule::or_kw);
|
||||||
@@ -140,7 +112,7 @@ fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
|||||||
|
|
||||||
fn build_and_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
fn build_and_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
||||||
debug_assert_eq!(pair.as_rule(), Rule::and_expr);
|
debug_assert_eq!(pair.as_rule(), Rule::and_expr);
|
||||||
// Same reasoning as above for `and_kw`.
|
// Same reasoning as `build_or_expr` for `and_kw`.
|
||||||
let mut terms = pair
|
let mut terms = pair
|
||||||
.into_inner()
|
.into_inner()
|
||||||
.filter(|p| p.as_rule() != Rule::and_kw);
|
.filter(|p| p.as_rule() != Rule::and_kw);
|
||||||
@@ -236,8 +208,7 @@ fn parse_value(s: &str) -> FilterValue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unescape a regex string literal's inner contents (`\"` -> `"`, `\\` -> `\`,
|
/// Unescape a regex string literal's inner contents (`\"` -> `"`, `\\` -> `\`, `\x` -> `x`).
|
||||||
/// any other `\x` -> `x`).
|
|
||||||
fn unescape(s: &str) -> String {
|
fn unescape(s: &str) -> String {
|
||||||
let mut out = String::with_capacity(s.len());
|
let mut out = String::with_capacity(s.len());
|
||||||
let mut chars = s.chars();
|
let mut chars = s.chars();
|
||||||
@@ -368,8 +339,6 @@ mod tests {
|
|||||||
let result = parse("regex(\"unterminated and sequence<");
|
let result = parse("regex(\"unterminated and sequence<");
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err();
|
let err = result.unwrap_err();
|
||||||
// Ensure the error message is human-readable and carries location
|
|
||||||
// info (pest errors always mention line/column context).
|
|
||||||
let msg = err.to_string();
|
let msg = err.to_string();
|
||||||
assert!(!msg.is_empty());
|
assert!(!msg.is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-12
@@ -3,11 +3,7 @@ use crate::timestamp::Timestamp;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
/// Key type for the engine's primary index.
|
/// Key type for the engine's primary index. Variant order matters: derived `Ord` compares the discriminant first, carving the map into contiguous ranges in declaration order (logs, params, metric declarations, buckets).
|
||||||
///
|
|
||||||
/// Variants are ordered top to bottom as declared (derived `Ord` compares the
|
|
||||||
/// discriminant first), which is what carves the map into contiguous ranges:
|
|
||||||
/// logs, then params, then metric declarations, then buckets.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||||
pub enum IndexKey {
|
pub enum IndexKey {
|
||||||
@@ -33,13 +29,7 @@ pub enum IndexKey {
|
|||||||
metric_name: String,
|
metric_name: String,
|
||||||
labels: BTreeMap<String, MetricPrimitive>,
|
labels: BTreeMap<String, MetricPrimitive>,
|
||||||
},
|
},
|
||||||
/// Ticket 023: internal bookkeeping (never returned by a query) tracking
|
/// Internal bookkeeping tracking the last event timestamp per metric+label, so a `Duration` param's first occurrence (which pushes no `Bucket`) still lets the next occurrence compute elapsed time.
|
||||||
/// the timestamp of the most recent counter event for one metric+label
|
|
||||||
/// combination, independent of whether that event actually pushed a
|
|
||||||
/// `Bucket` value — a `Duration`-kind `Value` param's *first* occurrence
|
|
||||||
/// of a label set has no previous event to diff against and pushes no
|
|
||||||
/// `Bucket` entry, but must still be recorded here so the *next*
|
|
||||||
/// occurrence can compute its elapsed time.
|
|
||||||
LastMetricEvent {
|
LastMetricEvent {
|
||||||
metric_name: String,
|
metric_name: String,
|
||||||
labels: BTreeMap<String, MetricPrimitive>,
|
labels: BTreeMap<String, MetricPrimitive>,
|
||||||
|
|||||||
+7
-18
@@ -30,9 +30,7 @@ pub async fn run(engine: mpsc::Sender<EngineMessage>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identifies a specific file instance on disk, so a recreated path (e.g. a
|
/// Identifies a specific file instance on disk, so a recreated path can be told apart from the same file simply growing.
|
||||||
/// tailed process restarting and reopening its log file) can be told apart from
|
|
||||||
/// the same file simply growing.
|
|
||||||
#[derive(PartialEq, Eq, Clone, Copy)]
|
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||||
struct FileId(#[cfg(unix)] u64, #[cfg(not(unix))] ());
|
struct FileId(#[cfg(unix)] u64, #[cfg(not(unix))] ());
|
||||||
|
|
||||||
@@ -55,13 +53,9 @@ async fn open(path: &Path) -> Option<(File, FileId)> {
|
|||||||
Some((file, file_id(&meta)))
|
Some((file, file_id(&meta)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tails `path` like `tail -f`: reads existing content, then keeps reading as
|
/// Tails `path` like `tail -f`, resuming from the start if the file is deleted/recreated or
|
||||||
/// new lines are appended. If the file is deleted and recreated at the same
|
/// truncated in place rather than exiting or getting stuck; `sequence` keeps incrementing
|
||||||
/// path (or truncated in place) — e.g. the tailed process restarted and
|
/// across a recreation since it's ottyl's own line counter, not tied to the source file.
|
||||||
/// reopened its log — tailing resumes against the new file from its start,
|
|
||||||
/// rather than exiting or getting stuck. `sequence` keeps incrementing across
|
|
||||||
/// a recreation event; it's ottyl's own line counter, not tied to the source
|
|
||||||
/// file.
|
|
||||||
pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
|
pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
|
||||||
let mut sequence: u64 = 0;
|
let mut sequence: u64 = 0;
|
||||||
|
|
||||||
@@ -81,9 +75,7 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
|
|||||||
let mut chunk = String::new();
|
let mut chunk = String::new();
|
||||||
match reader.read_line(&mut chunk).await {
|
match reader.read_line(&mut chunk).await {
|
||||||
Ok(0) => {
|
Ok(0) => {
|
||||||
// EOF: decide whether the file was truncated/recreated
|
// EOF: decide whether the file was truncated/recreated or just hasn't grown yet.
|
||||||
// (tail the new instance from scratch) or just hasn't
|
|
||||||
// grown yet (keep polling).
|
|
||||||
match tokio::fs::metadata(&path).await {
|
match tokio::fs::metadata(&path).await {
|
||||||
Ok(meta) => {
|
Ok(meta) => {
|
||||||
let new_id = file_id(&meta);
|
let new_id = file_id(&meta);
|
||||||
@@ -93,8 +85,7 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
|
|||||||
id = new_id;
|
id = new_id;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Path gone (mid-recreation); wait for it to
|
// Path gone (mid-recreation); wait for it to reappear and reopen fresh.
|
||||||
// reappear and reopen fresh.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tokio::time::sleep(POLL_INTERVAL).await;
|
tokio::time::sleep(POLL_INTERVAL).await;
|
||||||
@@ -119,8 +110,7 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// else: partial line at EOF (write in progress) — keep
|
// else: partial line at EOF (write in progress) — keep buffering.
|
||||||
// buffering and read again without treating it as final.
|
|
||||||
}
|
}
|
||||||
Err(_) => continue 'reopen,
|
Err(_) => continue 'reopen,
|
||||||
}
|
}
|
||||||
@@ -189,7 +179,6 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(recv_text(&mut rx).await, "long line before truncation");
|
assert_eq!(recv_text(&mut rx).await, "long line before truncation");
|
||||||
|
|
||||||
// Truncate in place (same inode, shorter content) then write new data.
|
|
||||||
let mut f = std::fs::OpenOptions::new()
|
let mut f = std::fs::OpenOptions::new()
|
||||||
.write(true)
|
.write(true)
|
||||||
.truncate(true)
|
.truncate(true)
|
||||||
|
|||||||
+4
-12
@@ -31,14 +31,11 @@ struct Args {
|
|||||||
#[arg(long, default_value_t = 0)]
|
#[arg(long, default_value_t = 0)]
|
||||||
port: u16,
|
port: u16,
|
||||||
|
|
||||||
/// Path to the settings file. Defaults to a per-OS config location
|
/// Path to the settings file. Defaults to a per-OS config location.
|
||||||
/// (e.g. `~/.config/ottyl/settings.json` on Linux).
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
settings_path: Option<PathBuf>,
|
settings_path: Option<PathBuf>,
|
||||||
|
|
||||||
/// File to tail for log lines, instead of reading from stdin. Behaves like
|
/// File to tail for log lines, instead of reading from stdin. Behaves like `tail -f`.
|
||||||
/// `tail -f`: reads existing content then keeps following appended lines,
|
|
||||||
/// and keeps tailing if the file is deleted/recreated at the same path.
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
file: Option<PathBuf>,
|
file: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
@@ -60,13 +57,8 @@ async fn main() {
|
|||||||
let _ = engine_tx.send(engine::EngineMessage::SetMaxLogs(max_logs)).await;
|
let _ = engine_tx.send(engine::EngineMessage::SetMaxLogs(max_logs)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ticket 009: reload the active profile's param/metric declarations into the
|
// Reload the active profile's declarations before ingest starts: there's no way to
|
||||||
// engine BEFORE starting ingest below. Ordering matters: declarations must be
|
// reprocess early lines later, so declaring after ingest starts could miss lines that raced ahead.
|
||||||
// in place before any line is ingested so that backfill/live-derivation
|
|
||||||
// semantics (ticket 003) apply uniformly to every line, including ones that
|
|
||||||
// arrive immediately once ingest starts (there's no "declare after the fact
|
|
||||||
// and go back to reprocess early lines" step, so doing it after ingest starts
|
|
||||||
// could silently miss lines that raced ahead of the declarations).
|
|
||||||
{
|
{
|
||||||
let (active_profile_name, profiles_dir) = {
|
let (active_profile_name, profiles_dir) = {
|
||||||
let mut settings_guard = settings.lock().unwrap();
|
let mut settings_guard = settings.lock().unwrap();
|
||||||
|
|||||||
+3
-11
@@ -10,9 +10,7 @@ pub enum MetricPrimitive {
|
|||||||
String(String),
|
String(String),
|
||||||
Number(Number),
|
Number(Number),
|
||||||
Timestamp(Timestamp),
|
Timestamp(Timestamp),
|
||||||
/// Milliseconds elapsed (ticket 023). Distinct from `Number` so a
|
/// Milliseconds elapsed; distinct from `Number` so it reads unambiguously in JSON/label form.
|
||||||
/// duration reads unambiguously in JSON/label form, even though it's
|
|
||||||
/// stored the same way (a plain `i64`).
|
|
||||||
Duration(i64),
|
Duration(i64),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,10 +47,7 @@ impl Ord for MetricPrimitive {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Textual form used for label-filter comparisons over the metrics API
|
/// Textual form used to compare label filters (query-string values) against a formatted `MetricPrimitive` rather than parsing them into one.
|
||||||
/// (`GET /api/metrics/:name?labels=foo=bar`): query-string values are plain
|
|
||||||
/// strings, so a label filter is compared against this formatted form rather
|
|
||||||
/// than trying to parse the filter value into a typed `MetricPrimitive`.
|
|
||||||
impl fmt::Display for MetricPrimitive {
|
impl fmt::Display for MetricPrimitive {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
@@ -74,10 +69,7 @@ pub enum Number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Number {
|
impl Number {
|
||||||
/// Widens to `f64` for cross-kind numeric comparisons (e.g. a filter
|
/// Widens to `f64` for cross-kind numeric comparisons; `pub(crate)` since the filter evaluator in `engine.rs` needs it.
|
||||||
/// query's `FilterValue::Number` against a declared metric's captured
|
|
||||||
/// `Number`). `pub(crate)` rather than private since ticket 007's filter
|
|
||||||
/// evaluator (in `engine.rs`) needs it and lives in a different module.
|
|
||||||
pub(crate) fn as_f64(&self) -> f64 {
|
pub(crate) fn as_f64(&self) -> f64 {
|
||||||
match self {
|
match self {
|
||||||
Number::Int(v) => *v as f64,
|
Number::Int(v) => *v as f64,
|
||||||
|
|||||||
+41
-212
@@ -1,33 +1,5 @@
|
|||||||
//! Named, persistable bundles of parsing/indexing rules ("profiles" / "presets"),
|
//! Named, persistable bundles of parsing/indexing rules and dashboards for a CLI app, persisted as a single `profiles.json`.
|
||||||
//! ticket 009.
|
//! `Settings.profiles` only tracks lightweight name/active pointers; this module owns the actual content.
|
||||||
//!
|
|
||||||
//! A `Profile` bundles the param declarations (ticket 001) and metric
|
|
||||||
//! declarations (ticket 002) needed to derive a given CLI app's structure from
|
|
||||||
//! its raw log output, plus any saved dashboards for that app. Profiles are
|
|
||||||
//! collected in a `ProfileStore`, persisted as a single `profiles.json` file
|
|
||||||
//! under `Settings.profiles.profiles_dir` (resolved relative to the settings
|
|
||||||
//! file's directory — the same convention ticket 010 uses for CSS storage). A
|
|
||||||
//! single file (rather than one file per profile) is simplest and avoids
|
|
||||||
//! filename-sanitization concerns for profile names.
|
|
||||||
//!
|
|
||||||
//! `Settings.profiles` (see `settings.rs`) only tracks lightweight pointers —
|
|
||||||
//! known profile names and which one is active — while this module owns the
|
|
||||||
//! actual declaration content. That mirrors how `Settings.css_entries` holds
|
|
||||||
//! lightweight `CssEntry` records while the CSS content itself lives in
|
|
||||||
//! separate files (ticket 010).
|
|
||||||
//!
|
|
||||||
//! On startup, `main.rs` loads the active profile's declarations into a fresh
|
|
||||||
//! engine by replaying `EngineMessage::DeclareParam`/`DeclareMetric` for each
|
|
||||||
//! stored declaration (see `load_into_engine` below) — this reproduces the
|
|
||||||
//! same parsing/indexing rules the profile had when last saved, without
|
|
||||||
//! requiring the user to re-declare anything by hand after a restart.
|
|
||||||
//!
|
|
||||||
//! Several public methods here (e.g. `ProfileStore::delete`,
|
|
||||||
//! `record_param_declaration`) have no caller yet within this worktree — CRUD
|
|
||||||
//! over profiles isn't exposed over HTTP until ticket 004 lands the API layer
|
|
||||||
//! and calls into this module. `#![allow(dead_code)]` mirrors how
|
|
||||||
//! `engine.rs`/`data_value.rs` handle the same not-yet-wired situation for
|
|
||||||
//! tickets 001/002.
|
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
@@ -46,22 +18,17 @@ use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, Eng
|
|||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
/// Name of the profile that always exists and can't be deleted (see
|
/// Name of the profile that always exists and can't be deleted (see `ProfileStore::ensure_default`).
|
||||||
/// `ProfileStore::ensure_default`).
|
|
||||||
pub const DEFAULT_PROFILE_NAME: &str = "Default";
|
pub const DEFAULT_PROFILE_NAME: &str = "Default";
|
||||||
|
|
||||||
/// Mirrors `data_value::MetricPrimitiveKind` with `Serialize`/`Deserialize` so
|
/// Mirrors `data_value::MetricPrimitiveKind` with `Serialize`/`Deserialize` so profiles can round-trip through JSON, since the engine-internal type only derives `Serialize`.
|
||||||
/// profiles can round-trip through JSON. `MetricPrimitiveKind` itself only
|
|
||||||
/// derives `Serialize` (it's an engine-internal type owned by tickets
|
|
||||||
/// 001/002/003, out of scope for this ticket to modify), so this module keeps
|
|
||||||
/// its own mirror and converts at the boundary.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum ProfileMetricPrimitiveKind {
|
pub enum ProfileMetricPrimitiveKind {
|
||||||
String,
|
String,
|
||||||
Number,
|
Number,
|
||||||
Timestamp,
|
Timestamp,
|
||||||
/// Mirrors `MetricPrimitiveKind::Duration` (ticket 023).
|
/// Mirrors `MetricPrimitiveKind::Duration`.
|
||||||
Duration,
|
Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +54,7 @@ impl From<ProfileMetricPrimitiveKind> for MetricPrimitiveKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A stored param declaration, matching `DeclareParamRequest`'s shape (ticket 001).
|
/// A stored param declaration, matching `DeclareParamRequest`'s shape.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ParamDeclarationRecord {
|
pub struct ParamDeclarationRecord {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -124,26 +91,21 @@ pub struct MetricParamRecord {
|
|||||||
pub param_name: String,
|
pub param_name: String,
|
||||||
pub capture_group: String,
|
pub capture_group: String,
|
||||||
pub kind: ProfileMetricPrimitiveKind,
|
pub kind: ProfileMetricPrimitiveKind,
|
||||||
/// See `ParamRole`. `#[serde(default)]` so profiles saved before ticket
|
/// See `ParamRole`; `#[serde(default)]` so profiles saved before this field existed still load, defaulting to `Label`.
|
||||||
/// 022 still load, with every param defaulting to `Label`.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub role: ParamRole,
|
pub role: ParamRole,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A stored metric declaration, matching `DeclareMetricRequest`'s shape (ticket 002).
|
/// A stored metric declaration, matching `DeclareMetricRequest`'s shape.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct MetricDeclarationRecord {
|
pub struct MetricDeclarationRecord {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub message_match_regex: String,
|
pub message_match_regex: String,
|
||||||
pub params: Vec<MetricParamRecord>,
|
pub params: Vec<MetricParamRecord>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::matched_labels` (ticket
|
/// See `crate::engine::DeclareMetricRequest::matched_labels`; `#[serde(default)]` so older profiles still load.
|
||||||
/// 025). `#[serde(default)]` so profiles saved before that ticket still
|
|
||||||
/// load.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub matched_labels: Option<std::collections::BTreeMap<String, String>>,
|
pub matched_labels: Option<std::collections::BTreeMap<String, String>>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::unmatched_labels` (ticket
|
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`; `#[serde(default)]` so older profiles still load.
|
||||||
/// 025). `#[serde(default)]` so profiles saved before that ticket still
|
|
||||||
/// load.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub unmatched_labels: Option<std::collections::BTreeMap<String, String>>,
|
pub unmatched_labels: Option<std::collections::BTreeMap<String, String>>,
|
||||||
}
|
}
|
||||||
@@ -190,8 +152,7 @@ impl From<&MetricDeclarationRecord> for DeclareMetricRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A chart type a dashboard panel can render (ticket 014). Kept in sync with
|
/// A chart type a dashboard panel can render; kept in sync with the frontend's `'bar' | 'line' | 'pie'` union (see `webapp/src/lib/api.ts`).
|
||||||
/// the frontend's `'bar' | 'line' | 'pie'` union (see `webapp/src/lib/api.ts`).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum ChartType {
|
pub enum ChartType {
|
||||||
@@ -200,8 +161,7 @@ pub enum ChartType {
|
|||||||
Pie,
|
Pie,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One panel within a dashboard: a PromQL query (ticket 013) plus how to
|
/// One panel within a dashboard: a PromQL query plus how to chart its result.
|
||||||
/// chart its result.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Panel {
|
pub struct Panel {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -210,7 +170,7 @@ pub struct Panel {
|
|||||||
pub chart_type: ChartType,
|
pub chart_type: ChartType,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A named set of panels, saved as part of a profile (ticket 014).
|
/// A named set of panels, saved as part of a profile.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Dashboard {
|
pub struct Dashboard {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -219,10 +179,7 @@ pub struct Dashboard {
|
|||||||
pub panels: Vec<Panel>,
|
pub panels: Vec<Panel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates an id for a newly-created dashboard/panel. Mirrors
|
/// Generates an id by hashing a seed (dashboard/panel name) with the current time, avoiding a `uuid` dependency for what's otherwise an opaque string.
|
||||||
/// `settings::generate_imported_css_id`: hashes a discriminating seed
|
|
||||||
/// (dashboard/panel name) together with the current time, avoiding a new
|
|
||||||
/// dependency (e.g. `uuid`) for what's otherwise a random-ish opaque string.
|
|
||||||
fn generate_id(seed: &str) -> String {
|
fn generate_id(seed: &str) -> String {
|
||||||
let mut hasher = DefaultHasher::new();
|
let mut hasher = DefaultHasher::new();
|
||||||
seed.hash(&mut hasher);
|
seed.hash(&mut hasher);
|
||||||
@@ -235,28 +192,17 @@ fn generate_id(seed: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Dashboard {
|
impl Dashboard {
|
||||||
/// A new dashboard named `name`, with no panels yet.
|
|
||||||
pub fn new(name: String) -> Dashboard {
|
pub fn new(name: String) -> Dashboard {
|
||||||
let id = generate_id(&name);
|
let id = generate_id(&name);
|
||||||
Dashboard { id, name, panels: Vec::new() }
|
Dashboard { id, name, panels: Vec::new() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ticket 025: name of the metric seeded into a brand-new `Default` profile,
|
/// Name of the metric seeded into a brand-new `Default` profile, used to drive its starter dashboard's two panels.
|
||||||
/// used to drive its starter dashboard's two panels.
|
|
||||||
const SEEDED_METRIC_NAME: &str = "log_category";
|
const SEEDED_METRIC_NAME: &str = "log_category";
|
||||||
const SEEDED_METRIC_LABEL: &str = "category";
|
const SEEDED_METRIC_LABEL: &str = "category";
|
||||||
|
|
||||||
/// A counter (ticket 022) that classifies every ingested line into exactly
|
/// A counter that splits every line into `category="error"`/`"other"` via `matched_labels`/`unmatched_labels` rather than a captured param, so case variants like "ERROR" collapse into one series; the `regex` crate lacks the lookaround needed to express "didn't match" any other way.
|
||||||
/// two buckets — `category="error"` or `category="other"` — without
|
|
||||||
/// capturing any text: `matched_labels`/`unmatched_labels` (ticket 025) apply
|
|
||||||
/// those fixed values directly, rather than a param capturing the literal
|
|
||||||
/// matched substring, so a case-insensitive match like `"ERROR"` vs `"Error"`
|
|
||||||
/// collapses into one clean series instead of fragmenting by source casing.
|
|
||||||
/// A plain regex alternation can't express the "didn't match" half of this
|
|
||||||
/// without lookaround, which the `regex` crate doesn't support (see
|
|
||||||
/// `crate::engine::DeclareMetricRequest::unmatched_labels`) — hence going
|
|
||||||
/// through the metric-declaration mechanism instead of the PromQL surface.
|
|
||||||
fn seeded_error_split_metric() -> MetricDeclarationRecord {
|
fn seeded_error_split_metric() -> MetricDeclarationRecord {
|
||||||
MetricDeclarationRecord {
|
MetricDeclarationRecord {
|
||||||
name: SEEDED_METRIC_NAME.to_string(),
|
name: SEEDED_METRIC_NAME.to_string(),
|
||||||
@@ -267,10 +213,7 @@ fn seeded_error_split_metric() -> MetricDeclarationRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The starter dashboard seeded into a brand-new `Default` profile (ticket
|
/// The starter dashboard seeded into a brand-new `Default` profile: a line chart and a pie chart, both querying the seeded error-split counter.
|
||||||
/// 025): a line chart and a pie chart, both querying the bare
|
|
||||||
/// `SEEDED_METRIC_NAME` counter so each renders both the "error" and "other"
|
|
||||||
/// series/slices from a single PromQL query, same as any user-created panel.
|
|
||||||
fn seeded_default_dashboard() -> Dashboard {
|
fn seeded_default_dashboard() -> Dashboard {
|
||||||
let mut dashboard = Dashboard::new("Overview".to_string());
|
let mut dashboard = Dashboard::new("Overview".to_string());
|
||||||
dashboard.panels = vec![
|
dashboard.panels = vec![
|
||||||
@@ -290,9 +233,7 @@ fn seeded_default_dashboard() -> Dashboard {
|
|||||||
dashboard
|
dashboard
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A named bundle of parsing/indexing rules for a particular CLI app: which
|
/// A named bundle of parsing/indexing rules for a particular CLI app, plus any saved dashboards.
|
||||||
/// params and metrics to derive from its log output, plus any saved
|
|
||||||
/// dashboards.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Profile {
|
pub struct Profile {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -300,13 +241,11 @@ pub struct Profile {
|
|||||||
pub params: Vec<ParamDeclarationRecord>,
|
pub params: Vec<ParamDeclarationRecord>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub metrics: Vec<MetricDeclarationRecord>,
|
pub metrics: Vec<MetricDeclarationRecord>,
|
||||||
/// Saved dashboards (ticket 014), each a named set of PromQL-backed panels.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dashboards: Vec<Dashboard>,
|
pub dashboards: Vec<Dashboard>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Profile {
|
impl Profile {
|
||||||
/// A new, empty profile with the given name.
|
|
||||||
pub fn new(name: String) -> Profile {
|
pub fn new(name: String) -> Profile {
|
||||||
Profile {
|
Profile {
|
||||||
name,
|
name,
|
||||||
@@ -317,18 +256,7 @@ impl Profile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sends every param/metric declaration stored in `profile` to the engine at
|
/// Replays every param/metric declaration stored in `profile` into `engine` (used on startup and in tests); any rejected declaration or send/recv failure is logged to stderr and skipped rather than panicking.
|
||||||
/// `engine`, as `EngineMessage::DeclareParam`/`DeclareMetric`. Used on startup
|
|
||||||
/// to reload the active profile's rules into a fresh engine (see `main.rs`),
|
|
||||||
/// and in tests to simulate that reload.
|
|
||||||
///
|
|
||||||
/// A stored declaration should generally already be valid — it round-tripped
|
|
||||||
/// through the engine's own validation when originally declared (see
|
|
||||||
/// `record_param_declaration`/`record_metric_declaration` below) — but this
|
|
||||||
/// doesn't assume that: any declaration the engine rejects (or any send/recv
|
|
||||||
/// failure, e.g. the engine channel closing) is logged to stderr and skipped
|
|
||||||
/// rather than panicking, so one bad stored declaration can't take the whole
|
|
||||||
/// process down.
|
|
||||||
pub async fn load_into_engine(engine: &EngineHandle, profile: &Profile) {
|
pub async fn load_into_engine(engine: &EngineHandle, profile: &Profile) {
|
||||||
for record in &profile.params {
|
for record in &profile.params {
|
||||||
let (respond_to, response) = oneshot::channel();
|
let (respond_to, response) = oneshot::channel();
|
||||||
@@ -388,8 +316,7 @@ pub enum ProfileError {
|
|||||||
NotFound,
|
NotFound,
|
||||||
/// A profile with the given name already exists.
|
/// A profile with the given name already exists.
|
||||||
DuplicateName,
|
DuplicateName,
|
||||||
/// The `Default` profile can't be deleted — there must always be at
|
/// The `Default` profile can't be deleted — there must always be at least one profile to fall back to.
|
||||||
/// least one profile to fall back to.
|
|
||||||
CannotDeleteDefault,
|
CannotDeleteDefault,
|
||||||
/// Underlying I/O failure reading/writing the profile store or settings file.
|
/// Underlying I/O failure reading/writing the profile store or settings file.
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
@@ -414,9 +341,7 @@ impl From<io::Error> for ProfileError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves `settings.profiles.profiles_dir` relative to `settings_path`'s
|
/// Resolves `settings.profiles.profiles_dir` relative to `settings_path`'s parent directory (or as absolute if it already is one).
|
||||||
/// parent directory (or treats it as absolute if it already is one) — the
|
|
||||||
/// same directory convention ticket 010 uses for CSS storage.
|
|
||||||
pub fn resolve_profiles_dir(settings: &Settings, settings_path: &Path) -> PathBuf {
|
pub fn resolve_profiles_dir(settings: &Settings, settings_path: &Path) -> PathBuf {
|
||||||
let profiles_dir = Path::new(&settings.profiles.profiles_dir);
|
let profiles_dir = Path::new(&settings.profiles.profiles_dir);
|
||||||
if profiles_dir.is_absolute() {
|
if profiles_dir.is_absolute() {
|
||||||
@@ -429,25 +354,19 @@ pub fn resolve_profiles_dir(settings: &Settings, settings_path: &Path) -> PathBu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path to the single file holding all profiles, within `profiles_dir` (see
|
/// Path to the single file holding all profiles, within `profiles_dir`.
|
||||||
/// `resolve_profiles_dir`).
|
|
||||||
pub fn profiles_store_path(profiles_dir: &Path) -> PathBuf {
|
pub fn profiles_store_path(profiles_dir: &Path) -> PathBuf {
|
||||||
profiles_dir.join("profiles.json")
|
profiles_dir.join("profiles.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// On-disk collection of all known profiles, persisted as a single JSON file
|
/// On-disk collection of all known profiles, persisted as a single JSON file.
|
||||||
/// (see module docs for why one file rather than one-per-profile).
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct ProfileStore {
|
pub struct ProfileStore {
|
||||||
profiles: Vec<Profile>,
|
profiles: Vec<Profile>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProfileStore {
|
impl ProfileStore {
|
||||||
/// Loads the store from `store_path`. A missing file is treated as an
|
/// Loads the store from `store_path`; a missing or unreadable/unparsable file is treated as an empty store rather than failing startup.
|
||||||
/// empty store (not an error) — there's no reason to fail startup just
|
|
||||||
/// because no profiles have been created yet. Any I/O or parse error
|
|
||||||
/// while reading an existing file is logged to stderr and treated as an
|
|
||||||
/// empty store, mirroring `Settings::load`'s non-fatal-error handling.
|
|
||||||
pub fn load(store_path: &Path) -> ProfileStore {
|
pub fn load(store_path: &Path) -> ProfileStore {
|
||||||
match fs::read_to_string(store_path) {
|
match fs::read_to_string(store_path) {
|
||||||
Ok(contents) => match serde_json::from_str::<Vec<Profile>>(&contents) {
|
Ok(contents) => match serde_json::from_str::<Vec<Profile>>(&contents) {
|
||||||
@@ -471,8 +390,7 @@ impl ProfileStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persists the store to `store_path` as pretty-printed JSON, creating
|
/// Persists the store to `store_path` as pretty-printed JSON, creating any missing parent directories first.
|
||||||
/// any missing parent directories first.
|
|
||||||
pub fn save(&self, store_path: &Path) -> io::Result<()> {
|
pub fn save(&self, store_path: &Path) -> io::Result<()> {
|
||||||
if let Some(parent) = store_path.parent() {
|
if let Some(parent) = store_path.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
@@ -482,26 +400,15 @@ impl ProfileStore {
|
|||||||
fs::write(store_path, contents)
|
fs::write(store_path, contents)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All known profiles.
|
|
||||||
pub fn list(&self) -> &[Profile] {
|
pub fn list(&self) -> &[Profile] {
|
||||||
&self.profiles
|
&self.profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Looks up a profile by name.
|
|
||||||
pub fn get(&self, name: &str) -> Option<&Profile> {
|
pub fn get(&self, name: &str) -> Option<&Profile> {
|
||||||
self.profiles.iter().find(|p| p.name == name)
|
self.profiles.iter().find(|p| p.name == name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new profile named `name`, persists the store to
|
/// Creates a new profile named `name` (persisting store and settings), copying the active profile's params/metrics/dashboards as a starting point rather than starting blank. Fails with `DuplicateName` if the name exists.
|
||||||
/// `store_path`, and records the name in `settings.profiles.names`
|
|
||||||
/// (persisting `settings` to `settings_path` too, keeping the two files in
|
|
||||||
/// sync). Fails with `ProfileError::DuplicateName` if a profile with that
|
|
||||||
/// name already exists.
|
|
||||||
///
|
|
||||||
/// The new profile starts as a copy of the currently active profile's
|
|
||||||
/// params/metrics/dashboards (or empty, if none is active) — a preset is
|
|
||||||
/// most useful as a variation on the one you were just using, not a
|
|
||||||
/// blank slate.
|
|
||||||
pub fn create(
|
pub fn create(
|
||||||
&mut self,
|
&mut self,
|
||||||
store_path: &Path,
|
store_path: &Path,
|
||||||
@@ -528,13 +435,7 @@ impl ProfileStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes the profile named `name`, persists the store to `store_path`,
|
/// Deletes the profile named `name` (persisting store and settings), clearing `active_profile` if it was active. Fails with `NotFound`, or `CannotDeleteDefault` for the `Default` profile.
|
||||||
/// and drops it from `settings.profiles.names` (persisting `settings` to
|
|
||||||
/// `settings_path` too). If the deleted profile was active, clears
|
|
||||||
/// `settings.profiles.active_profile`. Fails with `ProfileError::NotFound`
|
|
||||||
/// if no such profile exists, or `ProfileError::CannotDeleteDefault` for
|
|
||||||
/// the `Default` profile — there must always be at least one profile to
|
|
||||||
/// fall back to.
|
|
||||||
pub fn delete(
|
pub fn delete(
|
||||||
&mut self,
|
&mut self,
|
||||||
store_path: &Path,
|
store_path: &Path,
|
||||||
@@ -557,21 +458,7 @@ impl ProfileStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Guarantees a `Default` profile exists in this store, that it's tracked
|
/// Ensures a `Default` profile exists (seeding a starter dashboard/metric only on first creation), is tracked in `settings.profiles.names`, and falls back to active if nothing else is. Called once on startup before anything else touches profiles.
|
||||||
/// in `settings.profiles.names`, and that `settings.profiles.active_profile`
|
|
||||||
/// falls back to it when nothing else is active — so the app always has
|
|
||||||
/// at least one usable profile, even on a first run with no settings file
|
|
||||||
/// yet. Persists both the store and `settings` if either changed.
|
|
||||||
///
|
|
||||||
/// A newly-created `Default` profile (not one that already existed) is
|
|
||||||
/// seeded with a starter dashboard (ticket 025): a line chart and a pie
|
|
||||||
/// chart of "error" vs. non-"error" log volume, both querying
|
|
||||||
/// `seeded_error_split_metric`'s counter — editable/deletable afterward
|
|
||||||
/// like any other dashboard/metric, and never re-seeded or duplicated on
|
|
||||||
/// a later call once the profile exists.
|
|
||||||
///
|
|
||||||
/// Called once on startup (see `main.rs`) before anything else touches
|
|
||||||
/// profiles.
|
|
||||||
pub fn ensure_default(
|
pub fn ensure_default(
|
||||||
&mut self,
|
&mut self,
|
||||||
store_path: &Path,
|
store_path: &Path,
|
||||||
@@ -595,14 +482,7 @@ impl ProfileStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets (or clears, with `name: None`) the active profile: updates
|
/// Sets (or clears, with `None`) the active profile in `settings`. Fails with `NotFound` if `name` is `Some` and no such profile exists. Doesn't itself reload declarations into a running engine — that happens on next startup via `load_into_engine`.
|
||||||
/// `settings.profiles.active_profile` and persists `settings` to
|
|
||||||
/// `settings_path`. Fails with `ProfileError::NotFound` if `name` is
|
|
||||||
/// `Some` and no such profile exists in this store.
|
|
||||||
///
|
|
||||||
/// This only updates `Settings`; it does not itself reload the newly
|
|
||||||
/// active profile's declarations into a running engine (that happens on
|
|
||||||
/// the next startup — see `load_into_engine` and `main.rs`).
|
|
||||||
pub fn set_active(
|
pub fn set_active(
|
||||||
&self,
|
&self,
|
||||||
settings: &mut Settings,
|
settings: &mut Settings,
|
||||||
@@ -619,22 +499,7 @@ impl ProfileStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records a newly-declared param into `profile_name`'s stored
|
/// Records/updates a declared param into `profile_name`'s stored declarations (matched by param name), persisting the store. Intended to be called by the HTTP API alongside declaring against the engine, so declarations survive a restart.
|
||||||
/// declarations (replacing any existing record with the same param name,
|
|
||||||
/// so re-declaring updates it in place), persisting the store to
|
|
||||||
/// `store_path`.
|
|
||||||
///
|
|
||||||
/// **Integration hook for ticket 004 (HTTP API)**: when the HTTP API
|
|
||||||
/// declares a param against the engine (sending
|
|
||||||
/// `EngineMessage::DeclareParam`), it should also call this — with the
|
|
||||||
/// same `DeclareParamRequest` — against the currently-active profile, so
|
|
||||||
/// the declaration survives a restart (see `load_into_engine`). This
|
|
||||||
/// ticket (009) cannot wire that call itself: there's no declare-param
|
|
||||||
/// HTTP route yet for it to hook into. Call shape:
|
|
||||||
///
|
|
||||||
/// ```ignore
|
|
||||||
/// profile_store.record_param_declaration(&store_path, &active_profile_name, &request)?;
|
|
||||||
/// ```
|
|
||||||
pub fn record_param_declaration(
|
pub fn record_param_declaration(
|
||||||
&mut self,
|
&mut self,
|
||||||
store_path: &Path,
|
store_path: &Path,
|
||||||
@@ -655,13 +520,7 @@ impl ProfileStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records a newly-declared metric into `profile_name`'s stored
|
/// Records/updates a declared metric into `profile_name`'s stored declarations (matched by metric name), persisting the store; same integration intent as `record_param_declaration`.
|
||||||
/// declarations (replacing any existing record with the same metric name),
|
|
||||||
/// persisting the store to `store_path`.
|
|
||||||
///
|
|
||||||
/// **Integration hook for ticket 004 (HTTP API)**: same as
|
|
||||||
/// `record_param_declaration` above, but for
|
|
||||||
/// `EngineMessage::DeclareMetric`/`DeclareMetricRequest`.
|
|
||||||
pub fn record_metric_declaration(
|
pub fn record_metric_declaration(
|
||||||
&mut self,
|
&mut self,
|
||||||
store_path: &Path,
|
store_path: &Path,
|
||||||
@@ -682,16 +541,13 @@ impl ProfileStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lists the dashboards saved on `profile_name`. Fails with
|
/// Lists the dashboards saved on `profile_name`; fails with `NotFound` if it doesn't exist.
|
||||||
/// `ProfileError::NotFound` if no such profile exists.
|
|
||||||
pub fn list_dashboards(&self, profile_name: &str) -> Result<&[Dashboard], ProfileError> {
|
pub fn list_dashboards(&self, profile_name: &str) -> Result<&[Dashboard], ProfileError> {
|
||||||
let profile = self.get(profile_name).ok_or(ProfileError::NotFound)?;
|
let profile = self.get(profile_name).ok_or(ProfileError::NotFound)?;
|
||||||
Ok(&profile.dashboards)
|
Ok(&profile.dashboards)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new, empty dashboard named `name` on `profile_name`,
|
/// Creates a new, empty dashboard named `name` on `profile_name`, persisting the store; fails with `NotFound` if the profile doesn't exist.
|
||||||
/// persisting the store to `store_path`. Returns the new dashboard.
|
|
||||||
/// Fails with `ProfileError::NotFound` if no such profile exists.
|
|
||||||
pub fn create_dashboard(
|
pub fn create_dashboard(
|
||||||
&mut self,
|
&mut self,
|
||||||
store_path: &Path,
|
store_path: &Path,
|
||||||
@@ -709,12 +565,7 @@ impl ProfileStore {
|
|||||||
Ok(dashboard)
|
Ok(dashboard)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces the dashboard identified by `dashboard_id` on `profile_name`
|
/// Replaces the dashboard identified by `dashboard_id` with `dashboard`, persisting the store; `dashboard.id` is forced to `dashboard_id` so a caller can't change a dashboard's identity through this call.
|
||||||
/// with `dashboard` (e.g. after adding/removing/editing panels),
|
|
||||||
/// persisting the store to `store_path`. `dashboard.id` is forced to
|
|
||||||
/// `dashboard_id` so a caller can't accidentally change a dashboard's
|
|
||||||
/// identity through this call. Fails with `ProfileError::NotFound` if
|
|
||||||
/// either the profile or the dashboard doesn't exist.
|
|
||||||
pub fn save_dashboard(
|
pub fn save_dashboard(
|
||||||
&mut self,
|
&mut self,
|
||||||
store_path: &Path,
|
store_path: &Path,
|
||||||
@@ -738,10 +589,7 @@ impl ProfileStore {
|
|||||||
Ok(dashboard)
|
Ok(dashboard)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes the dashboard identified by `dashboard_id` from
|
/// Deletes the dashboard identified by `dashboard_id` from `profile_name`, persisting the store; fails with `NotFound` if either doesn't exist.
|
||||||
/// `profile_name`, persisting the store to `store_path`. Fails with
|
|
||||||
/// `ProfileError::NotFound` if either the profile or the dashboard
|
|
||||||
/// doesn't exist.
|
|
||||||
pub fn delete_dashboard(
|
pub fn delete_dashboard(
|
||||||
&mut self,
|
&mut self,
|
||||||
store_path: &Path,
|
store_path: &Path,
|
||||||
@@ -838,7 +686,6 @@ mod tests {
|
|||||||
assert!(store.get("web").is_some());
|
assert!(store.get("web").is_some());
|
||||||
assert_eq!(settings.profiles.names, vec!["web".to_string(), "worker".to_string()]);
|
assert_eq!(settings.profiles.names, vec!["web".to_string(), "worker".to_string()]);
|
||||||
|
|
||||||
// Duplicate creation is rejected.
|
|
||||||
let dup = store.create(&store_path, &mut settings, &settings_path, "web".to_string());
|
let dup = store.create(&store_path, &mut settings, &settings_path, "web".to_string());
|
||||||
assert!(matches!(dup, Err(ProfileError::DuplicateName)));
|
assert!(matches!(dup, Err(ProfileError::DuplicateName)));
|
||||||
|
|
||||||
@@ -870,8 +717,7 @@ mod tests {
|
|||||||
assert!(settings.profiles.names.contains(&DEFAULT_PROFILE_NAME.to_string()));
|
assert!(settings.profiles.names.contains(&DEFAULT_PROFILE_NAME.to_string()));
|
||||||
assert_eq!(settings.profiles.active_profile, Some(DEFAULT_PROFILE_NAME.to_string()));
|
assert_eq!(settings.profiles.active_profile, Some(DEFAULT_PROFILE_NAME.to_string()));
|
||||||
|
|
||||||
// Idempotent: doesn't duplicate the profile, and doesn't clobber an
|
// Idempotent: doesn't duplicate the profile or clobber an already-active (non-Default) profile.
|
||||||
// already-active (non-Default) profile.
|
|
||||||
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
|
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
|
||||||
store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap();
|
store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap();
|
||||||
store.ensure_default(&store_path, &mut settings, &settings_path).unwrap();
|
store.ensure_default(&store_path, &mut settings, &settings_path).unwrap();
|
||||||
@@ -908,8 +754,7 @@ mod tests {
|
|||||||
assert!(dashboard.panels.iter().any(|p| p.chart_type == ChartType::Line));
|
assert!(dashboard.panels.iter().any(|p| p.chart_type == ChartType::Line));
|
||||||
assert!(dashboard.panels.iter().any(|p| p.chart_type == ChartType::Pie));
|
assert!(dashboard.panels.iter().any(|p| p.chart_type == ChartType::Pie));
|
||||||
|
|
||||||
// Reload (simulating a restart) and ensure_default again: must not
|
// Reload (simulating a restart) and ensure_default again: must not re-seed or duplicate.
|
||||||
// re-seed or duplicate the dashboard/metric.
|
|
||||||
let mut store = ProfileStore::load(&store_path);
|
let mut store = ProfileStore::load(&store_path);
|
||||||
store.ensure_default(&store_path, &mut settings, &settings_path).unwrap();
|
store.ensure_default(&store_path, &mut settings, &settings_path).unwrap();
|
||||||
let default_profile = store.get(DEFAULT_PROFILE_NAME).unwrap();
|
let default_profile = store.get(DEFAULT_PROFILE_NAME).unwrap();
|
||||||
@@ -1076,19 +921,7 @@ mod tests {
|
|||||||
assert_eq!(profile.metrics.len(), 1);
|
assert_eq!(profile.metrics.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acceptance criterion: declaring params/metrics into a profile, "restarting"
|
/// Acceptance criterion: declaring params/metrics, "restarting" (reloading the store from disk), and replaying into a fresh engine reproduces the same declarations and still ingests lines, without re-declaring by hand.
|
||||||
/// (dropping and reloading the store from disk, rather than a literal process
|
|
||||||
/// restart), and replaying the saved profile against a fresh engine reproduces
|
|
||||||
/// the same declarations and still ingests lines correctly — without
|
|
||||||
/// re-declaring anything by hand.
|
|
||||||
///
|
|
||||||
/// Note: ticket 003 (metric bucket derivation) isn't implemented yet in this
|
|
||||||
/// worktree, so there's no bucket index to compare directly. What this test
|
|
||||||
/// verifies instead is the part ticket 009 owns: that a profile's declarations
|
|
||||||
/// round-trip through disk byte-for-byte and, once replayed via
|
|
||||||
/// `load_into_engine`, produce identical engine-visible declarations (which is
|
|
||||||
/// exactly the state ticket 003's derivation logic will read from) — and that
|
|
||||||
/// the engine keeps ingesting lines normally afterwards.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn profile_round_trip_reproduces_declarations_in_fresh_engine() {
|
async fn profile_round_trip_reproduces_declarations_in_fresh_engine() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -1096,8 +929,7 @@ mod tests {
|
|||||||
let settings_path = dir.path().join("settings.json");
|
let settings_path = dir.path().join("settings.json");
|
||||||
let mut settings = Settings::default();
|
let mut settings = Settings::default();
|
||||||
|
|
||||||
// Declare directly into a profile (as ticket 004's HTTP API will do via the
|
// Declare directly into a profile, as the HTTP API will do via the `record_*_declaration` hooks.
|
||||||
// `record_*_declaration` hooks once it exists).
|
|
||||||
let mut store = ProfileStore::load(&store_path);
|
let mut store = ProfileStore::load(&store_path);
|
||||||
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
|
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
|
||||||
store
|
store
|
||||||
@@ -1135,8 +967,7 @@ mod tests {
|
|||||||
tokio::spawn(crate::engine::run(engine_rx, settings_handle));
|
tokio::spawn(crate::engine::run(engine_rx, settings_handle));
|
||||||
load_into_engine(&engine_tx, &profile).await;
|
load_into_engine(&engine_tx, &profile).await;
|
||||||
|
|
||||||
// Ingest a line and confirm the engine is functioning with the replayed
|
// Ingest a line and confirm the engine works with the replayed declarations (no re-declaration needed).
|
||||||
// declarations in place (no re-declaration needed).
|
|
||||||
engine_tx
|
engine_tx
|
||||||
.send(EngineMessage::Ingest(IngestedLine {
|
.send(EngineMessage::Ingest(IngestedLine {
|
||||||
sequence: 0,
|
sequence: 0,
|
||||||
@@ -1185,9 +1016,7 @@ mod tests {
|
|||||||
assert_eq!(logs[0].text, "level=info request duration=42");
|
assert_eq!(logs[0].text, "level=info request duration=42");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acceptance criterion (ticket 014): creating a dashboard (with a panel),
|
/// Acceptance criterion: a dashboard (with a panel) survives reloading the profile store from disk.
|
||||||
/// then reloading the profile store from disk (simulating a restart),
|
|
||||||
/// still has the dashboard and its panel.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dashboard_create_save_delete_round_trip_and_persists_across_reload() {
|
fn dashboard_create_save_delete_round_trip_and_persists_across_reload() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
+11
-65
@@ -1,47 +1,5 @@
|
|||||||
//! Hand-rolled parser for a small PromQL subset (ticket 013).
|
//! Hand-rolled parser for a small PromQL subset: metric name + label matchers, an optional `[5m]` range, and a top-level `sum()`/`avg()`/`rate()`/`histogram()`/`gauge()` wrapper.
|
||||||
//!
|
//! Only exact-match (`=`) label matchers are supported; `rate()` requires a duration range selector, while `histogram(metric[width])` reuses that `[...]` slot for a bare bucket width instead.
|
||||||
//! Design decision: a crates.io PromQL parser (e.g. `promql-parser`) was considered
|
|
||||||
//! but rejected — the required subset here is tiny (metric name + label matchers,
|
|
||||||
//! plus an optional `[5m]` range and a single top-level `sum()`/`avg()`/`rate()`
|
|
||||||
//! wrapper), while a full PromQL parser brings a large, largely-unused AST plus an
|
|
||||||
//! extra maintenance dependency. Mirroring `filter_parser.rs`'s approach (a small,
|
|
||||||
//! engine-independent parser module) is simpler and easier to control/extend, and
|
|
||||||
//! keeps parsing fully decoupled from the engine's index — this module knows nothing
|
|
||||||
//! about `IndexKey`/`DataValue`, only `PromqlQuery`'s own types below.
|
|
||||||
//!
|
|
||||||
//! ## Supported grammar
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! query := aggregation | vector_selector
|
|
||||||
//! aggregation := agg_op "(" vector_selector ")"
|
|
||||||
//! agg_op := "sum" | "avg" | "rate" | "histogram" | "gauge"
|
|
||||||
//! vector_selector := metric_name label_matchers? range_selector?
|
|
||||||
//! metric_name := [a-zA-Z_][a-zA-Z0-9_]*
|
|
||||||
//! label_matchers := "{" label_matcher ("," label_matcher)* "}"
|
|
||||||
//! label_matcher := label_name "=" quoted_string
|
|
||||||
//! label_name := [a-zA-Z_][a-zA-Z0-9_]*
|
|
||||||
//! quoted_string := '"' (any char except '"' or '\')* '"'
|
|
||||||
//! range_selector := "[" digits duration_unit? "]"
|
|
||||||
//! duration_unit := "s" | "m" | "h" | "d"
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! Only exact-match label matchers (`=`) are supported — PromQL's `!=`, `=~`, `!~`
|
|
||||||
//! are out of scope for this ticket's required subset. `rate()` requires its inner
|
|
||||||
//! vector selector to carry a range selector with a duration unit (matching real
|
|
||||||
//! PromQL), and is rejected at parse time otherwise.
|
|
||||||
//!
|
|
||||||
//! Ticket 022 (metrics-as-counters rethink) adds two derived forms on top of the
|
|
||||||
//! same grammar, both consumed as regular `PromqlSeriesDto` series like every
|
|
||||||
//! other query shape:
|
|
||||||
//! - `histogram(metric_name[width])`: buckets every matching counter event's
|
|
||||||
//! (coerced-to-f64) value into fixed-width buckets of size `width` (a bare
|
|
||||||
//! number, no duration unit — reusing the `[...]` range-selector slot for a
|
|
||||||
//! different purpose than `rate()`'s duration), and emits one series per
|
|
||||||
//! bucket, labeled `le` (Prometheus's "less-than-or-equal" convention) with
|
|
||||||
//! that bucket's upper bound, holding a single `(timestamp_ms, count)` point.
|
|
||||||
//! - `gauge(metric_name)`: takes no range selector; emits the most-recent
|
|
||||||
//! matching counter event's value per label combination as a single point,
|
|
||||||
//! i.e. the counter's series collapsed down to its last value.
|
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -50,10 +8,9 @@ pub enum AggOp {
|
|||||||
Sum,
|
Sum,
|
||||||
Avg,
|
Avg,
|
||||||
Rate,
|
Rate,
|
||||||
/// Ticket 022: buckets counter values into fixed-width buckets. Requires
|
/// Buckets counter values into fixed-width buckets; requires `bucket_width`.
|
||||||
/// `bucket_width`.
|
|
||||||
Histogram,
|
Histogram,
|
||||||
/// Ticket 022: the most-recent counter value per label combination.
|
/// The most-recent counter value per label combination.
|
||||||
Gauge,
|
Gauge,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,23 +25,19 @@ pub struct PromqlQuery {
|
|||||||
pub agg: Option<AggOp>,
|
pub agg: Option<AggOp>,
|
||||||
pub metric_name: String,
|
pub metric_name: String,
|
||||||
pub matchers: Vec<LabelMatcher>,
|
pub matchers: Vec<LabelMatcher>,
|
||||||
/// Duration range selector, e.g. `[5m]`. Only meaningful for `rate()`.
|
/// Duration range selector, e.g. `[5m]`; only meaningful for `rate()`.
|
||||||
pub range: Option<Duration>,
|
pub range: Option<Duration>,
|
||||||
/// Bare-number range selector, e.g. `[10]`. Only meaningful for
|
/// Bare-number range selector, e.g. `[10]`; only meaningful for `histogram()`, where it's the bucket width.
|
||||||
/// `histogram()`, where it's the bucket width.
|
|
||||||
pub bucket_width: Option<f64>,
|
pub bucket_width: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a `[...]` range selector turned out to hold, disambiguated by whether
|
/// What a `[...]` range selector turned out to hold: a duration, or a bare width.
|
||||||
/// a duration unit followed the digits.
|
|
||||||
enum RangeSelector {
|
enum RangeSelector {
|
||||||
Duration(Duration),
|
Duration(Duration),
|
||||||
BareWidth(f64),
|
BareWidth(f64),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses a PromQL query string per the grammar documented above. Returns a
|
/// Parses a PromQL query string, returning a human-readable error message on malformed input.
|
||||||
/// human-readable error message (suitable for surfacing directly to an API caller)
|
|
||||||
/// on any malformed input.
|
|
||||||
pub fn parse_query(input: &str) -> Result<PromqlQuery, String> {
|
pub fn parse_query(input: &str) -> Result<PromqlQuery, String> {
|
||||||
let mut parser = Parser::new(input);
|
let mut parser = Parser::new(input);
|
||||||
let query = parser.parse_query()?;
|
let query = parser.parse_query()?;
|
||||||
@@ -143,7 +96,6 @@ impl<'a> Parser<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `[a-zA-Z_][a-zA-Z0-9_]*`, used for both metric names and label names.
|
|
||||||
fn parse_identifier(&mut self) -> Result<String, String> {
|
fn parse_identifier(&mut self) -> Result<String, String> {
|
||||||
self.skip_whitespace();
|
self.skip_whitespace();
|
||||||
let rest = self.remaining();
|
let rest = self.remaining();
|
||||||
@@ -214,9 +166,7 @@ impl<'a> Parser<'a> {
|
|||||||
Ok(matchers)
|
Ok(matchers)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `[<digits><unit>]` (e.g. `[5m]`, a duration — used by `rate()`) or a
|
/// Parses `[<digits><unit>]` (a duration, e.g. `[5m]`) or a bare `[<digits>]` (a bucket width).
|
||||||
/// bare `[<digits>]` with no unit (a plain number — used by `histogram()`
|
|
||||||
/// as a bucket width).
|
|
||||||
fn parse_range_selector(&mut self) -> Result<Option<RangeSelector>, String> {
|
fn parse_range_selector(&mut self) -> Result<Option<RangeSelector>, String> {
|
||||||
self.skip_whitespace();
|
self.skip_whitespace();
|
||||||
if self.peek() != Some('[') {
|
if self.peek() != Some('[') {
|
||||||
@@ -290,10 +240,7 @@ impl<'a> Parser<'a> {
|
|||||||
inner.agg = agg;
|
inner.agg = agg;
|
||||||
Ok(inner)
|
Ok(inner)
|
||||||
} else {
|
} else {
|
||||||
// Not (or not followed by) an aggregation call — rewind and parse the
|
// Rewind and parse as a plain vector selector, covering metric names that collide with an agg_op keyword.
|
||||||
// whole thing as a plain vector selector (covers metric names that
|
|
||||||
// happen to collide with an agg_op keyword, e.g. a metric named `rate`
|
|
||||||
// used without a call).
|
|
||||||
self.pos = checkpoint;
|
self.pos = checkpoint;
|
||||||
self.parse_vector_selector()
|
self.parse_vector_selector()
|
||||||
}
|
}
|
||||||
@@ -387,8 +334,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_aggregation() {
|
fn rejects_unknown_aggregation() {
|
||||||
// `count` isn't a supported agg_op, so this parses as a bare metric named
|
// `count` isn't a supported agg_op, so this parses as a bare metric name and trips on the trailing `(...)`.
|
||||||
// `count`, then trips on the unexpected trailing `(...)`.
|
|
||||||
let err = parse_query("count(requests)").unwrap_err();
|
let err = parse_query("count(requests)").unwrap_err();
|
||||||
assert!(err.contains("unexpected trailing input"));
|
assert!(err.contains("unexpected trailing input"));
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-102
@@ -1,9 +1,4 @@
|
|||||||
//! Process-wide settings model: colour scheme, CSS entries, and profile
|
//! Process-wide settings model: colour scheme, CSS entries, and profile pointers, persisted as JSON.
|
||||||
//! pointers. There is exactly one `Settings` instance at runtime, loaded once
|
|
||||||
//! on startup and persisted to disk whenever it changes (see `main.rs`).
|
|
||||||
//!
|
|
||||||
//! Persistence format is JSON (via `serde_json`, already a dependency) rather
|
|
||||||
//! than TOML, to keep the dependency list minimal.
|
|
||||||
|
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
@@ -19,8 +14,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::alerts::AlertRule;
|
use crate::alerts::AlertRule;
|
||||||
|
|
||||||
/// Shared, process-wide settings handle. Cheap to clone; lock is only held
|
/// Shared, process-wide settings handle. Cheap to clone.
|
||||||
/// briefly since settings changes are rare (unlike the engine's hot path).
|
|
||||||
pub type SettingsHandle = Arc<Mutex<Settings>>;
|
pub type SettingsHandle = Arc<Mutex<Settings>>;
|
||||||
|
|
||||||
/// User's preferred colour scheme for the webapp.
|
/// User's preferred colour scheme for the webapp.
|
||||||
@@ -40,17 +34,7 @@ pub enum ColorScheme {
|
|||||||
System,
|
System,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single CSS entry configured for the webapp: either content that ottyl
|
/// A single CSS entry: content ottyl stores itself ("imported") or a reference to an external path/URL ("linked", not deletable via the API). `is_theme` marks it as a selectable colour-scheme theme rather than always-injected CSS.
|
||||||
/// stores itself ("imported") or a reference to an external path/URL that
|
|
||||||
/// the webapp links to directly ("linked", and therefore not deletable via
|
|
||||||
/// the settings API since ottyl doesn't own it).
|
|
||||||
///
|
|
||||||
/// Ticket 010 builds the CSS import/link HTTP API around this shape.
|
|
||||||
///
|
|
||||||
/// `is_theme` (ticket 033) marks an entry as a full colour-scheme theme (a
|
|
||||||
/// `:root { --c-*: ...; }` variable set, selectable from the theme picker)
|
|
||||||
/// rather than arbitrary CSS that's always injected. `#[serde(default)]` so
|
|
||||||
/// existing settings.json files without the field still load as `false`.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||||
pub enum CssEntry {
|
pub enum CssEntry {
|
||||||
@@ -67,14 +51,10 @@ pub enum CssEntry {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Placeholder shape for profile management (ticket 009 builds CRUD on top
|
/// Tracks known profile names and which one is active; the profile files themselves live elsewhere.
|
||||||
/// of this). Profiles are expected to live as individual files under a
|
|
||||||
/// dedicated directory; this struct only tracks known profile names and
|
|
||||||
/// which one is currently active.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
pub struct Profiles {
|
pub struct Profiles {
|
||||||
/// Names of known profiles. Ticket 009 owns creating/loading the actual
|
/// Names of known profiles.
|
||||||
/// profile files.
|
|
||||||
pub names: Vec<String>,
|
pub names: Vec<String>,
|
||||||
/// Name of the currently active profile, if any.
|
/// Name of the currently active profile, if any.
|
||||||
pub active_profile: Option<String>,
|
pub active_profile: Option<String>,
|
||||||
@@ -83,27 +63,19 @@ pub struct Profiles {
|
|||||||
pub profiles_dir: String,
|
pub profiles_dir: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The single, process-wide settings instance.
|
/// The single, process-wide settings instance. Only `PartialEq` (not `Eq`): `alert_rules` carries `f64` fields, which aren't `Eq`.
|
||||||
///
|
|
||||||
/// Only `PartialEq` (not `Eq`): `alert_rules` carries `f64` fields
|
|
||||||
/// (`AlertCondition::Threshold`/`Ratio`), which aren't `Eq`.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub color_scheme: ColorScheme,
|
pub color_scheme: ColorScheme,
|
||||||
pub css_entries: Vec<CssEntry>,
|
pub css_entries: Vec<CssEntry>,
|
||||||
/// Id of the selected custom theme entry (a `css_entries` entry with
|
/// Id of the selected custom theme entry, if any; `None` means `color_scheme` alone determines the active palette.
|
||||||
/// `is_theme: true`), if any (ticket 033). `None` means `color_scheme`
|
|
||||||
/// alone determines the active palette. `#[serde(default)]` so existing
|
|
||||||
/// settings.json files without this field still load.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub custom_theme_id: Option<String>,
|
pub custom_theme_id: Option<String>,
|
||||||
pub profiles: Profiles,
|
pub profiles: Profiles,
|
||||||
/// Maximum number of ingested logs kept in memory. `None` (the default)
|
/// Maximum number of ingested logs kept in memory; `None` means unlimited.
|
||||||
/// means unlimited — matches today's unbounded retention.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub max_logs: Option<usize>,
|
pub max_logs: Option<usize>,
|
||||||
/// Alert rule definitions (ticket 017). `#[serde(default)]` so existing
|
/// Alert rule definitions.
|
||||||
/// settings.json files without this field still load.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub alert_rules: Vec<AlertRule>,
|
pub alert_rules: Vec<AlertRule>,
|
||||||
}
|
}
|
||||||
@@ -125,23 +97,13 @@ impl Default for Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computes the default settings file path using OS-conventional config
|
/// Computes the default settings file path (`<config_dir>/settings.json`) from OS-conventional config directories; `None` if none could be determined.
|
||||||
/// directories: `<config_dir>/settings.json`, where `config_dir` comes from
|
|
||||||
/// `directories::ProjectDirs::from("", "", "ottyl")`.
|
|
||||||
///
|
|
||||||
/// Returns `None` if no valid config directory could be determined for the
|
|
||||||
/// current OS/user (this mirrors `ProjectDirs::from`'s own fallibility).
|
|
||||||
pub fn default_settings_path() -> Option<PathBuf> {
|
pub fn default_settings_path() -> Option<PathBuf> {
|
||||||
ProjectDirs::from("", "", "ottyl").map(|dirs| dirs.config_dir().join("settings.json"))
|
ProjectDirs::from("", "", "ottyl").map(|dirs| dirs.config_dir().join("settings.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
/// Loads settings from `path`. If the file doesn't exist yet, a default
|
/// Loads settings from `path`, creating and persisting defaults if missing. Any I/O or parse error on an existing file is logged and treated as defaults, without overwriting the unreadable file.
|
||||||
/// `Settings` is created, persisted to `path`, and returned.
|
|
||||||
///
|
|
||||||
/// Any I/O or parse error while reading an existing file is treated as
|
|
||||||
/// non-fatal: it is logged to stderr and a default `Settings` is
|
|
||||||
/// returned (without overwriting the unreadable file).
|
|
||||||
pub fn load(path: &Path) -> Settings {
|
pub fn load(path: &Path) -> Settings {
|
||||||
match fs::read_to_string(path) {
|
match fs::read_to_string(path) {
|
||||||
Ok(contents) => match serde_json::from_str(&contents) {
|
Ok(contents) => match serde_json::from_str(&contents) {
|
||||||
@@ -174,8 +136,7 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persists these settings to `path` as pretty-printed JSON, creating
|
/// Persists these settings to `path` as pretty-printed JSON, creating any missing parent directories first.
|
||||||
/// any missing parent directories first.
|
|
||||||
pub fn save(&self, path: &Path) -> io::Result<()> {
|
pub fn save(&self, path: &Path) -> io::Result<()> {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
@@ -186,18 +147,14 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error returned by CSS-entry mutations and lookups (ticket 010).
|
/// Error returned by CSS-entry mutations and lookups.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum CssError {
|
pub enum CssError {
|
||||||
/// No entry with the given id exists.
|
/// No entry with the given id exists.
|
||||||
NotFound,
|
NotFound,
|
||||||
/// The entry exists but is a `Linked` entry, not an `Imported` one, so
|
/// The entry is a `Linked` entry, not `Imported` — ottyl doesn't own a linked entry's content, so there's nothing to delete or serve back.
|
||||||
/// the requested operation (delete / read-content) doesn't apply: ottyl
|
|
||||||
/// never owns a linked entry's content, so there's nothing to delete or
|
|
||||||
/// serve back.
|
|
||||||
NotImported,
|
NotImported,
|
||||||
/// Underlying I/O failure: writing/reading/removing the CSS file on
|
/// Underlying I/O failure: writing/reading/removing the CSS file, or persisting `settings.json`.
|
||||||
/// disk, or persisting `settings.json`.
|
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,14 +176,7 @@ impl From<io::Error> for CssError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computes the id used to reference a `CssEntry` over the API.
|
/// Computes the id used to reference a `CssEntry` over the API: the stored id for `Imported`, or a hash of `path_or_url` for `Linked` (which has no stored id), so the frontend can key both the same way.
|
||||||
///
|
|
||||||
/// For `Imported` entries this is just the stored id. `Linked` entries have
|
|
||||||
/// no stored id (ottyl doesn't own them), so one is derived deterministically
|
|
||||||
/// by hashing the `path_or_url`; this lets the frontend key/reference linked
|
|
||||||
/// entries the same way as imported ones (e.g. for list rendering) without
|
|
||||||
/// ottyl needing to persist an id it has no other use for. Because it's
|
|
||||||
/// derived from content, the same `path_or_url` always maps to the same id.
|
|
||||||
pub fn css_entry_id(entry: &CssEntry) -> String {
|
pub fn css_entry_id(entry: &CssEntry) -> String {
|
||||||
match entry {
|
match entry {
|
||||||
CssEntry::Imported { id, .. } => id.clone(),
|
CssEntry::Imported { id, .. } => id.clone(),
|
||||||
@@ -240,10 +190,7 @@ fn linked_entry_id(path_or_url: &str) -> String {
|
|||||||
format!("linked-{:016x}", hasher.finish())
|
format!("linked-{:016x}", hasher.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates an id for a newly-imported CSS entry. Not cryptographically
|
/// Generates an id for a newly-imported CSS entry by hashing filename, content, and time, avoiding a `uuid` dependency for what's otherwise an opaque string.
|
||||||
/// meaningful — just needs to be unique enough in practice: it hashes the
|
|
||||||
/// filename, content, and current time together, avoiding a new dependency
|
|
||||||
/// (e.g. `uuid`) for what's otherwise a random-ish opaque string.
|
|
||||||
fn generate_imported_css_id(filename: &str, content: &str) -> String {
|
fn generate_imported_css_id(filename: &str, content: &str) -> String {
|
||||||
let mut hasher = DefaultHasher::new();
|
let mut hasher = DefaultHasher::new();
|
||||||
filename.hash(&mut hasher);
|
filename.hash(&mut hasher);
|
||||||
@@ -261,15 +208,7 @@ fn imported_css_file_path(css_dir: &Path, id: &str) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
/// Imports raw CSS `content`: writes it to `<css_dir>/<id>.css` (id
|
/// Imports raw CSS `content`: writes it to `<css_dir>/<id>.css`, records a `CssEntry::Imported` in `css_entries`, and persists settings. Returns the generated id. `css_dir` is expected to be the settings file's parent directory.
|
||||||
/// generated from the filename/content/timestamp, see
|
|
||||||
/// `generate_imported_css_id`), records a `CssEntry::Imported { id,
|
|
||||||
/// filename, is_theme }` in `css_entries`, and persists settings to
|
|
||||||
/// `settings_path`. Returns the generated id.
|
|
||||||
///
|
|
||||||
/// `css_dir` is expected to be the settings file's parent directory
|
|
||||||
/// (i.e. CSS files live alongside `settings.json`). `is_theme` (ticket
|
|
||||||
/// 033) marks this entry as a selectable theme rather than arbitrary CSS.
|
|
||||||
pub fn import_css(
|
pub fn import_css(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
@@ -290,9 +229,7 @@ impl Settings {
|
|||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers a reference to an external CSS path/URL without copying
|
/// Registers a reference to an external CSS path/URL without copying its content, and persists settings.
|
||||||
/// its content. Persists settings to `settings_path`. `is_theme` (ticket
|
|
||||||
/// 033) marks this entry as a selectable theme rather than arbitrary CSS.
|
|
||||||
pub fn link_css(
|
pub fn link_css(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
@@ -307,14 +244,7 @@ impl Settings {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes an imported CSS entry identified by `id` (see
|
/// Removes an imported CSS entry identified by `id`: deletes its file under `css_dir`, drops it from `css_entries`, and persists settings. Fails with `NotFound` or `NotImported` (linked entries aren't deletable this way).
|
||||||
/// `css_entry_id`): deletes its file under `css_dir` and drops it from
|
|
||||||
/// `css_entries`, then persists settings to `settings_path`.
|
|
||||||
///
|
|
||||||
/// Returns `CssError::NotFound` if no entry matches `id`, and
|
|
||||||
/// `CssError::NotImported` if `id` refers to a `Linked` entry — linked
|
|
||||||
/// entries are never deletable this way since ottyl doesn't own their
|
|
||||||
/// content.
|
|
||||||
pub fn remove_css(
|
pub fn remove_css(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
@@ -331,8 +261,7 @@ impl Settings {
|
|||||||
Some(CssEntry::Imported { .. }) => {
|
Some(CssEntry::Imported { .. }) => {
|
||||||
let idx = index.unwrap();
|
let idx = index.unwrap();
|
||||||
self.css_entries.remove(idx);
|
self.css_entries.remove(idx);
|
||||||
// Best-effort: if the file is already gone, the settings
|
// Best-effort: the settings entry is already removed even if the file is gone.
|
||||||
// reference is still what matters and has been removed above.
|
|
||||||
let _ = fs::remove_file(imported_css_file_path(css_dir, id));
|
let _ = fs::remove_file(imported_css_file_path(css_dir, id));
|
||||||
self.save(settings_path)?;
|
self.save(settings_path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -340,13 +269,7 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads back the raw content of an imported CSS entry identified by
|
/// Reads back the raw content of an imported CSS entry identified by `id`. Fails with `NotFound` or `NotImported` (linked entries have no local content — the frontend fetches `path_or_url` directly).
|
||||||
/// `id` (see `css_entry_id`).
|
|
||||||
///
|
|
||||||
/// Returns `CssError::NotFound` if no entry matches `id`, and
|
|
||||||
/// `CssError::NotImported` if `id` refers to a `Linked` entry (there's no
|
|
||||||
/// local content to serve for those — the frontend should link/fetch the
|
|
||||||
/// original `path_or_url` directly instead).
|
|
||||||
pub fn css_content(&self, css_dir: &Path, id: &str) -> Result<String, CssError> {
|
pub fn css_content(&self, css_dir: &Path, id: &str) -> Result<String, CssError> {
|
||||||
match self.css_entries.iter().find(|entry| css_entry_id(entry) == id) {
|
match self.css_entries.iter().find(|entry| css_entry_id(entry) == id) {
|
||||||
None => Err(CssError::NotFound),
|
None => Err(CssError::NotFound),
|
||||||
@@ -364,9 +287,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_settings_path_ends_with_settings_json() {
|
fn default_settings_path_ends_with_settings_json() {
|
||||||
// We can't assert the exact OS path here without touching real
|
// Can't assert the exact OS path without touching real config dirs, so just check the shape.
|
||||||
// config dirs, but we can verify the path-computation logic shapes
|
|
||||||
// it as expected.
|
|
||||||
if let Some(path) = default_settings_path() {
|
if let Some(path) = default_settings_path() {
|
||||||
assert_eq!(path.file_name().unwrap(), "settings.json");
|
assert_eq!(path.file_name().unwrap(), "settings.json");
|
||||||
assert!(path.parent().is_some());
|
assert!(path.parent().is_some());
|
||||||
|
|||||||
+1
-5
@@ -1,11 +1,7 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
/// Wall-clock instant stored as nanoseconds since the Unix epoch.
|
/// Wall-clock instant stored as nanoseconds since the Unix epoch (not `std::time::Instant`, which has no epoch and can't be serialized).
|
||||||
///
|
|
||||||
/// The README's data model sketches this field as `Instant`, but `Instant` has no
|
|
||||||
/// epoch and can't be serialized to the frontend, so wall-clock time is stored here
|
|
||||||
/// instead.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||||
pub struct Timestamp(i64);
|
pub struct Timestamp(i64);
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
# 030 — Cut comments down to clear, direct one-liners
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Pass over the Rust codebase, the webapp source, and the webapp's in-app docs,
|
|
||||||
trimming every comment/doc-comment to a single clear, direct line where one is
|
|
||||||
needed at all, and removing it outright where it isn't.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- `src/*.rs`: many modules currently carry multi-paragraph `///`/`//!` blocks
|
|
||||||
(e.g. `src/settings.rs`'s module doc and per-method docs). Keep only what
|
|
||||||
explains a non-obvious *why* (a constraint, an invariant, a workaround);
|
|
||||||
drop restatements of what the code already says via naming.
|
|
||||||
- `webapp/src/**`: same treatment for inline comments and any JSDoc-style
|
|
||||||
blocks.
|
|
||||||
- The webapp's in-app documentation content (surfaced via the "?" help icons
|
|
||||||
from [021](021-docs-contextual-help.md)) — reduce prose there the same way:
|
|
||||||
clear and direct, not verbose.
|
|
||||||
- This is a mechanical cleanup pass, not a refactor — don't change behavior or
|
|
||||||
restructure code while doing it; keep it reviewable as a comments-only diff
|
|
||||||
where possible.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- No multi-line comment blocks remain outside of license/generated headers
|
|
||||||
(if any).
|
|
||||||
- Every remaining comment states a non-obvious reason, not a restatement of
|
|
||||||
the adjacent code.
|
|
||||||
- `cargo test` and the webapp build/tests still pass (confirms nothing beyond
|
|
||||||
comments changed).
|
|
||||||
|
|
||||||
## Depends on
|
|
||||||
|
|
||||||
None.
|
|
||||||
+8
-28
@@ -22,11 +22,7 @@ import { DocsNavProvider } from './lib/docsNav'
|
|||||||
|
|
||||||
const POLL_MS = 2000
|
const POLL_MS = 2000
|
||||||
|
|
||||||
/// Reads the standalone-page routing params written by `PopOutButton`
|
/// Reads the routing params `PopOutButton` writes: `section` picks which primary section to render full-page, and `startMs`/`endMs` carry over the range for sections with no `LogTimeline` of their own to pick one.
|
||||||
/// (ticket 036) — a `section` id selects one of the four primary sections to
|
|
||||||
/// render full-page with no nav/sibling chrome, and `startMs`/`endMs` carry
|
|
||||||
/// over the range that was active at pop-out time for sections that have no
|
|
||||||
/// `LogTimeline` of their own to pick one.
|
|
||||||
function parsePopOutQuery(): { section: PopOutSectionId | null; range: TimeRange | null } {
|
function parsePopOutQuery(): { section: PopOutSectionId | null; range: TimeRange | null } {
|
||||||
const query = new URLSearchParams(window.location.search)
|
const query = new URLSearchParams(window.location.search)
|
||||||
const section = query.get('section') as PopOutSectionId | null
|
const section = query.get('section') as PopOutSectionId | null
|
||||||
@@ -46,9 +42,7 @@ function StandalonePage({ children }: { children: ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Params has no lifted-state sibling to share `params`/`loadParams` with
|
/// Popped out on its own, Params has no lifted-state sibling to share `params`/`loadParams` with, so it owns a minimal fetch here instead.
|
||||||
/// when popped out on its own, so it owns a minimal fetch of its own here
|
|
||||||
/// rather than App's full `loadParams` machinery.
|
|
||||||
function StandaloneParams() {
|
function StandaloneParams() {
|
||||||
const [params, setParams] = useState<ParamDeclaration[]>([])
|
const [params, setParams] = useState<ParamDeclaration[]>([])
|
||||||
|
|
||||||
@@ -71,15 +65,12 @@ function StandaloneParams() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Metrics/Dashboards already own their own data fetching — they only need
|
/// Metrics/Dashboards own their own data fetching — they only need the range carried over via query params.
|
||||||
/// the range that was active at pop-out time, carried over via query params.
|
|
||||||
function StandaloneRanged({ section, range }: { section: 'metrics' | 'dashboards'; range: TimeRange | null }) {
|
function StandaloneRanged({ section, range }: { section: 'metrics' | 'dashboards'; range: TimeRange | null }) {
|
||||||
return <StandalonePage>{section === 'metrics' ? <Metrics range={range} /> : <Dashboards range={range} />}</StandalonePage>
|
return <StandalonePage>{section === 'metrics' ? <Metrics range={range} /> : <Dashboards range={range} />}</StandalonePage>
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The log viewer has its own `LogTimeline` to pick a range, so it doesn't
|
/// The log viewer has its own `LogTimeline`, so it needs its own copy of App's log/timeline polling state instead of a carried-over range.
|
||||||
/// need one carried over — it just needs its own copy of App's log/timeline
|
|
||||||
/// polling state, since none of that is shared with any sibling here.
|
|
||||||
function StandaloneLog() {
|
function StandaloneLog() {
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||||
const [params, setParams] = useState<ParamDeclaration[]>([])
|
const [params, setParams] = useState<ParamDeclaration[]>([])
|
||||||
@@ -194,11 +185,7 @@ function FullApp() {
|
|||||||
const [filterError, setFilterError] = useState<string | null>(null)
|
const [filterError, setFilterError] = useState<string | null>(null)
|
||||||
const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer')
|
const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer')
|
||||||
|
|
||||||
// Read from inside the poll loop below without making it a dependency —
|
// Read via ref inside the poll loop instead of depending on `range` directly, which would restart the interval every rolling-preset tick.
|
||||||
// a rolling preset's own re-resolution (ticket 024) updates `range`, and
|
|
||||||
// depending on `range` directly would restart the effect (and its
|
|
||||||
// interval) every tick, turning "poll every POLL_MS" into "poll as fast as
|
|
||||||
// React can re-render."
|
|
||||||
const rangeRef = useRef(range)
|
const rangeRef = useRef(range)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
rangeRef.current = range
|
rangeRef.current = range
|
||||||
@@ -221,9 +208,7 @@ function FullApp() {
|
|||||||
setRange(computePresetRange(id))
|
setRange(computePresetRange(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// A drag on LogTimeline always yields a fixed (non-rolling) range,
|
// A drag always yields a fixed (non-rolling) range, overriding any active preset.
|
||||||
// overriding whatever preset was active — same as picking a preset
|
|
||||||
// overrides a previous drag.
|
|
||||||
function handleManualRangeSelect(next: TimeRange | null) {
|
function handleManualRangeSelect(next: TimeRange | null) {
|
||||||
if (next === null) {
|
if (next === null) {
|
||||||
selectPreset('all')
|
selectPreset('all')
|
||||||
@@ -237,8 +222,7 @@ function FullApp() {
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
async function poll() {
|
async function poll() {
|
||||||
// Rolling presets "move with time": re-resolve on this same polling
|
// Rolling presets re-resolve on this same polling cadence instead of freezing the window at selection time.
|
||||||
// cadence rather than freezing the window at selection time.
|
|
||||||
const effectiveRange =
|
const effectiveRange =
|
||||||
activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current
|
activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current
|
||||||
if (activePreset && activePreset !== 'all') {
|
if (activePreset && activePreset !== 'all') {
|
||||||
@@ -273,11 +257,7 @@ function FullApp() {
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
clearInterval(id)
|
clearInterval(id)
|
||||||
}
|
}
|
||||||
// Intentionally excludes `range`: a manual drag (which does change
|
// Intentionally excludes `range` to avoid restarting this interval on every rolling-preset tick.
|
||||||
// `activePreset` to null) still gets an immediate poll; only a second
|
|
||||||
// drag made while already in fixed-range mode waits for the next
|
|
||||||
// interval tick, which is an acceptable trade-off for not restarting
|
|
||||||
// this effect (and its interval) every rolling-preset tick.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filter, activePreset])
|
}, [filter, activePreset])
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ const OPS: { value: CompareOp; label: string }[] = [
|
|||||||
{ value: 'ne', label: '!=' },
|
{ value: 'ne', label: '!=' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Parses a plain millisecond number or a humantime-style duration like
|
/// Parses a plain millisecond number or a humantime-style duration (`30s`/`5m`/`1h`) to mirror the backend's `window_ms` without a full humantime parser here.
|
||||||
/// `30s`/`5m`/`1h` into milliseconds. Mirrors the backend's `window_ms`
|
|
||||||
/// field without pulling in a full humantime parser for the webapp.
|
|
||||||
function parseWindowMs(input: string): number | null {
|
function parseWindowMs(input: string): number | null {
|
||||||
const trimmed = input.trim()
|
const trimmed = input.trim()
|
||||||
if (!trimmed) return null
|
if (!trimmed) return null
|
||||||
@@ -119,8 +117,6 @@ function formFromRule(rule: AlertRule): FormState {
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates the form's condition-specific fields, returning an error
|
|
||||||
/// message (or null if valid) plus the built `AlertCondition` when valid.
|
|
||||||
function buildCondition(form: FormState): { error: string | null; condition: AlertCondition | null } {
|
function buildCondition(form: FormState): { error: string | null; condition: AlertCondition | null } {
|
||||||
switch (form.kind) {
|
switch (form.kind) {
|
||||||
case 'immediate':
|
case 'immediate':
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { TimeRange } from './LogTimeline'
|
import type { TimeRange } from './LogTimeline'
|
||||||
|
|
||||||
/// localStorage key for which of the four primary sections (log viewer,
|
/// localStorage key holding a record of section id -> collapsed, mirroring LogPreview's VISIBLE_PARAM_COLUMNS_STORAGE_KEY pattern.
|
||||||
/// params, metrics, dashboards) are collapsed (ticket 035). Mirrors
|
|
||||||
/// LogPreview's VISIBLE_PARAM_COLUMNS_STORAGE_KEY pattern — one key holding
|
|
||||||
/// a record of section id -> collapsed.
|
|
||||||
const COLLAPSED_SECTIONS_STORAGE_KEY = 'ottyl.collapsedSections'
|
const COLLAPSED_SECTIONS_STORAGE_KEY = 'ottyl.collapsedSections'
|
||||||
|
|
||||||
function loadCollapsedSections(): Record<string, boolean> {
|
function loadCollapsedSections(): Record<string, boolean> {
|
||||||
@@ -27,10 +24,7 @@ function saveCollapsedSections(sections: Record<string, boolean>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persists a section's collapsed state across reloads. Deliberately
|
/// Returns only the boolean + setter, not a conditional-render helper — callers keep rendering their subtree and hide it with CSS (`hidden`), so collapsing never unmounts in-progress form state.
|
||||||
/// returns only the boolean + setter — callers keep rendering their
|
|
||||||
/// subtree, just hide it with CSS (`hidden`), so collapsing never unmounts
|
|
||||||
/// in-progress form state.
|
|
||||||
export function useSectionCollapsed(sectionId: string): [boolean, (next: boolean) => void] {
|
export function useSectionCollapsed(sectionId: string): [boolean, (next: boolean) => void] {
|
||||||
const [collapsed, setCollapsedState] = useState(() => loadCollapsedSections()[sectionId] ?? false)
|
const [collapsed, setCollapsedState] = useState(() => loadCollapsedSections()[sectionId] ?? false)
|
||||||
|
|
||||||
@@ -59,8 +53,7 @@ export function CollapseToggle({ collapsed, onToggle }: { collapsed: boolean; on
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The four primary sections that can be popped out into their own tab
|
/// The four primary sections poppable into their own tab; matches the `?section=` query param a standalone page reads.
|
||||||
/// (ticket 036) — matches the `?section=` query param a standalone page reads.
|
|
||||||
export type PopOutSectionId = 'log' | 'params' | 'metrics' | 'dashboards'
|
export type PopOutSectionId = 'log' | 'params' | 'metrics' | 'dashboards'
|
||||||
|
|
||||||
function popOutUrl(section: PopOutSectionId, range?: TimeRange | null): string {
|
function popOutUrl(section: PopOutSectionId, range?: TimeRange | null): string {
|
||||||
@@ -72,10 +65,7 @@ function popOutUrl(section: PopOutSectionId, range?: TimeRange | null): string {
|
|||||||
return `?${query.toString()}`
|
return `?${query.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens this section, full-page and with no surrounding chrome, in a new
|
/// `range` is only needed for sections without their own LogTimeline — the popped-out page can't pick a range itself, so the current one is carried over via query params.
|
||||||
/// tab. `range` is only needed for sections without their own LogTimeline —
|
|
||||||
/// the popped-out page has no way to pick a range itself, so the current one
|
|
||||||
/// is carried over via query params (see `App.tsx`'s standalone rendering).
|
|
||||||
export function PopOutButton({ section, range }: { section: PopOutSectionId; range?: TimeRange | null }) {
|
export function PopOutButton({ section, range }: { section: PopOutSectionId; range?: TimeRange | null }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -19,31 +19,22 @@ import { HelpIcon } from './HelpIcon'
|
|||||||
|
|
||||||
const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie']
|
const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie']
|
||||||
|
|
||||||
/// How long to wait after the last edit before autosaving (ticket 027) — long
|
/// Long enough that rapid typing doesn't spam the save endpoint, short enough that a pause reads as "done" and commits promptly.
|
||||||
/// enough that rapid typing in a title/query field doesn't spam the save
|
|
||||||
/// endpoint on every keystroke, short enough that a pause reads as "done for
|
|
||||||
/// now" and commits promptly.
|
|
||||||
const AUTOSAVE_DEBOUNCE_MS = 600
|
const AUTOSAVE_DEBOUNCE_MS = 600
|
||||||
|
|
||||||
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||||||
|
|
||||||
/// What actually gets persisted for a dashboard — used both to snapshot "last
|
/// What actually gets persisted for a dashboard — used both to snapshot "last saved" state and to build the `saveDashboard` request body.
|
||||||
/// saved" state and to build the `saveDashboard` request body.
|
|
||||||
function dashboardSaveShape(dashboard: Dashboard) {
|
function dashboardSaveShape(dashboard: Dashboard) {
|
||||||
return { name: dashboard.name, panels: dashboard.panels }
|
return { name: dashboard.name, panels: dashboard.panels }
|
||||||
}
|
}
|
||||||
|
|
||||||
function newPanelId(): string {
|
function newPanelId(): string {
|
||||||
// Client-side scratch id for a not-yet-saved panel; the backend treats
|
// Client-side scratch id for a not-yet-saved panel; only needs to be unique within one editing session since the backend treats panel ids as opaque strings.
|
||||||
// panel ids as opaque strings, so this only needs to be unique within one
|
|
||||||
// dashboard's editing session.
|
|
||||||
return `panel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
return `panel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One panel's live preview: fetches its PromQL series whenever the query,
|
/// Fetches the panel's PromQL series on query/chart-type/range change; a malformed query must render as an inline error rather than crash the page.
|
||||||
/// chart type, or active time range changes, and renders either the chart, a
|
|
||||||
/// loading state, or an inline error (a malformed query must not crash the
|
|
||||||
/// page — see ticket 014's acceptance criteria).
|
|
||||||
function PanelPreview({ panel, range }: { panel: Panel; range: TimeRange | null }) {
|
function PanelPreview({ panel, range }: { panel: Panel; range: TimeRange | null }) {
|
||||||
const [series, setSeries] = useState<PromqlSeries[]>([])
|
const [series, setSeries] = useState<PromqlSeries[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -85,8 +76,6 @@ function PanelPreview({ panel, range }: { panel: Panel; range: TimeRange | null
|
|||||||
return <PanelChart series={series} chartType={panel.chart_type} />
|
return <PanelChart series={series} chartType={panel.chart_type} />
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unobtrusive inline autosave indicator (ticket 027) — no blocking dialog,
|
|
||||||
/// just small text near the dashboard header.
|
|
||||||
function AutosaveStatus({ status, error }: { status: SaveStatus; error: string | null }) {
|
function AutosaveStatus({ status, error }: { status: SaveStatus; error: string | null }) {
|
||||||
if (status === 'saving') return <span className="text-xs text-text-faint">Saving…</span>
|
if (status === 'saving') return <span className="text-xs text-text-faint">Saving…</span>
|
||||||
if (status === 'saved') return <span className="text-xs text-text-faint">Saved</span>
|
if (status === 'saved') return <span className="text-xs text-text-faint">Saved</span>
|
||||||
@@ -109,10 +98,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
// JSON snapshot of the last-persisted { name, panels } shape for the
|
// Last-persisted shape for the current draft, so the autosave effect can skip when nothing actually changed.
|
||||||
// current draft — lets the autosave effect skip scheduling a save when
|
|
||||||
// nothing actually changed (e.g. re-selecting the same dashboard, or the
|
|
||||||
// re-render that follows a successful save itself).
|
|
||||||
const lastSavedRef = useRef<string | null>(null)
|
const lastSavedRef = useRef<string | null>(null)
|
||||||
const [collapsed, setCollapsed] = useSectionCollapsed('dashboards')
|
const [collapsed, setCollapsed] = useSectionCollapsed('dashboards')
|
||||||
|
|
||||||
@@ -138,9 +124,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => setActiveProfile(null))
|
.catch(() => setActiveProfile(null))
|
||||||
// Only load once on mount; switching the active profile elsewhere in the
|
// Only load once on mount; switching profiles elsewhere (NavMenu) requires a reload for now.
|
||||||
// app (NavMenu) requires a reload for now, same as other panels don't
|
|
||||||
// watch for profile changes mid-session.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -150,10 +134,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
|
|||||||
lastSavedRef.current = found ? JSON.stringify(dashboardSaveShape(found)) : null
|
lastSavedRef.current = found ? JSON.stringify(dashboardSaveShape(found)) : null
|
||||||
}, [selectedId, dashboards])
|
}, [selectedId, dashboards])
|
||||||
|
|
||||||
// Autosave (ticket 027): debounces edits so rapid typing doesn't spam the
|
// Skips entirely once the draft matches what's already persisted — covers both re-selecting an unchanged dashboard and this effect's own post-save re-render.
|
||||||
// save endpoint, and skips entirely once the draft matches what's already
|
|
||||||
// persisted (covers both re-selecting an unchanged dashboard and the
|
|
||||||
// re-render that follows this effect's own successful save).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!draft || !activeProfile) return
|
if (!draft || !activeProfile) return
|
||||||
const snapshot = JSON.stringify(dashboardSaveShape(draft))
|
const snapshot = JSON.stringify(dashboardSaveShape(draft))
|
||||||
@@ -170,8 +151,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
|
|||||||
setSaveStatus('saved')
|
setSaveStatus('saved')
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Deliberately leave `draft` as-is on failure — the in-progress edit
|
// Deliberately leave `draft` as-is on failure — the in-progress edit isn't lost, it just hasn't persisted yet.
|
||||||
// isn't lost, it just hasn't persisted yet.
|
|
||||||
setSaveStatus('error')
|
setSaveStatus('error')
|
||||||
setSaveError(err instanceof ApiError ? err.message : 'Failed to save dashboard')
|
setSaveError(err instanceof ApiError ? err.message : 'Failed to save dashboard')
|
||||||
}
|
}
|
||||||
@@ -180,8 +160,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
|
|||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout)
|
||||||
}, [draft, activeProfile])
|
}, [draft, activeProfile])
|
||||||
|
|
||||||
// Settles a transient "Saved" status back to idle after a moment, so it
|
// Settles a transient "Saved" status back to idle so it reads as a confirmation, not a permanent label.
|
||||||
// reads as a confirmation rather than a permanent label.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (saveStatus !== 'saved') return
|
if (saveStatus !== 'saved') return
|
||||||
const timeout = setTimeout(() => setSaveStatus('idle'), 1500)
|
const timeout = setTimeout(() => setSaveStatus('idle'), 1500)
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
import type { DocsTarget } from '../lib/docsNav'
|
import type { DocsTarget } from '../lib/docsNav'
|
||||||
|
|
||||||
/// One accordion section: a title plus its body, rendered as plain JSX so
|
/// One accordion section: title plus body as plain JSX, avoiding a markdown renderer for these static pages.
|
||||||
/// each page can mix prose, lists, and inline code without pulling in a
|
|
||||||
/// markdown renderer for what is currently five static pages.
|
|
||||||
interface DocSection {
|
interface DocSection {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
@@ -21,16 +19,15 @@ const SECTIONS: DocSection[] = [
|
|||||||
body: (
|
body: (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p>
|
<p>
|
||||||
ottyl turns a CLI program's stdout into a Grafana-like dashboard: pipe a program's output into it, and it
|
ottyl turns a CLI program's stdout into a Grafana-like dashboard. Pipe a program's output into it, and it
|
||||||
indexes each line in memory as it arrives, then serves a webapp dashboard over the same process.
|
indexes each line in memory as it arrives while serving a dashboard from the same process.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<Code>{'cli_command --verbose | ottyl'}</Code>
|
<Code>{'cli_command --verbose | ottyl'}</Code>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Everything — ingested log lines, declared params, declared metrics, and derived buckets — lives in one
|
Everything — log lines, params, metrics, and derived buckets — lives in one in-memory index for the
|
||||||
in-memory index for the life of the process. Nothing is written to disk unless you explicitly save a{' '}
|
process's lifetime. Nothing touches disk unless you save a <em>profile</em> or change a <em>setting</em>.
|
||||||
<em>profile</em> or change a <em>setting</em>.
|
|
||||||
</p>
|
</p>
|
||||||
<p className="font-semibold text-text">CLI arguments</p>
|
<p className="font-semibold text-text">CLI arguments</p>
|
||||||
<ul className="list-disc pl-5">
|
<ul className="list-disc pl-5">
|
||||||
@@ -47,19 +44,17 @@ const SECTIONS: DocSection[] = [
|
|||||||
</ul>
|
</ul>
|
||||||
<p className="font-semibold text-text">Settings</p>
|
<p className="font-semibold text-text">Settings</p>
|
||||||
<p>
|
<p>
|
||||||
There is exactly one <em>settings</em> file per machine, holding the colour scheme, any imported/linked CSS,
|
There is one <em>settings</em> file per machine, holding the colour scheme, any imported/linked CSS, and
|
||||||
and which profile is active. Settings "owns" profiles in that sense — it only tracks pointers (known
|
which profile is active. Settings only tracks profile pointers — the profiles themselves (params, metrics,
|
||||||
profile names, which one is active); the profiles themselves (params, metrics, dashboards) are stored
|
dashboards) are stored separately, next to the settings file by default.
|
||||||
separately, next to the settings file by default.
|
|
||||||
</p>
|
</p>
|
||||||
<p className="font-semibold text-text">Profiles</p>
|
<p className="font-semibold text-text">Profiles</p>
|
||||||
<p>
|
<p>
|
||||||
A profile is a named bundle of parsing/indexing rules for a particular CLI app — which params and metrics
|
A profile bundles the parsing/indexing rules for one CLI app — its params and metrics, plus any saved
|
||||||
to derive from its log output, plus any saved dashboards — so restarting ottyl against the same program
|
dashboards — so restarting ottyl against the same program doesn't lose your setup. A <Code>Default</Code>{' '}
|
||||||
doesn't lose your setup. There is always a <Code>Default</Code> profile, and it can't be deleted. Creating
|
profile always exists and can't be deleted. Creating a new profile copies the active one (or starts empty
|
||||||
a new profile copies the currently active one (or starts empty if none is active), so variations build on
|
if none is active), so variations build on what you already have. Switching profiles hot-swaps the running
|
||||||
what you already have rather than starting from scratch. Switching profiles hot-swaps the running engine's
|
engine's declared params/metrics immediately, with no restart needed.
|
||||||
declared params/metrics immediately, with no restart needed.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -70,16 +65,16 @@ const SECTIONS: DocSection[] = [
|
|||||||
body: (
|
body: (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p>
|
<p>
|
||||||
The filter bar above the timeline takes a syntax-aware query combining regex search over raw log text with
|
The filter bar takes a query combining regex search over raw log text with comparisons against declared
|
||||||
comparisons against declared metrics, joined by <Code>and</Code>/<Code>or</Code>:
|
metrics, joined by <Code>and</Code>/<Code>or</Code>:
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<Code>{'regex("error") and sequence<5 or timestamp>12:00'}</Code>
|
<Code>{'regex("error") and sequence<5 or timestamp>12:00'}</Code>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<Code>{'metric_name[param]<5'}</Code> compares a metric's captured param value against a number, string, or
|
<Code>{'metric_name[param]<5'}</Code> compares a metric's captured param against a number, string, or
|
||||||
timestamp. <Code>sequence</Code> and <Code>timestamp</Code> are reserved metric names — every ingested log
|
timestamp. <Code>sequence</Code> and <Code>timestamp</Code> are reserved metric names present on every log
|
||||||
line has both, so they're always available to filter on without declaring anything.
|
line, so they're always filterable without declaring anything.
|
||||||
</p>
|
</p>
|
||||||
<p className="font-semibold text-text">Date/time formats</p>
|
<p className="font-semibold text-text">Date/time formats</p>
|
||||||
<p>Timestamp comparisons accept several forms:</p>
|
<p>Timestamp comparisons accept several forms:</p>
|
||||||
@@ -91,9 +86,9 @@ const SECTIONS: DocSection[] = [
|
|||||||
<li>full dates</li>
|
<li>full dates</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
The same filter string is sent to both <Code>GET /api/logs</Code> and <Code>GET /api/timeline</Code> as the{' '}
|
The same filter string goes to both <Code>GET /api/logs</Code> and <Code>GET /api/timeline</Code> as the{' '}
|
||||||
<Code>filter</Code> query param and evaluated on the server; a parse error comes back from the server and
|
<Code>filter</Code> query param and is evaluated on the server; a parse error surfaces as inline text under
|
||||||
surfaces as inline text under the filter bar rather than crashing the page.
|
the filter bar rather than crashing the page.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -104,21 +99,20 @@ const SECTIONS: DocSection[] = [
|
|||||||
body: (
|
body: (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p>
|
<p>
|
||||||
A parameter declaration tells ottyl how to derive a named value from a raw log line: a regex that must
|
A parameter declaration tells ottyl how to derive a named value from a log line: a regex the message must
|
||||||
match the message, plus a capture group within it, plus a type (<Code>string</Code>, <Code>number</Code>,
|
match, a capture group, and a type (<Code>string</Code>, <Code>number</Code>, or <Code>timestamp</Code>).
|
||||||
or <Code>timestamp</Code>). Once declared, every already-ingested line gets retroactively indexed for that
|
Declaring one retroactively indexes every already-ingested line and checks every new line as it arrives.
|
||||||
param, and every new line is checked against it as it arrives.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Params are the building block metrics are made of (a metric declaration is really a message-match regex
|
Params are the building blocks metrics are made of (a metric is really a message-match regex plus a set of
|
||||||
plus a set of param matchers), and they're also what drives filtering and the log preview's column picker —
|
param matchers), and they drive filtering and the log preview's column picker — declaring a{' '}
|
||||||
declaring a <Code>duration</Code> param, for instance, makes <Code>duration</Code> available as a column in
|
<Code>duration</Code> param, for instance, makes it available as both a log-table column and a comparable
|
||||||
the log table and as a comparable value in the filter bar.
|
value in the filter bar.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
The Params panel declares a bare param directly (<Code>POST /api/params</Code>) — the same request the
|
The Params panel declares a bare param directly (<Code>POST /api/params</Code>) — the same request the
|
||||||
Metrics panel's declaration form makes per param matcher. Declaring one there immediately updates the log
|
Metrics panel's declaration form makes per param matcher. Declaring one there updates the log preview's
|
||||||
preview's column picker, with no page reload needed.
|
column picker immediately, with no page reload needed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -130,14 +124,14 @@ const SECTIONS: DocSection[] = [
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p>
|
<p>
|
||||||
The timeline above the log table renders log volume per bucket over the active window. Drag across it to
|
The timeline above the log table renders log volume per bucket over the active window. Drag across it to
|
||||||
select a fixed time range — it narrows the log table, metrics, and dashboards below to that window.
|
select a fixed range, narrowing the log table, metrics, and dashboards below to that window.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
The preset buttons (<Code>All</Code>, <Code>5m</Code>, <Code>15m</Code>, <Code>1h</Code>,{' '}
|
The preset buttons (<Code>All</Code>, <Code>5m</Code>, <Code>15m</Code>, <Code>1h</Code>,{' '}
|
||||||
<Code>24h</Code>) are Grafana-style shortcuts for the same thing: <Code>All</Code> clears the range
|
<Code>24h</Code>) are Grafana-style shortcuts: <Code>All</Code> clears the range entirely, and a relative
|
||||||
entirely, and a relative preset is <em>rolling</em> — its window keeps advancing to stay "last N minutes
|
preset is <em>rolling</em> — its window keeps advancing to stay "last N minutes from now" instead of
|
||||||
from now" rather than freezing at the moment you picked it. Dragging on the timeline while a preset is
|
freezing at selection time. Dragging while a preset is active switches to a fixed (non-rolling) range, same
|
||||||
active switches back to a fixed (non-rolling) range, same as dragging normally would.
|
as a normal drag.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -149,8 +143,8 @@ const SECTIONS: DocSection[] = [
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p>
|
<p>
|
||||||
The log table lists ingested lines newest-last, each with its sequence number, timestamp, and raw text. The
|
The log table lists ingested lines newest-last, each with its sequence number, timestamp, and raw text. The
|
||||||
column picker (top-right) adds a column per declared param, showing that line's derived value for it (or a
|
column picker (top-right) adds a column per declared param, showing that line's derived value (blank if it
|
||||||
blank if it didn't match) — a quick way to eyeball params without switching to the Metrics panel.
|
didn't match) — a quick way to eyeball params without switching to the Metrics panel.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -167,20 +161,19 @@ const SECTIONS: DocSection[] = [
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Each param is either a <strong>label</strong> (the default) or the metric's single <strong>value</strong>{' '}
|
Each param is either a <strong>label</strong> (the default) or the metric's single <strong>value</strong>{' '}
|
||||||
param. Label params identify which bucket an occurrence belongs to; a value param's captured number becomes
|
param. Labels identify which bucket an occurrence belongs to; a value param's captured number becomes the
|
||||||
that occurrence's stored value instead of a label — e.g. a <Code>duration</Code> param marked{' '}
|
occurrence's stored value instead of a label — e.g. a <Code>duration</Code> param marked <em>value</em>{' '}
|
||||||
<em>value</em> lets you query the distribution or latest reading of durations, not just how often they
|
lets you query the distribution or latest reading, not just how often it occurred. A metric with only
|
||||||
occurred. A metric with only label params still works exactly as before: every occurrence's value defaults
|
label params defaults every occurrence's value to a fixed <Code>1</Code> (a pure occurrence count).
|
||||||
to a fixed <Code>1</Code> (a pure occurrence count).
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Declaring a metric backfills it against everything already ingested, then continues indexing new lines as
|
Declaring a metric backfills it against everything already ingested, then keeps indexing new lines as they
|
||||||
they arrive — there's no need to restart ingestion or re-declare after the fact.
|
arrive — no need to restart ingestion or re-declare after the fact.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
The Metrics panel lets you declare a metric, pick one from the declared list, and see its bucket values
|
The Metrics panel lets you declare a metric, pick one from the declared list, and see its bucket values
|
||||||
grouped by label combination over the active time range — occurrence count per group, plus a small trend
|
grouped by label combination over the active time range — occurrence count per group, plus a small trend
|
||||||
sparkline. That same data is what dashboard panels query via PromQL (see below), including two forms{' '}
|
sparkline. Dashboard panels query that same data via PromQL (see below), including two forms{' '}
|
||||||
<em>derived</em> from a counter's value param rather than stored separately:
|
<em>derived</em> from a counter's value param rather than stored separately:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-5">
|
<ul className="list-disc pl-5">
|
||||||
@@ -188,7 +181,7 @@ const SECTIONS: DocSection[] = [
|
|||||||
<span className="font-semibold text-text">
|
<span className="font-semibold text-text">
|
||||||
<Code>histogram(metric_name[width])</Code>
|
<Code>histogram(metric_name[width])</Code>
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
— buckets every matching event's value into fixed-width buckets of size <Code>width</Code>, e.g.{' '}
|
— buckets each matching event's value into fixed-width buckets of size <Code>width</Code>, e.g.{' '}
|
||||||
<Code>histogram(durations[10])</Code>.
|
<Code>histogram(durations[10])</Code>.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -208,9 +201,9 @@ const SECTIONS: DocSection[] = [
|
|||||||
body: (
|
body: (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p>
|
<p>
|
||||||
Dashboards work like Grafana: each one is a named set of panels, and each panel is a PromQL query paired
|
Dashboards work like Grafana: each is a named set of panels, and each panel is a PromQL query paired with a
|
||||||
with a chart type — bar, line, or pie. Panels preview live as you edit the query, and a malformed query
|
chart type — bar, line, or pie. Panels preview live as you edit the query, and a malformed query shows an
|
||||||
shows an inline error instead of breaking the page.
|
inline error instead of breaking the page.
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-5">
|
<ul className="list-disc pl-5">
|
||||||
<li>
|
<li>
|
||||||
@@ -218,8 +211,7 @@ const SECTIONS: DocSection[] = [
|
|||||||
x-axis.
|
x-axis.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className="font-semibold text-text">Bar</span> — the latest value per series (a snapshot of "where
|
<span className="font-semibold text-text">Bar</span> — the latest value per series (a snapshot of "now").
|
||||||
things are now").
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className="font-semibold text-text">Pie</span> — each series' share of the summed total across the
|
<span className="font-semibold text-text">Pie</span> — each series' share of the summed total across the
|
||||||
@@ -227,8 +219,8 @@ const SECTIONS: DocSection[] = [
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
Dashboards are saved per profile, so they travel with a profile when you switch or copy it, and require an
|
Dashboards are saved per profile, so they travel with it when you switch or copy it — an active profile is
|
||||||
active profile to create or save — pick or create one from the settings menu first.
|
required to create or save one; pick or create one from the settings menu first.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -239,12 +231,12 @@ const SECTIONS: DocSection[] = [
|
|||||||
body: (
|
body: (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p>
|
<p>
|
||||||
An alert rule fires a notification when its condition matches a newly-ingested log line — evaluated per
|
An alert rule fires a notification when its condition matches a newly-ingested log line, evaluated per line
|
||||||
line as it arrives, not backfilled against history. There are four condition types:
|
as it arrives rather than backfilled against history. There are four condition types:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-5">
|
<ul className="list-disc pl-5">
|
||||||
<li>
|
<li>
|
||||||
<span className="font-semibold text-text">Immediate</span> — fires once per line matching a query.
|
<span className="font-semibold text-text">Immediate</span> — fires once per matching line.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className="font-semibold text-text">Threshold</span> — fires when a query's match count within a
|
<span className="font-semibold text-text">Threshold</span> — fires when a query's match count within a
|
||||||
@@ -260,8 +252,8 @@ const SECTIONS: DocSection[] = [
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's query syntax, but only the subset
|
Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's syntax, but only the subset that
|
||||||
that evaluates against a single line's own fields — <Code>regex(...)</Code>, <Code>sequence</Code>,{' '}
|
evaluates a single line's own fields — <Code>regex(...)</Code>, <Code>sequence</Code>,{' '}
|
||||||
<Code>timestamp</Code>, and <Code>and</Code>/<Code>or</Code>. A <Code>metric_name[param]</Code> comparison
|
<Code>timestamp</Code>, and <Code>and</Code>/<Code>or</Code>. A <Code>metric_name[param]</Code> comparison
|
||||||
isn't supported in an alert query.
|
isn't supported in an alert query.
|
||||||
</p>
|
</p>
|
||||||
@@ -270,10 +262,9 @@ const SECTIONS: DocSection[] = [
|
|||||||
<Code>5m</Code>, <Code>1h</Code>.
|
<Code>5m</Code>, <Code>1h</Code>.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Every rule delivers to an <em>ntfy</em> sink: a topic (required) and an optional server override (defaults
|
Every rule delivers to an <em>ntfy</em> sink: a topic (required) and an optional server override (default{' '}
|
||||||
to <Code>https://ntfy.sh</Code>). "Send test notification" delivers to whatever topic/server is currently
|
<Code>https://ntfy.sh</Code>). "Send test notification" delivers to the form's current topic/server without
|
||||||
entered in the form, without needing to save the rule first — handy for verifying the sink before
|
needing to save the rule first.
|
||||||
committing to it.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -284,9 +275,9 @@ const SECTIONS: DocSection[] = [
|
|||||||
body: (
|
body: (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p>
|
<p>
|
||||||
The Settings panel controls process-wide state: colour scheme, the maximum number of logs kept in memory
|
The Settings panel controls process-wide state: colour scheme, the max number of logs kept in memory
|
||||||
(blank means unlimited), and imported/linked CSS. These apply immediately and persist to the single
|
(blank means unlimited), and imported/linked CSS. Changes apply immediately and persist to the settings
|
||||||
settings file on disk — no save button needed.
|
file on disk — no save button needed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -298,15 +289,14 @@ const SECTIONS: DocSection[] = [
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p>
|
<p>
|
||||||
The Presets panel manages profiles: create, switch, and delete them. Switching activates a profile's
|
The Presets panel manages profiles: create, switch, and delete them. Switching activates a profile's
|
||||||
params/metrics in the running engine immediately, with no restart needed — see the <em>General idea</em>{' '}
|
params/metrics in the running engine immediately, no restart needed — see <em>General idea</em> above for
|
||||||
section above for what a profile stores.
|
what a profile stores.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
/** How long a flashed section stays highlighted before settling back to normal. */
|
|
||||||
const FLASH_DURATION_MS = 1200
|
const FLASH_DURATION_MS = 1200
|
||||||
|
|
||||||
export function DocsPanel({ target }: { target?: DocsTarget | null }) {
|
export function DocsPanel({ target }: { target?: DocsTarget | null }) {
|
||||||
@@ -314,9 +304,7 @@ export function DocsPanel({ target }: { target?: DocsTarget | null }) {
|
|||||||
const [flashId, setFlashId] = useState<string | null>(null)
|
const [flashId, setFlashId] = useState<string | null>(null)
|
||||||
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||||
|
|
||||||
// Keyed on `target?.nonce` (not `target?.sectionId`) so clicking the same
|
// Keyed on `target?.nonce`, not `target?.sectionId`, so clicking the same "?" icon twice re-triggers the flash instead of no-op'ing when already open.
|
||||||
// "?" icon twice in a row re-triggers the flash rather than being a no-op
|
|
||||||
// because the panel is already open to that section.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!target) return
|
if (!target) return
|
||||||
setOpenId(target.sectionId)
|
setOpenId(target.sectionId)
|
||||||
|
|||||||
@@ -2,19 +2,11 @@ import type { ReactNode } from 'react'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { HelpIcon } from './HelpIcon'
|
import { HelpIcon } from './HelpIcon'
|
||||||
|
|
||||||
/// Shared styling for a single FilterBar-syntax query input, reused by
|
// Reused by `Alerts.tsx`'s condition editor so alert conditions look like the filter bar without duplicating its draft/submit state.
|
||||||
/// `Alerts.tsx`'s condition editor so query-based alert conditions look like
|
|
||||||
/// the top-level filter bar without dragging in its own draft/submit state.
|
|
||||||
export const queryInputClassName =
|
export const queryInputClassName =
|
||||||
'flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none'
|
'flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none'
|
||||||
|
|
||||||
/**
|
// The filter string is parsed and evaluated server-side (`filter` param on `GET /api/logs`/`GET /api/timeline`); this just manages draft text and surfaces the server's parse error.
|
||||||
* Input for the syntax-aware pest-parsed filter language described in the
|
|
||||||
* README (regex + metric comparisons combined with `and`/`or`). The raw
|
|
||||||
* filter string is sent to the server (`GET /api/logs` / `GET /api/timeline`
|
|
||||||
* `filter` param) and evaluated there; this component just manages the
|
|
||||||
* draft text and surfaces any parse error the server reports back.
|
|
||||||
*/
|
|
||||||
export function FilterBar({
|
export function FilterBar({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { useDocsNav } from '../lib/docsNav'
|
import { useDocsNav } from '../lib/docsNav'
|
||||||
|
|
||||||
/**
|
// Opens the Docs panel scrolled to (and flashing) sectionId, instead of the user hunting for it in the Docs accordion (ticket 021).
|
||||||
* A small "?" affordance that opens the Docs panel scrolled to (and
|
|
||||||
* flashing) a specific section (ticket 021), instead of the user having to
|
|
||||||
* find it themselves in the Docs accordion.
|
|
||||||
*/
|
|
||||||
export function HelpIcon({ sectionId, label }: { sectionId: string; label?: string }) {
|
export function HelpIcon({ sectionId, label }: { sectionId: string; label?: string }) {
|
||||||
const { openSection } = useDocsNav()
|
const { openSection } = useDocsNav()
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { metricPrimitiveToDisplayString, type LogEntry, type ParamDeclaration } from '../lib/api'
|
import { metricPrimitiveToDisplayString, type LogEntry, type ParamDeclaration } from '../lib/api'
|
||||||
import { HelpIcon } from './HelpIcon'
|
import { HelpIcon } from './HelpIcon'
|
||||||
|
|
||||||
/// localStorage key for LogPreview's selected param columns. Namespaced
|
// Namespaced under `ottyl.` so future per-viewer preferences elsewhere in the app won't collide with this key.
|
||||||
/// under `ottyl.` since this is the app's first localStorage usage — kept
|
|
||||||
/// distinct enough that future per-viewer preferences elsewhere in the app
|
|
||||||
/// won't collide with it.
|
|
||||||
const VISIBLE_PARAM_COLUMNS_STORAGE_KEY = 'ottyl.logPreview.visibleParamColumns'
|
const VISIBLE_PARAM_COLUMNS_STORAGE_KEY = 'ottyl.logPreview.visibleParamColumns'
|
||||||
|
|
||||||
function loadVisibleParamColumns(): string[] {
|
function loadVisibleParamColumns(): string[] {
|
||||||
@@ -23,8 +20,7 @@ function saveVisibleParamColumns(columns: string[]) {
|
|||||||
try {
|
try {
|
||||||
localStorage.setItem(VISIBLE_PARAM_COLUMNS_STORAGE_KEY, JSON.stringify(columns))
|
localStorage.setItem(VISIBLE_PARAM_COLUMNS_STORAGE_KEY, JSON.stringify(columns))
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort — a private browsing mode or full storage just means the
|
// best-effort — private browsing or full storage just means the selection won't persist across reloads
|
||||||
// selection won't persist across reloads.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,9 +97,7 @@ export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDe
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only render columns for params that are both selected and still known to
|
// Filter out stale localStorage entries for since-removed params, so they don't leave a dangling column.
|
||||||
// exist, so a stale localStorage entry for a since-removed param doesn't
|
|
||||||
// leave a dangling column.
|
|
||||||
const columns = visibleParamColumns.filter((name) => paramNames.includes(name))
|
const columns = visibleParamColumns.filter((name) => paramNames.includes(name))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ export interface TimeRange {
|
|||||||
endMs: number
|
endMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A Grafana-style relative time-range preset (ticket 024). `'all'` clears
|
// `'all'` clears the range (the implicit default); the rest resolve to a window ending "now" at the moment they're (re-)computed — see `computePresetRange`.
|
||||||
/// the range (today's implicit default); the rest resolve to a window
|
|
||||||
/// ending "now" at the moment they're (re-)computed — see `computePresetRange`.
|
|
||||||
export type TimeRangePresetId = 'all' | '5m' | '15m' | '1h' | '24h'
|
export type TimeRangePresetId = 'all' | '5m' | '15m' | '1h' | '24h'
|
||||||
|
|
||||||
export const TIME_RANGE_PRESETS: { id: TimeRangePresetId; label: string; ms: number | null }[] = [
|
export const TIME_RANGE_PRESETS: { id: TimeRangePresetId; label: string; ms: number | null }[] = [
|
||||||
@@ -20,10 +18,7 @@ export const TIME_RANGE_PRESETS: { id: TimeRangePresetId; label: string; ms: num
|
|||||||
{ id: '24h', label: '24h', ms: 24 * 60 * 60 * 1000 },
|
{ id: '24h', label: '24h', ms: 24 * 60 * 60 * 1000 },
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Resolves a preset to a concrete `{startMs, endMs}` window as of *now* —
|
// Callers re-invoke this on their existing poll cadence (see `App.tsx`) to keep a rolling preset's window moving with time, rather than freezing it at selection time.
|
||||||
/// callers re-invoke this on the same cadence they already poll on to keep a
|
|
||||||
/// rolling preset's window moving with time (see `App.tsx`), rather than
|
|
||||||
/// freezing it at selection time.
|
|
||||||
export function computePresetRange(id: TimeRangePresetId): TimeRange | null {
|
export function computePresetRange(id: TimeRangePresetId): TimeRange | null {
|
||||||
const preset = TIME_RANGE_PRESETS.find((p) => p.id === id)
|
const preset = TIME_RANGE_PRESETS.find((p) => p.id === id)
|
||||||
if (!preset || preset.ms == null) return null
|
if (!preset || preset.ms == null) return null
|
||||||
@@ -131,9 +126,7 @@ export function LogTimeline({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* A dragged (custom, non-preset) selection — presets already show
|
{/* Only shown for a dragged (non-preset) selection — presets already show their own active state above. */}
|
||||||
their own active state above, so this only appears for the
|
|
||||||
"fixed range from a drag" case. */}
|
|
||||||
{range && activePreset === null && (
|
{range && activePreset === null && (
|
||||||
<button className="underline hover:text-text-secondary" onClick={() => onSelectPreset('all')}>
|
<button className="underline hover:text-text-secondary" onClick={() => onSelectPreset('all')}>
|
||||||
Clear selection
|
Clear selection
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ import { HelpIcon } from './HelpIcon'
|
|||||||
|
|
||||||
const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' }
|
const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' }
|
||||||
|
|
||||||
/// One row in the "labels -> occurrence count" table for the selected
|
// One row in the labels -> occurrence count table; `values` keeps the raw points for the sparkline rendering.
|
||||||
/// metric's bucket values over the active range. `values` keeps the raw
|
|
||||||
/// points (used for the sparkline-ish rendering) alongside the count.
|
|
||||||
interface LabelGroup {
|
interface LabelGroup {
|
||||||
key: string
|
key: string
|
||||||
labels: Record<string, string>
|
labels: Record<string, string>
|
||||||
@@ -52,9 +50,7 @@ function groupByLabels(values: BucketValue[]): LabelGroup[] {
|
|||||||
return Array.from(groups.values()).sort((a, b) => b.count - a.count)
|
return Array.from(groups.values()).sort((a, b) => b.count - a.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A crude text sparkline: buckets a group's values into a fixed number of
|
// A crude text sparkline using block characters — not a real chart, that's ticket 014's job.
|
||||||
/// slots across the visible range and renders relative bar heights using
|
|
||||||
/// block characters. Not a real chart — that's ticket 014's job.
|
|
||||||
const SPARK_CHARS = ' ▁▂▃▄▅▆▇█'
|
const SPARK_CHARS = ' ▁▂▃▄▅▆▇█'
|
||||||
function sparkline(values: BucketValue[], range: TimeRange | null): string {
|
function sparkline(values: BucketValue[], range: TimeRange | null): string {
|
||||||
if (values.length === 0) return ''
|
if (values.length === 0) return ''
|
||||||
@@ -98,11 +94,7 @@ export function Metrics({ range }: { range: TimeRange | null }) {
|
|||||||
loadMetrics()
|
loadMetrics()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Refetch-on-change rather than continuous polling: bucket values only
|
// Refetch-on-change rather than continuous polling: bucket values only move when the selected metric or range changes, so no interval is needed here like `App.tsx`'s log/timeline polling.
|
||||||
// need to move when the selected metric or the active time range
|
|
||||||
// changes, and a declaration's backfill (ticket 003) is already reflected
|
|
||||||
// by the time `declareMetric`'s promise resolves, so there's no need for
|
|
||||||
// an interval here the way `App.tsx`'s log/timeline polling has.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
setBucketValues([])
|
setBucketValues([])
|
||||||
|
|||||||
@@ -41,18 +41,14 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
|
|||||||
setOpenPanel((current) => (current === name ? null : name))
|
setOpenPanel((current) => (current === name ? null : name))
|
||||||
}
|
}
|
||||||
|
|
||||||
// A "?" icon (ticket 021) always opens Docs to that section and flashes it,
|
// A "?" icon always opens Docs to its section and flashes it, even if Docs is already open elsewhere — hence forcing `openPanel` here rather than toggling.
|
||||||
// even if Docs is already open to a different section — hence forcing
|
|
||||||
// `openPanel` here rather than toggling.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!docsNav.target) return
|
if (!docsNav.target) return
|
||||||
setOpenPanel('docs')
|
setOpenPanel('docs')
|
||||||
setDocsPanelTarget(docsNav.target)
|
setDocsPanelTarget(docsNav.target)
|
||||||
}, [docsNav.target])
|
}, [docsNav.target])
|
||||||
|
|
||||||
// Opening Docs the normal way (this button, not a "?" icon) behaves as
|
// Opening Docs via this button (not a "?" icon) has no forced section or flash, so clear any pending help-triggered target.
|
||||||
// before: no forced section, no flash — so any pending help-triggered
|
|
||||||
// target is cleared.
|
|
||||||
function handleDocsButtonClick() {
|
function handleDocsButtonClick() {
|
||||||
togglePanel('docs')
|
togglePanel('docs')
|
||||||
setDocsPanelTarget(null)
|
setDocsPanelTarget(null)
|
||||||
|
|||||||
@@ -15,24 +15,17 @@ import {
|
|||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import type { ChartType, PromqlSeries } from '../lib/api'
|
import type { ChartType, PromqlSeries } from '../lib/api'
|
||||||
|
|
||||||
/// Renders a label combination as a short, stable string for use as a series
|
/// Renders a label combination as a series key/legend string, e.g. `{"path":"/a"}` -> `path=/a`; no labels renders as `value`.
|
||||||
/// key/legend entry — e.g. `{"path":"/a"}` -> `path=/a`. A series with no
|
|
||||||
/// labels at all (a bare metric name with no matchers) renders as `value`.
|
|
||||||
export function seriesLabel(labels: Record<string, string>): string {
|
export function seriesLabel(labels: Record<string, string>): string {
|
||||||
const entries = Object.entries(labels)
|
const entries = Object.entries(labels)
|
||||||
if (entries.length === 0) return 'value'
|
if (entries.length === 0) return 'value'
|
||||||
return entries.map(([k, v]) => `${k}=${v}`).join(', ')
|
return entries.map(([k, v]) => `${k}=${v}`).join(', ')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixed, small categorical palette (rather than pulling in a colour-scale
|
// Fixed, small categorical palette to avoid a colour-scale dependency; cycles if there are more series than colours.
|
||||||
// dependency) — cycles if there are more series than colours.
|
|
||||||
const COLORS = ['#38bdf8', '#f472b6', '#a78bfa', '#4ade80', '#fbbf24', '#fb7185', '#2dd4bf', '#f97316']
|
const COLORS = ['#38bdf8', '#f472b6', '#a78bfa', '#4ade80', '#fbbf24', '#fb7185', '#2dd4bf', '#f97316']
|
||||||
|
|
||||||
/// A line chart with time on the x-axis, value on the y-axis, and one line
|
// Merges series into one row-per-timestamp dataset (recharts wants a single array keyed by series name); a timestamp missing from a series is left undefined there, which recharts skips when drawing that line.
|
||||||
/// per label combination (series). Points across series are merged into one
|
|
||||||
/// row-per-timestamp dataset (recharts wants a single array of objects keyed
|
|
||||||
/// by series name) — timestamps that only one series has are left `undefined`
|
|
||||||
/// for the others, which recharts simply skips when drawing that line.
|
|
||||||
function toLineData(series: PromqlSeries[]): { rows: Record<string, number | string>[]; keys: string[] } {
|
function toLineData(series: PromqlSeries[]): { rows: Record<string, number | string>[]; keys: string[] } {
|
||||||
const keys = series.map((s) => seriesLabel(s.labels))
|
const keys = series.map((s) => seriesLabel(s.labels))
|
||||||
const byTimestamp = new Map<number, Record<string, number | string>>()
|
const byTimestamp = new Map<number, Record<string, number | string>>()
|
||||||
@@ -58,14 +51,7 @@ function formatTimestamp(ts: number): string {
|
|||||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bar/pie charts need a single scalar per series, but the input is a time
|
// Bar/pie need a single scalar per series from a time series: bar uses the latest point (current snapshot), pie uses the sum (share of total volume).
|
||||||
/// series — inherently lossy. Aggregation choices (documented here rather
|
|
||||||
/// than hidden in the code):
|
|
||||||
/// - bar: the *latest* point per series (a snapshot of "where things are
|
|
||||||
/// now" — the common Grafana single-stat-per-series bar use case).
|
|
||||||
/// - pie: the *sum* of all points per series, shown as a share of the total
|
|
||||||
/// across all series (proportion of total volume, the common Grafana pie
|
|
||||||
/// use case — e.g. "which path got the most requests over this range").
|
|
||||||
function latestValue(series: PromqlSeries): number {
|
function latestValue(series: PromqlSeries): number {
|
||||||
if (series.points.length === 0) return 0
|
if (series.points.length === 0) return 0
|
||||||
return series.points[series.points.length - 1][1]
|
return series.points[series.points.length - 1][1]
|
||||||
@@ -133,7 +119,6 @@ export function PanelChart({ series, chartType }: { series: PromqlSeries[]; char
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pie: share of total across label combinations (see aggregation note above).
|
|
||||||
const rows = series.map((s) => ({ name: seriesLabel(s.labels), value: sumValue(s) }))
|
const rows = series.map((s) => ({ name: seriesLabel(s.labels), value: sumValue(s) }))
|
||||||
const total = rows.reduce((sum, r) => sum + r.value, 0)
|
const total = rows.reduce((sum, r) => sum + r.value, 0)
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
|
|||||||
+12
-34
@@ -1,7 +1,4 @@
|
|||||||
/// `MetricPrimitive` is a Rust enum with derived `Serialize`, so its JSON
|
// Externally-tagged JSON matching the Rust `MetricPrimitive` enum's derived `Serialize`, e.g. `{ "Duration": 1500 }` (milliseconds).
|
||||||
/// form is externally tagged, e.g. `{ "String": "info" }`, `{ "Number":
|
|
||||||
/// { "Int": 5 } }`, `{ "Timestamp": 1710000000000 }`, `{ "Duration": 1500 }`
|
|
||||||
/// (milliseconds, ticket 023).
|
|
||||||
export type MetricPrimitive =
|
export type MetricPrimitive =
|
||||||
| { String: string }
|
| { String: string }
|
||||||
| { Number: { Int: number } | { Float: number } }
|
| { Number: { Int: number } | { Float: number } }
|
||||||
@@ -20,9 +17,7 @@ export interface LogEntry {
|
|||||||
sequence: number
|
sequence: number
|
||||||
timestamp_ms: number
|
timestamp_ms: number
|
||||||
text: string
|
text: string
|
||||||
/// Derived param values (declared via `POST /api/params`) that matched
|
// A param with no match on this line is simply absent from the map.
|
||||||
/// this line's text, keyed by param name. A param with no match on this
|
|
||||||
/// line is simply absent from the map.
|
|
||||||
params: Record<string, MetricPrimitive>
|
params: Record<string, MetricPrimitive>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +41,6 @@ export interface Stats {
|
|||||||
last_timestamp_ms: number | null
|
last_timestamp_ms: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a param contributes to a bucket's labels or is its captured value (ticket 022). */
|
|
||||||
export type ParamRole = 'label' | 'value'
|
export type ParamRole = 'label' | 'value'
|
||||||
|
|
||||||
export interface MetricParamMatcher {
|
export interface MetricParamMatcher {
|
||||||
@@ -61,7 +55,7 @@ export interface MetricDeclaration {
|
|||||||
name: string
|
name: string
|
||||||
message_match_regex: string
|
message_match_regex: string
|
||||||
params: MetricParamMatcher[]
|
params: MetricParamMatcher[]
|
||||||
/** Fixed labels applied on a match/non-match, on top of any captured params (ticket 025). */
|
/** Fixed labels applied on a match/non-match, on top of any captured params. */
|
||||||
matched_labels?: Record<string, string> | null
|
matched_labels?: Record<string, string> | null
|
||||||
unmatched_labels?: Record<string, string> | null
|
unmatched_labels?: Record<string, string> | null
|
||||||
}
|
}
|
||||||
@@ -73,8 +67,7 @@ export interface BucketValue {
|
|||||||
value: MetricPrimitive
|
value: MetricPrimitive
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error shape returned by API handlers on a 4xx/5xx response:
|
// Error shape returned by API handlers on a 4xx/5xx response (see `ErrorDto` on the backend).
|
||||||
/// `{ "error": "..." }` (see `ErrorDto` on the backend).
|
|
||||||
export interface ApiErrorBody {
|
export interface ApiErrorBody {
|
||||||
error: string
|
error: string
|
||||||
}
|
}
|
||||||
@@ -103,7 +96,7 @@ export interface Settings {
|
|||||||
active_profile: string | null
|
active_profile: string | null
|
||||||
/** Maximum ingested logs kept in memory; `null` means unlimited. */
|
/** Maximum ingested logs kept in memory; `null` means unlimited. */
|
||||||
max_logs: number | null
|
max_logs: number | null
|
||||||
/** Id of the selected custom theme entry (ticket 033), or `null` if `color_scheme` alone applies. */
|
/** Id of the selected custom theme entry, or `null` if `color_scheme` alone applies. */
|
||||||
custom_theme_id: string | null
|
custom_theme_id: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +107,7 @@ export interface CssEntry {
|
|||||||
kind: CssEntryKind
|
kind: CssEntryKind
|
||||||
filename: string | null
|
filename: string | null
|
||||||
path_or_url: string | null
|
path_or_url: string | null
|
||||||
/** Whether this entry is a selectable colour-scheme theme (ticket 033) rather than arbitrary CSS. */
|
/** Whether this entry is a selectable colour-scheme theme rather than arbitrary CSS. */
|
||||||
is_theme: boolean
|
is_theme: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,9 +118,7 @@ export interface ProfileSummary {
|
|||||||
dashboard_count: number
|
dashboard_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One chart-ready time series returned by `GET /api/query` (ticket 013):
|
// One chart-ready time series from `GET /api/query`: a label combination plus its `(timestamp_ms, value)` points, sorted by timestamp.
|
||||||
/// a label combination plus its `(timestamp_ms, value)` points, sorted by
|
|
||||||
/// timestamp. See `PromqlSeriesDto` on the backend.
|
|
||||||
export interface PromqlSeries {
|
export interface PromqlSeries {
|
||||||
labels: Record<string, string>
|
labels: Record<string, string>
|
||||||
points: [number, number][]
|
points: [number, number][]
|
||||||
@@ -148,20 +139,16 @@ export interface Dashboard {
|
|||||||
panels: Panel[]
|
panels: Panel[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Comparison operator shared by `AlertCondition::Threshold`/`Ratio` and
|
|
||||||
/// filter-query metric comparisons (see `CompareOp` on the backend).
|
|
||||||
export type CompareOp = 'lt' | 'gt' | 'le' | 'ge' | 'eq' | 'ne'
|
export type CompareOp = 'lt' | 'gt' | 'le' | 'ge' | 'eq' | 'ne'
|
||||||
|
|
||||||
/// What triggers an alert rule (ticket 017/018). Externally tagged on
|
// Externally tagged on `kind`, matching `AlertCondition`'s `#[serde(tag = "kind")]` on the backend.
|
||||||
/// `kind`, matching `AlertCondition`'s `#[serde(tag = "kind")]`.
|
|
||||||
export type AlertCondition =
|
export type AlertCondition =
|
||||||
| { kind: 'immediate'; query: string }
|
| { kind: 'immediate'; query: string }
|
||||||
| { kind: 'threshold'; query: string; op: CompareOp; threshold: number; window_ms: number }
|
| { kind: 'threshold'; query: string; op: CompareOp; threshold: number; window_ms: number }
|
||||||
| { kind: 'newvalue'; field_regex: string; capture_group: string }
|
| { kind: 'newvalue'; field_regex: string; capture_group: string }
|
||||||
| { kind: 'ratio'; query_a: string; query_b: string; op: CompareOp; ratio: number; window_ms: number }
|
| { kind: 'ratio'; query_a: string; query_b: string; op: CompareOp; ratio: number; window_ms: number }
|
||||||
|
|
||||||
/// Where a fired alert is delivered (ticket 019). Only one sink kind exists
|
// Only one sink kind exists today, but this stays a tagged union to match the backend.
|
||||||
/// today, but this stays a tagged union to match the backend.
|
|
||||||
export type AlertSink = { kind: 'ntfy'; topic: string; server: string | null }
|
export type AlertSink = { kind: 'ntfy'; topic: string; server: string | null }
|
||||||
|
|
||||||
export interface AlertRule {
|
export interface AlertRule {
|
||||||
@@ -189,11 +176,7 @@ async function getJson<T>(path: string): Promise<T> {
|
|||||||
return res.json() as Promise<T>
|
return res.json() as Promise<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POSTs a JSON body to `path`. On a non-ok response, attempts to parse the
|
// Throws `ApiError` (not `Error`) with the parsed `{ "error": "..." }` message so callers can surface it inline.
|
||||||
/// body as `{ "error": "..." }` (the shape every declaration endpoint uses
|
|
||||||
/// for validation failures) and throws an `ApiError` with that message so
|
|
||||||
/// callers can surface it inline; falls back to a generic message if the
|
|
||||||
/// body isn't in that shape.
|
|
||||||
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -357,10 +340,7 @@ export function activateProfile(name: string): Promise<Settings> {
|
|||||||
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST')
|
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST')
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluates a PromQL query (ticket 013) via `GET /api/query`. Throws on a
|
// Throws on a malformed query (400); callers should catch and surface the message inline rather than letting it crash the page.
|
||||||
/// malformed query (`400` with `{ "error": "..." }`), so callers should catch
|
|
||||||
/// and surface the message inline (e.g. per-panel) rather than letting it
|
|
||||||
/// crash the page.
|
|
||||||
export function fetchPromqlSeries(params: {
|
export function fetchPromqlSeries(params: {
|
||||||
promql: string
|
promql: string
|
||||||
startMs?: number
|
startMs?: number
|
||||||
@@ -419,9 +399,7 @@ export function deleteAlertRule(id: string): Promise<void> {
|
|||||||
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'DELETE')
|
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'DELETE')
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delivers a test notification to `topic`/`server` (ticket 020) without
|
// Delivers a test notification without requiring the rule to be saved first; throws `ApiError` on failure so callers can surface it inline.
|
||||||
/// requiring the rule to be saved first. Throws `ApiError` on failure (e.g.
|
|
||||||
/// ntfy unreachable) so callers can surface it inline.
|
|
||||||
export function sendTestAlertNotification(topic: string, server: string | null): Promise<void> {
|
export function sendTestAlertNotification(topic: string, server: string | null): Promise<void> {
|
||||||
return sendJson('/api/settings/alerts/test', 'POST', { topic, server })
|
return sendJson('/api/settings/alerts/test', 'POST', { topic, server })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'
|
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
/** One request to open the Docs panel scrolled to a specific section (ticket 021). */
|
// One request to open the Docs panel scrolled to a specific section.
|
||||||
export interface DocsTarget {
|
export interface DocsTarget {
|
||||||
sectionId: string
|
sectionId: string
|
||||||
/** Bumped on every request, even to the same section, so a repeat click re-triggers the flash. */
|
/** Bumped on every request, even to the same section, so a repeat click re-triggers the flash. */
|
||||||
|
|||||||
@@ -1,20 +1,4 @@
|
|||||||
/**
|
// A theme file is a CSS file with one `:root { --c-*: ...; }` block defining every variable in `THEME_CSS_VARS`; `useSettingsPanel` applies the selected one as an unscoped stylesheet only while no built-in `html.<scheme>` class is present, so it wins the cascade without rewriting the selector.
|
||||||
* Downloadable/re-uploadable custom themes (ticket 033).
|
|
||||||
*
|
|
||||||
* A theme file is a small CSS file containing exactly one `:root { --c-*:
|
|
||||||
* ...; }` block defining every variable in `THEME_CSS_VARS` (the full set
|
|
||||||
* `index.css`'s `:root`/`html.<scheme>` blocks use to skin the app). Using
|
|
||||||
* `:root` (rather than a scoped selector) keeps the file trivially portable:
|
|
||||||
* the same content works whether it's applied at `:root` or scoped, and
|
|
||||||
* round-tripping a downloaded file back through upload reproduces it
|
|
||||||
* byte-for-byte-equivalent (same variable values).
|
|
||||||
*
|
|
||||||
* Applying a selected custom theme is left to `useSettingsPanel`, which
|
|
||||||
* injects the entry's CSS as an unscoped stylesheet *only* while no built-in
|
|
||||||
* `html.<scheme>` class is present — since both forms target `:root` with
|
|
||||||
* equal specificity, the later stylesheet in the cascade wins, so no
|
|
||||||
* rewriting of the file's own `:root` selector is needed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Canonical list of every `--c-*` variable every theme in `index.css` defines. */
|
/** Canonical list of every `--c-*` variable every theme in `index.css` defines. */
|
||||||
export const THEME_CSS_VARS = [
|
export const THEME_CSS_VARS = [
|
||||||
@@ -42,16 +26,7 @@ export const THEME_CSS_VARS = [
|
|||||||
|
|
||||||
export type ThemeVars = Record<(typeof THEME_CSS_VARS)[number], string>
|
export type ThemeVars = Record<(typeof THEME_CSS_VARS)[number], string>
|
||||||
|
|
||||||
/**
|
// Fully-resolved variable values for each built-in theme, including inherited fallbacks, so a downloaded file is always complete; keep in sync with `index.css` by hand, there's no build step linking the two.
|
||||||
* Built-in themes' fully-resolved variable values, matching `index.css`'s
|
|
||||||
* current cascade (a theme that doesn't override `--c-danger-*` there falls
|
|
||||||
* back to `:root`'s dark values there, so those are inlined here too — a
|
|
||||||
* downloaded built-in file is always complete, never relies on a `:root`
|
|
||||||
* fallback the download mechanism doesn't have access to).
|
|
||||||
*
|
|
||||||
* Keep in sync with `webapp/src/index.css` by hand; there's no built step
|
|
||||||
* that derives one from the other.
|
|
||||||
*/
|
|
||||||
export const BUILTIN_THEMES: Record<string, ThemeVars> = {
|
export const BUILTIN_THEMES: Record<string, ThemeVars> = {
|
||||||
dark: {
|
dark: {
|
||||||
'--c-surface-0': '#0a0a0a',
|
'--c-surface-0': '#0a0a0a',
|
||||||
@@ -253,7 +228,6 @@ export const BUILTIN_THEMES: Record<string, ThemeVars> = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generates a downloadable theme CSS file's content from a full variable map. */
|
|
||||||
export function generateThemeCss(vars: ThemeVars): string {
|
export function generateThemeCss(vars: ThemeVars): string {
|
||||||
const lines = THEME_CSS_VARS.map((name) => ` ${name}: ${vars[name]};`)
|
const lines = THEME_CSS_VARS.map((name) => ` ${name}: ${vars[name]};`)
|
||||||
return `:root {\n${lines.join('\n')}\n}\n`
|
return `:root {\n${lines.join('\n')}\n}\n`
|
||||||
@@ -264,14 +238,7 @@ export interface ParsedTheme {
|
|||||||
missing: string[]
|
missing: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Parses `--c-*: value;` declarations from the first `:root { ... }` block (or the whole file, tolerant of a dropped selector); reports missing canonical variables so callers can reject an incomplete upload.
|
||||||
* Parses a theme CSS file's content, pulling `--c-*: value;` declarations
|
|
||||||
* out of its first `:root { ... }` block (or, failing that, out of the
|
|
||||||
* whole file — tolerant of a hand-edited file that dropped the selector).
|
|
||||||
* Returns every recognized variable found plus the list of canonical
|
|
||||||
* variables (see `THEME_CSS_VARS`) that were missing, so callers can reject
|
|
||||||
* an incomplete upload with a clear error.
|
|
||||||
*/
|
|
||||||
export function parseThemeCss(content: string): ParsedTheme {
|
export function parseThemeCss(content: string): ParsedTheme {
|
||||||
const rootMatch = content.match(/:root\s*\{([^}]*)\}/)
|
const rootMatch = content.match(/:root\s*\{([^}]*)\}/)
|
||||||
const body = rootMatch ? rootMatch[1] : content
|
const body = rootMatch ? rootMatch[1] : content
|
||||||
@@ -286,7 +253,6 @@ export function parseThemeCss(content: string): ParsedTheme {
|
|||||||
return { vars, missing }
|
return { vars, missing }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Triggers a browser download of `content` as `filename` (a `.css` file). */
|
|
||||||
export function downloadThemeFile(filename: string, content: string) {
|
export function downloadThemeFile(filename: string, content: string) {
|
||||||
const blob = new Blob([content], { type: 'text/css' })
|
const blob = new Blob([content], { type: 'text/css' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
|
|||||||
@@ -17,29 +17,16 @@ import {
|
|||||||
type Settings,
|
type Settings,
|
||||||
} from './api'
|
} from './api'
|
||||||
|
|
||||||
/** `data-*` marker used to find/remove previously-injected CSS tags on re-apply. */
|
// `data-*` marker used to find/remove previously-injected CSS tags on re-apply.
|
||||||
const INJECTED_CSS_ATTR = 'data-ottyl-css-id'
|
const INJECTED_CSS_ATTR = 'data-ottyl-css-id'
|
||||||
|
|
||||||
/** `data-*` marker for the single injected `<link>` for the active custom theme, if any (ticket 033). */
|
// `data-*` marker for the single injected `<link>` for the active custom theme, if any.
|
||||||
const INJECTED_THEME_ATTR = 'data-ottyl-theme-css-id'
|
const INJECTED_THEME_ATTR = 'data-ottyl-theme-css-id'
|
||||||
|
|
||||||
/** Themes selectable independently of `system`, each backed by a `html.<name>` block in `index.css`. */
|
// Themes selectable independently of `system`, each backed by a `html.<name>` block in `index.css`.
|
||||||
const THEME_CLASSES = ['light', 'dark', 'grey', 'red', 'green', 'blue', 'yellow', 'pink', 'orange'] as const
|
const THEME_CLASSES = ['light', 'dark', 'grey', 'red', 'green', 'blue', 'yellow', 'pink', 'orange'] as const
|
||||||
|
|
||||||
/**
|
// Reflects `colorScheme` onto `<html>` as a class + `data-color-scheme`; when a custom theme is active, no built-in class is added at all, since a class selector would out-specificity its injected `:root` override (see `applyCustomThemeCss`) and mask it.
|
||||||
* Resolves `colorScheme` to an effective theme (following the OS preference
|
|
||||||
* for `system`, which only ever resolves to `light`/`dark`) and reflects it
|
|
||||||
* onto `<html>` as both a class (see `THEME_CLASSES`) and a
|
|
||||||
* `data-color-scheme` attribute. Every visual role the app uses is a CSS
|
|
||||||
* custom property keyed off that class (see `index.css`), so switching it
|
|
||||||
* re-skins the whole app, not just the base page background.
|
|
||||||
*
|
|
||||||
* `customThemeActive` (ticket 033): when a custom theme is selected, no
|
|
||||||
* built-in `html.<scheme>` class is added at all — its own injected `:root`
|
|
||||||
* override (see `applyCustomThemeCss`) is the only thing setting the
|
|
||||||
* `--c-*` variables, and adding a built-in class alongside it would win the
|
|
||||||
* cascade (a class selector is more specific than `:root`) and mask it.
|
|
||||||
*/
|
|
||||||
function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean) {
|
function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean) {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
root.classList.remove(...THEME_CLASSES)
|
root.classList.remove(...THEME_CLASSES)
|
||||||
@@ -53,17 +40,7 @@ function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean)
|
|||||||
root.dataset.colorScheme = colorScheme
|
root.dataset.colorScheme = colorScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Injects a `<link>` per CSS entry, replacing any this hook previously injected. Only non-theme entries are applied unconditionally; theme entries are applied separately, only while selected — see `applyCustomThemeCss`.
|
||||||
* Injects a `<link>` tag per CSS entry into `<head>` (imported entries point
|
|
||||||
* at `GET /api/settings/css/:id/content`, linked entries point straight at
|
|
||||||
* their `path_or_url`), after removing any tags this hook previously
|
|
||||||
* injected — so the rendered app reflects the current entry list immediately
|
|
||||||
* on every change, without a page reload.
|
|
||||||
*
|
|
||||||
* Only arbitrary (non-theme) entries are applied unconditionally this way;
|
|
||||||
* theme entries (`is_theme: true`) are applied separately and only while
|
|
||||||
* selected, see `applyCustomThemeCss`.
|
|
||||||
*/
|
|
||||||
function applyCssEntries(entries: CssEntry[]) {
|
function applyCssEntries(entries: CssEntry[]) {
|
||||||
document.querySelectorAll(`link[${INJECTED_CSS_ATTR}]`).forEach((el) => el.remove())
|
document.querySelectorAll(`link[${INJECTED_CSS_ATTR}]`).forEach((el) => el.remove())
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -77,13 +54,7 @@ function applyCssEntries(entries: CssEntry[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Same injection mechanism as `applyCssEntries`, kept separate since this one is conditional on `settings.custom_theme_id`; `entry` null just removes any previously-injected tag.
|
||||||
* Injects the single active custom theme's CSS (ticket 033) as a
|
|
||||||
* `<link>` — same mechanism as `applyCssEntries`, kept separate since this
|
|
||||||
* one is conditional on `settings.custom_theme_id`, not always-on. `entry`
|
|
||||||
* is `null` when no custom theme is selected, which just removes any
|
|
||||||
* previously-injected tag.
|
|
||||||
*/
|
|
||||||
function applyCustomThemeCss(entry: CssEntry | null) {
|
function applyCustomThemeCss(entry: CssEntry | null) {
|
||||||
document.querySelectorAll(`link[${INJECTED_THEME_ATTR}]`).forEach((el) => el.remove())
|
document.querySelectorAll(`link[${INJECTED_THEME_ATTR}]`).forEach((el) => el.remove())
|
||||||
if (!entry) return
|
if (!entry) return
|
||||||
@@ -96,12 +67,7 @@ function applyCustomThemeCss(entry: CssEntry | null) {
|
|||||||
document.head.appendChild(link)
|
document.head.appendChild(link)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Owns fetching + mutating settings/CSS/profile state for the NavMenu panels, and applies colour scheme / imported CSS to the document as a side effect so changes are visible immediately, with no reload.
|
||||||
* Owns fetching + mutating settings/CSS/profile state for the NavMenu panels
|
|
||||||
* (ticket 011), and applies the colour scheme / imported CSS to the document
|
|
||||||
* as a side effect whenever that state changes — so toggling either is
|
|
||||||
* visible in the running app immediately, with no reload.
|
|
||||||
*/
|
|
||||||
export function useSettingsPanel() {
|
export function useSettingsPanel() {
|
||||||
const [settings, setSettings] = useState<Settings | null>(null)
|
const [settings, setSettings] = useState<Settings | null>(null)
|
||||||
const [cssEntries, setCssEntries] = useState<CssEntry[]>([])
|
const [cssEntries, setCssEntries] = useState<CssEntry[]>([])
|
||||||
@@ -162,8 +128,7 @@ export function useSettingsPanel() {
|
|||||||
const setColorScheme = useCallback(
|
const setColorScheme = useCallback(
|
||||||
(colorScheme: ColorScheme) =>
|
(colorScheme: ColorScheme) =>
|
||||||
guarded(async () => {
|
guarded(async () => {
|
||||||
// Selecting a built-in scheme always deselects any custom theme —
|
// Selecting a built-in scheme always deselects any custom theme — the two are mutually exclusive (see `applyColorScheme`).
|
||||||
// the two are mutually exclusive (see `applyColorScheme`).
|
|
||||||
const next = await updateSettings({ colorScheme, customThemeId: null })
|
const next = await updateSettings({ colorScheme, customThemeId: null })
|
||||||
setSettings(next)
|
setSettings(next)
|
||||||
})(),
|
})(),
|
||||||
@@ -220,8 +185,7 @@ export function useSettingsPanel() {
|
|||||||
guarded(async () => {
|
guarded(async () => {
|
||||||
await deleteCssEntry(id)
|
await deleteCssEntry(id)
|
||||||
await refreshCss()
|
await refreshCss()
|
||||||
// The removed entry may have been the active custom theme — fall
|
// The removed entry may have been the active custom theme — fall back to `color_scheme` rather than leaving a dangling selection.
|
||||||
// back to `color_scheme` rather than leaving a dangling selection.
|
|
||||||
if (settings?.custom_theme_id === id) {
|
if (settings?.custom_theme_id === id) {
|
||||||
const next = await updateSettings({ customThemeId: null })
|
const next = await updateSettings({ customThemeId: null })
|
||||||
setSettings(next)
|
setSettings(next)
|
||||||
|
|||||||
Reference in New Issue
Block a user