Compare commits
39
Commits
v0.1.0
..
d17cfe9b83
@@ -0,0 +1,45 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
ottyl turns a piped command's stdout (or a tailed file) into a local observability dashboard: searchable log timeline, ad-hoc metrics extracted from log lines via regex, and Grafana-style dashboards — all served from a single self-contained binary for the lifetime of that process. It can also poll a Prometheus/OpenMetrics endpoint alongside log ingestion. See `README.md` for the user-facing feature list and CLI usage.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build # builds webapp (npm install + vite build) then the release Rust binary; webapp/dist is embedded into the binary
|
||||||
|
make dev # frontend dev server with hot reload, proxying API calls to a separately running `cargo run` backend (see webapp/vite.config.ts)
|
||||||
|
make test # cargo test + frontend build (frontend has no test suite, just `tsc -b && vite build`)
|
||||||
|
make lint # cargo clippy --all-targets + oxlint (webapp)
|
||||||
|
make run # build then run ./target/release/ottyl
|
||||||
|
```
|
||||||
|
|
||||||
|
- Single backend test: `cargo test <test_name>`
|
||||||
|
- Backend tests live inline in each `src/*.rs` file (`#[cfg(test)] mod tests`), not in a separate `tests/` dir.
|
||||||
|
- The webapp has no unit tests — `npm run build` (tsc + vite) is the only frontend check besides lint.
|
||||||
|
- `webapp/dist` must exist before `cargo build` will produce a working binary (it's embedded via `fs_embed!`), so always build the frontend first — `make build` handles ordering; don't run `cargo build` alone after frontend changes without rebuilding `webapp/dist`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Backend: Rust (axum + tokio), single binary, in `src/`. Frontend: React 19 + TypeScript + Tailwind + Recharts, in `webapp/`, built with Vite and embedded into the binary at build time (`src/api.rs`'s `WEBAPP_DIST` via `fs_embed!("webapp/dist")`).
|
||||||
|
|
||||||
|
**Data flow:** `main.rs` wires everything together at startup — loads `Settings`, spawns the engine actor task, replays the active profile's param/metric declarations into it (before ingest starts, since there's no way to reprocess earlier lines later), then starts ingest (stdin/file tailing and/or Prometheus polling) and the axum HTTP server.
|
||||||
|
|
||||||
|
- **`engine.rs`** (largest file, ~3400 lines) — the actor owning all live state: ingested log lines, declared params/metrics, computed metric buckets, alert evaluation. Runs as a single tokio task; all access is through `EngineHandle` sending `EngineMessage` over an mpsc channel and awaiting a response via oneshot — there is no shared mutable engine state accessed directly from request handlers. This is the file to read first to understand how declarations, filtering, and bucketing actually work.
|
||||||
|
- **`api.rs`** — axum routes and HTTP-facing `ApiState` (holds `EngineHandle` + `SettingsHandle`). Translates HTTP requests into `EngineMessage`s and DTOs back into JSON. Also serves the embedded webapp.
|
||||||
|
- **`settings.rs` / `profile.rs`** — persisted configuration. A **profile** is per-app: parsing rules (param/metric declarations) and dashboards, saved so they're there again next time that app runs through ottyl (see README's "Profiles"). Declarations are persisted here so the engine can replay them on restart; `api.rs` handlers that mutate engine state (e.g. `declare_metric`) call a corresponding persist method here so the two stay in sync — this dual-write is a recurring pattern (see `ApiState::persist_param_declaration` / `persist_metric_declaration` in `api.rs`), and adding a new declaration-mutating endpoint means updating both sides plus the load-time replay path.
|
||||||
|
- **`filter_parser.rs`** (grammar in `filter_parser.pest`, via `pest`) + **`filter_datetime.rs`** — parse the search/filter bar syntax (regex matches, metric comparisons like `latency_ms[value]<200`, relative/absolute datetimes).
|
||||||
|
- **`promql.rs`** — parses and evaluates the PromQL-style queries backing dashboard panels.
|
||||||
|
- **`logfmt.rs` / `ingest.rs`** — line ingestion: tailing stdin/a file, structured logfmt-style extraction, and polling a Prometheus/OpenMetrics `--metrics-url`.
|
||||||
|
- **`alerts.rs`** — alert rule evaluation and ntfy delivery.
|
||||||
|
- **`dto.rs`** — shared request/response types between `api.rs` and the frontend's `webapp/src/lib/api.ts`; **`data_value.rs`** / **`metric_primitive.rs`** — the value/type model for params and metrics; **`index_key.rs`** / **`rate.rs`** — bucket indexing and rate computation helpers.
|
||||||
|
|
||||||
|
Frontend structure: `webapp/src/App.tsx` is the shell; `webapp/src/components/` has one component per feature area (`FilterBar`, `LogTimeline`, `Metrics`, `Params`, `Dashboards`, `Alerts`, `Docs`, `NavMenu`, ...) that map closely to the backend concepts above. `webapp/src/lib/api.ts` is the typed client for the Rust API; `webapp/src/lib/polling.ts` handles refresh polling; `webapp/src/lib/themes.ts` covers the style-preset/CSS system.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **`tickets/`** holds scoped, written-up work items (problem statement, pointers to exact call sites with `file.rs:line` references, and an explicit scope section) — check here for planned/in-flight work before starting something that might overlap. Tickets are removed from this directory once implemented (see recent commit history — e.g. "Remove ticket 047 (implemented: ...)").
|
||||||
|
- Declaration-related features (params, metrics) consistently need three coordinated changes: an `EngineMessage` variant + handler in `engine.rs`, a persistence method in `settings.rs`/`profile.rs`, and an `api.rs` route wiring them together (plus a webapp UI affordance). Grep for an existing similar declaration (e.g. how metric declaration is wired) as the template before adding a new one.
|
||||||
|
- Doc comments in this codebase tend to explain *why* a non-obvious choice was made (e.g. ordering constraints, a workaround, a past ticket's rationale) rather than restating what the code does — match that style rather than adding descriptive comments.
|
||||||
Generated
+17
@@ -774,6 +774,12 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minimal-lexical"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
@@ -785,6 +791,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "7.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"minimal-lexical",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -824,6 +840,7 @@ dependencies = [
|
|||||||
"http-body-util",
|
"http-body-util",
|
||||||
"humantime",
|
"humantime",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
|
"nom",
|
||||||
"pest",
|
"pest",
|
||||||
"pest_derive",
|
"pest_derive",
|
||||||
"regex",
|
"regex",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
nom = "7"
|
||||||
pest = "2"
|
pest = "2"
|
||||||
pest_derive = "2"
|
pest_derive = "2"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
|
|||||||
+122
-48
@@ -1,9 +1,16 @@
|
|||||||
//! Alert rules: definitions, trigger evaluation, and ntfy delivery.
|
//! Alert rules: definitions, trigger evaluation, and ntfy delivery.
|
||||||
//!
|
//!
|
||||||
//! Alert queries are restricted to regex/sequence/timestamp/json[field] — a metric[param] comparison would require a full bucket rescan per line, breaking the proportional-cost guarantee. json[field] is fine here since, like regex/sequence/timestamp, it only inspects the line's own text/fields, no index lookup.
|
//! Alert queries are restricted to regex/ottyl_seq/ottyl_ts/ottyl_fmt/json[field] — a metric[param] comparison would require a full bucket rescan per line, breaking the proportional-cost guarantee. json[field] is fine here since, like regex/ottyl_seq/ottyl_ts/ottyl_fmt, it only inspects the line's own text/fields, no index lookup.
|
||||||
|
//!
|
||||||
|
//! `Threshold`/`Ratio` windows are maintained via `crate::rate::RateWindow` — the same windowed-rate
|
||||||
|
//! implementation PromQL's `rate()` uses (`crate::engine::evaluate_promql`) — rather than a
|
||||||
|
//! bespoke `VecDeque`+prune loop, so the two agree on what "windowed rate" means (ticket 040). A
|
||||||
|
//! window is also backfilled once, on first evaluation, from already-ingested logs within its own
|
||||||
|
//! trailing window (see `backfill_window`): a rule created mid-session (or a process that replays
|
||||||
|
//! existing log content, e.g. `--file` tailing) no longer starts with a silently empty window.
|
||||||
|
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::io;
|
use std::io;
|
||||||
@@ -13,9 +20,12 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::engine::{apply_compare_op, eval_json_comparison};
|
use crate::data_value::DataValue;
|
||||||
|
use crate::engine::{self, apply_compare_op, eval_json_comparison};
|
||||||
use crate::filter_datetime;
|
use crate::filter_datetime;
|
||||||
use crate::filter_parser::{self, CompareOp, FilterExpr, FilterValue};
|
use crate::filter_parser::{self, CompareOp, FilterExpr, FilterValue};
|
||||||
|
use crate::index_key::IndexKey;
|
||||||
|
use crate::rate::RateWindow;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use crate::timestamp::Timestamp;
|
use crate::timestamp::Timestamp;
|
||||||
|
|
||||||
@@ -135,24 +145,43 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-rule in-memory evaluation state; lives only for the process's lifetime — not persisted, not backfilled against already-ingested logs.
|
/// Per-rule in-memory evaluation state; lives only for the process's lifetime — not persisted
|
||||||
|
/// across a restart with no replayed log history. Each `RateWindow` is backfilled once (see
|
||||||
|
/// `backfill_window`) from whatever matching history is already in the index at first evaluation,
|
||||||
|
/// so — unlike before ticket 040 — a rule doesn't start blind to matches ingested before it was
|
||||||
|
/// declared, or before this evaluation loop last started, as long as that history is still in the
|
||||||
|
/// in-memory log index (e.g. `--file` tailing replays existing file content from the start on
|
||||||
|
/// every process start).
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct AlertRuntimeState {
|
pub struct AlertRuntimeState {
|
||||||
threshold_matches: VecDeque<i64>,
|
threshold_window: RateWindow,
|
||||||
ratio_a_matches: VecDeque<i64>,
|
threshold_backfilled: bool,
|
||||||
ratio_b_matches: VecDeque<i64>,
|
ratio_a_window: RateWindow,
|
||||||
|
ratio_a_backfilled: bool,
|
||||||
|
ratio_b_window: RateWindow,
|
||||||
|
ratio_b_backfilled: bool,
|
||||||
seen_values: HashSet<String>,
|
seen_values: HashSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `timestamps` is push_back-ordered, so once the front entry is within the window, every entry behind it is too.
|
/// Builds a `RateWindow` seeded from already-ingested logs matching `query`, bounded to
|
||||||
fn prune_window(timestamps: &mut VecDeque<i64>, now_ms: i64, window_ms: i64) {
|
/// `window_ms` before `now` (not the full log history) so the cost stays proportional to the
|
||||||
while let Some(&front) = timestamps.front() {
|
/// window rather than everything ever ingested. `before_sequence` excludes the line currently
|
||||||
if now_ms - front > window_ms {
|
/// being evaluated, which the caller pushes separately if it matches.
|
||||||
timestamps.pop_front();
|
fn backfill_window(
|
||||||
} else {
|
index: &BTreeMap<IndexKey, DataValue>,
|
||||||
break;
|
query: &str,
|
||||||
}
|
before_sequence: u64,
|
||||||
}
|
now: Timestamp,
|
||||||
|
window_ms: i64,
|
||||||
|
) -> RateWindow {
|
||||||
|
let since_ms = now.as_millis() - window_ms;
|
||||||
|
let matching_timestamps =
|
||||||
|
engine::logs_since(index, before_sequence, since_ms, now.as_millis()).filter_map(
|
||||||
|
|(sequence, timestamp, text)| {
|
||||||
|
(eval_alert_query(query, sequence, timestamp, text) == Ok(true)).then_some(timestamp)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
RateWindow::backfill(matching_timestamps, now, window_ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluates a FilterBar query against a single line's own fields only (metric[param] comparisons aren't supported — see module doc; json[field] is supported since it's also line-local).
|
/// Evaluates a FilterBar query against a single line's own fields only (metric[param] comparisons aren't supported — see module doc; json[field] is supported since it's also line-local).
|
||||||
@@ -167,16 +196,16 @@ fn eval_alert_expr(expr: &FilterExpr, sequence: u64, timestamp: Timestamp, text:
|
|||||||
let re = Regex::new(pattern).map_err(|err| format!("invalid regex in alert query: {err}"))?;
|
let re = Regex::new(pattern).map_err(|err| format!("invalid regex in alert query: {err}"))?;
|
||||||
Ok(re.is_match(text))
|
Ok(re.is_match(text))
|
||||||
}
|
}
|
||||||
FilterExpr::Comparison { metric, param: None, op, value } if metric == "sequence" => {
|
FilterExpr::Comparison { metric, param: None, op, value } if metric == "ottyl_seq" => {
|
||||||
let target = match value {
|
let target = match value {
|
||||||
FilterValue::Number(n) => *n,
|
FilterValue::Number(n) => *n,
|
||||||
FilterValue::Raw(raw) => raw
|
FilterValue::Raw(raw) => raw
|
||||||
.parse::<f64>()
|
.parse::<f64>()
|
||||||
.map_err(|_| format!("'sequence' comparisons require a numeric value, got {raw:?}"))?,
|
.map_err(|_| format!("'ottyl_seq' comparisons require a numeric value, got {raw:?}"))?,
|
||||||
};
|
};
|
||||||
Ok(apply_compare_op((sequence as f64).total_cmp(&target), *op))
|
Ok(apply_compare_op((sequence as f64).total_cmp(&target), *op))
|
||||||
}
|
}
|
||||||
FilterExpr::Comparison { metric, param: None, op, value } if metric == "timestamp" => {
|
FilterExpr::Comparison { metric, param: None, op, value } if metric == "ottyl_ts" => {
|
||||||
let target_ms = match value {
|
let target_ms = match value {
|
||||||
FilterValue::Number(n) => *n as i64,
|
FilterValue::Number(n) => *n as i64,
|
||||||
FilterValue::Raw(raw) => filter_datetime::parse_datetime(raw, Timestamp::now())
|
FilterValue::Raw(raw) => filter_datetime::parse_datetime(raw, Timestamp::now())
|
||||||
@@ -185,8 +214,15 @@ fn eval_alert_expr(expr: &FilterExpr, sequence: u64, timestamp: Timestamp, text:
|
|||||||
};
|
};
|
||||||
Ok(apply_compare_op(timestamp.as_millis().cmp(&target_ms), *op))
|
Ok(apply_compare_op(timestamp.as_millis().cmp(&target_ms), *op))
|
||||||
}
|
}
|
||||||
|
FilterExpr::Comparison { metric, param: None, op, value } if metric == "ottyl_fmt" => {
|
||||||
|
let target = match value {
|
||||||
|
FilterValue::Number(n) => n.to_string(),
|
||||||
|
FilterValue::Raw(raw) => raw.clone(),
|
||||||
|
};
|
||||||
|
Ok(apply_compare_op(crate::logfmt::detect_format(text).cmp(target.as_str()), *op))
|
||||||
|
}
|
||||||
FilterExpr::Comparison { metric, .. } => Err(format!(
|
FilterExpr::Comparison { metric, .. } => Err(format!(
|
||||||
"alert queries only support regex()/sequence/timestamp (not metric comparisons like \
|
"alert queries only support regex()/ottyl_seq/ottyl_ts/ottyl_fmt (not metric comparisons like \
|
||||||
'{metric}[param]'): metric-based alert conditions aren't supported yet"
|
'{metric}[param]'): metric-based alert conditions aren't supported yet"
|
||||||
)),
|
)),
|
||||||
FilterExpr::JsonComparison { field, op, value } => {
|
FilterExpr::JsonComparison { field, op, value } => {
|
||||||
@@ -205,6 +241,7 @@ fn eval_alert_expr(expr: &FilterExpr, sequence: u64, timestamp: Timestamp, text:
|
|||||||
pub fn evaluate_alerts(
|
pub fn evaluate_alerts(
|
||||||
rules: &[AlertRule],
|
rules: &[AlertRule],
|
||||||
states: &mut HashMap<String, AlertRuntimeState>,
|
states: &mut HashMap<String, AlertRuntimeState>,
|
||||||
|
index: &BTreeMap<IndexKey, DataValue>,
|
||||||
sequence: u64,
|
sequence: u64,
|
||||||
timestamp: Timestamp,
|
timestamp: Timestamp,
|
||||||
text: &str,
|
text: &str,
|
||||||
@@ -215,7 +252,9 @@ pub fn evaluate_alerts(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let state = states.entry(rule.id.clone()).or_default();
|
let state = states.entry(rule.id.clone()).or_default();
|
||||||
if let Some(message) = evaluate_condition(&rule.condition, state, sequence, timestamp, text, &rule.name) {
|
if let Some(message) =
|
||||||
|
evaluate_condition(&rule.condition, state, index, sequence, timestamp, text, &rule.name)
|
||||||
|
{
|
||||||
fired.push((rule.clone(), message));
|
fired.push((rule.clone(), message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,6 +264,7 @@ pub fn evaluate_alerts(
|
|||||||
fn evaluate_condition(
|
fn evaluate_condition(
|
||||||
condition: &AlertCondition,
|
condition: &AlertCondition,
|
||||||
state: &mut AlertRuntimeState,
|
state: &mut AlertRuntimeState,
|
||||||
|
index: &BTreeMap<IndexKey, DataValue>,
|
||||||
sequence: u64,
|
sequence: u64,
|
||||||
timestamp: Timestamp,
|
timestamp: Timestamp,
|
||||||
text: &str,
|
text: &str,
|
||||||
@@ -240,12 +280,14 @@ fn evaluate_condition(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
AlertCondition::Threshold { query, op, threshold, window_ms } => {
|
AlertCondition::Threshold { query, op, threshold, window_ms } => {
|
||||||
|
if !state.threshold_backfilled {
|
||||||
|
state.threshold_window = backfill_window(index, query, sequence, timestamp, *window_ms);
|
||||||
|
state.threshold_backfilled = true;
|
||||||
|
}
|
||||||
match eval_alert_query(query, sequence, timestamp, text) {
|
match eval_alert_query(query, sequence, timestamp, text) {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
let now_ms = timestamp.as_millis();
|
state.threshold_window.push(timestamp, *window_ms);
|
||||||
state.threshold_matches.push_back(now_ms);
|
let count = state.threshold_window.count() as f64;
|
||||||
prune_window(&mut state.threshold_matches, now_ms, *window_ms);
|
|
||||||
let count = state.threshold_matches.len() as f64;
|
|
||||||
apply_compare_op(count.total_cmp(threshold), *op).then(|| {
|
apply_compare_op(count.total_cmp(threshold), *op).then(|| {
|
||||||
format!("{rule_name}: match count {count} {op:?} threshold {threshold} (window {window_ms}ms)")
|
format!("{rule_name}: match count {count} {op:?} threshold {threshold} (window {window_ms}ms)")
|
||||||
})
|
})
|
||||||
@@ -285,17 +327,22 @@ fn evaluate_condition(
|
|||||||
if !matched_a && !matched_b {
|
if !matched_a && !matched_b {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let now_ms = timestamp.as_millis();
|
if !state.ratio_a_backfilled {
|
||||||
|
state.ratio_a_window = backfill_window(index, query_a, sequence, timestamp, *window_ms);
|
||||||
|
state.ratio_a_backfilled = true;
|
||||||
|
}
|
||||||
|
if !state.ratio_b_backfilled {
|
||||||
|
state.ratio_b_window = backfill_window(index, query_b, sequence, timestamp, *window_ms);
|
||||||
|
state.ratio_b_backfilled = true;
|
||||||
|
}
|
||||||
if matched_a {
|
if matched_a {
|
||||||
state.ratio_a_matches.push_back(now_ms);
|
state.ratio_a_window.push(timestamp, *window_ms);
|
||||||
prune_window(&mut state.ratio_a_matches, now_ms, *window_ms);
|
|
||||||
}
|
}
|
||||||
if matched_b {
|
if matched_b {
|
||||||
state.ratio_b_matches.push_back(now_ms);
|
state.ratio_b_window.push(timestamp, *window_ms);
|
||||||
prune_window(&mut state.ratio_b_matches, now_ms, *window_ms);
|
|
||||||
}
|
}
|
||||||
let count_a = state.ratio_a_matches.len() as f64;
|
let count_a = state.ratio_a_window.count() as f64;
|
||||||
let count_b = state.ratio_b_matches.len() as f64;
|
let count_b = state.ratio_b_window.count() as f64;
|
||||||
if count_b == 0.0 {
|
if count_b == 0.0 {
|
||||||
// Zero denominator: skip rather than treat the ratio as infinite.
|
// Zero denominator: skip rather than treat the ratio as infinite.
|
||||||
return None;
|
return None;
|
||||||
@@ -417,12 +464,13 @@ mod tests {
|
|||||||
fn immediate_fires_on_matching_line_only() {
|
fn immediate_fires_on_matching_line_only() {
|
||||||
let rules = vec![immediate_rule(r#"regex("error")"#)];
|
let rules = vec![immediate_rule(r#"regex("error")"#)];
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
|
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
||||||
|
|
||||||
let (seq, ts, text) = line("all good", 0, 0);
|
let (seq, ts, text) = line("all good", 0, 0);
|
||||||
assert!(evaluate_alerts(&rules, &mut states, seq, ts, text).is_empty());
|
assert!(evaluate_alerts(&rules, &mut states, &index, seq, ts, text).is_empty());
|
||||||
|
|
||||||
let (seq, ts, text) = line("an error happened", 1, 1);
|
let (seq, ts, text) = line("an error happened", 1, 1);
|
||||||
let fired = evaluate_alerts(&rules, &mut states, seq, ts, text);
|
let fired = evaluate_alerts(&rules, &mut states, &index, seq, ts, text);
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
assert!(fired[0].1.contains("an error happened"));
|
assert!(fired[0].1.contains("an error happened"));
|
||||||
}
|
}
|
||||||
@@ -432,8 +480,9 @@ mod tests {
|
|||||||
let mut rule = immediate_rule(r#"regex("error")"#);
|
let mut rule = immediate_rule(r#"regex("error")"#);
|
||||||
rule.enabled = false;
|
rule.enabled = false;
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
|
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
||||||
let (seq, ts, text) = line("error!", 0, 0);
|
let (seq, ts, text) = line("error!", 0, 0);
|
||||||
assert!(evaluate_alerts(&[rule], &mut states, seq, ts, text).is_empty());
|
assert!(evaluate_alerts(&[rule], &mut states, &index, seq, ts, text).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -450,14 +499,15 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
|
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
||||||
for (seq, ms) in [(0u64, 0i64), (1, 1000)] {
|
for (seq, ms) in [(0u64, 0i64), (1, 1000)] {
|
||||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, seq, Timestamp::from_millis(ms), "error");
|
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, seq, Timestamp::from_millis(ms), "error");
|
||||||
assert!(fired.is_empty(), "should not fire before threshold is crossed");
|
assert!(fired.is_empty(), "should not fire before threshold is crossed");
|
||||||
}
|
}
|
||||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, 2, Timestamp::from_millis(2000), "error");
|
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 2, Timestamp::from_millis(2000), "error");
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
// Stays quiet for a non-matching line even though the window is full.
|
// Stays quiet for a non-matching line even though the window is full.
|
||||||
let fired = evaluate_alerts(&[rule], &mut states, 3, Timestamp::from_millis(2100), "fine");
|
let fired = evaluate_alerts(&[rule], &mut states, &index, 3, Timestamp::from_millis(2100), "fine");
|
||||||
assert!(fired.is_empty());
|
assert!(fired.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,9 +525,10 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
assert!(evaluate_alerts(&[rule.clone()], &mut states, 0, Timestamp::from_millis(0), "error").is_empty());
|
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
||||||
|
assert!(evaluate_alerts(&[rule.clone()], &mut states, &index, 0, Timestamp::from_millis(0), "error").is_empty());
|
||||||
// Second match arrives well after the first has aged out of the window.
|
// Second match arrives well after the first has aged out of the window.
|
||||||
assert!(evaluate_alerts(&[rule], &mut states, 1, Timestamp::from_millis(5_000), "error").is_empty());
|
assert!(evaluate_alerts(&[rule], &mut states, &index, 1, Timestamp::from_millis(5_000), "error").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -492,11 +543,12 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, 0, Timestamp::from_millis(0), "agent=curl");
|
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
||||||
|
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 0, Timestamp::from_millis(0), "agent=curl");
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, 1, Timestamp::from_millis(1), "agent=curl");
|
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 1, Timestamp::from_millis(1), "agent=curl");
|
||||||
assert!(fired.is_empty());
|
assert!(fired.is_empty());
|
||||||
let fired = evaluate_alerts(&[rule], &mut states, 2, Timestamp::from_millis(2), "agent=wget");
|
let fired = evaluate_alerts(&[rule], &mut states, &index, 2, Timestamp::from_millis(2), "agent=wget");
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,16 +567,37 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
|
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
||||||
// Only an "error" so far; no "request" yet, so denominator is 0 -> skip.
|
// Only an "error" so far; no "request" yet, so denominator is 0 -> skip.
|
||||||
assert!(evaluate_alerts(&[rule.clone()], &mut states, 0, Timestamp::from_millis(0), "error").is_empty());
|
assert!(evaluate_alerts(&[rule.clone()], &mut states, &index, 0, Timestamp::from_millis(0), "error").is_empty());
|
||||||
// One request: ratio 1/1 = 1.0 >= 0.5 -> fires.
|
// One request: ratio 1/1 = 1.0 >= 0.5 -> fires.
|
||||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, 1, Timestamp::from_millis(1), "request");
|
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 1, Timestamp::from_millis(1), "request");
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
// A second request: ratio 1/2 = 0.5 still >= 0.5 -> fires again.
|
// A second request: ratio 1/2 = 0.5 still >= 0.5 -> fires again.
|
||||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, 2, Timestamp::from_millis(2), "request");
|
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 2, Timestamp::from_millis(2), "request");
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
// A third request with no more errors: ratio drops to 1/3 -> no longer fires.
|
// A third request with no more errors: ratio drops to 1/3 -> no longer fires.
|
||||||
let fired = evaluate_alerts(&[rule], &mut states, 3, Timestamp::from_millis(3), "request");
|
let fired = evaluate_alerts(&[rule], &mut states, &index, 3, Timestamp::from_millis(3), "request");
|
||||||
|
assert!(fired.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ottyl_fmt_alert_query_matches_detected_format() {
|
||||||
|
let rule = immediate_rule("ottyl_fmt==json");
|
||||||
|
let mut states = HashMap::new();
|
||||||
|
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
||||||
|
|
||||||
|
let fired = evaluate_alerts(
|
||||||
|
&[rule.clone()],
|
||||||
|
&mut states,
|
||||||
|
&index,
|
||||||
|
0,
|
||||||
|
Timestamp::from_millis(0),
|
||||||
|
r#"{"level":"error"}"#,
|
||||||
|
);
|
||||||
|
assert_eq!(fired.len(), 1);
|
||||||
|
|
||||||
|
let fired = evaluate_alerts(&[rule], &mut states, &index, 1, Timestamp::from_millis(1), "plain text");
|
||||||
assert!(fired.is_empty());
|
assert!(fired.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,8 +605,9 @@ mod tests {
|
|||||||
fn metric_comparison_in_alert_query_is_rejected_not_panicking() {
|
fn metric_comparison_in_alert_query_is_rejected_not_panicking() {
|
||||||
let rule = immediate_rule("some_metric[param]==5");
|
let rule = immediate_rule("some_metric[param]==5");
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
|
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
||||||
// Should log a warning and simply not fire, never panic.
|
// Should log a warning and simply not fire, never panic.
|
||||||
let fired = evaluate_alerts(&[rule], &mut states, 0, Timestamp::from_millis(0), "anything");
|
let fired = evaluate_alerts(&[rule], &mut states, &index, 0, Timestamp::from_millis(0), "anything");
|
||||||
assert!(fired.is_empty());
|
assert!(fired.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+267
-116
@@ -13,7 +13,7 @@ use crate::engine::{
|
|||||||
EngineResponse,
|
EngineResponse,
|
||||||
};
|
};
|
||||||
use crate::alerts::{deliver_ntfy, AlertError};
|
use crate::alerts::{deliver_ntfy, AlertError};
|
||||||
use crate::profile::{self, Profile, ProfileError, ProfileStore};
|
use crate::profile::{self, Dashboard, ParamDeclarationRecord, Profile, ProfileError};
|
||||||
use crate::settings::{CssError, SettingsHandle};
|
use crate::settings::{CssError, SettingsHandle};
|
||||||
use axum::extract::{Path as AxumPath, Query, State};
|
use axum::extract::{Path as AxumPath, Query, State};
|
||||||
use axum::http::{header, StatusCode};
|
use axum::http::{header, StatusCode};
|
||||||
@@ -43,11 +43,11 @@ impl ApiState {
|
|||||||
|
|
||||||
/// 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.
|
/// Best-effort: a no-op if no profile is active, and I/O failures are only logged, since the engine declaration is already the source of truth.
|
||||||
fn persist_param_declaration(&self, request: &DeclareParamRequest) {
|
fn persist_param_declaration(&self, request: &DeclareParamRequest) {
|
||||||
let Some((store_path, profile_name)) = self.active_profile_location() else {
|
let mut settings = self.settings.lock().unwrap();
|
||||||
|
let Some(profile_name) = settings.active_profile.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut store = ProfileStore::load(&store_path);
|
if let Err(err) = settings.record_param_declaration(&self.settings_path, &profile_name, request) {
|
||||||
if let Err(err) = store.record_param_declaration(&store_path, &profile_name, request) {
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"warning: failed to persist param declaration '{}' into profile '{profile_name}': {err}",
|
"warning: failed to persist param declaration '{}' into profile '{profile_name}': {err}",
|
||||||
request.name
|
request.name
|
||||||
@@ -55,13 +55,31 @@ impl ApiState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same as `persist_param_declaration`, for metric declarations.
|
/// Same as `persist_param_declaration`, for metric declarations. Also promotes any of the
|
||||||
fn persist_metric_declaration(&self, request: &DeclareMetricRequest) {
|
/// metric's referenced params that only exist as auto-derived engine state (see
|
||||||
let Some((store_path, profile_name)) = self.active_profile_location() else {
|
/// `promoted_param_records`) into persisted profile params, so a metric built against an
|
||||||
|
/// auto-detected logfmt key survives a restart instead of being rejected as "undeclared" once
|
||||||
|
/// declarations replay before any log line re-derives it.
|
||||||
|
async fn persist_metric_declaration(&self, request: &DeclareMetricRequest) {
|
||||||
|
let profile_name = {
|
||||||
|
let settings = self.settings.lock().unwrap();
|
||||||
|
let Some(profile_name) = settings.active_profile.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut store = ProfileStore::load(&store_path);
|
profile_name
|
||||||
if let Err(err) = store.record_metric_declaration(&store_path, &profile_name, request) {
|
};
|
||||||
|
let existing_params = {
|
||||||
|
let settings = self.settings.lock().unwrap();
|
||||||
|
settings.profile(&profile_name).map(|p| p.params.clone()).unwrap_or_default()
|
||||||
|
};
|
||||||
|
let promoted = promoted_param_records(&self.engine, &existing_params, request).await;
|
||||||
|
let mut settings = self.settings.lock().unwrap();
|
||||||
|
if let Err(err) = settings.record_metric_declaration(
|
||||||
|
&self.settings_path,
|
||||||
|
&profile_name,
|
||||||
|
request,
|
||||||
|
&promoted,
|
||||||
|
) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"warning: failed to persist metric declaration '{}' into profile '{profile_name}': {err}",
|
"warning: failed to persist metric declaration '{}' into profile '{profile_name}': {err}",
|
||||||
request.name
|
request.name
|
||||||
@@ -70,27 +88,33 @@ impl ApiState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Same as `persist_metric_declaration`, for an in-place edit (`PUT /api/metrics/{name}`) — also drops the persisted record under `old_name` when the edit renames the metric, so a rename doesn't leave a stale duplicate declaration in the profile file.
|
/// Same as `persist_metric_declaration`, for an in-place edit (`PUT /api/metrics/{name}`) — also drops the persisted record under `old_name` when the edit renames the metric, so a rename doesn't leave a stale duplicate declaration in the profile file.
|
||||||
fn persist_metric_declaration_update(&self, old_name: &str, request: &DeclareMetricRequest) {
|
async fn persist_metric_declaration_update(&self, old_name: &str, request: &DeclareMetricRequest) {
|
||||||
let Some((store_path, profile_name)) = self.active_profile_location() else {
|
let profile_name = {
|
||||||
|
let settings = self.settings.lock().unwrap();
|
||||||
|
let Some(profile_name) = settings.active_profile.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut store = ProfileStore::load(&store_path);
|
profile_name
|
||||||
if let Err(err) =
|
};
|
||||||
store.record_metric_declaration_update(&store_path, &profile_name, old_name, request)
|
let existing_params = {
|
||||||
{
|
let settings = self.settings.lock().unwrap();
|
||||||
|
settings.profile(&profile_name).map(|p| p.params.clone()).unwrap_or_default()
|
||||||
|
};
|
||||||
|
let promoted = promoted_param_records(&self.engine, &existing_params, request).await;
|
||||||
|
let mut settings = self.settings.lock().unwrap();
|
||||||
|
if let Err(err) = settings.record_metric_declaration_update(
|
||||||
|
&self.settings_path,
|
||||||
|
&profile_name,
|
||||||
|
old_name,
|
||||||
|
request,
|
||||||
|
&promoted,
|
||||||
|
) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"warning: failed to persist metric declaration update '{}' into profile '{profile_name}': {err}",
|
"warning: failed to persist metric declaration update '{}' into profile '{profile_name}': {err}",
|
||||||
request.name
|
request.name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn active_profile_location(&self) -> Option<(PathBuf, String)> {
|
|
||||||
let settings = self.settings.lock().unwrap();
|
|
||||||
let profile_name = settings.profiles.active_profile.clone()?;
|
|
||||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &self.settings_path);
|
|
||||||
Some((profile::profiles_store_path(&profiles_dir), profile_name))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: PathBuf) -> Router {
|
pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: PathBuf) -> Router {
|
||||||
@@ -177,6 +201,47 @@ async fn ask(engine: &EngineHandle, query: EngineQuery) -> Result<EngineResponse
|
|||||||
response.await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)
|
response.await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Any of `request`'s referenced params (`label_params`/`value_param`) not already present in
|
||||||
|
/// `existing_params` are looked up live in the engine and returned as records ready to persist
|
||||||
|
/// alongside the metric (see `ProfileStore::record_metric_declaration`'s `promoted_params`). A
|
||||||
|
/// metric can validly reference a param that's only auto-derived engine state (see
|
||||||
|
/// `crate::engine::auto_declare_params`) — that param was never written into any profile, so without
|
||||||
|
/// this promotion the metric would be rejected as "undeclared" the next time declarations replay on
|
||||||
|
/// startup, before any log line has re-derived it.
|
||||||
|
async fn promoted_param_records(
|
||||||
|
engine: &EngineHandle,
|
||||||
|
existing_params: &[ParamDeclarationRecord],
|
||||||
|
request: &DeclareMetricRequest,
|
||||||
|
) -> Vec<ParamDeclarationRecord> {
|
||||||
|
let missing: Vec<&str> = request
|
||||||
|
.label_params
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.chain(request.value_param.as_deref())
|
||||||
|
.filter(|name| !existing_params.iter().any(|p| p.name == **name))
|
||||||
|
.collect();
|
||||||
|
if missing.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let Ok(EngineResponse::ParamDeclarations(declarations)) =
|
||||||
|
ask(engine, EngineQuery::ParamDeclarations).await
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
declarations
|
||||||
|
.into_iter()
|
||||||
|
.filter(|param| missing.contains(¶m.name.as_str()))
|
||||||
|
.map(|param| ParamDeclarationRecord {
|
||||||
|
name: param.name,
|
||||||
|
message_match_regex: param.message_match_regex,
|
||||||
|
capture_group: param.capture_group,
|
||||||
|
json_field_path: param.json_field_path,
|
||||||
|
kind: param.kind.into(),
|
||||||
|
elapsed_since_last: param.elapsed_since_last,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_logs(
|
async fn get_logs(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
Query(params): Query<LogsParams>,
|
Query(params): Query<LogsParams>,
|
||||||
@@ -244,6 +309,10 @@ async fn declare_param(
|
|||||||
capture_group: request.capture_group,
|
capture_group: request.capture_group,
|
||||||
json_field_path: request.json_field_path,
|
json_field_path: request.json_field_path,
|
||||||
kind: request.kind,
|
kind: request.kind,
|
||||||
|
elapsed_since_last: request.elapsed_since_last,
|
||||||
|
// A request that reached here was accepted by `declare_param`, which always stores
|
||||||
|
// it as non-auto (see `engine::declare_param`).
|
||||||
|
auto_derived: false,
|
||||||
};
|
};
|
||||||
(StatusCode::CREATED, Json(dto)).into_response()
|
(StatusCode::CREATED, Json(dto)).into_response()
|
||||||
}
|
}
|
||||||
@@ -276,11 +345,12 @@ async fn declare_metric(
|
|||||||
}
|
}
|
||||||
match response.await {
|
match response.await {
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
state.persist_metric_declaration(&request);
|
state.persist_metric_declaration(&request).await;
|
||||||
let dto = MetricDeclarationDto {
|
let dto = MetricDeclarationDto {
|
||||||
name: request.name,
|
name: request.name,
|
||||||
message_match_regex: request.message_match_regex,
|
message_match_regex: request.message_match_regex,
|
||||||
params: request.params,
|
label_params: request.label_params,
|
||||||
|
value_param: request.value_param,
|
||||||
matched_labels: request.matched_labels,
|
matched_labels: request.matched_labels,
|
||||||
unmatched_labels: request.unmatched_labels,
|
unmatched_labels: request.unmatched_labels,
|
||||||
};
|
};
|
||||||
@@ -309,11 +379,12 @@ async fn update_metric(
|
|||||||
}
|
}
|
||||||
match response.await {
|
match response.await {
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
state.persist_metric_declaration_update(&name, &request);
|
state.persist_metric_declaration_update(&name, &request).await;
|
||||||
let dto = MetricDeclarationDto {
|
let dto = MetricDeclarationDto {
|
||||||
name: request.name,
|
name: request.name,
|
||||||
message_match_regex: request.message_match_regex,
|
message_match_regex: request.message_match_regex,
|
||||||
params: request.params,
|
label_params: request.label_params,
|
||||||
|
value_param: request.value_param,
|
||||||
matched_labels: request.matched_labels,
|
matched_labels: request.matched_labels,
|
||||||
unmatched_labels: request.unmatched_labels,
|
unmatched_labels: request.unmatched_labels,
|
||||||
};
|
};
|
||||||
@@ -560,9 +631,7 @@ fn profile_error_response(err: ProfileError) -> axum::response::Response {
|
|||||||
|
|
||||||
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 summaries: Vec<ProfileSummaryDto> = settings.profiles.iter().map(ProfileSummaryDto::from).collect();
|
||||||
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
|
||||||
let summaries: Vec<ProfileSummaryDto> = store.list().iter().map(ProfileSummaryDto::from).collect();
|
|
||||||
Json(summaries).into_response()
|
Json(summaries).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,12 +640,9 @@ async fn create_profile(
|
|||||||
Json(body): Json<CreateProfileRequest>,
|
Json(body): Json<CreateProfileRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let mut settings = state.settings.lock().unwrap();
|
let mut settings = state.settings.lock().unwrap();
|
||||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
match settings.create_profile(&state.settings_path, body.name.clone()) {
|
||||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
|
||||||
let mut store = ProfileStore::load(&store_path);
|
|
||||||
match store.create(&store_path, &mut settings, &state.settings_path, body.name.clone()) {
|
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let profile = store.get(&body.name).expect("profile was just created by store.create");
|
let profile = settings.profile(&body.name).expect("profile was just created by create_profile");
|
||||||
(StatusCode::CREATED, Json(ProfileSummaryDto::from(profile))).into_response()
|
(StatusCode::CREATED, Json(ProfileSummaryDto::from(profile))).into_response()
|
||||||
}
|
}
|
||||||
Err(err) => profile_error_response(err),
|
Err(err) => profile_error_response(err),
|
||||||
@@ -589,21 +655,21 @@ async fn delete_profile(
|
|||||||
AxumPath(name): AxumPath<String>,
|
AxumPath(name): AxumPath<String>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let mut settings = state.settings.lock().unwrap();
|
let mut settings = state.settings.lock().unwrap();
|
||||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
match settings.delete_profile(&state.settings_path, &name) {
|
||||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
|
||||||
let mut store = ProfileStore::load(&store_path);
|
|
||||||
match store.delete(&store_path, &mut settings, &state.settings_path, &name) {
|
|
||||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Err(err) => profile_error_response(err),
|
Err(err) => profile_error_response(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes every currently-declared param/metric from the engine, then replays `profile`'s declarations into it.
|
/// Removes every currently-declared param/metric from the engine, then replays `profile`'s declarations into it.
|
||||||
|
/// Auto-derived params (`ParamDeclarationDto::auto_derived`) are left alone — they aren't part of
|
||||||
|
/// any profile, just facts discovered from the log stream, so a profile switch shouldn't discard
|
||||||
|
/// them (they'd otherwise only reappear once a new line re-introduces that key).
|
||||||
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
|
||||||
{
|
{
|
||||||
for param in params {
|
for param in params.into_iter().filter(|param| !param.auto_derived) {
|
||||||
let (respond_to, response) = oneshot::channel();
|
let (respond_to, response) = oneshot::channel();
|
||||||
if engine
|
if engine
|
||||||
.send(EngineMessage::RemoveParamDeclaration { name: param.name, respond_to })
|
.send(EngineMessage::RemoveParamDeclaration { name: param.name, respond_to })
|
||||||
@@ -637,11 +703,8 @@ async fn activate_profile(
|
|||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let activated_profile = {
|
let activated_profile = {
|
||||||
let mut settings = state.settings.lock().unwrap();
|
let mut settings = state.settings.lock().unwrap();
|
||||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
match settings.set_active_profile(&state.settings_path, Some(name.clone())) {
|
||||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
Ok(()) => Ok(settings.profile(&name).cloned()),
|
||||||
let store = ProfileStore::load(&store_path);
|
|
||||||
match store.set_active(&mut settings, &state.settings_path, Some(name.clone())) {
|
|
||||||
Ok(()) => Ok(store.get(&name).cloned()),
|
|
||||||
Err(err) => Err(err),
|
Err(err) => Err(err),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -663,9 +726,7 @@ async fn list_dashboards(
|
|||||||
AxumPath(name): AxumPath<String>,
|
AxumPath(name): AxumPath<String>,
|
||||||
) -> impl IntoResponse {
|
) -> 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);
|
match settings.list_dashboards(&name) {
|
||||||
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
|
||||||
match store.list_dashboards(&name) {
|
|
||||||
Ok(dashboards) => {
|
Ok(dashboards) => {
|
||||||
let dtos: Vec<DashboardDto> = dashboards.iter().map(DashboardDto::from).collect();
|
let dtos: Vec<DashboardDto> = dashboards.iter().map(DashboardDto::from).collect();
|
||||||
Json(dtos).into_response()
|
Json(dtos).into_response()
|
||||||
@@ -679,11 +740,8 @@ async fn create_dashboard(
|
|||||||
AxumPath(name): AxumPath<String>,
|
AxumPath(name): AxumPath<String>,
|
||||||
Json(body): Json<CreateDashboardRequest>,
|
Json(body): Json<CreateDashboardRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let settings = state.settings.lock().unwrap();
|
let mut settings = state.settings.lock().unwrap();
|
||||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
match settings.create_dashboard(&state.settings_path, &name, body.name) {
|
||||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
|
||||||
let mut store = ProfileStore::load(&store_path);
|
|
||||||
match store.create_dashboard(&store_path, &name, body.name) {
|
|
||||||
Ok(dashboard) => (StatusCode::CREATED, Json(DashboardDto::from(&dashboard))).into_response(),
|
Ok(dashboard) => (StatusCode::CREATED, Json(DashboardDto::from(&dashboard))).into_response(),
|
||||||
Err(err) => profile_error_response(err),
|
Err(err) => profile_error_response(err),
|
||||||
}
|
}
|
||||||
@@ -694,9 +752,7 @@ async fn get_dashboard(
|
|||||||
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
||||||
) -> impl IntoResponse {
|
) -> 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);
|
match settings.list_dashboards(&name) {
|
||||||
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
|
||||||
match store.list_dashboards(&name) {
|
|
||||||
Ok(dashboards) => match dashboards.iter().find(|d| d.id == dashboard_id) {
|
Ok(dashboards) => match dashboards.iter().find(|d| d.id == dashboard_id) {
|
||||||
Some(dashboard) => Json(DashboardDto::from(dashboard)).into_response(),
|
Some(dashboard) => Json(DashboardDto::from(dashboard)).into_response(),
|
||||||
None => profile_error_response(ProfileError::NotFound),
|
None => profile_error_response(ProfileError::NotFound),
|
||||||
@@ -710,16 +766,13 @@ async fn save_dashboard(
|
|||||||
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
||||||
Json(body): Json<SaveDashboardRequest>,
|
Json(body): Json<SaveDashboardRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let settings = state.settings.lock().unwrap();
|
let mut settings = state.settings.lock().unwrap();
|
||||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
let dashboard = Dashboard {
|
||||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
|
||||||
let mut store = ProfileStore::load(&store_path);
|
|
||||||
let dashboard = crate::profile::Dashboard {
|
|
||||||
id: dashboard_id.clone(),
|
id: dashboard_id.clone(),
|
||||||
name: body.name,
|
name: body.name,
|
||||||
panels: body.panels.iter().map(|p| p.into()).collect(),
|
panels: body.panels.iter().map(|p| p.into()).collect(),
|
||||||
};
|
};
|
||||||
match store.save_dashboard(&store_path, &name, &dashboard_id, dashboard) {
|
match settings.save_dashboard(&state.settings_path, &name, &dashboard_id, dashboard) {
|
||||||
Ok(dashboard) => Json(DashboardDto::from(&dashboard)).into_response(),
|
Ok(dashboard) => Json(DashboardDto::from(&dashboard)).into_response(),
|
||||||
Err(err) => profile_error_response(err),
|
Err(err) => profile_error_response(err),
|
||||||
}
|
}
|
||||||
@@ -729,11 +782,8 @@ 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)>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let settings = state.settings.lock().unwrap();
|
let mut settings = state.settings.lock().unwrap();
|
||||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
match settings.delete_dashboard(&state.settings_path, &name, &dashboard_id) {
|
||||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
|
||||||
let mut store = ProfileStore::load(&store_path);
|
|
||||||
match store.delete_dashboard(&store_path, &name, &dashboard_id) {
|
|
||||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Err(err) => profile_error_response(err),
|
Err(err) => profile_error_response(err),
|
||||||
}
|
}
|
||||||
@@ -906,9 +956,29 @@ mod tests {
|
|||||||
assert!(body["error"].as_str().unwrap().contains("invalid regex"));
|
assert!(body["error"].as_str().unwrap().contains("invalid regex"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Declares a standalone param via `POST /api/params` — most metric-declaration tests need one
|
||||||
|
/// or more already-declared params to reference by name (ticket 037), rather than embedding a
|
||||||
|
/// regex/capture-group/kind inline in the metric request.
|
||||||
|
async fn declare_test_param(app: &Router, name: &str, pattern: &str, capture_group: &str, kind: &str) {
|
||||||
|
let (status, _body) = send_json(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/params",
|
||||||
|
json!({
|
||||||
|
"name": name,
|
||||||
|
"message_match_regex": pattern,
|
||||||
|
"capture_group": capture_group,
|
||||||
|
"kind": kind,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn declares_metric_and_shows_up_in_list() {
|
async fn declares_metric_and_shows_up_in_list() {
|
||||||
let (app, _engine, _settings, _dir) = test_app();
|
let (app, _engine, _settings, _dir) = test_app();
|
||||||
|
declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await;
|
||||||
let (status, body) = send_json(
|
let (status, body) = send_json(
|
||||||
&app,
|
&app,
|
||||||
"POST",
|
"POST",
|
||||||
@@ -916,9 +986,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "requests",
|
"name": "requests",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+)",
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
||||||
"params": [
|
"label_params": ["path"]
|
||||||
{ "param_name": "path", "capture_group": "path", "kind": "string" }
|
|
||||||
]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -933,7 +1001,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn rejects_metric_with_unknown_capture_group() {
|
async fn rejects_metric_referencing_undeclared_param() {
|
||||||
let (app, _engine, _settings, _dir) = test_app();
|
let (app, _engine, _settings, _dir) = test_app();
|
||||||
let (status, body) = send_json(
|
let (status, body) = send_json(
|
||||||
&app,
|
&app,
|
||||||
@@ -942,9 +1010,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "requests",
|
"name": "requests",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+)",
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
||||||
"params": [
|
"label_params": ["duration"]
|
||||||
{ "param_name": "duration", "capture_group": "duration", "kind": "number" }
|
|
||||||
]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -955,6 +1021,8 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn updates_metric_declaration_in_place_rather_than_duplicating() {
|
async fn updates_metric_declaration_in_place_rather_than_duplicating() {
|
||||||
let (app, _engine, _settings, _dir) = test_app();
|
let (app, _engine, _settings, _dir) = test_app();
|
||||||
|
declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await;
|
||||||
|
declare_test_param(&app, "duration", r"duration=(?P<duration>\d+)", "duration", "number").await;
|
||||||
send_json(
|
send_json(
|
||||||
&app,
|
&app,
|
||||||
"POST",
|
"POST",
|
||||||
@@ -962,9 +1030,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "requests",
|
"name": "requests",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+)",
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
||||||
"params": [
|
"label_params": ["path"]
|
||||||
{ "param_name": "path", "capture_group": "path", "kind": "string" }
|
|
||||||
]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -976,23 +1042,20 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "requests",
|
"name": "requests",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+) duration=(?P<duration>\d+)",
|
"message_match_regex": r"request path=(?P<path>\S+) duration=(?P<duration>\d+)",
|
||||||
"params": [
|
"label_params": ["path", "duration"]
|
||||||
{ "param_name": "path", "capture_group": "path", "kind": "string" },
|
|
||||||
{ "param_name": "duration", "capture_group": "duration", "kind": "number" }
|
|
||||||
]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
assert_eq!(body["name"], "requests");
|
assert_eq!(body["name"], "requests");
|
||||||
assert_eq!(body["params"].as_array().unwrap().len(), 2);
|
assert_eq!(body["label_params"].as_array().unwrap().len(), 2);
|
||||||
|
|
||||||
// Still exactly one declaration named "requests", now with the updated regex/params — not a second declaration alongside the original.
|
// Still exactly one declaration named "requests", now with the updated regex/params — not a second declaration alongside the original.
|
||||||
let (status, body) = get_json(&app, "/api/metrics").await;
|
let (status, body) = get_json(&app, "/api/metrics").await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
let metrics = body.as_array().unwrap();
|
let metrics = body.as_array().unwrap();
|
||||||
assert_eq!(metrics.len(), 1);
|
assert_eq!(metrics.len(), 1);
|
||||||
assert_eq!(metrics[0]["params"].as_array().unwrap().len(), 2);
|
assert_eq!(metrics[0]["label_params"].as_array().unwrap().len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1005,7 +1068,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "does-not-exist",
|
"name": "does-not-exist",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+)",
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
||||||
"params": []
|
"label_params": []
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1023,7 +1086,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "requests",
|
"name": "requests",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+)",
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
||||||
"params": []
|
"label_params": []
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1035,9 +1098,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "requests",
|
"name": "requests",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+)",
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
||||||
"params": [
|
"label_params": ["duration"]
|
||||||
{ "param_name": "duration", "capture_group": "duration", "kind": "number" }
|
|
||||||
]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1049,6 +1110,7 @@ mod tests {
|
|||||||
async fn queries_bucket_values_with_label_filter_and_range() {
|
async fn queries_bucket_values_with_label_filter_and_range() {
|
||||||
let (app, engine, _settings, _dir) = test_app();
|
let (app, engine, _settings, _dir) = test_app();
|
||||||
|
|
||||||
|
declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await;
|
||||||
send_json(
|
send_json(
|
||||||
&app,
|
&app,
|
||||||
"POST",
|
"POST",
|
||||||
@@ -1056,9 +1118,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "requests",
|
"name": "requests",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+)",
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
||||||
"params": [
|
"label_params": ["path"]
|
||||||
{ "param_name": "path", "capture_group": "path", "kind": "string" }
|
|
||||||
]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1103,6 +1163,7 @@ mod tests {
|
|||||||
async fn promql_query_returns_series_for_seeded_fixture() {
|
async fn promql_query_returns_series_for_seeded_fixture() {
|
||||||
let (app, engine, _settings, _dir) = test_app();
|
let (app, engine, _settings, _dir) = test_app();
|
||||||
|
|
||||||
|
declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await;
|
||||||
send_json(
|
send_json(
|
||||||
&app,
|
&app,
|
||||||
"POST",
|
"POST",
|
||||||
@@ -1110,9 +1171,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "requests",
|
"name": "requests",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+)",
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
||||||
"params": [
|
"label_params": ["path"]
|
||||||
{ "param_name": "path", "capture_group": "path", "kind": "string" }
|
|
||||||
]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1158,20 +1217,10 @@ mod tests {
|
|||||||
|
|
||||||
// Use the same shared settings handle the router was built with — a separately-loaded Settings wouldn't be visible to the running app.
|
// Use the same shared settings handle the router was built with — a separately-loaded Settings wouldn't be visible to the running app.
|
||||||
let settings_path = dir.path().join("settings.json");
|
let settings_path = dir.path().join("settings.json");
|
||||||
let profiles_dir = {
|
|
||||||
let settings = settings_handle.lock().unwrap();
|
|
||||||
profile::resolve_profiles_dir(&settings, &settings_path)
|
|
||||||
};
|
|
||||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
|
||||||
let mut store = ProfileStore::load(&store_path);
|
|
||||||
{
|
{
|
||||||
let mut settings = settings_handle.lock().unwrap();
|
let mut settings = settings_handle.lock().unwrap();
|
||||||
store
|
settings.create_profile(&settings_path, "web".to_string()).unwrap();
|
||||||
.create(&store_path, &mut settings, &settings_path, "web".to_string())
|
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
|
||||||
.unwrap();
|
|
||||||
store
|
|
||||||
.set_active(&mut settings, &settings_path, Some("web".to_string()))
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let (status, _body) = send_json(
|
let (status, _body) = send_json(
|
||||||
@@ -1188,12 +1237,71 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
assert_eq!(status, StatusCode::CREATED);
|
assert_eq!(status, StatusCode::CREATED);
|
||||||
|
|
||||||
let reloaded_store = ProfileStore::load(&store_path);
|
let reloaded_settings = Settings::load(&settings_path);
|
||||||
let profile = reloaded_store.get("web").unwrap();
|
let profile = reloaded_settings.profile("web").unwrap();
|
||||||
assert_eq!(profile.params.len(), 1);
|
assert_eq!(profile.params.len(), 1);
|
||||||
assert_eq!(profile.params[0].name, "level");
|
assert_eq!(profile.params[0].name, "level");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test: declaring a metric against a param that only exists as auto-derived engine
|
||||||
|
/// state (discovered from an ingested logfmt key, never manually declared via `POST
|
||||||
|
/// /api/params`) must persist that param into the active profile too, not just the metric —
|
||||||
|
/// otherwise a restart replays the metric before any log line re-derives the auto param, and the
|
||||||
|
/// engine rejects it as referencing an undeclared param (see `promoted_param_records`).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn declaring_metric_against_auto_derived_param_promotes_it_into_the_profile() {
|
||||||
|
let (app, engine, settings_handle, dir) = test_app();
|
||||||
|
|
||||||
|
let settings_path = dir.path().join("settings.json");
|
||||||
|
{
|
||||||
|
let mut settings = settings_handle.lock().unwrap();
|
||||||
|
settings.create_profile(&settings_path, "web".to_string()).unwrap();
|
||||||
|
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingest a line carrying a logfmt key before any param is manually declared, so "dx" only
|
||||||
|
// exists as engine-internal auto-derived state (never persisted anywhere).
|
||||||
|
ingest_lines(&engine, 0, &["dx=1 moved"]).await;
|
||||||
|
|
||||||
|
let (status, _body) = send_json(
|
||||||
|
&app,
|
||||||
|
"POST",
|
||||||
|
"/api/metrics",
|
||||||
|
json!({
|
||||||
|
"name": "direction_x",
|
||||||
|
"message_match_regex": r"dx=(?P<dx>\S+) moved",
|
||||||
|
"label_params": ["dx"]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::CREATED);
|
||||||
|
|
||||||
|
let reloaded_settings = Settings::load(&settings_path);
|
||||||
|
let profile = reloaded_settings.profile("web").unwrap();
|
||||||
|
assert_eq!(profile.metrics.len(), 1);
|
||||||
|
assert_eq!(profile.metrics[0].name, "direction_x");
|
||||||
|
// The auto-derived "dx" param got promoted into the profile alongside the metric.
|
||||||
|
assert_eq!(profile.params.len(), 1);
|
||||||
|
assert_eq!(profile.params[0].name, "dx");
|
||||||
|
|
||||||
|
// Simulate a restart: replay the persisted profile into a fresh engine with no prior ingestion.
|
||||||
|
let (fresh_engine_tx, fresh_engine_rx) = mpsc::channel(16);
|
||||||
|
let fresh_settings_handle = std::sync::Arc::new(std::sync::Mutex::new(Settings::default()));
|
||||||
|
tokio::spawn(crate::engine::run(fresh_engine_rx, fresh_settings_handle));
|
||||||
|
profile::load_into_engine(&fresh_engine_tx, profile).await;
|
||||||
|
|
||||||
|
let (respond_to, response) = oneshot::channel();
|
||||||
|
fresh_engine_tx
|
||||||
|
.send(EngineMessage::Query { query: EngineQuery::MetricDeclarations, respond_to })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let EngineResponse::MetricDeclarations(metrics) = response.await.unwrap() else {
|
||||||
|
panic!("unexpected response");
|
||||||
|
};
|
||||||
|
assert_eq!(metrics.len(), 1);
|
||||||
|
assert_eq!(metrics[0].name, "direction_x");
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_request(app: &Router, uri: &str) -> StatusCode {
|
async fn delete_request(app: &Router, uri: &str) -> StatusCode {
|
||||||
let request = Request::builder().method("DELETE").uri(uri).body(Body::empty()).unwrap();
|
let request = Request::builder().method("DELETE").uri(uri).body(Body::empty()).unwrap();
|
||||||
app.clone().oneshot(request).await.unwrap().status()
|
app.clone().oneshot(request).await.unwrap().status()
|
||||||
@@ -1271,7 +1379,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn saved_dashboard_persists_across_simulated_restart() {
|
async fn saved_dashboard_persists_across_simulated_restart() {
|
||||||
let (app, _engine, settings_handle, dir) = test_app();
|
let (app, _engine, _settings_handle, dir) = test_app();
|
||||||
|
|
||||||
let (status, _body) = send_json(&app, "POST", "/api/settings/profiles", json!({ "name": "web" })).await;
|
let (status, _body) = send_json(&app, "POST", "/api/settings/profiles", json!({ "name": "web" })).await;
|
||||||
assert_eq!(status, StatusCode::CREATED);
|
assert_eq!(status, StatusCode::CREATED);
|
||||||
@@ -1298,15 +1406,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Simulate a restart by reloading the profile store fresh from disk, independent of the running app/engine.
|
// Simulate a restart by reloading settings fresh from disk, 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 reloaded_settings = Settings::load(&settings_path);
|
||||||
let settings = settings_handle.lock().unwrap();
|
let dashboards = reloaded_settings.list_dashboards("web").unwrap();
|
||||||
profile::resolve_profiles_dir(&settings, &settings_path)
|
|
||||||
};
|
|
||||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
|
||||||
let reloaded_store = ProfileStore::load(&store_path);
|
|
||||||
let dashboards = reloaded_store.list_dashboards("web").unwrap();
|
|
||||||
assert_eq!(dashboards.len(), 1);
|
assert_eq!(dashboards.len(), 1);
|
||||||
assert_eq!(dashboards[0].name, "Overview");
|
assert_eq!(dashboards[0].name, "Overview");
|
||||||
assert_eq!(dashboards[0].panels.len(), 1);
|
assert_eq!(dashboards[0].panels.len(), 1);
|
||||||
@@ -1385,4 +1488,52 @@ mod tests {
|
|||||||
let (_, body) = get_json(&app, "/api/logs").await;
|
let (_, body) = get_json(&app, "/api/logs").await;
|
||||||
assert_eq!(body.as_array().unwrap().len(), 3);
|
assert_eq!(body.as_array().unwrap().len(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ticket 045: seeded fixture mixing lines that carry the sort param with lines that don't,
|
||||||
|
/// exercised through the actual HTTP endpoint rather than just `handle_query` directly.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_logs_sort_by_orders_by_param_and_toggles_missing_param_handling() {
|
||||||
|
let (app, engine, _settings, _dir) = test_app();
|
||||||
|
|
||||||
|
declare_test_param(&app, "priority", r"priority=(?P<priority>\d+)", "priority", "number").await;
|
||||||
|
|
||||||
|
ingest_lines(
|
||||||
|
&engine,
|
||||||
|
0,
|
||||||
|
&[
|
||||||
|
"priority=3 boom", // sequence 0, has param
|
||||||
|
"no param here", // sequence 1, missing
|
||||||
|
"priority=1 boom", // sequence 2, has param
|
||||||
|
"also missing", // sequence 3, missing
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Default order (no sort_by): unchanged, plain ingest/ottyl_seq order.
|
||||||
|
let (status, body) = get_json(&app, "/api/logs").await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let sequences: Vec<u64> = body.as_array().unwrap().iter().map(|e| e["sequence"].as_u64().unwrap()).collect();
|
||||||
|
assert_eq!(sequences, vec![0, 1, 2, 3]);
|
||||||
|
|
||||||
|
// sort_by=priority with the fallback toggle on (the default): entries carrying the param
|
||||||
|
// sorted ascending by its value, then the missing-param entries ordered by ottyl_seq.
|
||||||
|
let (status, body) = get_json(&app, "/api/logs?sort_by=priority").await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let sequences: Vec<u64> = body.as_array().unwrap().iter().map(|e| e["sequence"].as_u64().unwrap()).collect();
|
||||||
|
assert_eq!(sequences, vec![2, 0, 1, 3]);
|
||||||
|
|
||||||
|
// Same sort_by, but with the fallback toggle explicitly off: missing-param lines are dropped.
|
||||||
|
let (status, body) = get_json(&app, "/api/logs?sort_by=priority&sort_missing_fallback=false").await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let sequences: Vec<u64> = body.as_array().unwrap().iter().map(|e| e["sequence"].as_u64().unwrap()).collect();
|
||||||
|
assert_eq!(sequences, vec![2, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_logs_sort_by_unknown_param_returns_400() {
|
||||||
|
let (app, _engine, _settings, _dir) = test_app();
|
||||||
|
let (status, body) = get_json(&app, "/api/logs?sort_by=does_not_exist").await;
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
|
assert!(body["error"].as_str().unwrap().contains("does_not_exist"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-30
@@ -3,29 +3,50 @@ use crate::timestamp::Timestamp;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
/// A named, standalone parameter: how to pull a value out of a matching log line, and what kind it
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
/// parses as. Ticket 037 unified this with the metric-declaration-embedded matcher that used to
|
||||||
pub struct RegexToMetricPrimitiveMatcher {
|
/// exist alongside it (`RegexToMetricPrimitiveMatcher`) — a metric now references params by name
|
||||||
pub param_name: String,
|
/// (`MetricDeclaration::label_params`/`value_param`) instead of redeclaring its own extractor per
|
||||||
|
/// param, so a param declared once (e.g. via the Params panel) can be reused as a label by one
|
||||||
|
/// metric and as the value by another without re-entering its regex/capture-group/kind.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ParamDeclaration {
|
||||||
|
pub name: String,
|
||||||
|
pub message_match_regex: String,
|
||||||
pub capture_group: String,
|
pub capture_group: String,
|
||||||
/// Alternative extraction source to `capture_group`: a dot-separated JSON field path (e.g.
|
/// Alternative extraction source to `capture_group`: a dot-separated JSON field path (e.g.
|
||||||
/// `foo.bar`), read from lines that parse as a JSON object instead of via the regex capture
|
/// `foo.bar`), read from lines that parse as a JSON object instead of via the regex capture
|
||||||
/// group. When present and non-empty, this takes precedence over `capture_group`.
|
/// group. When present and non-empty, this takes precedence over `capture_group`.
|
||||||
/// `#[serde(default)]` so profile files/requests saved before this field existed still load
|
/// `#[serde(default)]` so profile files/requests saved before this field existed still load
|
||||||
/// as plain regex-capture-group matchers.
|
/// as plain regex-capture-group params.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub json_field_path: Option<String>,
|
pub json_field_path: Option<String>,
|
||||||
pub kind: MetricPrimitiveKind,
|
pub kind: MetricPrimitiveKind,
|
||||||
/// 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`.
|
/// A third extraction source, alongside `capture_group`/`json_field_path`: when set, this
|
||||||
|
/// param's value is never parsed from text at all — it's computed as elapsed time since the
|
||||||
|
/// previous event that produced the same label set (ticket 038). Takes precedence over
|
||||||
|
/// `json_field_path`/`capture_group` when set, and `message_match_regex`/`capture_group`/
|
||||||
|
/// `json_field_path` are simply unused in that case (`declare_param` doesn't validate them for
|
||||||
|
/// this shape). Only meaningful paired with `kind == Duration`, since elapsed time is the only
|
||||||
|
/// primitive this computation can produce — `declare_param` enforces that pairing.
|
||||||
|
/// `#[serde(default)]` so params declared before this field existed keep parsing as plain
|
||||||
|
/// regex/JSON-field extraction.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub role: ParamRole,
|
pub elapsed_since_last: bool,
|
||||||
|
/// Set when this param was discovered by `crate::engine::auto_declare_params` (a nom-based
|
||||||
|
/// `key=value` scan run on every ingested line) rather than declared through `POST /api/params`.
|
||||||
|
/// `declare_param` refuses to overwrite an auto-derived param by name, and it's never persisted
|
||||||
|
/// to a profile — see `crate::engine::declare_param` and `crate::api::reload_engine_declarations`.
|
||||||
|
/// `#[serde(default)]` so profiles/requests saved before this field existed still load as
|
||||||
|
/// ordinary (non-auto) params.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_derived: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads a value out of a JSON object by dot-separated field path (e.g. `"foo.bar"`), for
|
/// Reads a value out of a JSON object by dot-separated field path (e.g. `"foo.bar"`), for
|
||||||
/// [`RegexToMetricPrimitiveMatcher::json_field_path`] and the sibling `json_field_path` on a
|
/// [`ParamDeclaration::json_field_path`]. Returns `None` when `text` doesn't parse as a JSON
|
||||||
/// standalone param declaration. Returns `None` when `text` doesn't parse as a JSON object, the
|
/// object, the path doesn't resolve to a value, or the resolved value isn't a scalar
|
||||||
/// path doesn't resolve to a value, or the resolved value isn't a scalar (string/number/bool) —
|
/// (string/number/bool) — there's no sensible flat text form of a nested object/array to feed into
|
||||||
/// there's no sensible flat text form of a nested object/array to feed into
|
|
||||||
/// `parse_metric_primitive`.
|
/// `parse_metric_primitive`.
|
||||||
pub fn extract_json_field(text: &str, field_path: &str) -> Option<String> {
|
pub fn extract_json_field(text: &str, field_path: &str) -> Option<String> {
|
||||||
let root: serde_json::Value = serde_json::from_str(text).ok()?;
|
let root: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||||
@@ -46,15 +67,6 @@ pub fn extract_json_field(text: &str, field_path: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// See [`RegexToMetricPrimitiveMatcher::role`].
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum ParamRole {
|
|
||||||
#[default]
|
|
||||||
Label,
|
|
||||||
Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `rename_all = "lowercase"` matches other `kind`-style enums, so JSON bodies use "string"/"number"/"timestamp".
|
/// `rename_all = "lowercase"` matches other `kind`-style enums, so JSON 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)]
|
||||||
@@ -63,7 +75,11 @@ pub enum MetricPrimitiveKind {
|
|||||||
String,
|
String,
|
||||||
Number,
|
Number,
|
||||||
Timestamp,
|
Timestamp,
|
||||||
/// 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.
|
/// A literal i64 millisecond count. Note this is unrelated to
|
||||||
|
/// `ParamDeclaration::elapsed_since_last`, which is a separate, independent extraction source —
|
||||||
|
/// a param using that source always parses/produces `Duration`, but not every `Duration`-kind
|
||||||
|
/// param is computed that way (one can also just parse a literal duration out of captured text,
|
||||||
|
/// like any other kind).
|
||||||
Duration,
|
Duration,
|
||||||
/// A standalone param declared with this kind doesn't require (or use) a capture group: its value is
|
/// A standalone param declared with this kind doesn't require (or use) a capture group: its value is
|
||||||
/// simply whether `message_match_regex` matches the line, case-insensitively or per the regex's own
|
/// simply whether `message_match_regex` matches the line, case-insensitively or per the regex's own
|
||||||
@@ -79,17 +95,15 @@ pub enum DataValue {
|
|||||||
/// Paired with `IndexKey::IngestedLog`.
|
/// Paired with `IndexKey::IngestedLog`.
|
||||||
LogLiteral { text: String },
|
LogLiteral { text: String },
|
||||||
/// Paired with `IndexKey::ParamDeclaration`.
|
/// Paired with `IndexKey::ParamDeclaration`.
|
||||||
ParamDeclaration {
|
ParamDeclaration(ParamDeclaration),
|
||||||
message_match_regex: String,
|
/// Paired with `IndexKey::MetricDeclaration`. `label_params`/`value_param` reference standalone
|
||||||
capture_group: String,
|
/// `ParamDeclaration`s by name (resolved against the engine's param store in
|
||||||
/// See `RegexToMetricPrimitiveMatcher::json_field_path`.
|
/// `crate::engine::apply_metric_to_log`) rather than embedding their own extractor.
|
||||||
json_field_path: Option<String>,
|
|
||||||
kind: MetricPrimitiveKind,
|
|
||||||
},
|
|
||||||
/// Paired with `IndexKey::MetricDeclaration`.
|
|
||||||
MetricDeclaration {
|
MetricDeclaration {
|
||||||
message_match_regex: String,
|
message_match_regex: String,
|
||||||
params: Vec<RegexToMetricPrimitiveMatcher>,
|
label_params: Vec<String>,
|
||||||
|
/// At most one value param per metric; its primitive becomes the event's bucket value instead of a label.
|
||||||
|
value_param: Option<String>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::matched_labels`.
|
/// See `crate::engine::DeclareMetricRequest::matched_labels`.
|
||||||
matched_labels: Option<BTreeMap<String, String>>,
|
matched_labels: Option<BTreeMap<String, String>>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
|
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
|
||||||
|
|||||||
+21
-5
@@ -1,5 +1,5 @@
|
|||||||
use crate::alerts::AlertCondition;
|
use crate::alerts::AlertCondition;
|
||||||
use crate::data_value::{MetricPrimitiveKind, RegexToMetricPrimitiveMatcher};
|
use crate::data_value::MetricPrimitiveKind;
|
||||||
use crate::metric_primitive::MetricPrimitive;
|
use crate::metric_primitive::MetricPrimitive;
|
||||||
use crate::profile::{ChartType, Dashboard, Panel, Profile};
|
use crate::profile::{ChartType, Dashboard, Panel, Profile};
|
||||||
use crate::settings::{ColorScheme, CssEntry, Settings};
|
use crate::settings::{ColorScheme, CssEntry, Settings};
|
||||||
@@ -20,16 +20,24 @@ pub struct ParamDeclarationDto {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub message_match_regex: String,
|
pub message_match_regex: String,
|
||||||
pub capture_group: String,
|
pub capture_group: String,
|
||||||
/// See `crate::data_value::RegexToMetricPrimitiveMatcher::json_field_path`.
|
/// See `crate::data_value::ParamDeclaration::json_field_path`.
|
||||||
pub json_field_path: Option<String>,
|
pub json_field_path: Option<String>,
|
||||||
pub kind: MetricPrimitiveKind,
|
pub kind: MetricPrimitiveKind,
|
||||||
|
/// See `crate::data_value::ParamDeclaration::elapsed_since_last`.
|
||||||
|
pub elapsed_since_last: bool,
|
||||||
|
/// See `crate::data_value::ParamDeclaration::auto_derived`. The frontend uses this to disable
|
||||||
|
/// editing — `POST /api/params` also rejects a request naming an auto-derived param.
|
||||||
|
pub auto_derived: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct MetricDeclarationDto {
|
pub struct MetricDeclarationDto {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub message_match_regex: String,
|
pub message_match_regex: String,
|
||||||
pub params: Vec<RegexToMetricPrimitiveMatcher>,
|
/// Names of standalone `ParamDeclaration`s used as this metric's bucket labels.
|
||||||
|
pub label_params: Vec<String>,
|
||||||
|
/// Name of the standalone `ParamDeclaration` (if any) whose primitive becomes this metric's per-event bucket value instead of a label.
|
||||||
|
pub value_param: Option<String>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::matched_labels`.
|
/// 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`.
|
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
|
||||||
@@ -70,8 +78,16 @@ 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, e.g. `regex("error") and sequence<5`.
|
/// Raw FilterBar query string, e.g. `regex("error") and ottyl_seq<5`.
|
||||||
pub filter: Option<String>,
|
pub filter: Option<String>,
|
||||||
|
/// Ticket 045: an already-declared param name to order the returned entries by, in place of the
|
||||||
|
/// default ingest order (which tracks `ottyl_ts`). `None` (the default) leaves ordering
|
||||||
|
/// unchanged.
|
||||||
|
pub sort_by: Option<String>,
|
||||||
|
/// Ticket 045: only meaningful alongside `sort_by`. `true` (the default) orders lines missing
|
||||||
|
/// that param among themselves by `ottyl_seq` and appends them after the sorted lines; `false`
|
||||||
|
/// drops those lines from the response entirely.
|
||||||
|
pub sort_missing_fallback: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -190,7 +206,7 @@ impl From<&Settings> for SettingsDto {
|
|||||||
fn from(settings: &Settings) -> Self {
|
fn from(settings: &Settings) -> Self {
|
||||||
SettingsDto {
|
SettingsDto {
|
||||||
color_scheme: settings.color_scheme,
|
color_scheme: settings.color_scheme,
|
||||||
active_profile: settings.profiles.active_profile.clone(),
|
active_profile: settings.active_profile.clone(),
|
||||||
max_logs: settings.max_logs,
|
max_logs: settings.max_logs,
|
||||||
custom_theme_id: settings.custom_theme_id.clone(),
|
custom_theme_id: settings.custom_theme_id.clone(),
|
||||||
ntfy_topic: settings.ntfy_topic.clone(),
|
ntfy_topic: settings.ntfy_topic.clone(),
|
||||||
|
|||||||
+1267
-445
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,9 @@
|
|||||||
// - regex("...") raw-text regex search over log lines
|
// - regex("...") raw-text regex search over log lines
|
||||||
// - metric_name[param] <op> value metric comparisons
|
// - metric_name[param] <op> value metric comparisons
|
||||||
// - json[field] <op> value JSON field comparisons (line parsed as JSON)
|
// - json[field] <op> value JSON field comparisons (line parsed as JSON)
|
||||||
// - sequence <op> value reserved metric name (no [param])
|
// - ottyl_seq <op> value reserved metric name (no [param])
|
||||||
// - timestamp <op> value reserved metric name (no [param])
|
// - ottyl_ts <op> value reserved metric name (no [param])
|
||||||
|
// - ottyl_fmt <op> value reserved metric name (no [param])
|
||||||
// - `and` / `or` boolean combinators, with `and` binding tighter than `or`
|
// - `and` / `or` boolean combinators, with `and` binding tighter than `or`
|
||||||
// - parenthesized sub-expressions
|
// - parenthesized sub-expressions
|
||||||
|
|
||||||
|
|||||||
+28
-14
@@ -284,12 +284,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_reserved_metric_sequence() {
|
fn parses_reserved_metric_ottyl_seq() {
|
||||||
let expr = parse("sequence<5").expect("should parse");
|
let expr = parse("ottyl_seq<5").expect("should parse");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
expr,
|
expr,
|
||||||
FilterExpr::Comparison {
|
FilterExpr::Comparison {
|
||||||
metric: "sequence".to_string(),
|
metric: "ottyl_seq".to_string(),
|
||||||
param: None,
|
param: None,
|
||||||
op: CompareOp::Lt,
|
op: CompareOp::Lt,
|
||||||
value: FilterValue::Number(5.0),
|
value: FilterValue::Number(5.0),
|
||||||
@@ -298,12 +298,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_reserved_metric_timestamp_as_raw_value() {
|
fn parses_reserved_metric_ottyl_ts_as_raw_value() {
|
||||||
let expr = parse("timestamp>12:00").expect("should parse");
|
let expr = parse("ottyl_ts>12:00").expect("should parse");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
expr,
|
expr,
|
||||||
FilterExpr::Comparison {
|
FilterExpr::Comparison {
|
||||||
metric: "timestamp".to_string(),
|
metric: "ottyl_ts".to_string(),
|
||||||
param: None,
|
param: None,
|
||||||
op: CompareOp::Gt,
|
op: CompareOp::Gt,
|
||||||
value: FilterValue::Raw("12:00".to_string()),
|
value: FilterValue::Raw("12:00".to_string()),
|
||||||
@@ -311,6 +311,20 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_reserved_metric_ottyl_fmt() {
|
||||||
|
let expr = parse("ottyl_fmt==json").expect("should parse");
|
||||||
|
assert_eq!(
|
||||||
|
expr,
|
||||||
|
FilterExpr::Comparison {
|
||||||
|
metric: "ottyl_fmt".to_string(),
|
||||||
|
param: None,
|
||||||
|
op: CompareOp::Eq,
|
||||||
|
value: FilterValue::Raw("json".to_string()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_json_field_comparison() {
|
fn parses_json_field_comparison() {
|
||||||
let expr = parse("json[level]==error").expect("should parse");
|
let expr = parse("json[level]==error").expect("should parse");
|
||||||
@@ -339,7 +353,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn json_field_composes_with_and_or() {
|
fn json_field_composes_with_and_or() {
|
||||||
let expr = parse(r#"json[level]==error and sequence<5"#).expect("should parse");
|
let expr = parse(r#"json[level]==error and ottyl_seq<5"#).expect("should parse");
|
||||||
let expected = FilterExpr::And(
|
let expected = FilterExpr::And(
|
||||||
Box::new(FilterExpr::JsonComparison {
|
Box::new(FilterExpr::JsonComparison {
|
||||||
field: "level".to_string(),
|
field: "level".to_string(),
|
||||||
@@ -347,7 +361,7 @@ mod tests {
|
|||||||
value: FilterValue::Raw("error".to_string()),
|
value: FilterValue::Raw("error".to_string()),
|
||||||
}),
|
}),
|
||||||
Box::new(FilterExpr::Comparison {
|
Box::new(FilterExpr::Comparison {
|
||||||
metric: "sequence".to_string(),
|
metric: "ottyl_seq".to_string(),
|
||||||
param: None,
|
param: None,
|
||||||
op: CompareOp::Lt,
|
op: CompareOp::Lt,
|
||||||
value: FilterValue::Number(5.0),
|
value: FilterValue::Number(5.0),
|
||||||
@@ -358,21 +372,21 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_and_or_with_and_binding_tighter() {
|
fn parses_and_or_with_and_binding_tighter() {
|
||||||
let expr = parse(r#"regex("error") and sequence<5 or timestamp>12:00"#)
|
let expr = parse(r#"regex("error") and ottyl_seq<5 or ottyl_ts>12:00"#)
|
||||||
.expect("should parse");
|
.expect("should parse");
|
||||||
|
|
||||||
let expected = FilterExpr::Or(
|
let expected = FilterExpr::Or(
|
||||||
Box::new(FilterExpr::And(
|
Box::new(FilterExpr::And(
|
||||||
Box::new(FilterExpr::Regex("error".to_string())),
|
Box::new(FilterExpr::Regex("error".to_string())),
|
||||||
Box::new(FilterExpr::Comparison {
|
Box::new(FilterExpr::Comparison {
|
||||||
metric: "sequence".to_string(),
|
metric: "ottyl_seq".to_string(),
|
||||||
param: None,
|
param: None,
|
||||||
op: CompareOp::Lt,
|
op: CompareOp::Lt,
|
||||||
value: FilterValue::Number(5.0),
|
value: FilterValue::Number(5.0),
|
||||||
}),
|
}),
|
||||||
)),
|
)),
|
||||||
Box::new(FilterExpr::Comparison {
|
Box::new(FilterExpr::Comparison {
|
||||||
metric: "timestamp".to_string(),
|
metric: "ottyl_ts".to_string(),
|
||||||
param: None,
|
param: None,
|
||||||
op: CompareOp::Gt,
|
op: CompareOp::Gt,
|
||||||
value: FilterValue::Raw("12:00".to_string()),
|
value: FilterValue::Raw("12:00".to_string()),
|
||||||
@@ -384,20 +398,20 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parenthesized_expression_overrides_precedence() {
|
fn parenthesized_expression_overrides_precedence() {
|
||||||
let expr = parse(r#"regex("error") and (sequence<5 or timestamp>12:00)"#)
|
let expr = parse(r#"regex("error") and (ottyl_seq<5 or ottyl_ts>12:00)"#)
|
||||||
.expect("should parse");
|
.expect("should parse");
|
||||||
|
|
||||||
let expected = FilterExpr::And(
|
let expected = FilterExpr::And(
|
||||||
Box::new(FilterExpr::Regex("error".to_string())),
|
Box::new(FilterExpr::Regex("error".to_string())),
|
||||||
Box::new(FilterExpr::Or(
|
Box::new(FilterExpr::Or(
|
||||||
Box::new(FilterExpr::Comparison {
|
Box::new(FilterExpr::Comparison {
|
||||||
metric: "sequence".to_string(),
|
metric: "ottyl_seq".to_string(),
|
||||||
param: None,
|
param: None,
|
||||||
op: CompareOp::Lt,
|
op: CompareOp::Lt,
|
||||||
value: FilterValue::Number(5.0),
|
value: FilterValue::Number(5.0),
|
||||||
}),
|
}),
|
||||||
Box::new(FilterExpr::Comparison {
|
Box::new(FilterExpr::Comparison {
|
||||||
metric: "timestamp".to_string(),
|
metric: "ottyl_ts".to_string(),
|
||||||
param: None,
|
param: None,
|
||||||
op: CompareOp::Gt,
|
op: CompareOp::Gt,
|
||||||
value: FilterValue::Raw("12:00".to_string()),
|
value: FilterValue::Raw("12:00".to_string()),
|
||||||
|
|||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
//! Fast, tolerant `key=value`/`key="value"` tokenizer (logfmt-ish), used by
|
||||||
|
//! `crate::engine::auto_declare_params` to discover param names as lines are ingested. Scans
|
||||||
|
//! anywhere in the line rather than requiring the whole line to be logfmt, so it works on lines
|
||||||
|
//! with an arbitrary non-logfmt prefix/suffix (e.g. `ts=... msg="..." x1=-4 y1=34`).
|
||||||
|
|
||||||
|
use nom::branch::alt;
|
||||||
|
use nom::bytes::complete::{escaped, take_while1};
|
||||||
|
use nom::character::complete::{char, none_of, one_of};
|
||||||
|
use nom::sequence::delimited;
|
||||||
|
use nom::IResult;
|
||||||
|
|
||||||
|
fn key(input: &str) -> IResult<&str, &str> {
|
||||||
|
take_while1(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quoted_value(input: &str) -> IResult<&str, &str> {
|
||||||
|
delimited(
|
||||||
|
char('"'),
|
||||||
|
alt((escaped(none_of("\\\""), '\\', one_of("\"\\nt")), nom::combinator::success(""))),
|
||||||
|
char('"'),
|
||||||
|
)(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bareword_value(input: &str) -> IResult<&str, &str> {
|
||||||
|
take_while1(|c: char| !c.is_whitespace())(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `key=value` pair found on a line: the key, the raw captured value (unescaped as-is, quotes
|
||||||
|
/// stripped but any inner `\"`/`\\` left literal since callers only need it for numeric-kind
|
||||||
|
/// inference), and whether the value was quoted.
|
||||||
|
fn pair(input: &str) -> IResult<&str, (&str, &str, bool)> {
|
||||||
|
let (input, k) = key(input)?;
|
||||||
|
let (input, _) = char('=')(input)?;
|
||||||
|
if let Ok((rest, v)) = quoted_value(input) {
|
||||||
|
Ok((rest, (k, v, true)))
|
||||||
|
} else {
|
||||||
|
let (rest, v) = bareword_value(input)?;
|
||||||
|
Ok((rest, (k, v, false)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds every `key=value`/`key="value"` pair anywhere in `line`. A position that doesn't start a
|
||||||
|
/// pair is skipped one char at a time, so this never fails outright — a line with no pairs at all
|
||||||
|
/// (plain prose, a JSON blob) just returns an empty vec.
|
||||||
|
pub fn extract_pairs(line: &str) -> Vec<(&str, &str, bool)> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let mut rest = line;
|
||||||
|
while !rest.is_empty() {
|
||||||
|
match pair(rest) {
|
||||||
|
Ok((remaining, found)) => {
|
||||||
|
results.push(found);
|
||||||
|
rest = remaining;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let mut chars = rest.chars();
|
||||||
|
chars.next();
|
||||||
|
rest = chars.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value of the `ottyl_fmt` reserved param (`crate::engine::compile_filter`/`eval_alert_expr`):
|
||||||
|
/// which format a line was recognized as when parsed. JSON is checked first since a JSON object can
|
||||||
|
/// incidentally contain `key=value`-shaped substrings that `extract_pairs` would otherwise pick up;
|
||||||
|
/// only a top-level object counts (arrays/scalars aren't what `crate::engine::eval_json_comparison`
|
||||||
|
/// treats as JSON either). Anything else falls back to logfmt-style pair extraction, and a line with
|
||||||
|
/// neither is `"none"`.
|
||||||
|
pub fn detect_format(text: &str) -> &'static str {
|
||||||
|
if serde_json::from_str::<serde_json::Value>(text).is_ok_and(|value| value.is_object()) {
|
||||||
|
"json"
|
||||||
|
} else if !extract_pairs(text).is_empty() {
|
||||||
|
"logfmt"
|
||||||
|
} else {
|
||||||
|
"none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_quoted_value() {
|
||||||
|
let pairs = extract_pairs(r#"msg="entity_moved""#);
|
||||||
|
assert_eq!(pairs, vec![("msg", "entity_moved", true)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_unquoted_bareword_value() {
|
||||||
|
let pairs = extract_pairs("x1=-4");
|
||||||
|
assert_eq!(pairs, vec![("x1", "-4", false)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_mixed_quoted_and_unquoted_pairs_in_one_line() {
|
||||||
|
let pairs = extract_pairs(r#"ts=1788648105773 msg="entity_moved" x1=-4 y1=34"#);
|
||||||
|
assert_eq!(
|
||||||
|
pairs,
|
||||||
|
vec![
|
||||||
|
("ts", "1788648105773", false),
|
||||||
|
("msg", "entity_moved", true),
|
||||||
|
("x1", "-4", false),
|
||||||
|
("y1", "34", false),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_empty_for_lines_with_no_pairs() {
|
||||||
|
assert!(extract_pairs("just some plain prose here").is_empty());
|
||||||
|
assert!(extract_pairs(r#"{"foo": {"bar": 42}}"#).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_panic_on_empty_value_at_end_of_line() {
|
||||||
|
assert_eq!(extract_pairs("key="), Vec::<(&str, &str, bool)>::new());
|
||||||
|
assert_eq!(extract_pairs(r#"key="""#), vec![("key", "", true)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_escaped_quote_inside_quoted_value() {
|
||||||
|
let pairs = extract_pairs(r#"msg="he said \"hi\"""#);
|
||||||
|
assert_eq!(pairs, vec![("msg", r#"he said \"hi\""#, true)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_json_object_lines_as_json() {
|
||||||
|
assert_eq!(detect_format(r#"{"level":"error","code":500}"#), "json");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_key_value_lines_as_logfmt() {
|
||||||
|
assert_eq!(detect_format(r#"ts=1788648105773 msg="entity_moved" x1=-4"#), "logfmt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_plain_prose_as_none() {
|
||||||
|
assert_eq!(detect_format("just some plain prose here"), "none");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bare_json_array_or_scalar_is_not_treated_as_json() {
|
||||||
|
// Not an object, so falls through to logfmt/none like `eval_json_comparison` would treat it.
|
||||||
|
assert_eq!(detect_format("[1,2,3]"), "none");
|
||||||
|
assert_eq!(detect_format("42"), "none");
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-16
@@ -7,9 +7,11 @@ mod filter_datetime;
|
|||||||
mod filter_parser;
|
mod filter_parser;
|
||||||
mod index_key;
|
mod index_key;
|
||||||
mod ingest;
|
mod ingest;
|
||||||
|
mod logfmt;
|
||||||
mod metric_primitive;
|
mod metric_primitive;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod promql;
|
mod promql;
|
||||||
|
mod rate;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod timestamp;
|
mod timestamp;
|
||||||
|
|
||||||
@@ -20,7 +22,6 @@ use clap::Parser;
|
|||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use profile::ProfileStore;
|
|
||||||
use settings::Settings;
|
use settings::Settings;
|
||||||
|
|
||||||
/// The resolved default settings path, rendered into `--help` so `--settings-path`'s help text
|
/// The resolved default settings path, rendered into `--help` so `--settings-path`'s help text
|
||||||
@@ -74,28 +75,28 @@ async fn main() {
|
|||||||
// Reload the active profile's declarations before ingest starts: there's no way to
|
// Reload the active profile's declarations before ingest starts: there's no way to
|
||||||
// reprocess early lines later, so declaring after ingest starts could miss lines that raced ahead.
|
// reprocess early lines later, so declaring after ingest starts could miss lines that raced ahead.
|
||||||
{
|
{
|
||||||
let (active_profile_name, profiles_dir) = {
|
let (active_profile_name, active_profile) = {
|
||||||
let mut settings_guard = settings.lock().unwrap();
|
let mut settings_guard = settings.lock().unwrap();
|
||||||
let profiles_dir = profile::resolve_profiles_dir(&settings_guard, &settings_path);
|
settings_guard
|
||||||
let mut store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
.ensure_default_profile(&settings_path)
|
||||||
store
|
|
||||||
.ensure_default(&profile::profiles_store_path(&profiles_dir), &mut settings_guard, &settings_path)
|
|
||||||
.expect("failed to ensure the Default profile exists");
|
.expect("failed to ensure the Default profile exists");
|
||||||
(settings_guard.profiles.active_profile.clone(), profiles_dir)
|
let active_profile_name = settings_guard.active_profile.clone();
|
||||||
|
let active_profile = active_profile_name
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|name| settings_guard.profile(name).cloned());
|
||||||
|
(active_profile_name, active_profile)
|
||||||
};
|
};
|
||||||
if let Some(profile_name) = active_profile_name {
|
match (active_profile_name, active_profile) {
|
||||||
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
(Some(_), Some(active_profile)) => {
|
||||||
match store.get(&profile_name) {
|
profile::load_into_engine(&engine_tx, &active_profile).await;
|
||||||
Some(active_profile) => {
|
|
||||||
profile::load_into_engine(&engine_tx, active_profile).await;
|
|
||||||
}
|
}
|
||||||
None => {
|
(Some(profile_name), None) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"warning: active profile '{profile_name}' not found under {}; skipping",
|
"warning: active profile '{profile_name}' not found in {}; skipping",
|
||||||
profiles_dir.display()
|
settings_path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
(None, _) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+693
-320
File diff suppressed because it is too large
Load Diff
+55
-4
@@ -1,6 +1,7 @@
|
|||||||
//! 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.
|
//! Hand-rolled parser for a small PromQL subset: metric name + label matchers, an optional `[5m]` range, and a top-level `sum()`/`avg()`/`count()`/`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.
|
//! 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.
|
||||||
//! `sum()`/`avg()` may additionally wrap a `rate(...)` call directly (e.g. `sum(rate(requests[5m]))`), and either may be followed by a `by (label, ...)` clause to preserve one series per distinct value of the named labels instead of collapsing to a single series.
|
//! `sum()`/`avg()` may additionally wrap a `rate(...)` call directly (e.g. `sum(rate(requests[5m]))`), and either may be followed by a `by (label, ...)` clause to preserve one series per distinct value of the named labels instead of collapsing to a single series.
|
||||||
|
//! `count()` is a plain vector selector wrapper only (no `rate()` nesting) — it counts occurrences per group regardless of the metric's value, which is the deliberate opt-in for occurrence counting that `sum()`/`avg()` no longer fall back to for a `String`-kind metric (see `crate::engine::evaluate_promql`).
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -8,6 +9,9 @@ use std::time::Duration;
|
|||||||
pub enum AggOp {
|
pub enum AggOp {
|
||||||
Sum,
|
Sum,
|
||||||
Avg,
|
Avg,
|
||||||
|
/// Occurrence count per group, ignoring the metric's stored value entirely — the explicit,
|
||||||
|
/// opt-in-by-name alternative to summing a `String`-kind metric's coerced-to-1.0 values.
|
||||||
|
Count,
|
||||||
Rate,
|
Rate,
|
||||||
/// Buckets counter values into fixed-width buckets; requires `bucket_width`.
|
/// Buckets counter values into fixed-width buckets; requires `bucket_width`.
|
||||||
Histogram,
|
Histogram,
|
||||||
@@ -278,12 +282,17 @@ impl<'a> Parser<'a> {
|
|||||||
let agg = match ident.as_str() {
|
let agg = match ident.as_str() {
|
||||||
"sum" => Some(AggOp::Sum),
|
"sum" => Some(AggOp::Sum),
|
||||||
"avg" => Some(AggOp::Avg),
|
"avg" => Some(AggOp::Avg),
|
||||||
|
"count" => Some(AggOp::Count),
|
||||||
"rate" => Some(AggOp::Rate),
|
"rate" => Some(AggOp::Rate),
|
||||||
"histogram" => Some(AggOp::Histogram),
|
"histogram" => Some(AggOp::Histogram),
|
||||||
"gauge" => Some(AggOp::Gauge),
|
"gauge" => Some(AggOp::Gauge),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
self.skip_whitespace();
|
self.skip_whitespace();
|
||||||
|
// `sum by (label) (metric)`: a `by (...)` clause may also precede the aggregation's
|
||||||
|
// parenthesized argument, not just follow it.
|
||||||
|
let prefix_by_labels = if agg.is_some() { self.parse_by_clause()? } else { Vec::new() };
|
||||||
|
self.skip_whitespace();
|
||||||
if agg.is_some() && self.peek() == Some('(') {
|
if agg.is_some() && self.peek() == Some('(') {
|
||||||
self.expect_char('(')?;
|
self.expect_char('(')?;
|
||||||
self.skip_whitespace();
|
self.skip_whitespace();
|
||||||
@@ -318,7 +327,15 @@ impl<'a> Parser<'a> {
|
|||||||
};
|
};
|
||||||
self.expect_char(')')?;
|
self.expect_char(')')?;
|
||||||
inner.agg = agg;
|
inner.agg = agg;
|
||||||
inner.by_labels = self.parse_by_clause()?;
|
let trailing_by_labels = self.parse_by_clause()?;
|
||||||
|
if !prefix_by_labels.is_empty() && !trailing_by_labels.is_empty() {
|
||||||
|
return Err(
|
||||||
|
"by (...) clause may only appear once, either before or after the aggregation's argument"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
inner.by_labels =
|
||||||
|
if prefix_by_labels.is_empty() { trailing_by_labels } else { prefix_by_labels };
|
||||||
Ok(inner)
|
Ok(inner)
|
||||||
} else {
|
} else {
|
||||||
// Rewind and parse as a plain vector selector, covering metric names that collide with an agg_op keyword.
|
// Rewind and parse as a plain vector selector, covering metric names that collide with an agg_op keyword.
|
||||||
@@ -415,11 +432,26 @@ 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 name and trips on the trailing `(...)`.
|
// `median` isn't a supported agg_op, so this parses as a bare metric name and trips on the trailing `(...)`.
|
||||||
let err = parse_query("count(requests)").unwrap_err();
|
let err = parse_query("median(requests)").unwrap_err();
|
||||||
assert!(err.contains("unexpected trailing input"));
|
assert!(err.contains("unexpected trailing input"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_count_aggregation() {
|
||||||
|
let query = parse_query("count(requests)").unwrap();
|
||||||
|
assert_eq!(query.agg, Some(AggOp::Count));
|
||||||
|
assert_eq!(query.metric_name, "requests");
|
||||||
|
assert!(!query.inner_rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_count_with_by_clause() {
|
||||||
|
let query = parse_query("count(requests) by (path)").unwrap();
|
||||||
|
assert_eq!(query.agg, Some(AggOp::Count));
|
||||||
|
assert_eq!(query.by_labels, vec!["path".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_regex_label_matcher() {
|
fn rejects_regex_label_matcher() {
|
||||||
let err = parse_query(r#"requests{path=~"/a"}"#).unwrap_err();
|
let err = parse_query(r#"requests{path=~"/a"}"#).unwrap_err();
|
||||||
@@ -527,4 +559,23 @@ mod tests {
|
|||||||
fn rejects_unterminated_by_clause() {
|
fn rejects_unterminated_by_clause() {
|
||||||
assert!(parse_query("sum(requests) by (path").is_err());
|
assert!(parse_query("sum(requests) by (path").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_sum_with_prefix_by_clause() {
|
||||||
|
let query = parse_query("sum by (path) (requests)").unwrap();
|
||||||
|
assert_eq!(query.agg, Some(AggOp::Sum));
|
||||||
|
assert_eq!(query.by_labels, vec!["path".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_sum_with_prefix_by_clause_multiple_labels() {
|
||||||
|
let query = parse_query("sum by (path, method) (requests)").unwrap();
|
||||||
|
assert_eq!(query.by_labels, vec!["path".to_string(), "method".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_by_clause_both_before_and_after_aggregation() {
|
||||||
|
let err = parse_query("sum by (path) (requests) by (method)").unwrap_err();
|
||||||
|
assert!(err.contains("only appear once"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
//! Ticket 040: the single windowed-rate/count implementation in the codebase, shared by
|
||||||
|
//! PromQL's `rate()` (`crate::engine::evaluate_promql`, via the `windowed_rate_series` adapter
|
||||||
|
//! there) and alert `Threshold`/`Ratio` conditions (`crate::alerts`). Previously each computed
|
||||||
|
//! its own trailing window independently — PromQL scanning indexed bucket data, alerts scanning
|
||||||
|
//! raw log-line matches with a hand-rolled `VecDeque` — and could disagree for equivalent-looking
|
||||||
|
//! rules simply because the window boundary was implemented twice.
|
||||||
|
//!
|
||||||
|
//! `count_in_window` is the one place that decides what "falls in the trailing window" means;
|
||||||
|
//! everything else in this module is built on top of it, either by batch-rescanning a full point
|
||||||
|
//! series (`rate_at`/`windowed_rate_series`, fine for PromQL's historical/one-shot evaluation) or
|
||||||
|
//! incrementally (`RateWindow`, for alerts, which see one point at a time and need cost
|
||||||
|
//! proportional to the window rather than a full rescan per ingested line).
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::timestamp::Timestamp;
|
||||||
|
|
||||||
|
/// How many `points` fall in the trailing `(now - window_ms, now]` window. Points are occurrence
|
||||||
|
/// markers rather than a continuous reading, so only their timestamps matter — the boundary is
|
||||||
|
/// exclusive on the old end and inclusive of `now` itself, matching the original
|
||||||
|
/// `windowed_rate_series` behavior this replaces.
|
||||||
|
fn count_in_window(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64) -> usize {
|
||||||
|
let now_ms = now.as_millis();
|
||||||
|
let window_start = now_ms - window_ms;
|
||||||
|
points
|
||||||
|
.iter()
|
||||||
|
.filter(|(ts, _)| {
|
||||||
|
let ms = ts.as_millis();
|
||||||
|
ms > window_start && ms <= now_ms
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trailing-window occurrence rate (points per second) ending at `now`: how many `points` fall in
|
||||||
|
/// `(now - window_ms, now]`, divided by the window width in seconds.
|
||||||
|
pub fn rate_at(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64) -> f64 {
|
||||||
|
let window_seconds = window_ms as f64 / 1000.0;
|
||||||
|
count_in_window(points, now, window_ms) as f64 / window_seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluates `rate_at` at every one of `points`' own timestamps, turning an occurrence series
|
||||||
|
/// into a windowed rate series — used by PromQL's `rate()` so it plots as a real line rather than
|
||||||
|
/// one trailing point.
|
||||||
|
pub fn windowed_rate_series(points: &[(Timestamp, f64)], window_ms: i64) -> Vec<(Timestamp, f64)> {
|
||||||
|
points.iter().map(|(ts, _)| (*ts, rate_at(points, *ts, window_ms))).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Incrementally maintained trailing window: for a caller that sees points arrive one at a time
|
||||||
|
/// (alert `Threshold`/`Ratio` conditions, evaluated per ingested line) and needs cost proportional
|
||||||
|
/// to the window rather than `count_in_window`'s full rescan per call. Assumes points are pushed
|
||||||
|
/// in non-decreasing timestamp order, true for ingested log lines.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct RateWindow {
|
||||||
|
timestamps: VecDeque<Timestamp>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateWindow {
|
||||||
|
/// Builds a window pre-populated from already-seen history (e.g. currently-ingested logs
|
||||||
|
/// matching an alert's query), so a freshly-created window starts with whatever of that
|
||||||
|
/// history already falls in the trailing window instead of empty.
|
||||||
|
pub fn backfill(timestamps: impl IntoIterator<Item = Timestamp>, now: Timestamp, window_ms: i64) -> Self {
|
||||||
|
let mut window = Self::default();
|
||||||
|
for ts in timestamps {
|
||||||
|
window.push(ts, window_ms);
|
||||||
|
}
|
||||||
|
window.prune(now, window_ms);
|
||||||
|
window
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records one occurrence at `timestamp` and drops anything that has aged out of the
|
||||||
|
/// resulting trailing window.
|
||||||
|
pub fn push(&mut self, timestamp: Timestamp, window_ms: i64) {
|
||||||
|
self.timestamps.push_back(timestamp);
|
||||||
|
self.prune(timestamp, window_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same boundary as `count_in_window`: drops entries at or before `now - window_ms`.
|
||||||
|
fn prune(&mut self, now: Timestamp, window_ms: i64) {
|
||||||
|
let window_start = now.as_millis() - window_ms;
|
||||||
|
while let Some(&front) = self.timestamps.front() {
|
||||||
|
if front.as_millis() <= window_start {
|
||||||
|
self.timestamps.pop_front();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count(&self) -> usize {
|
||||||
|
self.timestamps.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_at_matches_hand_computed_window() {
|
||||||
|
let points = vec![
|
||||||
|
(Timestamp::from_millis(0), 1.0),
|
||||||
|
(Timestamp::from_millis(1000), 1.0),
|
||||||
|
(Timestamp::from_millis(2000), 1.0),
|
||||||
|
];
|
||||||
|
// (1000, 2000]: only the point at 2000.
|
||||||
|
assert_eq!(rate_at(&points, Timestamp::from_millis(2000), 1000), 1.0);
|
||||||
|
// (0, 2000]: points at 1000 and 2000 -> 2 points / 2s.
|
||||||
|
assert_eq!(rate_at(&points, Timestamp::from_millis(2000), 2000), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn windowed_rate_series_matches_rate_at_at_each_point() {
|
||||||
|
let points = vec![
|
||||||
|
(Timestamp::from_millis(0), 1.0),
|
||||||
|
(Timestamp::from_millis(500), 1.0),
|
||||||
|
(Timestamp::from_millis(1000), 1.0),
|
||||||
|
];
|
||||||
|
let series = windowed_rate_series(&points, 1000);
|
||||||
|
for (ts, rate) in &series {
|
||||||
|
assert_eq!(*rate, rate_at(&points, *ts, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_window_incremental_count_matches_batch_count_in_window() {
|
||||||
|
let mut window = RateWindow::default();
|
||||||
|
let millis = [0, 200, 400, 1200];
|
||||||
|
for ms in millis {
|
||||||
|
window.push(Timestamp::from_millis(ms), 1000);
|
||||||
|
}
|
||||||
|
let points: Vec<(Timestamp, f64)> =
|
||||||
|
millis.iter().map(|ms| (Timestamp::from_millis(*ms), 1.0)).collect();
|
||||||
|
assert_eq!(window.count(), count_in_window(&points, Timestamp::from_millis(1200), 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_window_backfill_seeds_from_history_within_the_window() {
|
||||||
|
let history = [0, 500, 2000, 2500].map(Timestamp::from_millis);
|
||||||
|
// now=2500ms, window=1000ms -> window (1500, 2500]: only 2000 and 2500 qualify.
|
||||||
|
let window = RateWindow::backfill(history, Timestamp::from_millis(2500), 1000);
|
||||||
|
assert_eq!(window.count(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
+222
-38
@@ -1,4 +1,5 @@
|
|||||||
//! Process-wide settings model: colour scheme, CSS entries, and profile pointers, persisted as JSON.
|
//! Process-wide settings model: colour scheme, CSS entries, and profiles (ticket 049 folded
|
||||||
|
//! profile storage inline here instead of a separate `profiles.json`), persisted as JSON.
|
||||||
|
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
@@ -13,6 +14,7 @@ use directories::ProjectDirs;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::alerts::AlertRule;
|
use crate::alerts::AlertRule;
|
||||||
|
use crate::profile::Profile;
|
||||||
|
|
||||||
/// Shared, process-wide settings handle. Cheap to clone.
|
/// Shared, process-wide settings handle. Cheap to clone.
|
||||||
pub type SettingsHandle = Arc<Mutex<Settings>>;
|
pub type SettingsHandle = Arc<Mutex<Settings>>;
|
||||||
@@ -35,6 +37,9 @@ pub enum ColorScheme {
|
|||||||
Yellow,
|
Yellow,
|
||||||
Pink,
|
Pink,
|
||||||
Orange,
|
Orange,
|
||||||
|
/// Brighter, light-surfaced counterpart to `Green` (ticket 048).
|
||||||
|
#[serde(rename = "light_green")]
|
||||||
|
LightGreen,
|
||||||
#[default]
|
#[default]
|
||||||
System,
|
System,
|
||||||
}
|
}
|
||||||
@@ -51,27 +56,101 @@ pub enum CssEntry {
|
|||||||
Linked { path_or_url: String },
|
Linked { path_or_url: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tracks known profile names and which one is active; the profile files themselves live elsewhere.
|
/// Legacy on-disk shape of the (pre-049) `Profiles` pointer struct: only names/active-profile/dir
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
/// pointers, with the actual profile content living in a separate `profiles.json`. Kept only so an
|
||||||
pub struct Profiles {
|
/// old `settings.json` still parses; see `ProfilesOnDisk` and `Settings::migrate_legacy_profiles_file`.
|
||||||
/// Names of known profiles.
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
pub names: Vec<String>,
|
struct LegacyProfilesPointer {
|
||||||
/// Name of the currently active profile, if any.
|
// `names` is intentionally not captured: it was always redundant with the legacy
|
||||||
pub active_profile: Option<String>,
|
// `profiles.json`'s actual profile list, which is what migration reads instead.
|
||||||
/// Directory (relative to the settings file's directory, or absolute)
|
#[serde(default)]
|
||||||
/// where individual profile files are expected to live.
|
active_profile: Option<String>,
|
||||||
pub profiles_dir: String,
|
/// Directory (relative to the settings file's directory, or absolute) the legacy `profiles.json`
|
||||||
|
/// lived under. Every version of this codebase ever wrote `"profiles"` here (no API ever let a
|
||||||
|
/// user change it), so migration doesn't bother re-deriving a custom value from this field.
|
||||||
|
#[serde(default)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
profiles_dir: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On-disk shape of the `profiles` field: either the new inline `Vec<Profile>` (ticket 049) or the
|
||||||
|
/// old pointer-struct shape, so a `settings.json` written before this ticket still deserializes.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum ProfilesOnDisk {
|
||||||
|
Inline(Vec<Profile>),
|
||||||
|
Legacy(LegacyProfilesPointer),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProfilesOnDisk {
|
||||||
|
fn default() -> Self {
|
||||||
|
ProfilesOnDisk::Inline(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw on-disk shape of `Settings`, used only to support deserializing an old `settings.json` whose
|
||||||
|
/// `profiles` field was the pointer struct and had no top-level `active_profile` — see `Settings`'s
|
||||||
|
/// `#[serde(from = ...)]`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct SettingsOnDisk {
|
||||||
|
color_scheme: ColorScheme,
|
||||||
|
#[serde(default)]
|
||||||
|
css_entries: Vec<CssEntry>,
|
||||||
|
#[serde(default)]
|
||||||
|
custom_theme_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
profiles: ProfilesOnDisk,
|
||||||
|
#[serde(default)]
|
||||||
|
active_profile: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
max_logs: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
alert_rules: Vec<AlertRule>,
|
||||||
|
#[serde(default)]
|
||||||
|
ntfy_topic: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
ntfy_server: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SettingsOnDisk> for Settings {
|
||||||
|
fn from(on_disk: SettingsOnDisk) -> Self {
|
||||||
|
let (profiles, legacy_active_profile) = match on_disk.profiles {
|
||||||
|
ProfilesOnDisk::Inline(profiles) => (profiles, None),
|
||||||
|
ProfilesOnDisk::Legacy(pointer) => (Vec::new(), pointer.active_profile),
|
||||||
|
};
|
||||||
|
Settings {
|
||||||
|
color_scheme: on_disk.color_scheme,
|
||||||
|
css_entries: on_disk.css_entries,
|
||||||
|
custom_theme_id: on_disk.custom_theme_id,
|
||||||
|
profiles,
|
||||||
|
active_profile: on_disk.active_profile.or(legacy_active_profile),
|
||||||
|
max_logs: on_disk.max_logs,
|
||||||
|
alert_rules: on_disk.alert_rules,
|
||||||
|
ntfy_topic: on_disk.ntfy_topic,
|
||||||
|
ntfy_server: on_disk.ntfy_server,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The single, process-wide settings instance. Only `PartialEq` (not `Eq`): `alert_rules` carries `f64` fields, which aren't `Eq`.
|
/// The single, process-wide settings instance. Only `PartialEq` (not `Eq`): `alert_rules` carries `f64` fields, which aren't `Eq`.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
///
|
||||||
|
/// `profiles`/`active_profile` used to be a separate `Profiles` pointer struct referencing a
|
||||||
|
/// standalone `profiles.json` (see `crate::profile::ProfileStore`, removed by ticket 049); profile
|
||||||
|
/// data now lives inline here, as the single source of truth. `Deserialize` goes through
|
||||||
|
/// `SettingsOnDisk` so a `settings.json` written before this ticket still loads (see
|
||||||
|
/// `migrate_legacy_profiles_file`, which then imports the old `profiles.json`, if any, on `load`).
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(from = "SettingsOnDisk")]
|
||||||
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, if any; `None` means `color_scheme` alone determines the active palette.
|
/// Id of the selected custom theme entry, if any; `None` means `color_scheme` alone determines the active palette.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub custom_theme_id: Option<String>,
|
pub custom_theme_id: Option<String>,
|
||||||
pub profiles: Profiles,
|
/// Every known profile, inline (ticket 049).
|
||||||
|
pub profiles: Vec<Profile>,
|
||||||
|
/// Name of the currently active profile, if any.
|
||||||
|
pub active_profile: Option<String>,
|
||||||
/// Maximum number of ingested logs kept in memory; `None` means unlimited.
|
/// Maximum number of ingested logs kept in memory; `None` means unlimited.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub max_logs: Option<usize>,
|
pub max_logs: Option<usize>,
|
||||||
@@ -86,36 +165,21 @@ pub struct Settings {
|
|||||||
pub ntfy_server: Option<String>,
|
pub ntfy_server: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Settings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Settings {
|
|
||||||
color_scheme: ColorScheme::default(),
|
|
||||||
css_entries: Vec::new(),
|
|
||||||
custom_theme_id: None,
|
|
||||||
profiles: Profiles {
|
|
||||||
names: Vec::new(),
|
|
||||||
active_profile: None,
|
|
||||||
profiles_dir: "profiles".to_string(),
|
|
||||||
},
|
|
||||||
max_logs: None,
|
|
||||||
alert_rules: Vec::new(),
|
|
||||||
ntfy_topic: None,
|
|
||||||
ntfy_server: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Computes the default settings file path (`<config_dir>/settings.json`) from OS-conventional config directories; `None` if none could be determined.
|
/// Computes the default settings file path (`<config_dir>/settings.json`) from OS-conventional config directories; `None` if none could be determined.
|
||||||
pub fn default_settings_path() -> Option<PathBuf> {
|
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`, 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.
|
/// 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. Also runs the one-time legacy `profiles.json` migration (see `migrate_legacy_profiles_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::<Settings>(&contents) {
|
||||||
Ok(settings) => settings,
|
Ok(mut settings) => {
|
||||||
|
settings.migrate_legacy_profiles_file(path);
|
||||||
|
settings.migrate_legacy_profile_shapes(path);
|
||||||
|
settings
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"warning: failed to parse settings file at {}: {err}; using defaults",
|
"warning: failed to parse settings file at {}: {err}; using defaults",
|
||||||
@@ -125,7 +189,8 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||||
let settings = Settings::default();
|
let mut settings = Settings::default();
|
||||||
|
settings.migrate_legacy_profiles_file(path);
|
||||||
if let Err(save_err) = settings.save(path) {
|
if let Err(save_err) = settings.save(path) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"warning: failed to create default settings file at {}: {save_err}",
|
"warning: failed to create default settings file at {}: {save_err}",
|
||||||
@@ -153,6 +218,60 @@ impl Settings {
|
|||||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
||||||
fs::write(path, contents)
|
fs::write(path, contents)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One-time migration (ticket 049): if `self.profiles` is empty (a fresh `Settings`, or one
|
||||||
|
/// parsed from a pre-049 `settings.json` whose profiles lived in a separate file) and a legacy
|
||||||
|
/// `<settings_path's parent>/profiles/profiles.json` exists, reads it in, merges it into
|
||||||
|
/// `self.profiles`, persists `self`, and — only once that save succeeds — renames the old file
|
||||||
|
/// (`.migrated` suffix) so it isn't re-imported on a later load. Every prior version of this
|
||||||
|
/// codebase always used the fixed `"profiles"` subdirectory name (no API ever let it be
|
||||||
|
/// reconfigured), so that's the only location checked. Best-effort: any failure is logged and
|
||||||
|
/// left for a future load to retry rather than panicking.
|
||||||
|
fn migrate_legacy_profiles_file(&mut self, settings_path: &Path) {
|
||||||
|
if !self.profiles.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let legacy_path = settings_path
|
||||||
|
.parent()
|
||||||
|
.map(|parent| parent.join("profiles"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from("profiles"))
|
||||||
|
.join("profiles.json");
|
||||||
|
let Some(profiles) = crate::profile::load_legacy_profile_store(&legacy_path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.profiles = profiles;
|
||||||
|
if let Err(err) = self.save(settings_path) {
|
||||||
|
eprintln!(
|
||||||
|
"warning: failed to persist profiles migrated from {}: {err}",
|
||||||
|
legacy_path.display()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let migrated_path = legacy_path.with_extension("json.migrated");
|
||||||
|
if let Err(err) = fs::rename(&legacy_path, &migrated_path) {
|
||||||
|
eprintln!(
|
||||||
|
"warning: migrated profiles from {} into settings.json, but failed to rename the old file: {err}",
|
||||||
|
legacy_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrates any pre-037/pre-038 metric or param shapes still present in `self.profiles` into
|
||||||
|
/// their current form (see `crate::profile::migrate_legacy_profile_shapes`), persisting `self`
|
||||||
|
/// if anything changed. Runs on every load — previously done inside the now-removed
|
||||||
|
/// `ProfileStore::load` (ticket 049 folded profile storage into `settings.json`) — so a profile
|
||||||
|
/// saved by an older version of ottyl, whether loaded inline here or just merged in by
|
||||||
|
/// `migrate_legacy_profiles_file`, still gets migrated forward.
|
||||||
|
fn migrate_legacy_profile_shapes(&mut self, settings_path: &Path) {
|
||||||
|
if crate::profile::migrate_legacy_profile_shapes(&mut self.profiles)
|
||||||
|
&& let Err(err) = self.save(settings_path)
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"warning: failed to persist migrated profile shapes to {}: {err}",
|
||||||
|
settings_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error returned by CSS-entry mutations and lookups.
|
/// Error returned by CSS-entry mutations and lookups.
|
||||||
@@ -315,6 +434,20 @@ mod tests {
|
|||||||
assert_eq!(settings.color_scheme, ColorScheme::Dark);
|
assert_eq!(settings.color_scheme, ColorScheme::Dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A `settings.json` written before ticket 049 (nested `Profiles` pointer struct, no top-level
|
||||||
|
/// `active_profile`) still parses: the pointer's `active_profile` carries over, and `profiles`
|
||||||
|
/// starts empty (the real content lived in a separate `profiles.json`, migrated separately by
|
||||||
|
/// `Settings::load`, not by plain deserialization).
|
||||||
|
#[test]
|
||||||
|
fn legacy_profiles_pointer_shape_deserializes_with_empty_inline_profiles() {
|
||||||
|
let settings: Settings = serde_json::from_str(
|
||||||
|
r#"{"color_scheme":"dark","css_entries":[],"custom_theme_id":null,"profiles":{"names":["web"],"active_profile":"web","profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(settings.profiles.is_empty());
|
||||||
|
assert_eq!(settings.active_profile, Some("web".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn round_trips_through_save_and_load() {
|
fn round_trips_through_save_and_load() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -329,8 +462,8 @@ mod tests {
|
|||||||
settings.css_entries.push(CssEntry::Linked {
|
settings.css_entries.push(CssEntry::Linked {
|
||||||
path_or_url: "https://example.com/style.css".to_string(),
|
path_or_url: "https://example.com/style.css".to_string(),
|
||||||
});
|
});
|
||||||
settings.profiles.names.push("default".to_string());
|
settings.profiles.push(crate::profile::Profile::new("default".to_string()));
|
||||||
settings.profiles.active_profile = Some("default".to_string());
|
settings.active_profile = Some("default".to_string());
|
||||||
settings.alert_rules.push(crate::alerts::AlertRule {
|
settings.alert_rules.push(crate::alerts::AlertRule {
|
||||||
id: "rule1".to_string(),
|
id: "rule1".to_string(),
|
||||||
name: "error spike".to_string(),
|
name: "error spike".to_string(),
|
||||||
@@ -458,4 +591,55 @@ mod tests {
|
|||||||
let result = settings.css_content(&css_dir, &linked_id);
|
let result = settings.css_content(&css_dir, &linked_id);
|
||||||
assert!(matches!(result, Err(CssError::NotImported)));
|
assert!(matches!(result, Err(CssError::NotImported)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Acceptance criterion (ticket 049): a pre-existing legacy `profiles_dir/profiles.json` is
|
||||||
|
/// merged into `settings.json` on the next load, and the old file is renamed (not re-imported on
|
||||||
|
/// a subsequent load).
|
||||||
|
#[test]
|
||||||
|
fn load_migrates_legacy_profiles_file_into_inline_settings() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let settings_path = dir.path().join("settings.json");
|
||||||
|
let profiles_dir = dir.path().join("profiles");
|
||||||
|
fs::create_dir_all(&profiles_dir).unwrap();
|
||||||
|
let legacy_path = profiles_dir.join("profiles.json");
|
||||||
|
fs::write(
|
||||||
|
&legacy_path,
|
||||||
|
r#"[{"name":"web","params":[],"metrics":[],"dashboards":[]}]"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// No settings.json yet: simulates upgrading a pre-049 install that never wrote one either,
|
||||||
|
// or one that did but whose nested pointer already carried an active_profile name.
|
||||||
|
fs::write(
|
||||||
|
&settings_path,
|
||||||
|
r#"{"color_scheme":"system","css_entries":[],"custom_theme_id":null,"profiles":{"names":["web"],"active_profile":"web","profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let settings = Settings::load(&settings_path);
|
||||||
|
assert_eq!(settings.profiles.len(), 1);
|
||||||
|
assert_eq!(settings.profiles[0].name, "web");
|
||||||
|
assert_eq!(settings.active_profile, Some("web".to_string()));
|
||||||
|
|
||||||
|
// The legacy file is gone (renamed), so it isn't re-imported.
|
||||||
|
assert!(!legacy_path.exists());
|
||||||
|
let migrated_path = legacy_path.with_extension("json.migrated");
|
||||||
|
assert!(migrated_path.exists());
|
||||||
|
|
||||||
|
// Reload from disk: the migrated settings.json now carries profiles inline directly.
|
||||||
|
let reloaded = Settings::load(&settings_path);
|
||||||
|
assert_eq!(reloaded.profiles.len(), 1);
|
||||||
|
assert_eq!(reloaded.profiles[0].name, "web");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh install (no legacy file at all) never creates `profiles.json` — it's not even looked
|
||||||
|
/// for beyond the one migration check, and `profiles` simply stays empty.
|
||||||
|
#[test]
|
||||||
|
fn load_without_legacy_file_leaves_profiles_empty() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let settings_path = dir.path().join("settings.json");
|
||||||
|
|
||||||
|
let settings = Settings::load(&settings_path);
|
||||||
|
assert!(settings.profiles.is_empty());
|
||||||
|
assert!(!dir.path().join("profiles").exists());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# 050 — Bound logfmt-overfit param cardinality; derive params from `log stream`-style structured headers
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
A real-world `log stream` (macOS unified log) dump (`huig_dump.txt`, ~700
|
||||||
|
lines) exposes two related problems in the auto-param-derivation path
|
||||||
|
(`auto_declare_params` in `src/engine.rs`, backed by `extract_pairs` in
|
||||||
|
`src/logfmt.rs`, run on *every* ingested line at `src/engine.rs:155`):
|
||||||
|
|
||||||
|
1. **`=`-overfitting produces runaway, low-value param cardinality.**
|
||||||
|
`extract_pairs` scans anywhere in the line for `key=value`/`key="value"`
|
||||||
|
and treats every match as a declarable param. On the sample dump this
|
||||||
|
discovers **220 distinct auto-declared param names** from under 700
|
||||||
|
lines — one-off WiFi/radio stat dumps alone contribute lines with
|
||||||
|
20-27 `key=value` pairs each (e.g. `txRTSFrm=169 txRTSFail=0
|
||||||
|
rxCTSUcast=168 ...`), most of which are internal counters that will
|
||||||
|
never recur meaningfully. Some matches are outright false positives:
|
||||||
|
base64-encoded blobs ending in padding (e.g.
|
||||||
|
`rKTEtG7JmnThhoKPbZiVrA=='>,`) get parsed as a `key=value` pair because
|
||||||
|
`=` precedes non-whitespace. Since `auto_declare_params` "never
|
||||||
|
overwrites an existing declaration" and there is no cap or pruning
|
||||||
|
anywhere in the ingest path, every distinct key seen ever becomes a
|
||||||
|
permanent declaration — this is almost certainly the source of the
|
||||||
|
reported ingest lag on this input (per-line nom scanning cost is
|
||||||
|
presumably fine; the unbounded, ever-growing declaration/index set is
|
||||||
|
the more likely culprit and should be measured either way) and it
|
||||||
|
clutters the params UI with noise no user asked for.
|
||||||
|
|
||||||
|
2. **The input's real structure is being ignored.** `log stream` output is
|
||||||
|
not logfmt at all — it's a fixed-column header (`Timestamp`, `Thread`,
|
||||||
|
`Type`, `Activity`, `PID`, `TTL`) followed by a free-text
|
||||||
|
`process: message` tail (see header row and body of `huig_dump.txt`).
|
||||||
|
`Type` (`Default`/`Error`/`Activity`), the process name (`kernel`,
|
||||||
|
`airportd`, `bluetoothd`, `WindowServer`, ...), and `PID` are
|
||||||
|
low-cardinality, genuinely useful params that could be derived
|
||||||
|
directly and cheaply from column position — with no `=`-scanning, no
|
||||||
|
ambiguity, and no risk of false positives — instead of (or in addition
|
||||||
|
to) whatever generic `key=value` pairs happen to appear later in the
|
||||||
|
line.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add a bound/safeguard on `auto_declare_params` so a single burst of
|
||||||
|
unusual lines can't permanently inflate the declared-param set:
|
||||||
|
options include (pick and justify one, or combine) a cap on the total
|
||||||
|
number of auto-derived declarations, a per-line cap on how many
|
||||||
|
`key=value` pairs from one line are eligible for auto-declaration, and/or
|
||||||
|
a heuristic to reject candidate pairs that look like base64 fragments
|
||||||
|
(trailing `=`/`==` with a base64-alphabet value and no realistic bareword
|
||||||
|
shape) in `src/logfmt.rs`'s `extract_pairs`/`pair`.
|
||||||
|
- Add an optional structured-header extractor for `log stream`-shaped
|
||||||
|
input: detect the fixed-column header/row shape (timestamp, thread id,
|
||||||
|
log type, activity id, pid, ttl, `process: message`) and surface
|
||||||
|
`Type`/process-name/`PID` as derivable params, analogous to how
|
||||||
|
`ottyl_fmt` (ticket 044) records which format a line matched. Decide
|
||||||
|
whether this is a new `ottyl_fmt` variant (e.g. `"log_stream"`) or a
|
||||||
|
separate always-attempted extraction that runs alongside
|
||||||
|
logfmt/JSON detection, and record the decision in the PR.
|
||||||
|
- Use `huig_dump.txt` (already in the repo root) as the seed fixture for
|
||||||
|
tests — copy the relevant lines into a test fixture under wherever
|
||||||
|
existing ingest fixtures live, rather than reading the file from the
|
||||||
|
repo root at test time.
|
||||||
|
- Re-measure ingest throughput on this fixture before/after to confirm
|
||||||
|
the lag is actually addressed, and note the before/after numbers in the
|
||||||
|
PR description.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Ingesting `huig_dump.txt`'s lines no longer produces hundreds of
|
||||||
|
permanent auto-declared params from one-off stat-dump lines; the bound
|
||||||
|
chosen is verified by a test asserting the declared-param count stays
|
||||||
|
under a stated threshold after ingesting the fixture.
|
||||||
|
- Base64-padding false positives (e.g. the `==`-terminated blobs in the
|
||||||
|
fixture) are not auto-declared as params.
|
||||||
|
- `Type`, process name, and `PID` are available as derivable/queryable
|
||||||
|
params for `log stream`-shaped lines, verified against the fixture.
|
||||||
|
- Existing logfmt/JSON auto-declaration behavior for non-`log
|
||||||
|
stream`-shaped lines (existing tests in `src/logfmt.rs`/`src/engine.rs`)
|
||||||
|
is unchanged.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
044 (reserved param naming / `ottyl_fmt` — this ticket likely extends the
|
||||||
|
same format-detection mechanism).
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 051 — Allow deleting a declared metric
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
There is currently no way to delete a metric once declared. `src/api.rs`
|
||||||
|
only routes `GET`/`POST /api/metrics` and `GET`/`PUT /api/metrics/{name}`
|
||||||
|
(`src/api.rs:132-135`) — there is no `DELETE`. The engine already has the
|
||||||
|
machinery for it internally: `EngineMessage::RemoveMetricDeclaration`
|
||||||
|
(`src/engine.rs:103`) removes a metric's declaration from the index, but
|
||||||
|
it is only ever sent as part of `delete_profile`'s cleanup
|
||||||
|
(`src/api.rs:689`) when an entire profile is deleted, not as a
|
||||||
|
user-facing action on a single metric. Persistence has the same gap:
|
||||||
|
`Settings`/profile storage (`src/profile.rs`, post ticket 049 now inline
|
||||||
|
in `settings.json`) has `record_metric_declaration`/
|
||||||
|
`record_metric_declaration_update` but no corresponding removal method, so
|
||||||
|
even if a metric were removed from the live engine index there'd be
|
||||||
|
nothing to stop it reappearing on next load. The webapp UI
|
||||||
|
(`webapp/src/components/Metrics.tsx`) has no delete affordance either —
|
||||||
|
compare to param declarations, which have the same underlying gap (no
|
||||||
|
`RemoveParamDeclaration`-triggering UI or persisted-removal path either),
|
||||||
|
though this ticket is scoped to metrics only.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add `DELETE /api/metrics/{name}` in `src/api.rs`, sending
|
||||||
|
`EngineMessage::RemoveMetricDeclaration` to the engine (same message
|
||||||
|
the profile-delete cleanup path already uses) and returning 404 if the
|
||||||
|
metric doesn't exist.
|
||||||
|
- Add a persisted-removal method (e.g. `Settings::remove_metric_declaration`
|
||||||
|
or equivalent, alongside `record_metric_declaration`/
|
||||||
|
`record_metric_declaration_update` in `src/profile.rs`/`src/settings.rs`)
|
||||||
|
so a deleted metric doesn't reappear when declarations replay from
|
||||||
|
`settings.json` on the next restart, and call it from the new route the
|
||||||
|
same way `persist_metric_declaration` is called from `declare_metric`.
|
||||||
|
- Add a delete affordance in `webapp/src/components/Metrics.tsx` (icon
|
||||||
|
button or similar, following whatever confirm-before-destructive-action
|
||||||
|
pattern already exists elsewhere in the settings/profile UI — check
|
||||||
|
`NavMenu.tsx`'s profile deletion for the existing convention) that calls
|
||||||
|
the new endpoint and removes the metric from the list on success.
|
||||||
|
- Decide and document what happens to bucket-value history already
|
||||||
|
recorded under the deleted metric's name (e.g. `MetricPrimitive` samples
|
||||||
|
indexed by metric name in the engine) — either leave it orphaned
|
||||||
|
(queryable by name won't work without a declaration, but doesn't need
|
||||||
|
active cleanup) or purge it; state the choice in the PR.
|
||||||
|
- Decide and document what happens to dashboard panels or alert rules
|
||||||
|
whose PromQL query text/filter references the deleted metric by name —
|
||||||
|
at minimum they should fail gracefully (a clear "unknown metric" error
|
||||||
|
rather than a panic or silent empty result) rather than needing to be
|
||||||
|
actively kept in sync; a full dependent-reference check/warning before
|
||||||
|
delete is out of scope unless trivial.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- A metric can be deleted via the API and the UI; it disappears from
|
||||||
|
`GET /api/metrics` immediately and does not reappear after a restart.
|
||||||
|
- Deleting an unknown metric name returns a clear 404, not a 500 or panic.
|
||||||
|
- A dashboard panel or alert rule referencing the deleted metric by name
|
||||||
|
fails with a readable error rather than crashing the engine or API,
|
||||||
|
verified with a seeded fixture.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
None (touches the same engine/persistence seam as ticket 049, but that
|
||||||
|
ticket is already merged).
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 052 — Allow clearing all params, then re-extract them from already-ingested logs
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
There is no bulk "clear params" action. The engine already supports
|
||||||
|
removing a single param declaration internally —
|
||||||
|
`EngineMessage::RemoveParamDeclaration { name, respond_to }`
|
||||||
|
(`src/engine.rs:85-92`), handled at `src/engine.rs:190-197` by removing
|
||||||
|
the `IndexKey::ParamDeclaration` entry — but it is only ever invoked from
|
||||||
|
`reload_engine_declarations` (`src/api.rs:664-682`), a profile-switch
|
||||||
|
cleanup path that filters out auto-derived params (comment at
|
||||||
|
`src/api.rs:665-668`) and removes the rest one at a time. There is no
|
||||||
|
route exposing this to the user, no persistence-side removal method
|
||||||
|
(`src/profile.rs` has `record_param_declaration` at `profile.rs:606` but
|
||||||
|
nothing to un-record it), and no webapp affordance
|
||||||
|
(`webapp/src/lib/api.ts:283-287` only exposes `getParams`/`declareParam`).
|
||||||
|
|
||||||
|
The harder half of this request — "logs get re-iterated to extract
|
||||||
|
params from them" — is not just a rewording of `auto_declare_params`.
|
||||||
|
That function runs once, at ingest time, against each incoming line
|
||||||
|
(`src/engine.rs:154`, `auto_declare_params` defined at
|
||||||
|
`src/engine.rs:307-325`, backed by `extract_pairs` in `src/logfmt.rs`) and
|
||||||
|
is never replayed. Re-extraction after a clear means running that same
|
||||||
|
logic again, after the fact, against lines already sitting in the index.
|
||||||
|
This is structurally possible: raw ingested text is retained as
|
||||||
|
`IndexKey::IngestedLog { sequence, timestamp } -> DataValue::LogLiteral`
|
||||||
|
in the same `BTreeMap` (`src/engine.rs:148-152`), occupying the index
|
||||||
|
range between `IndexKey::Start` and `IndexKey::LogParamSplit`
|
||||||
|
(iterated at `src/engine.rs:1166`, `1181`) — but only for lines that
|
||||||
|
haven't already been evicted by `max_logs`/`evict_excess_logs`
|
||||||
|
(`src/engine.rs:151-156`). Lines evicted before a clear+re-extract cannot
|
||||||
|
regain their params; this bound should be surfaced to the user, not
|
||||||
|
silently ignored.
|
||||||
|
|
||||||
|
Note `main.rs:75-96`'s `profile::load_into_engine` replay path is not
|
||||||
|
directly reusable here — it replays *declarations* into a fresh engine
|
||||||
|
before ingest starts, whereas this feature must re-run *extraction*
|
||||||
|
against logs the engine already holds.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add a bulk "clear params" `EngineMessage` (or reuse
|
||||||
|
`RemoveParamDeclaration` in a loop, mirroring
|
||||||
|
`reload_engine_declarations`'s existing pattern at
|
||||||
|
`src/api.rs:664-682`) that removes all declared params — deciding
|
||||||
|
whether auto-derived and manually-declared params are both cleared or
|
||||||
|
only one category, and stating that choice in the PR (the existing
|
||||||
|
`reload_engine_declarations` precedent excludes auto-derived ones for a
|
||||||
|
reason worth re-reading before copying it here).
|
||||||
|
- Add a persisted-removal counterpart in `src/profile.rs`/`src/settings.rs`
|
||||||
|
so cleared params don't reappear from `settings.json` on next load.
|
||||||
|
- After clearing, re-run `auto_declare_params` against every retained
|
||||||
|
`IndexKey::IngestedLog` entry still in the index (oldest to newest, to
|
||||||
|
preserve declaration-order semantics), producing a fresh param set from
|
||||||
|
currently-available log text.
|
||||||
|
- Expose this as a single user-facing action (`DELETE /api/params` or
|
||||||
|
similar in `src/api.rs`, plus a button in whatever webapp component
|
||||||
|
hosts the params list) — not a per-param loop the user has to drive
|
||||||
|
from the UI.
|
||||||
|
- Surface to the user (via response body or UI copy) that re-extraction
|
||||||
|
only covers logs still retained in memory, not lines already evicted by
|
||||||
|
`max_logs`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Triggering "clear params" removes all param declarations from
|
||||||
|
`GET /api/params` and they do not reappear after a restart.
|
||||||
|
- Immediately after clearing, params re-appear derived from the log text
|
||||||
|
still held in the engine's index, without requiring new lines to be
|
||||||
|
ingested, verified with a seeded fixture where params are cleared and
|
||||||
|
re-extracted from a fixed set of already-ingested lines.
|
||||||
|
- If some lines have been evicted before the clear, the re-extracted
|
||||||
|
param set reflects only the retained subset, and this is not reported
|
||||||
|
as an error.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
None (touches the same `RemoveParamDeclaration`/`reload_engine_declarations`
|
||||||
|
seam used by profile switching).
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 053 — Move the log sort selector from Log Preview to the Timeline
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The "Sort by" control lives in the wrong component. It's currently
|
||||||
|
rendered inside `LogPreview`'s own section header
|
||||||
|
(`webapp/src/components/LogPreview.tsx:327-336`) as a `SortPicker`
|
||||||
|
(`LogPreview.tsx:68-107`, a `<select>` plus an "Fall back to ottyl_seq"
|
||||||
|
checkbox), with its `LogSortSettings` type and
|
||||||
|
`loadLogSortSettings`/`saveLogSortSettings` persistence helpers
|
||||||
|
(`LogPreview.tsx:37-66`, localStorage key `ottyl.logPreview.sort`) also
|
||||||
|
defined there. `LogTimeline.tsx` has no sort-related prop at all
|
||||||
|
(`LogTimeline.tsx:44-56`) — its header only has time-range preset buttons
|
||||||
|
and a "Clear selection" link (`LogTimeline.tsx:118-149`).
|
||||||
|
|
||||||
|
The actual sort state is owned one level up, duplicated identically in
|
||||||
|
`App.tsx`'s two top-level render modes: `FullApp`
|
||||||
|
(`App.tsx:389-394`) and `StandaloneLog` (`App.tsx:263-268`), both via
|
||||||
|
`useState<LogSortSettings>(loadLogSortSettings)` plus a
|
||||||
|
`handleSortSettingsChange` callback. Both currently pass
|
||||||
|
`sortSettings`/`onSortSettingsChange` only to `LogPreview`
|
||||||
|
(`App.tsx:361`, `App.tsx:527`), never to `LogTimeline`
|
||||||
|
(`App.tsx:237-243`, `354-360`/`520-526`). The setting already drives
|
||||||
|
`fetchLogs` (`App.tsx:319-326`, `464-471`, dependency at `App.tsx:347`,
|
||||||
|
`494`), which forwards `sortBy`/`sortMissingFallback` to
|
||||||
|
`GET /api/logs` via `fetchLogs` in `webapp/src/lib/api.ts:242-262`. The
|
||||||
|
backend already fully supports this — `src/dto.rs:86-90`'s
|
||||||
|
`sort_by`/`sort_missing_fallback` fields, validated and applied in
|
||||||
|
`src/engine.rs:1223-1281` (400 if `sort_by` isn't a declared param,
|
||||||
|
`engine.rs:1227`; missing-value fallback behavior at `engine.rs:1260-1281`).
|
||||||
|
The timeline query/DTO backing `fetchTimeline`
|
||||||
|
(`webapp/src/lib/api.ts:264-274`) has no `sort_by` field and doesn't need
|
||||||
|
one — this is a frontend-only relocation of where the control lives, not
|
||||||
|
a backend change.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Move `SortPicker` (and ideally the `LogSortSettings` type plus
|
||||||
|
`loadLogSortSettings`/`saveLogSortSettings`) out of `LogPreview.tsx`
|
||||||
|
into `LogTimeline.tsx`, rendering it in the Timeline's header
|
||||||
|
(`LogTimeline.tsx:118-149`) alongside the existing range controls.
|
||||||
|
- Add `sortSettings`/`onSortSettingsChange` props to `LogTimeline`.
|
||||||
|
- Update both `App.tsx` render sites (`FullApp` around line 237,
|
||||||
|
`StandaloneLog` around line 354/520) to pass `sortSettings`/
|
||||||
|
`onSortSettingsChange` to `LogTimeline` instead of `LogPreview`.
|
||||||
|
- `LogPreview` keeps rendering the `logs` prop as already sorted by the
|
||||||
|
parent's `fetchLogs` call — remove its own `SortPicker` usage and the
|
||||||
|
now-unused sort-control markup from its header, but keep consuming
|
||||||
|
`sortSettings` if it still needs read access to it (e.g. for a
|
||||||
|
"sorted by X" label), or drop that too if not needed.
|
||||||
|
- No backend changes — `sort_by`/`sort_missing_fallback` on
|
||||||
|
`GET /api/logs` are unaffected.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- The "Sort by" selector and "fall back to ottyl_seq" checkbox appear in
|
||||||
|
the Timeline component's header, not in Log Preview's.
|
||||||
|
- Changing sort order from the Timeline's control changes the order of
|
||||||
|
entries shown in Log Preview (same behavior as today, just relocated).
|
||||||
|
- The chosen sort setting still persists across reloads (localStorage key
|
||||||
|
can be renamed if it moves file, but must not silently reset to
|
||||||
|
default on the first load after this change — migrate or fall back
|
||||||
|
gracefully).
|
||||||
|
- `make build` (webapp `tsc -b && vite build`) passes with no leftover
|
||||||
|
unused imports/types in `LogPreview.tsx`.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 054 — `log stream` output isn't recognized as a known format (folds into ticket 050)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The report was "`log stream |& ottyl` should capture the format
|
||||||
|
properly... tab separated format is being ignored, add it as a known
|
||||||
|
type." Investigating `huig_dump.txt` (a real `log stream` capture already
|
||||||
|
in the repo root) shows the premise needs correcting: the file is **not**
|
||||||
|
tab-separated. `grep -P "\t"` finds a literal tab character on only 3 of
|
||||||
|
697 lines, and none of those are the header or a representative data row
|
||||||
|
— they're incidental (two `libinfo check path:` continuation lines, one
|
||||||
|
WiFi stat line with an embedded tab mid-message). `od -c` on the header
|
||||||
|
and data rows confirms the real structure is **space-padded fixed-width
|
||||||
|
columns** (`Timestamp`, `Thread`, `Type`, `Activity`, `PID`, `TTL`,
|
||||||
|
followed by a free-text `process: message` tail) — visually aligned
|
||||||
|
columns that read as tab-separated but aren't.
|
||||||
|
|
||||||
|
There is no TSV handling anywhere in the codebase today: `src/logfmt.rs`,
|
||||||
|
`src/engine.rs`, and `src/ingest.rs` contain zero references to tabs or
|
||||||
|
TSV. Format detection is entirely `detect_format` (`src/logfmt.rs:70-78`):
|
||||||
|
try JSON, else fall back to `extract_pairs` (`logfmt.rs:45-59`) for
|
||||||
|
logfmt, else `"none"` — no third branch exists, and `ottyl_fmt`
|
||||||
|
(`src/engine.rs:1005`, filter compilation at `engine.rs:775`, `824`) only
|
||||||
|
ever reports one of those three values.
|
||||||
|
|
||||||
|
This is a duplicate of, not distinct from, ticket 050
|
||||||
|
(`tickets/050-structured-header-extraction-and-bounded-logfmt-overfit.md`),
|
||||||
|
which already identifies the exact same root cause — `log stream`
|
||||||
|
output "is not logfmt at all — it's a fixed-column header... followed by
|
||||||
|
a free-text `process: message` tail" — and already scopes a structured-
|
||||||
|
header extractor plus the question of whether it becomes a new
|
||||||
|
`ottyl_fmt` variant (e.g. `"log_stream"`). The reporter most likely
|
||||||
|
visually misread the aligned whitespace columns as tab-separated.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Do not implement a separate "TSV `ottyl_fmt` type" — there is no
|
||||||
|
evidence of genuinely tab-delimited `log stream` output in this
|
||||||
|
codebase's fixtures, and building detection for a format that doesn't
|
||||||
|
occur would be speculative.
|
||||||
|
- Close/merge this wishlist item into ticket 050's existing scope: its
|
||||||
|
structured-header extractor for `log stream`-shaped input already
|
||||||
|
covers "capturing the format properly" for this input shape.
|
||||||
|
- If the user can supply a genuinely tab-delimited sample (e.g. from a
|
||||||
|
different `log stream --style` flag, or a shell pipeline that
|
||||||
|
reformats the output with real tabs), re-open this as a distinct
|
||||||
|
ticket scoped to that concrete input — file:line references above
|
||||||
|
should make it easy to confirm one way or the other before implementing
|
||||||
|
anything.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- No code changes result directly from this ticket; instead, ticket 050
|
||||||
|
is updated (or its implementation, when picked up, is confirmed) to
|
||||||
|
cover recognizing `log stream`'s fixed-column shape as a known format.
|
||||||
|
- If a true TSV sample is later provided, a new ticket is filed against
|
||||||
|
it specifically, rather than reopening this one with an unconfirmed
|
||||||
|
premise.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
050 (this ticket's concern is already inside 050's scope; treat as
|
||||||
|
duplicate/fold-in rather than independent work).
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# 055 — Make `ottyl_*` reserved params usable in dashboard (PromQL) queries, not just filters
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Three reserved params exist, all hardcoded in the filter compiler:
|
||||||
|
`ottyl_seq` (ingest sequence number), `ottyl_ts` (ingest timestamp,
|
||||||
|
supports relative/absolute values), and `ottyl_fmt` (auto-detected
|
||||||
|
`json`/`logfmt`/`none`) — defined at `src/engine.rs:773-775, 795, 806, 824`,
|
||||||
|
grammar comments at `src/filter_parser.pest:7-9`, docs at
|
||||||
|
`webapp/src/components/Docs.tsx:105-106, 375` ("reserved metric names
|
||||||
|
present on every log line, so they're always filterable without
|
||||||
|
declaring anything"). Alert conditions restrict to the same three plus
|
||||||
|
`regex()`/`json[field]` (`src/alerts.rs:3, 199-225`).
|
||||||
|
|
||||||
|
These already work end-to-end for the filter/query bar syntax used by
|
||||||
|
`GET /api/logs`, `GET /api/timeline`, and alert conditions — that part of
|
||||||
|
"accessible parameters for queries" is true today. What's missing is
|
||||||
|
dashboards: PromQL-style panel queries (`src/promql.rs`,
|
||||||
|
`evaluate_promql` in `src/engine.rs:1562-1601`) have no special case for
|
||||||
|
`ottyl_*` anywhere. A PromQL query resolves `metric_name` via
|
||||||
|
`bucket_values(index, &parsed.metric_name)`
|
||||||
|
(`src/engine.rs`, called from `evaluate_promql`), which only returns data
|
||||||
|
for names backed by a `DataValue::MetricDeclaration` and bucket entries
|
||||||
|
created through `POST /api/metrics`. `ottyl_*` values are computed
|
||||||
|
line-locally at filter-compile time and never written into declared-
|
||||||
|
metric buckets, so `sum(ottyl_seq)` or similar in a dashboard panel would
|
||||||
|
silently return an empty series rather than real data.
|
||||||
|
|
||||||
|
Separately, `ottyl_*` never appear in `GET /api/params`
|
||||||
|
(`src/api.rs:281-287` → `EngineQuery::ParamDeclarations` →
|
||||||
|
`param_declarations_range`, `src/engine.rs:1340-1355`), since that only
|
||||||
|
lists explicit `ParamDeclaration` index entries and `ottyl_*` never
|
||||||
|
create one. `FilterBar.tsx` (`webapp/src/components/FilterBar.tsx:36-41`)
|
||||||
|
has no autocomplete/suggestion list at all today (plain text input), so
|
||||||
|
there's nothing currently excluding them from UI suggestions either —
|
||||||
|
but if param suggestions are ever added, this gap should be kept in mind.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add a special case in `evaluate_promql`/`src/promql.rs` (analogous to
|
||||||
|
the filter compiler's handling at `src/engine.rs:773-775`) that
|
||||||
|
synthesizes a virtual bucket series for `ottyl_seq`/`ottyl_ts`/
|
||||||
|
`ottyl_fmt` metric names from the ingested-log index, instead of
|
||||||
|
requiring a `MetricDeclaration`. Decide and document in the PR how
|
||||||
|
`ottyl_fmt`'s categorical string values map onto a numeric time
|
||||||
|
series (e.g. count-by-value, or reject it from PromQL numeric
|
||||||
|
aggregation with a clear error, since `ottyl_seq`/`ottyl_ts` are
|
||||||
|
naturally numeric but `ottyl_fmt` is not).
|
||||||
|
- Decide whether `ottyl_*` should appear in `GET /api/params` (or a
|
||||||
|
parallel listing) so dashboard panel builders in the webapp can
|
||||||
|
discover them without prior knowledge of the reserved names; if yes,
|
||||||
|
extend `param_declarations_range` or add a small hardcoded merge at the
|
||||||
|
API layer rather than forcing them into the index as real
|
||||||
|
`ParamDeclaration` entries (they aren't user-declared and shouldn't be
|
||||||
|
deletable via ticket 051's/052's params-removal paths).
|
||||||
|
- Update `README.md`/`Docs.tsx` to state whether `ottyl_*` is usable in
|
||||||
|
dashboard queries, once implemented (or explicitly document it as
|
||||||
|
filter-only, if the numeric/categorical mismatch above makes broad
|
||||||
|
PromQL support impractical for `ottyl_fmt`).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- A dashboard panel using a PromQL query over `ottyl_seq` or `ottyl_ts`
|
||||||
|
(e.g. `count(ottyl_seq)` or a rate over `ottyl_ts`) returns real data
|
||||||
|
from ingested logs, verified with a seeded fixture.
|
||||||
|
- The behavior for `ottyl_fmt` in a PromQL query is either implemented
|
||||||
|
(with a stated value-to-series mapping) or explicitly rejected with a
|
||||||
|
clear, documented error — not a silent empty result.
|
||||||
|
- `GET /api/params` behavior for `ottyl_*` is decided and documented;
|
||||||
|
if included, they're clearly marked as reserved/non-deletable rather
|
||||||
|
than indistinguishable from user-declared params.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
None (independent of the params-removal tickets, but note the
|
||||||
|
non-deletable requirement above if `ottyl_*` end up listed alongside
|
||||||
|
regular params).
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# 056 — Add `Light *` variants for the remaining colour schemes
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Ticket 048 already added one light-surfaced variant, `light_green`
|
||||||
|
(`webapp/src/lib/themes.ts:231-254`, commit `5371d3e`: "Adds a new
|
||||||
|
'Light Green' scheme (light-surfaced, same green/magenta hue family as
|
||||||
|
'Green') selectable alongside the existing built-ins"). The wishlist
|
||||||
|
comment that prompted 048 — "we need a `Light Green` alongside `Green`"
|
||||||
|
— is now clarified as applying to *every* colour scheme, not just green:
|
||||||
|
the current built-in set is `dark`, `light`, `red`, `green`, `blue`,
|
||||||
|
`yellow`, `pink`, `orange`, `light_green`
|
||||||
|
(`webapp/src/lib/themes.ts:38-255`), so `red`, `blue`, `yellow`, `pink`,
|
||||||
|
and `orange` still have no light-surfaced counterpart.
|
||||||
|
|
||||||
|
Adding a scheme is not fully data-driven from `themes.ts` alone — six
|
||||||
|
files must be kept in sync per scheme, per the no-build-step-links-them
|
||||||
|
comment at `webapp/src/index.css:1-7`:
|
||||||
|
|
||||||
|
1. `webapp/src/index.css:264-300` — the `html.light_green` CSS block is
|
||||||
|
the reference recipe: same hue + same accent hues as the dark
|
||||||
|
counterpart, but with `light`'s inverted (high-lightness)
|
||||||
|
surface/border/text scale, and `light`'s hue-neutral danger colors
|
||||||
|
(comment at `index.css:264-268` documents this explicitly).
|
||||||
|
2. `webapp/src/lib/themes.ts:231-254` — the matching `BUILTIN_THEMES`
|
||||||
|
entry (used for CSS export/download and validation).
|
||||||
|
3. `webapp/src/lib/api.ts:83-93` — the `ColorScheme` TS union type.
|
||||||
|
4. `src/settings.rs:30-42` — the matching Rust `ColorScheme` enum variant,
|
||||||
|
with `#[serde(rename = "light_<name>")]`.
|
||||||
|
5. `webapp/src/lib/useSettingsPanel.ts:24-34` — `THEME_CLASSES`, used to
|
||||||
|
apply/strip the `html.*` class.
|
||||||
|
6. `webapp/src/components/NavMenu.tsx:20-31` — `COLOR_SCHEMES`, which
|
||||||
|
drives the picker UI; `schemeLabel()` (`NavMenu.tsx:34-35`) already
|
||||||
|
auto-titles `light_red` → "Light Red" by replacing underscores, so no
|
||||||
|
extra label wiring is needed once a name is added to this array.
|
||||||
|
|
||||||
|
`PanelChart.tsx`'s multi-accent `color-mix()` series-color logic (touched
|
||||||
|
by ticket 048) is generic per-theme rather than per-scheme-name, so it
|
||||||
|
likely needs no changes — worth a quick check while implementing, not
|
||||||
|
expected to require edits.
|
||||||
|
|
||||||
|
Tickets 047/048's original files were deleted on implementation (git
|
||||||
|
history only shows their removal commits, `5cb2dfd`/`7c331e7`; bodies
|
||||||
|
aren't recoverable), but commit `5371d3e`'s message and the resulting
|
||||||
|
`light_green` block are sufficient as the template.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add `light_red`, `light_blue`, `light_yellow`, `light_pink`, and
|
||||||
|
`light_orange` following the exact `light_green` recipe: same base
|
||||||
|
hue and accent-hue choices as the corresponding dark-surfaced scheme,
|
||||||
|
`light`'s inverted surface/border/text lightness scale, `light`'s
|
||||||
|
neutral danger colors.
|
||||||
|
- Update all six files listed above for each new scheme:
|
||||||
|
`webapp/src/index.css`, `webapp/src/lib/themes.ts`,
|
||||||
|
`webapp/src/lib/api.ts`, `src/settings.rs`,
|
||||||
|
`webapp/src/lib/useSettingsPanel.ts`, `webapp/src/components/NavMenu.tsx`.
|
||||||
|
- Verify `schemeLabel()` produces correct titles ("Light Red", "Light
|
||||||
|
Blue", etc.) without additional label-mapping code.
|
||||||
|
- Spot-check `PanelChart.tsx`'s series-color logic against one new light
|
||||||
|
scheme to confirm it needs no changes (or fix it if it does).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- All five new schemes are selectable in the settings picker
|
||||||
|
(`NavMenu.tsx`), persist correctly, and are downloadable as CSS via
|
||||||
|
the existing "download built-in theme" path.
|
||||||
|
- Each new scheme's CSS block round-trips through `Settings`
|
||||||
|
serialization/deserialization (`src/settings.rs`) without errors,
|
||||||
|
verified with a test analogous to any existing `ColorScheme`
|
||||||
|
serialization test.
|
||||||
|
- `make build` and `make lint` pass with the new schemes added.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
048 (implemented; this ticket extends its established pattern to the
|
||||||
|
remaining base hues).
|
||||||
+33
-7
@@ -9,7 +9,7 @@ import {
|
|||||||
type TimeRange,
|
type TimeRange,
|
||||||
type TimeRangePresetId,
|
type TimeRangePresetId,
|
||||||
} from './components/LogTimeline'
|
} from './components/LogTimeline'
|
||||||
import { LogPreview } from './components/LogPreview'
|
import { LogPreview, loadLogSortSettings, saveLogSortSettings, type LogSortSettings } from './components/LogPreview'
|
||||||
import { Params } from './components/Params'
|
import { Params } from './components/Params'
|
||||||
import { Metrics } from './components/Metrics'
|
import { Metrics } from './components/Metrics'
|
||||||
import { Dashboards } from './components/Dashboards'
|
import { Dashboards } from './components/Dashboards'
|
||||||
@@ -260,6 +260,12 @@ function StandaloneLog() {
|
|||||||
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>('all')
|
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>('all')
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
const [filterError, setFilterError] = useState<string | null>(null)
|
const [filterError, setFilterError] = useState<string | null>(null)
|
||||||
|
const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings)
|
||||||
|
|
||||||
|
function handleSortSettingsChange(next: LogSortSettings) {
|
||||||
|
setSortSettings(next)
|
||||||
|
saveLogSortSettings(next)
|
||||||
|
}
|
||||||
|
|
||||||
const rangeRef = useRef(range)
|
const rangeRef = useRef(range)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -310,7 +316,14 @@ function StandaloneLog() {
|
|||||||
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
|
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
|
||||||
filter: filter || undefined,
|
filter: filter || undefined,
|
||||||
}),
|
}),
|
||||||
fetchLogs({ startMs: effectiveRange?.startMs, endMs: effectiveRange?.endMs, limit: 1000, filter: filter || undefined }),
|
fetchLogs({
|
||||||
|
startMs: effectiveRange?.startMs,
|
||||||
|
endMs: effectiveRange?.endMs,
|
||||||
|
limit: 1000,
|
||||||
|
filter: filter || undefined,
|
||||||
|
sortBy: sortSettings.sortBy ?? undefined,
|
||||||
|
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined,
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setBuckets(nextBuckets)
|
setBuckets(nextBuckets)
|
||||||
@@ -331,7 +344,7 @@ function StandaloneLog() {
|
|||||||
clearInterval(id)
|
clearInterval(id)
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filter, activePreset])
|
}, [filter, activePreset, sortSettings])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StandalonePage>
|
<StandalonePage>
|
||||||
@@ -345,7 +358,7 @@ function StandaloneLog() {
|
|||||||
activePreset={activePreset}
|
activePreset={activePreset}
|
||||||
onSelectPreset={selectPreset}
|
onSelectPreset={selectPreset}
|
||||||
/>
|
/>
|
||||||
<LogPreview logs={logs} params={params} />
|
<LogPreview logs={logs} params={params} sortSettings={sortSettings} onSortSettingsChange={handleSortSettingsChange} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</StandalonePage>
|
</StandalonePage>
|
||||||
@@ -373,6 +386,12 @@ 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')
|
||||||
const [logViewerHeight, setLogViewerHeight] = useState(() => loadLogViewerHeight())
|
const [logViewerHeight, setLogViewerHeight] = useState(() => loadLogViewerHeight())
|
||||||
|
const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings)
|
||||||
|
|
||||||
|
function handleSortSettingsChange(next: LogSortSettings) {
|
||||||
|
setSortSettings(next)
|
||||||
|
saveLogSortSettings(next)
|
||||||
|
}
|
||||||
|
|
||||||
function handleLogViewerResize(deltaY: number) {
|
function handleLogViewerResize(deltaY: number) {
|
||||||
setLogViewerHeight((current) => clampLogViewerHeight(current + deltaY))
|
setLogViewerHeight((current) => clampLogViewerHeight(current + deltaY))
|
||||||
@@ -442,7 +461,14 @@ function FullApp() {
|
|||||||
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
|
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
|
||||||
filter: filter || undefined,
|
filter: filter || undefined,
|
||||||
}),
|
}),
|
||||||
fetchLogs({ startMs: effectiveRange?.startMs, endMs: effectiveRange?.endMs, limit: 1000, filter: filter || undefined }),
|
fetchLogs({
|
||||||
|
startMs: effectiveRange?.startMs,
|
||||||
|
endMs: effectiveRange?.endMs,
|
||||||
|
limit: 1000,
|
||||||
|
filter: filter || undefined,
|
||||||
|
sortBy: sortSettings.sortBy ?? undefined,
|
||||||
|
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined,
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setStats(nextStats)
|
setStats(nextStats)
|
||||||
@@ -465,7 +491,7 @@ function FullApp() {
|
|||||||
}
|
}
|
||||||
// Intentionally excludes `range` to avoid restarting this interval on every rolling-preset tick.
|
// Intentionally excludes `range` to avoid restarting this interval on every rolling-preset tick.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filter, activePreset])
|
}, [filter, activePreset, sortSettings])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocsNavProvider>
|
<DocsNavProvider>
|
||||||
@@ -498,7 +524,7 @@ function FullApp() {
|
|||||||
activePreset={activePreset}
|
activePreset={activePreset}
|
||||||
onSelectPreset={selectPreset}
|
onSelectPreset={selectPreset}
|
||||||
/>
|
/>
|
||||||
<LogPreview logs={logs} params={params} />
|
<LogPreview logs={logs} params={params} sortSettings={sortSettings} onSortSettingsChange={handleSortSettingsChange} />
|
||||||
</div>
|
</div>
|
||||||
{!logViewerCollapsed && <ResizeHandle onResize={handleLogViewerResize} onResizeEnd={handleLogViewerResizeEnd} />}
|
{!logViewerCollapsed && <ResizeHandle onResize={handleLogViewerResize} onResizeEnd={handleLogViewerResizeEnd} />}
|
||||||
<Params params={params} onDeclared={loadParams} />
|
<Params params={params} onDeclared={loadParams} />
|
||||||
|
|||||||
@@ -50,9 +50,10 @@ const SECTIONS: DocSection[] = [
|
|||||||
</ul>
|
</ul>
|
||||||
<p className="font-semibold text-text">Settings</p>
|
<p className="font-semibold text-text">Settings</p>
|
||||||
<p>
|
<p>
|
||||||
One <em>settings</em> file per machine holds the colour scheme, imported/linked CSS, the ntfy sink, and
|
One <em>settings</em> file per machine holds the active style (a built-in colour scheme or an
|
||||||
which profile is active. Settings only tracks profile pointers — the profiles themselves (params, metrics,
|
imported/linked stylesheet — see below), the ntfy sink, and which profile is active. Settings only tracks
|
||||||
dashboards) are stored separately, next to the settings file by default.
|
profile pointers — the profiles themselves (params, metrics, dashboards) are stored 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>
|
||||||
@@ -87,19 +88,23 @@ const SECTIONS: DocSection[] = [
|
|||||||
<Code>{'regex("error")'}</Code> — lines containing "error".
|
<Code>{'regex("error")'}</Code> — lines containing "error".
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Code>{'regex("error") and sequence<5'}</Code> — lines containing "error" with sequence under 5.
|
<Code>{'regex("error") and ottyl_seq<5'}</Code> — lines containing "error" with sequence under 5.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Code>{'requests[duration]>100'}</Code> — the <Code>requests</Code> metric's captured{' '}
|
<Code>{'requests[duration]>100'}</Code> — the <Code>requests</Code> metric's captured{' '}
|
||||||
<Code>duration</Code> param over 100.
|
<Code>duration</Code> param over 100.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Code>{'timestamp>-5m'}</Code> — lines from the last 5 minutes.
|
<Code>{'ottyl_ts>-5m'}</Code> — lines from the last 5 minutes.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Code>{'ottyl_fmt==json'}</Code> — lines auto-detected as JSON.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
<Code>sequence</Code> and <Code>timestamp</Code> are reserved metric names present on every log line, so
|
<Code>ottyl_seq</Code>, <Code>ottyl_ts</Code>, and <Code>ottyl_fmt</Code> are reserved metric names present
|
||||||
they're always filterable without declaring anything.
|
on every log line, so they're always filterable without declaring anything. <Code>ottyl_fmt</Code> holds the
|
||||||
|
auto-detected format of the line's text — <Code>json</Code>, <Code>logfmt</Code>, or <Code>none</Code>.
|
||||||
</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 three forms:</p>
|
<p>Timestamp comparisons accept three forms:</p>
|
||||||
@@ -121,7 +126,7 @@ const SECTIONS: DocSection[] = [
|
|||||||
<ul className="list-disc pl-5">
|
<ul className="list-disc pl-5">
|
||||||
<li>
|
<li>
|
||||||
Only lines mentioning "timeout" in the last 15 minutes:{' '}
|
Only lines mentioning "timeout" in the last 15 minutes:{' '}
|
||||||
<Code>{'regex("timeout") and timestamp>-15m'}</Code>
|
<Code>{'regex("timeout") and ottyl_ts>-15m'}</Code>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Requests slower than 200ms, once a <Code>requests</Code> metric has a <Code>value</Code>-role{' '}
|
Requests slower than 200ms, once a <Code>requests</Code> metric has a <Code>value</Code>-role{' '}
|
||||||
@@ -134,7 +139,7 @@ const SECTIONS: DocSection[] = [
|
|||||||
<Code>{'requests[path]==/checkout and regex("5[0-9]{2}")'}</Code>
|
<Code>{'requests[path]==/checkout and regex("5[0-9]{2}")'}</Code>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Everything before noon on a specific date: <Code>{'timestamp<2024-06-15T12:00:00Z'}</Code>
|
Everything before noon on a specific date: <Code>{'ottyl_ts<2024-06-15T12:00:00Z'}</Code>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -367,8 +372,9 @@ const SECTIONS: DocSection[] = [
|
|||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's syntax, restricted to{' '}
|
Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's syntax, restricted to{' '}
|
||||||
<Code>regex(...)</Code>, <Code>sequence</Code>, <Code>timestamp</Code>, and <Code>and</Code>/<Code>or</Code>
|
<Code>regex(...)</Code>, <Code>ottyl_seq</Code>, <Code>ottyl_ts</Code>, <Code>ottyl_fmt</Code>, and{' '}
|
||||||
. A <Code>metric_name[param]</Code> comparison isn't supported in an alert query.
|
<Code>and</Code>/<Code>or</Code>. A <Code>metric_name[param]</Code> comparison isn't supported in an alert
|
||||||
|
query.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
A window accepts a plain number of milliseconds or a humantime-style duration: <Code>30s</Code>,{' '}
|
A window accepts a plain number of milliseconds or a humantime-style duration: <Code>30s</Code>,{' '}
|
||||||
@@ -419,9 +425,10 @@ 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 ntfy sink alert rules deliver to, the
|
The Settings panel controls process-wide state: the active style — one unified list of built-in colour
|
||||||
max number of logs kept in memory (blank means unlimited), and imported/linked CSS. Changes apply
|
schemes and any imported/linked stylesheets, any of which can be a simple palette or a full layout
|
||||||
immediately and persist to the settings file on disk. There is no save button.
|
redesign — the ntfy sink alert rules deliver to, and the max number of logs kept in memory (blank means
|
||||||
|
unlimited). Changes apply immediately and persist to the settings file on disk. There is no save button.
|
||||||
</p>
|
</p>
|
||||||
<p className="font-semibold text-text">Worked example</p>
|
<p className="font-semibold text-text">Worked example</p>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function FilterBar({
|
|||||||
{collapseToggle}
|
{collapseToggle}
|
||||||
<input
|
<input
|
||||||
className={queryInputClassName}
|
className={queryInputClassName}
|
||||||
placeholder='regex("error") and sequence<5 or timestamp>12:00'
|
placeholder='regex("error") and ottyl_seq<5 or ottyl_ts>12:00'
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -25,6 +25,87 @@ function saveVisibleParamColumns(columns: string[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ticket 045: ordering key + missing-param handling for the log preview / timeline. Scoped like
|
||||||
|
// `VISIBLE_PARAM_COLUMNS_STORAGE_KEY` and `App.tsx`'s `LOG_VIEWER_HEIGHT_STORAGE_KEY` above — one
|
||||||
|
// shared "log viewer" concept, so a single localStorage key covers both the inline viewer
|
||||||
|
// (`FullApp`) and its popout (`StandaloneLog`) rather than each keeping its own copy, which would
|
||||||
|
// surprise a user switching between the two. There's no dashboard/panel-scoped analog for this
|
||||||
|
// setting to divide by (unlike e.g. per-dashboard panels), so a single global key matches how the
|
||||||
|
// rest of the log viewer's own settings are scoped.
|
||||||
|
const LOG_SORT_STORAGE_KEY = 'ottyl.logPreview.sort'
|
||||||
|
|
||||||
|
export interface LogSortSettings {
|
||||||
|
/** An already-declared param name to order log lines by, or `null` for the default (`ottyl_ts`-tracking) order. */
|
||||||
|
sortBy: string | null
|
||||||
|
/** Only meaningful when `sortBy` is set. `true` (default) orders lines missing that param by `ottyl_seq`, appended after the sorted lines; `false` drops them from view. */
|
||||||
|
sortMissingFallback: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LOG_SORT_SETTINGS: LogSortSettings = { sortBy: null, sortMissingFallback: true }
|
||||||
|
|
||||||
|
export function loadLogSortSettings(): LogSortSettings {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LOG_SORT_STORAGE_KEY)
|
||||||
|
if (!raw) return DEFAULT_LOG_SORT_SETTINGS
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!parsed || typeof parsed !== 'object') return DEFAULT_LOG_SORT_SETTINGS
|
||||||
|
const sortBy = typeof parsed.sortBy === 'string' ? parsed.sortBy : null
|
||||||
|
const sortMissingFallback = typeof parsed.sortMissingFallback === 'boolean' ? parsed.sortMissingFallback : true
|
||||||
|
return { sortBy, sortMissingFallback }
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_LOG_SORT_SETTINGS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveLogSortSettings(settings: LogSortSettings) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOG_SORT_STORAGE_KEY, JSON.stringify(settings))
|
||||||
|
} catch {
|
||||||
|
// best-effort — private browsing or full storage just means the selection won't persist across reloads
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortPicker({
|
||||||
|
paramNames,
|
||||||
|
settings,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
paramNames: string[]
|
||||||
|
settings: LogSortSettings
|
||||||
|
onChange: (next: LogSortSettings) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-text-secondary">
|
||||||
|
<label className="flex items-center gap-1">
|
||||||
|
Sort by
|
||||||
|
<select
|
||||||
|
value={settings.sortBy ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...settings, sortBy: e.target.value || null })}
|
||||||
|
className="rounded border border-border-soft bg-surface-0 px-1 py-0.5 text-text"
|
||||||
|
>
|
||||||
|
<option value="">ottyl_ts (default)</option>
|
||||||
|
{paramNames.map((name) => (
|
||||||
|
<option key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{settings.sortBy && (
|
||||||
|
<label className="flex cursor-pointer items-center gap-1" title="How lines missing the sort param are handled">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.sortMissingFallback}
|
||||||
|
onChange={(e) => onChange({ ...settings, sortMissingFallback: e.target.checked })}
|
||||||
|
className="accent-text-muted"
|
||||||
|
/>
|
||||||
|
Fall back to ottyl_seq
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ColumnPicker({
|
function ColumnPicker({
|
||||||
paramNames,
|
paramNames,
|
||||||
visible,
|
visible,
|
||||||
@@ -209,7 +290,18 @@ function useScrollAnchor(containerRef: React.RefObject<HTMLDivElement | null>, d
|
|||||||
}, deps)
|
}, deps)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDeclaration[] }) {
|
export function LogPreview({
|
||||||
|
logs,
|
||||||
|
params,
|
||||||
|
sortSettings,
|
||||||
|
onSortSettingsChange,
|
||||||
|
}: {
|
||||||
|
logs: LogEntry[]
|
||||||
|
params: ParamDeclaration[]
|
||||||
|
/** Ticket 045: current ordering choice, owned by the caller (it also feeds `fetchLogs`) rather than internally like `visibleParamColumns` — see `App.tsx`'s poll loop. */
|
||||||
|
sortSettings: LogSortSettings
|
||||||
|
onSortSettingsChange: (next: LogSortSettings) => void
|
||||||
|
}) {
|
||||||
const paramNames = params.map((d) => d.name)
|
const paramNames = params.map((d) => d.name)
|
||||||
const [visibleParamColumns, setVisibleParamColumns] = useState<string[]>(loadVisibleParamColumns)
|
const [visibleParamColumns, setVisibleParamColumns] = useState<string[]>(loadVisibleParamColumns)
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -237,8 +329,11 @@ export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDe
|
|||||||
Log preview
|
Log preview
|
||||||
<HelpIcon sectionId="log-preview" label="Help: Log preview" />
|
<HelpIcon sectionId="log-preview" label="Help: Log preview" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<SortPicker paramNames={paramNames} settings={sortSettings} onChange={onSortSettingsChange} />
|
||||||
<ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} />
|
<ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div ref={scrollContainerRef} className="flex-1 overflow-auto">
|
<div ref={scrollContainerRef} className="flex-1 overflow-auto">
|
||||||
<table className="w-full border-collapse text-left text-xs">
|
<table className="w-full border-collapse text-left text-xs">
|
||||||
<thead className="sticky top-0 bg-surface-0 text-text-subtle">
|
<thead className="sticky top-0 bg-surface-0 text-text-subtle">
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import {
|
|||||||
declareMetric,
|
declareMetric,
|
||||||
fetchMetricBucketValues,
|
fetchMetricBucketValues,
|
||||||
fetchMetricDeclarations,
|
fetchMetricDeclarations,
|
||||||
|
fetchParamDeclarations,
|
||||||
metricPrimitiveToDisplayString,
|
metricPrimitiveToDisplayString,
|
||||||
updateMetric,
|
updateMetric,
|
||||||
type BucketValue,
|
type BucketValue,
|
||||||
type MetricDeclaration,
|
type MetricDeclaration,
|
||||||
type MetricParamMatcher,
|
type ParamDeclaration,
|
||||||
} from '../lib/api'
|
} from '../lib/api'
|
||||||
import {
|
import {
|
||||||
CollapseToggle,
|
CollapseToggle,
|
||||||
@@ -22,8 +23,6 @@ import {
|
|||||||
import { HelpIcon } from './HelpIcon'
|
import { HelpIcon } from './HelpIcon'
|
||||||
import { RegexHelpIcon } from './RegexHelpIcon'
|
import { RegexHelpIcon } from './RegexHelpIcon'
|
||||||
|
|
||||||
const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' }
|
|
||||||
|
|
||||||
// One row in the labels -> occurrence count table; `values` keeps the raw points for the sparkline rendering.
|
// One row in the labels -> occurrence count table; `values` keeps the raw points for the sparkline rendering.
|
||||||
interface LabelGroup {
|
interface LabelGroup {
|
||||||
key: string
|
key: string
|
||||||
@@ -80,12 +79,16 @@ function sparkline(values: BucketValue[], range: TimeRange | null): string {
|
|||||||
|
|
||||||
export function Metrics({ range, activePreset }: { range: TimeRange | null; activePreset?: TimeRangePresetId | null }) {
|
export function Metrics({ range, activePreset }: { range: TimeRange | null; activePreset?: TimeRangePresetId | null }) {
|
||||||
const [metrics, setMetrics] = useState<MetricDeclaration[]>([])
|
const [metrics, setMetrics] = useState<MetricDeclaration[]>([])
|
||||||
|
const [params, setParams] = useState<ParamDeclaration[]>([])
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
const [bucketValues, setBucketValues] = useState<BucketValue[]>([])
|
const [bucketValues, setBucketValues] = useState<BucketValue[]>([])
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [regex, setRegex] = useState('')
|
const [regex, setRegex] = useState('')
|
||||||
const [matchers, setMatchers] = useState<MetricParamMatcher[]>([{ ...EMPTY_MATCHER }])
|
// A metric picks its label/value params from the params already declared in the Params panel
|
||||||
|
// (ticket 037) rather than re-entering a regex/capture-group/kind per param.
|
||||||
|
const [labelParams, setLabelParams] = useState<string[]>([])
|
||||||
|
const [valueParam, setValueParam] = useState<string>('')
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [collapsed, setCollapsed] = useSectionCollapsed('metrics')
|
const [collapsed, setCollapsed] = useSectionCollapsed('metrics')
|
||||||
@@ -101,8 +104,17 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadParams() {
|
||||||
|
try {
|
||||||
|
setParams(await fetchParamDeclarations())
|
||||||
|
} catch {
|
||||||
|
// engine not reachable yet; leave the previous list in place
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMetrics()
|
loadMetrics()
|
||||||
|
loadParams()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 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.
|
// 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.
|
||||||
@@ -124,23 +136,18 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
}
|
}
|
||||||
}, [selected, range])
|
}, [selected, range])
|
||||||
|
|
||||||
function updateMatcher(index: number, patch: Partial<MetricParamMatcher>) {
|
function toggleLabelParam(paramName: string) {
|
||||||
setMatchers((prev) => prev.map((m, i) => (i === index ? { ...m, ...patch } : m)))
|
setLabelParams((prev) =>
|
||||||
}
|
prev.includes(paramName) ? prev.filter((name) => name !== paramName) : [...prev, paramName],
|
||||||
|
)
|
||||||
function addMatcher() {
|
|
||||||
setMatchers((prev) => [...prev, { ...EMPTY_MATCHER }])
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeMatcher(index: number) {
|
|
||||||
setMatchers((prev) => prev.filter((_, i) => i !== index))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
setEditingName(null)
|
setEditingName(null)
|
||||||
setName('')
|
setName('')
|
||||||
setRegex('')
|
setRegex('')
|
||||||
setMatchers([{ ...EMPTY_MATCHER }])
|
setLabelParams([])
|
||||||
|
setValueParam('')
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +156,8 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
setEditingName(metric.name)
|
setEditingName(metric.name)
|
||||||
setName(metric.name)
|
setName(metric.name)
|
||||||
setRegex(metric.message_match_regex)
|
setRegex(metric.message_match_regex)
|
||||||
setMatchers(metric.params.length > 0 ? metric.params.map((p) => ({ ...p })) : [{ ...EMPTY_MATCHER }])
|
setLabelParams(metric.label_params)
|
||||||
|
setValueParam(metric.value_param ?? '')
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +174,8 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
const request: MetricDeclaration = {
|
const request: MetricDeclaration = {
|
||||||
name,
|
name,
|
||||||
message_match_regex: regex,
|
message_match_regex: regex,
|
||||||
params: matchers.filter((m) => m.param_name && m.capture_group),
|
label_params: labelParams,
|
||||||
|
value_param: valueParam || null,
|
||||||
}
|
}
|
||||||
const saved = editingName ? await updateMetric(editingName, request) : await declareMetric(request)
|
const saved = editingName ? await updateMetric(editingName, request) : await declareMetric(request)
|
||||||
resetForm()
|
resetForm()
|
||||||
@@ -227,61 +236,49 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 flex items-center justify-between text-xs text-text-subtle">
|
<label className="mb-1 block text-xs text-text-subtle">
|
||||||
<span>Params</span>
|
Label params
|
||||||
<button
|
{params.length === 0 && (
|
||||||
type="button"
|
<span className="ml-1 text-text-faint">(declare a param in the Params panel below first)</span>
|
||||||
onClick={addMatcher}
|
)}
|
||||||
className="rounded border border-border-soft px-2 py-0.5 text-text-secondary hover:bg-surface-2"
|
</label>
|
||||||
>
|
<div className="max-h-32 space-y-0.5 overflow-auto rounded border border-border-soft bg-surface-1 p-1.5">
|
||||||
+ Add
|
{params.length === 0 ? (
|
||||||
</button>
|
<span className="text-xs text-text-faint">No params declared yet.</span>
|
||||||
</div>
|
) : (
|
||||||
<div className="space-y-1">
|
params.map((p) => (
|
||||||
{matchers.map((matcher, i) => (
|
<label key={p.name} className="flex items-center gap-1.5 text-xs text-text-secondary">
|
||||||
<div key={i} className="flex items-center gap-1">
|
|
||||||
<input
|
<input
|
||||||
className="w-24 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
type="checkbox"
|
||||||
value={matcher.param_name}
|
checked={labelParams.includes(p.name)}
|
||||||
onChange={(e) => updateMatcher(i, { param_name: e.target.value })}
|
onChange={() => toggleLabelParam(p.name)}
|
||||||
placeholder="param_name"
|
|
||||||
/>
|
/>
|
||||||
<input
|
<span className="font-mono">{p.name}</span>
|
||||||
className="w-24 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
<span className="text-text-faint">({p.elapsed_since_last ? 'elapsed since previous' : p.kind})</span>
|
||||||
value={matcher.capture_group}
|
</label>
|
||||||
onChange={(e) => updateMatcher(i, { capture_group: e.target.value })}
|
))
|
||||||
placeholder="capture_group"
|
)}
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className="rounded border border-border-soft bg-surface-1 px-1 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
|
|
||||||
value={matcher.kind}
|
|
||||||
onChange={(e) => updateMatcher(i, { kind: e.target.value as MetricParamMatcher['kind'] })}
|
|
||||||
>
|
|
||||||
<option value="string">string</option>
|
|
||||||
<option value="number">number</option>
|
|
||||||
<option value="timestamp">timestamp</option>
|
|
||||||
<option value="duration">duration</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="rounded border border-border-soft bg-surface-1 px-1 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
|
|
||||||
value={matcher.role ?? 'label'}
|
|
||||||
onChange={(e) => updateMatcher(i, { role: e.target.value as MetricParamMatcher['role'] })}
|
|
||||||
title="Label params identify a bucket; at most one Value param becomes the counter's per-event captured value (used by histogram()/gauge()). A Value param of kind 'duration' is derived automatically — elapsed time since the previous match with the same labels — rather than parsed from the capture group."
|
|
||||||
>
|
|
||||||
<option value="label">label</option>
|
|
||||||
<option value="value">value</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeMatcher(i)}
|
|
||||||
disabled={matchers.length === 1}
|
|
||||||
className="rounded border border-border-soft px-1.5 py-1 text-xs text-text-muted hover:bg-surface-2 disabled:opacity-30"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="mb-1 block text-xs text-text-subtle"
|
||||||
|
title="At most one param becomes the counter's per-event captured value (used by histogram()/gauge()) instead of a label. An 'elapsed since previous event' param (declared in the Params panel) is computed automatically — elapsed time since the previous match with the same labels — rather than parsed from its capture group."
|
||||||
|
>
|
||||||
|
Value param
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
|
||||||
|
value={valueParam}
|
||||||
|
onChange={(e) => setValueParam(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">(none — occurrence count)</option>
|
||||||
|
{params.map((p) => (
|
||||||
|
<option key={p.name} value={p.name}>
|
||||||
|
{p.name} ({p.elapsed_since_last ? 'elapsed since previous' : p.kind})
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</div>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{editingName && (
|
{editingName && (
|
||||||
<div className="rounded border border-accent bg-accent/5 px-2 py-1 text-xs text-accent-text">
|
<div className="rounded border border-accent bg-accent/5 px-2 py-1 text-xs text-accent-text">
|
||||||
@@ -325,7 +322,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
}`}
|
}`}
|
||||||
title={m.message_match_regex}
|
title={m.message_match_regex}
|
||||||
>
|
>
|
||||||
{m.name} ({m.params.map((p) => p.param_name).join(', ') || 'no params'})
|
{m.name} ({m.label_params.join(', ') || 'no params'})
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -342,17 +339,17 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
<span className="font-mono">{selectedMetric.message_match_regex}</span>
|
<span className="font-mono">{selectedMetric.message_match_regex}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-text-subtle">Params: </span>
|
<span className="text-text-subtle">Label params: </span>
|
||||||
{selectedMetric.params.length === 0 ? (
|
{selectedMetric.label_params.length === 0 ? (
|
||||||
<span className="text-text-faint">none</span>
|
<span className="text-text-faint">none</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-mono">
|
<span className="font-mono">{selectedMetric.label_params.join(', ')}</span>
|
||||||
{selectedMetric.params
|
|
||||||
.map((p) => `${p.param_name}←${p.capture_group} (${p.kind}, ${p.role ?? 'label'})`)
|
|
||||||
.join(', ')}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-text-subtle">Value param: </span>
|
||||||
|
<span className="font-mono">{selectedMetric.value_param ?? <span className="text-text-faint">none</span>}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{groups.length === 0 ? (
|
{groups.length === 0 ? (
|
||||||
|
|||||||
+125
-114
@@ -27,8 +27,14 @@ const COLOR_SCHEMES: ColorScheme[] = [
|
|||||||
'yellow',
|
'yellow',
|
||||||
'pink',
|
'pink',
|
||||||
'orange',
|
'orange',
|
||||||
|
'light_green',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** Display label for a `ColorScheme`: underscores become spaces so `capitalize` (which titles each word) renders e.g. "light_green" as "Light Green". */
|
||||||
|
function schemeLabel(scheme: ColorScheme): string {
|
||||||
|
return scheme.replace(/_/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
export function NavMenu({ stats }: { stats: Stats | null }) {
|
export function NavMenu({ stats }: { stats: Stats | null }) {
|
||||||
const [openPanel, setOpenPanel] = useState<PanelName>(null)
|
const [openPanel, setOpenPanel] = useState<PanelName>(null)
|
||||||
const panel = useSettingsPanel()
|
const panel = useSettingsPanel()
|
||||||
@@ -252,8 +258,10 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
setCssUploadError(null)
|
setCssUploadError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every imported/linked stylesheet is always a selectable theme (ticket 020), so it's always
|
// Every imported/linked stylesheet is always a selectable style, listed alongside the built-in
|
||||||
// validated against the canonical `--c-*` variable set before it's sent.
|
// colour schemes (ticket 020/047), so it's always validated against the canonical `--c-*`
|
||||||
|
// variable set before it's sent — that's a floor, not a ceiling: the stylesheet is free to also
|
||||||
|
// carry rules for any other selector, up to and including a full layout redesign.
|
||||||
async function handleCssImport(e: React.FormEvent) {
|
async function handleCssImport(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setCssUploadError(null)
|
setCssUploadError(null)
|
||||||
@@ -294,8 +302,14 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5 text-sm text-text-secondary">
|
<div className="flex flex-col gap-5 text-sm text-text-secondary">
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Colour scheme</h3>
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
<div className="flex flex-wrap gap-1">
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">Style</h3>
|
||||||
|
<span className="text-[11px] text-text-faint">
|
||||||
|
one list of interchangeable styles — a built-in palette or your own imported/linked stylesheet. Any of
|
||||||
|
them can be a simple colour swap or a full layout redesign; only one is active at a time.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 flex flex-wrap gap-1">
|
||||||
{COLOR_SCHEMES.map((scheme) => (
|
{COLOR_SCHEMES.map((scheme) => (
|
||||||
<div key={scheme} className="flex items-center">
|
<div key={scheme} className="flex items-center">
|
||||||
<button
|
<button
|
||||||
@@ -307,14 +321,14 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
: 'border-border-soft text-text-muted hover:bg-surface-2'
|
: 'border-border-soft text-text-muted hover:bg-surface-2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{scheme}
|
{schemeLabel(scheme)}
|
||||||
</button>
|
</button>
|
||||||
{scheme !== 'system' && (
|
{scheme !== 'system' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => downloadBuiltinTheme(scheme)}
|
onClick={() => downloadBuiltinTheme(scheme)}
|
||||||
className="rounded-r border border-l-0 border-border-soft px-1.5 py-1 text-xs text-text-faint hover:bg-surface-2 hover:text-text"
|
className="rounded-r border border-l-0 border-border-soft px-1.5 py-1 text-xs text-text-faint hover:bg-surface-2 hover:text-text"
|
||||||
aria-label={`Download ${scheme} theme`}
|
aria-label={`Download ${schemeLabel(scheme)} theme`}
|
||||||
title="Download as .css"
|
title="Download as .css"
|
||||||
>
|
>
|
||||||
↓
|
↓
|
||||||
@@ -323,6 +337,111 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{panel.cssEntries.length === 0 ? (
|
||||||
|
<div className="mb-2 text-xs text-text-faint">No styles imported or linked yet.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="mb-2 flex flex-col gap-1">
|
||||||
|
{panel.cssEntries.map((entry) => (
|
||||||
|
<li
|
||||||
|
key={entry.id}
|
||||||
|
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => panel.setCustomTheme(entry.id)}
|
||||||
|
className={`block w-full truncate text-left capitalize ${
|
||||||
|
panel.activeCustomTheme?.id === entry.id ? 'text-text' : 'text-text-muted hover:text-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entry.filename ?? entry.path_or_url}
|
||||||
|
</button>
|
||||||
|
<div className="text-[10px] uppercase text-text-faint">{entry.kind}</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadCssEntry(entry)}
|
||||||
|
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-text"
|
||||||
|
aria-label={`Download ${entry.filename ?? entry.path_or_url}`}
|
||||||
|
title="Download as .css"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
{entry.kind === 'imported' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => panel.removeCssEntry(entry.id)}
|
||||||
|
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
|
||||||
|
aria-label={`Delete ${entry.filename}`}
|
||||||
|
>
|
||||||
|
delete
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-faintest" title="Linked CSS isn't deletable here">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mb-2 text-[11px] text-text-faint">
|
||||||
|
Add your own style below — a stylesheet, uploaded or linked by URL/path. It must at least define every{' '}
|
||||||
|
<code>--c-*</code> variable the built-ins do, but nothing stops it from also targeting any other selector
|
||||||
|
in the app (layout, spacing, fonts, anything) — it isn't limited to palette overrides.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleCssImport} className="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".css,text/css"
|
||||||
|
onChange={handleCssFileChange}
|
||||||
|
className="text-xs text-text-muted file:mr-2 file:rounded file:border file:border-border-soft file:bg-surface-2 file:px-2 file:py-1 file:text-text"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="enter name"
|
||||||
|
value={cssFilename}
|
||||||
|
onChange={(e) => setCssFilename(e.target.value)}
|
||||||
|
className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="Paste CSS content here"
|
||||||
|
value={cssContent}
|
||||||
|
onChange={(e) => setCssContent(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="resize-y rounded border border-border-soft bg-surface-1 px-2 py-1 font-mono text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||||
|
/>
|
||||||
|
{cssUploadError && <div className="text-xs text-danger-text-strong">{cssUploadError}</div>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form onSubmit={handleCssLink} className="mt-2 flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="https://example.com/style.css"
|
||||||
|
value={cssLinkUrl}
|
||||||
|
onChange={(e) => setCssLinkUrl(e.target.value)}
|
||||||
|
className="min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="shrink-0 rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -378,114 +497,6 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">CSS</h3>
|
|
||||||
<span className="text-[11px] text-text-faint">
|
|
||||||
imported/linked stylesheets — each one becomes a selectable alternative to the built-in colour schemes
|
|
||||||
above
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{panel.cssEntries.length === 0 ? (
|
|
||||||
<div className="mb-2 text-xs text-text-faint">No CSS configured.</div>
|
|
||||||
) : (
|
|
||||||
<ul className="mb-2 flex flex-col gap-1">
|
|
||||||
{panel.cssEntries.map((entry) => (
|
|
||||||
<li
|
|
||||||
key={entry.id}
|
|
||||||
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => panel.setCustomTheme(entry.id)}
|
|
||||||
className={`block w-full truncate text-left capitalize ${
|
|
||||||
panel.activeCustomTheme?.id === entry.id ? 'text-text' : 'text-text-muted hover:text-text'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{entry.filename ?? entry.path_or_url}
|
|
||||||
</button>
|
|
||||||
<div className="text-[10px] uppercase text-text-faint">{entry.kind}</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-2 flex shrink-0 items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => downloadCssEntry(entry)}
|
|
||||||
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-text"
|
|
||||||
aria-label={`Download ${entry.filename ?? entry.path_or_url}`}
|
|
||||||
title="Download as .css"
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
{entry.kind === 'imported' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => panel.removeCssEntry(entry.id)}
|
|
||||||
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
|
|
||||||
aria-label={`Delete ${entry.filename}`}
|
|
||||||
>
|
|
||||||
delete
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="text-text-faintest" title="Linked CSS isn't deletable here">
|
|
||||||
—
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleCssImport} className="flex flex-col gap-2">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".css,text/css"
|
|
||||||
onChange={handleCssFileChange}
|
|
||||||
className="text-xs text-text-muted file:mr-2 file:rounded file:border file:border-border-soft file:bg-surface-2 file:px-2 file:py-1 file:text-text"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="enter name"
|
|
||||||
value={cssFilename}
|
|
||||||
onChange={(e) => setCssFilename(e.target.value)}
|
|
||||||
className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
placeholder="Paste CSS content here"
|
|
||||||
value={cssContent}
|
|
||||||
onChange={(e) => setCssContent(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
className="resize-y rounded border border-border-soft bg-surface-1 px-2 py-1 font-mono text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
|
||||||
/>
|
|
||||||
{cssUploadError && <div className="text-xs text-danger-text-strong">{cssUploadError}</div>}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form onSubmit={handleCssLink} className="mt-2 flex flex-col gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="https://example.com/style.css"
|
|
||||||
value={cssLinkUrl}
|
|
||||||
onChange={(e) => setCssLinkUrl(e.target.value)}
|
|
||||||
className="min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="shrink-0 rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
Link
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,38 @@ export function seriesLabel(labels: Record<string, string>): string {
|
|||||||
return entries.map(([k, v]) => `${k}=${v}`).join(', ')
|
return entries.map(([k, v]) => `${k}=${v}`).join(', ')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixed, small categorical palette to avoid a colour-scale dependency; cycles if there are more series than colours.
|
// Categorical palette drawn from the active theme's accent set (ticket 048: every built-in theme
|
||||||
const COLORS = ['#38bdf8', '#f472b6', '#a78bfa', '#4ade80', '#fbbf24', '#fb7185', '#2dd4bf', '#f97316']
|
// exposes --c-accent/--c-accent-2/--c-accent-3, not just one accent hue) instead of a hardcoded,
|
||||||
|
// theme-independent hex list. `color-mix` tints/shades those three base accents into a fuller
|
||||||
|
// 8-colour cycle (matching the previous fixed palette's size, so MAX_KEPT_SERIES/squashing below is
|
||||||
|
// unaffected) without any JS colour math or a getComputedStyle read — the browser resolves the CSS
|
||||||
|
// variables per-theme at paint time, same as `stroke="var(--c-border)"` elsewhere in this file.
|
||||||
|
const COLORS = [
|
||||||
|
'var(--c-accent)',
|
||||||
|
'var(--c-accent-2)',
|
||||||
|
'var(--c-accent-3)',
|
||||||
|
'color-mix(in srgb, var(--c-accent) 60%, white)',
|
||||||
|
'color-mix(in srgb, var(--c-accent-2) 60%, white)',
|
||||||
|
'color-mix(in srgb, var(--c-accent-3) 60%, white)',
|
||||||
|
'color-mix(in srgb, var(--c-accent) 55%, black)',
|
||||||
|
'color-mix(in srgb, var(--c-accent-2) 55%, black)',
|
||||||
|
]
|
||||||
|
|
||||||
|
// One less than the palette size: above this many series, keep the top MAX_KEPT_SERIES (by value) and squash the
|
||||||
|
// rest into a single "Other" bucket, so the squashed range never exceeds COLORS.length and never repeats a colour.
|
||||||
|
const MAX_KEPT_SERIES = COLORS.length - 1
|
||||||
|
|
||||||
|
// Sorts rows by value descending and, if there are more than MAX_KEPT_SERIES, keeps the top MAX_KEPT_SERIES and
|
||||||
|
// collapses the remainder into a single "Other" row summing their values. Below the threshold, rows are returned
|
||||||
|
// unchanged so panels with few series keep their existing bar/slice order and colour assignment.
|
||||||
|
function squashRows(rows: { name: string; value: number }[]): { name: string; value: number }[] {
|
||||||
|
if (rows.length <= MAX_KEPT_SERIES) return rows
|
||||||
|
const sorted = [...rows].sort((a, b) => b.value - a.value)
|
||||||
|
const kept = sorted.slice(0, MAX_KEPT_SERIES)
|
||||||
|
const rest = sorted.slice(MAX_KEPT_SERIES)
|
||||||
|
const otherValue = rest.reduce((sum, r) => sum + r.value, 0)
|
||||||
|
return [...kept, { name: `Other (${rest.length})`, value: otherValue }]
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
// Merges series into one row-per-timestamp dataset (recharts wants a single array keyed by series name); a timestamp missing from a series is left undefined there, which recharts skips when drawing that line.
|
||||||
function toLineData(series: PromqlSeries[]): { rows: Record<string, number | string>[]; keys: string[] } {
|
function toLineData(series: PromqlSeries[]): { rows: Record<string, number | string>[]; keys: string[] } {
|
||||||
@@ -101,7 +131,7 @@ export function PanelChart({ series, chartType }: { series: PromqlSeries[]; char
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (chartType === 'bar') {
|
if (chartType === 'bar') {
|
||||||
const rows = series.map((s) => ({ name: seriesLabel(s.labels), value: latestValue(s) }))
|
const rows = squashRows(series.map((s) => ({ name: seriesLabel(s.labels), value: latestValue(s) })))
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
<BarChart data={rows} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
<BarChart data={rows} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
||||||
@@ -119,7 +149,7 @@ export function PanelChart({ series, chartType }: { series: PromqlSeries[]; char
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = series.map((s) => ({ name: seriesLabel(s.labels), value: sumValue(s) }))
|
const rows = squashRows(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) {
|
||||||
return <div className="flex h-48 items-center justify-center text-xs text-text-faint">No non-zero values in range.</div>
|
return <div className="flex h-48 items-center justify-center text-xs text-text-faint">No non-zero values in range.</div>
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import { RegexHelpIcon } from './RegexHelpIcon'
|
|||||||
|
|
||||||
const EMPTY_KIND: ParamDeclaration['kind'] = 'string'
|
const EMPTY_KIND: ParamDeclaration['kind'] = 'string'
|
||||||
|
|
||||||
type ExtractionSource = 'regex' | 'json_field'
|
// 'elapsed_since_last' is always offered, regardless of what the message match regex looks like —
|
||||||
|
// unlike 'json_field' (which only makes sense once the sample line looks like a JSON object), this
|
||||||
|
// param's value is never extracted from text at all: it's elapsed time since the previous event
|
||||||
|
// sharing the same label set (ticket 038).
|
||||||
|
type ExtractionSource = 'regex' | 'json_field' | 'elapsed_since_last'
|
||||||
|
|
||||||
/// A JSON *object* (not an array/scalar) parses cleanly and pretty-prints with braces — the same
|
/// A JSON *object* (not an array/scalar) parses cleanly and pretty-prints with braces — the same
|
||||||
/// shape a JSON field path can be read from, so this is the "line looks like JSON" gate that
|
/// shape a JSON field path can be read from, so this is the "line looks like JSON" gate that
|
||||||
@@ -39,35 +43,75 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
|||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [collapsed, setCollapsed] = useSectionCollapsed('params')
|
const [collapsed, setCollapsed] = useSectionCollapsed('params')
|
||||||
|
// Name of the declaration currently loaded into the form for editing, or `null` when the form is
|
||||||
|
// declaring a brand-new param. The name field is locked while editing — `declareParam` overwrites
|
||||||
|
// in place by name, so changing it here would silently create a second param and orphan the
|
||||||
|
// original (and anything referencing it by name) rather than renaming it.
|
||||||
|
const [editingName, setEditingName] = useState<string | null>(null)
|
||||||
|
|
||||||
const isBoolean = kind === 'boolean'
|
const isBoolean = kind === 'boolean'
|
||||||
|
const isElapsedSinceLast = source === 'elapsed_since_last'
|
||||||
// The message match regex doubles as a sample line here so a pasted JSON log line unlocks the
|
// The message match regex doubles as a sample line here so a pasted JSON log line unlocks the
|
||||||
// "JSON field" extraction option — regex-based params are unaffected either way.
|
// "JSON field" extraction option — regex-based params are unaffected either way. Not consulted at
|
||||||
|
// all for "elapsed since previous event", which is always offered.
|
||||||
const detectedJson = useMemo(() => looksLikeJsonObject(regex), [regex])
|
const detectedJson = useMemo(() => looksLikeJsonObject(regex), [regex])
|
||||||
const effectiveSource: ExtractionSource = detectedJson && !isBoolean ? source : 'regex'
|
const effectiveSource: ExtractionSource = isElapsedSinceLast
|
||||||
|
? 'elapsed_since_last'
|
||||||
|
: detectedJson && !isBoolean
|
||||||
|
? source
|
||||||
|
: 'regex'
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
function handleSourceChange(next: ExtractionSource) {
|
||||||
e.preventDefault()
|
setSource(next)
|
||||||
setSubmitError(null)
|
// "Elapsed since previous event" always produces a Duration value — never text-extracted — so
|
||||||
setSubmitting(true)
|
// there's no separate kind to pick; force it rather than leaving a stale/mismatched kind selected.
|
||||||
try {
|
if (next === 'elapsed_since_last') setKind('duration')
|
||||||
// A boolean-kind param never uses a capture group — its value is just whether the regex matched.
|
}
|
||||||
await declareParam({
|
|
||||||
name,
|
function resetForm() {
|
||||||
message_match_regex: regex,
|
setEditingName(null)
|
||||||
capture_group: isBoolean || effectiveSource === 'json_field' ? '' : captureGroup,
|
|
||||||
json_field_path: effectiveSource === 'json_field' ? jsonFieldPath : null,
|
|
||||||
kind,
|
|
||||||
})
|
|
||||||
setName('')
|
setName('')
|
||||||
setRegex('')
|
setRegex('')
|
||||||
setCaptureGroup('')
|
setCaptureGroup('')
|
||||||
setJsonFieldPath('')
|
setJsonFieldPath('')
|
||||||
setSource('regex')
|
setSource('regex')
|
||||||
setKind(EMPTY_KIND)
|
setKind(EMPTY_KIND)
|
||||||
|
setSubmitError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads `param`'s current values into the declaration form for editing — a click handler for a
|
||||||
|
// declared-param badge.
|
||||||
|
function editParam(param: ParamDeclaration) {
|
||||||
|
setEditingName(param.name)
|
||||||
|
setName(param.name)
|
||||||
|
setRegex(param.message_match_regex)
|
||||||
|
setCaptureGroup(param.capture_group)
|
||||||
|
setJsonFieldPath(param.json_field_path ?? '')
|
||||||
|
setSource(param.elapsed_since_last ? 'elapsed_since_last' : param.json_field_path ? 'json_field' : 'regex')
|
||||||
|
setKind(param.kind)
|
||||||
|
setSubmitError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitError(null)
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
// A boolean-kind param never uses a capture group — its value is just whether the regex
|
||||||
|
// matched. An elapsed-since-last param never uses a capture group, JSON field path, or even
|
||||||
|
// its own message match regex — its value is computed, not extracted from text at all.
|
||||||
|
await declareParam({
|
||||||
|
name,
|
||||||
|
message_match_regex: effectiveSource === 'elapsed_since_last' ? '' : regex,
|
||||||
|
capture_group: isBoolean || effectiveSource !== 'regex' ? '' : captureGroup,
|
||||||
|
json_field_path: effectiveSource === 'json_field' ? jsonFieldPath : null,
|
||||||
|
kind: effectiveSource === 'elapsed_since_last' ? 'duration' : kind,
|
||||||
|
elapsed_since_last: effectiveSource === 'elapsed_since_last',
|
||||||
|
})
|
||||||
|
resetForm()
|
||||||
onDeclared()
|
onDeclared()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSubmitError(err instanceof ApiError ? err.message : 'Failed to declare param')
|
setSubmitError(err instanceof ApiError ? err.message : `Failed to ${editingName ? 'update' : 'declare'} param`)
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -95,15 +139,19 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
|||||||
<div className="flex gap-4 p-3" hidden={collapsed}>
|
<div className="flex gap-4 p-3" hidden={collapsed}>
|
||||||
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
|
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-text-subtle">Name</label>
|
<label className="mb-1 block text-xs text-text-subtle">
|
||||||
|
Name{editingName && ' (locked while editing)'}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
className="w-full 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"
|
className="w-full 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 disabled:opacity-50"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="path"
|
placeholder="path"
|
||||||
required
|
required
|
||||||
|
disabled={editingName !== null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{!isElapsedSinceLast && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 flex items-center gap-1 text-xs text-text-subtle">
|
<label className="mb-1 flex items-center gap-1 text-xs text-text-subtle">
|
||||||
Message match regex
|
Message match regex
|
||||||
@@ -117,20 +165,25 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{detectedJson && !isBoolean && (
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-text-subtle">Extraction</label>
|
<label className="mb-1 block text-xs text-text-subtle">Extraction</label>
|
||||||
<select
|
<select
|
||||||
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
|
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
|
||||||
value={source}
|
value={source}
|
||||||
onChange={(e) => setSource(e.target.value as ExtractionSource)}
|
onChange={(e) => handleSourceChange(e.target.value as ExtractionSource)}
|
||||||
>
|
>
|
||||||
<option value="regex">Regex capture group</option>
|
<option value="regex">Regex capture group</option>
|
||||||
<option value="json_field">JSON field</option>
|
{detectedJson && !isBoolean && <option value="json_field">JSON field</option>}
|
||||||
|
<option value="elapsed_since_last">Elapsed since previous event</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{isElapsedSinceLast ? (
|
||||||
{effectiveSource === 'json_field' ? (
|
<p className="text-xs text-text-faint">
|
||||||
|
Computed automatically — elapsed time since the previous event that shares the same
|
||||||
|
labels wherever this param is used, not parsed from the log line.
|
||||||
|
</p>
|
||||||
|
) : effectiveSource === 'json_field' ? (
|
||||||
<div className="flex items-end gap-1">
|
<div className="flex items-end gap-1">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<label className="mb-1 block text-xs text-text-subtle">JSON field path</label>
|
<label className="mb-1 block text-xs text-text-subtle">JSON field path</label>
|
||||||
@@ -181,14 +234,31 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{editingName && (
|
||||||
|
<div className="rounded border border-accent bg-accent/5 px-2 py-1 text-xs text-accent-text">
|
||||||
|
Editing "{editingName}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
|
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
|
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? 'Declaring…' : 'Declare param'}
|
{submitting ? (editingName ? 'Saving…' : 'Declaring…') : editingName ? 'Save changes' : 'Declare param'}
|
||||||
</button>
|
</button>
|
||||||
|
{editingName && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetForm}
|
||||||
|
disabled={submitting}
|
||||||
|
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -196,17 +266,25 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
|||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{params.length === 0 && <span className="text-xs text-text-faint">No params declared yet.</span>}
|
{params.length === 0 && <span className="text-xs text-text-faint">No params declared yet.</span>}
|
||||||
{params.map((p) => (
|
{params.map((p) => (
|
||||||
<span
|
<button
|
||||||
key={p.name}
|
key={p.name}
|
||||||
className="rounded border border-border-soft px-2 py-1 text-xs text-text-secondary"
|
type="button"
|
||||||
title={p.message_match_regex}
|
onClick={() => editParam(p)}
|
||||||
|
className={`rounded border px-2 py-1 text-xs ${
|
||||||
|
editingName === p.name
|
||||||
|
? 'border-accent bg-accent/10 text-accent-text'
|
||||||
|
: 'border-border-soft text-text-secondary hover:bg-surface-2'
|
||||||
|
}`}
|
||||||
|
title={p.elapsed_since_last ? undefined : p.message_match_regex}
|
||||||
>
|
>
|
||||||
{p.name} ({p.kind}
|
{p.name} ({p.kind}
|
||||||
{p.kind === 'boolean'
|
{p.elapsed_since_last
|
||||||
|
? ', elapsed since previous'
|
||||||
|
: p.kind === 'boolean'
|
||||||
? ''
|
? ''
|
||||||
: `, ${p.json_field_path ? `json field ${p.json_field_path}` : `group ${p.capture_group}`}`}
|
: `, ${p.json_field_path ? `json field ${p.json_field_path}` : `group ${p.capture_group}`}`}
|
||||||
)
|
)
|
||||||
</span>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,14 @@
|
|||||||
* composite K = H+150 (mod 360), then fill --c-surface-, --c-border-,
|
* composite K = H+150 (mod 360), then fill --c-surface-, --c-border-,
|
||||||
* --c-text- as hsl(H, ...) at the saturation/lightness steps below, accent
|
* --c-text- as hsl(H, ...) at the saturation/lightness steps below, accent
|
||||||
* + accent-text as hsl(C, ...), and accent-soft as hsl(K, ...).
|
* + accent-text as hsl(C, ...), and accent-soft as hsl(K, ...).
|
||||||
|
*
|
||||||
|
* --c-accent (base look, unchanged above) is joined by two more accent
|
||||||
|
* colours (ticket 048) so charts/highlights can draw from a 3-colour set
|
||||||
|
* instead of one hue: --c-accent-2 sits at H+90 and --c-accent-3 at H+270 —
|
||||||
|
* a quadrature pair straddling the base hue, distinct from both the
|
||||||
|
* complement (accent) and the split-complement (accent-soft). These are
|
||||||
|
* purely additive: nothing that already reads --c-accent/-soft/-text
|
||||||
|
* changes look.
|
||||||
*/
|
*/
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
@@ -62,6 +70,8 @@
|
|||||||
--c-accent: #0ea5e9;
|
--c-accent: #0ea5e9;
|
||||||
--c-accent-soft: #0369a1;
|
--c-accent-soft: #0369a1;
|
||||||
--c-accent-text: #7dd3fc;
|
--c-accent-text: #7dd3fc;
|
||||||
|
--c-accent-2: #a78bfa;
|
||||||
|
--c-accent-3: #4ade80;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Exact lightness mirror of :root's neutral-950..50 scale (Tailwind neutral,
|
/* Exact lightness mirror of :root's neutral-950..50 scale (Tailwind neutral,
|
||||||
@@ -91,6 +101,8 @@ html.light {
|
|||||||
--c-accent: #0284c7;
|
--c-accent: #0284c7;
|
||||||
--c-accent-soft: #7dd3fc;
|
--c-accent-soft: #7dd3fc;
|
||||||
--c-accent-text: #0369a1;
|
--c-accent-text: #0369a1;
|
||||||
|
--c-accent-2: #7c3aed;
|
||||||
|
--c-accent-3: #16a34a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* base hue 0 (red): surfaces/borders/text tinted red; accent complement 180
|
/* base hue 0 (red): surfaces/borders/text tinted red; accent complement 180
|
||||||
@@ -115,6 +127,8 @@ html.red {
|
|||||||
--c-accent: hsl(180 70% 62%);
|
--c-accent: hsl(180 70% 62%);
|
||||||
--c-accent-soft: hsl(150 55% 34%);
|
--c-accent-soft: hsl(150 55% 34%);
|
||||||
--c-accent-text: hsl(180 75% 80%);
|
--c-accent-text: hsl(180 75% 80%);
|
||||||
|
--c-accent-2: hsl(90 65% 58%);
|
||||||
|
--c-accent-3: hsl(270 65% 68%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* base hue 140 (green): surfaces/borders/text tinted green; accent complement
|
/* base hue 140 (green): surfaces/borders/text tinted green; accent complement
|
||||||
@@ -139,6 +153,8 @@ html.green {
|
|||||||
--c-accent: hsl(320 70% 62%);
|
--c-accent: hsl(320 70% 62%);
|
||||||
--c-accent-soft: hsl(290 55% 34%);
|
--c-accent-soft: hsl(290 55% 34%);
|
||||||
--c-accent-text: hsl(320 75% 80%);
|
--c-accent-text: hsl(320 75% 80%);
|
||||||
|
--c-accent-2: hsl(230 65% 62%);
|
||||||
|
--c-accent-3: hsl(50 65% 58%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* base hue 210 (blue): surfaces/borders/text tinted blue; accent complement
|
/* base hue 210 (blue): surfaces/borders/text tinted blue; accent complement
|
||||||
@@ -163,6 +179,8 @@ html.blue {
|
|||||||
--c-accent: hsl(30 70% 62%);
|
--c-accent: hsl(30 70% 62%);
|
||||||
--c-accent-soft: hsl(0 55% 34%);
|
--c-accent-soft: hsl(0 55% 34%);
|
||||||
--c-accent-text: hsl(30 75% 80%);
|
--c-accent-text: hsl(30 75% 80%);
|
||||||
|
--c-accent-2: hsl(300 65% 68%);
|
||||||
|
--c-accent-3: hsl(120 65% 55%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* base hue 50 (yellow): surfaces/borders/text tinted yellow; accent
|
/* base hue 50 (yellow): surfaces/borders/text tinted yellow; accent
|
||||||
@@ -187,6 +205,8 @@ html.yellow {
|
|||||||
--c-accent: hsl(230 70% 62%);
|
--c-accent: hsl(230 70% 62%);
|
||||||
--c-accent-soft: hsl(200 55% 34%);
|
--c-accent-soft: hsl(200 55% 34%);
|
||||||
--c-accent-text: hsl(230 75% 80%);
|
--c-accent-text: hsl(230 75% 80%);
|
||||||
|
--c-accent-2: hsl(140 65% 55%);
|
||||||
|
--c-accent-3: hsl(320 65% 68%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* base hue 330 (pink): surfaces/borders/text tinted pink; accent complement
|
/* base hue 330 (pink): surfaces/borders/text tinted pink; accent complement
|
||||||
@@ -211,6 +231,8 @@ html.pink {
|
|||||||
--c-accent: hsl(150 70% 62%);
|
--c-accent: hsl(150 70% 62%);
|
||||||
--c-accent-soft: hsl(120 55% 34%);
|
--c-accent-soft: hsl(120 55% 34%);
|
||||||
--c-accent-text: hsl(150 75% 80%);
|
--c-accent-text: hsl(150 75% 80%);
|
||||||
|
--c-accent-2: hsl(60 65% 58%);
|
||||||
|
--c-accent-3: hsl(240 65% 68%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* base hue 30 (orange): surfaces/borders/text tinted orange; accent
|
/* base hue 30 (orange): surfaces/borders/text tinted orange; accent
|
||||||
@@ -235,6 +257,43 @@ html.orange {
|
|||||||
--c-accent: hsl(210 70% 62%);
|
--c-accent: hsl(210 70% 62%);
|
||||||
--c-accent-soft: hsl(180 55% 34%);
|
--c-accent-soft: hsl(180 55% 34%);
|
||||||
--c-accent-text: hsl(210 75% 80%);
|
--c-accent-text: hsl(210 75% 80%);
|
||||||
|
--c-accent-2: hsl(120 65% 55%);
|
||||||
|
--c-accent-3: hsl(300 65% 68%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brighter, light-surfaced counterpart to `green` (ticket 048): same base
|
||||||
|
hue (140) and accent hues as `green`, but with a `light`-style inverted
|
||||||
|
surface/border/text scale (high lightness instead of low) so it reads as
|
||||||
|
a distinctly brighter theme, not a re-tint of the dark `green`. Danger
|
||||||
|
colours are `light`'s (hue-neutral), matching how `light` itself doesn't
|
||||||
|
tint danger either. */
|
||||||
|
html.light_green {
|
||||||
|
--c-surface-0: hsl(140 40% 96%);
|
||||||
|
--c-surface-1: hsl(140 35% 92%);
|
||||||
|
--c-surface-2: hsl(140 30% 85%);
|
||||||
|
|
||||||
|
--c-border: hsl(140 25% 80%);
|
||||||
|
--c-border-soft: hsl(140 22% 70%);
|
||||||
|
--c-border-faint: hsl(140 35% 92%);
|
||||||
|
--c-border-strong: hsl(140 20% 40%);
|
||||||
|
|
||||||
|
--c-text: hsl(140 30% 12%);
|
||||||
|
--c-text-secondary: hsl(140 20% 25%);
|
||||||
|
--c-text-muted: hsl(140 15% 40%);
|
||||||
|
--c-text-subtle: hsl(140 10% 50%);
|
||||||
|
--c-text-faint: hsl(140 10% 65%);
|
||||||
|
--c-text-faintest: hsl(140 12% 80%);
|
||||||
|
|
||||||
|
--c-danger-bg: #fee2e2;
|
||||||
|
--c-danger-border: #fca5a5;
|
||||||
|
--c-danger-text: #b91c1c;
|
||||||
|
--c-danger-text-strong: #991b1b;
|
||||||
|
|
||||||
|
--c-accent: hsl(320 70% 40%);
|
||||||
|
--c-accent-soft: hsl(290 60% 85%);
|
||||||
|
--c-accent-text: hsl(320 75% 28%);
|
||||||
|
--c-accent-2: hsl(230 70% 45%);
|
||||||
|
--c-accent-3: hsl(50 70% 38%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -262,6 +321,8 @@ html.orange {
|
|||||||
--color-accent: var(--c-accent);
|
--color-accent: var(--c-accent);
|
||||||
--color-accent-soft: var(--c-accent-soft);
|
--color-accent-soft: var(--c-accent-soft);
|
||||||
--color-accent-text: var(--c-accent-text);
|
--color-accent-text: var(--c-accent-text);
|
||||||
|
--color-accent-2: var(--c-accent-2);
|
||||||
|
--color-accent-3: var(--c-accent-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
|
|||||||
+13
-13
@@ -30,6 +30,8 @@ export interface ParamDeclaration {
|
|||||||
/** Alternative to `capture_group`: a dot-separated JSON field path (e.g. "foo.bar"), read from lines that parse as a JSON object. When set (non-empty), takes precedence over `capture_group`. */
|
/** Alternative to `capture_group`: a dot-separated JSON field path (e.g. "foo.bar"), read from lines that parse as a JSON object. When set (non-empty), takes precedence over `capture_group`. */
|
||||||
json_field_path?: string | null
|
json_field_path?: string | null
|
||||||
kind: 'string' | 'number' | 'timestamp' | 'duration' | 'boolean'
|
kind: 'string' | 'number' | 'timestamp' | 'duration' | 'boolean'
|
||||||
|
/** A third extraction source, distinct from `capture_group`/`json_field_path`: when true, this param's value is never parsed from text — it's computed as elapsed time since the previous event sharing the same label set. Only meaningful paired with `kind: 'duration'`. */
|
||||||
|
elapsed_since_last?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimelineBucket {
|
export interface TimelineBucket {
|
||||||
@@ -47,22 +49,13 @@ export interface Stats {
|
|||||||
approx_bytes_total: number
|
approx_bytes_total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ParamRole = 'label' | 'value'
|
|
||||||
|
|
||||||
export interface MetricParamMatcher {
|
|
||||||
param_name: string
|
|
||||||
capture_group: string
|
|
||||||
/** See `ParamDeclaration.json_field_path`. */
|
|
||||||
json_field_path?: string | null
|
|
||||||
kind: 'string' | 'number' | 'timestamp' | 'duration'
|
|
||||||
/** Defaults to 'label' server-side when omitted. */
|
|
||||||
role?: ParamRole
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MetricDeclaration {
|
export interface MetricDeclaration {
|
||||||
name: string
|
name: string
|
||||||
message_match_regex: string
|
message_match_regex: string
|
||||||
params: MetricParamMatcher[]
|
/** Names of already-declared `ParamDeclaration`s (see the Params panel) whose values become this metric's bucket labels. */
|
||||||
|
label_params: string[]
|
||||||
|
/** Name of an already-declared param (if any) whose value becomes this metric's per-event bucket value instead of a label. */
|
||||||
|
value_param?: string | null
|
||||||
/** Fixed labels applied on a match/non-match, on top of any captured params. */
|
/** 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
|
||||||
@@ -97,6 +90,7 @@ export type ColorScheme =
|
|||||||
| 'yellow'
|
| 'yellow'
|
||||||
| 'pink'
|
| 'pink'
|
||||||
| 'orange'
|
| 'orange'
|
||||||
|
| 'light_green'
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
color_scheme: ColorScheme
|
color_scheme: ColorScheme
|
||||||
@@ -251,6 +245,10 @@ export function fetchLogs(params: {
|
|||||||
endMs?: number
|
endMs?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
filter?: string
|
filter?: string
|
||||||
|
/** Ticket 045: an already-declared param name to order entries by, instead of the default (`ottyl_ts`-tracking) order. */
|
||||||
|
sortBy?: string
|
||||||
|
/** Ticket 045: only meaningful alongside `sortBy`. `true` (default) orders lines missing that param by `ottyl_seq`, appended after the sorted lines; `false` drops them. */
|
||||||
|
sortMissingFallback?: boolean
|
||||||
}): Promise<LogEntry[]> {
|
}): Promise<LogEntry[]> {
|
||||||
const search = new URLSearchParams()
|
const search = new URLSearchParams()
|
||||||
if (params.sinceSequence !== undefined) search.set('since_sequence', String(params.sinceSequence))
|
if (params.sinceSequence !== undefined) search.set('since_sequence', String(params.sinceSequence))
|
||||||
@@ -258,6 +256,8 @@ export function fetchLogs(params: {
|
|||||||
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
|
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
|
||||||
if (params.limit !== undefined) search.set('limit', String(params.limit))
|
if (params.limit !== undefined) search.set('limit', String(params.limit))
|
||||||
if (params.filter) search.set('filter', params.filter)
|
if (params.filter) search.set('filter', params.filter)
|
||||||
|
if (params.sortBy) search.set('sort_by', params.sortBy)
|
||||||
|
if (params.sortMissingFallback !== undefined) search.set('sort_missing_fallback', String(params.sortMissingFallback))
|
||||||
return getJson(`/api/logs?${search.toString()}`)
|
return getJson(`/api/logs?${search.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
// 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.
|
// A user-supplied style is any CSS file that defines every variable in `THEME_CSS_VARS` somewhere in
|
||||||
|
// a `:root { --c-*: ...; }` block (ticket 020/047: styles are selectable on equal footing with the
|
||||||
|
// built-ins, and there's no requirement that this block be the *only* thing the file contains — a
|
||||||
|
// style is free to also target any other selector, e.g. to restyle layout, spacing, or fonts, not
|
||||||
|
// just the palette). `useSettingsPanel` applies the selected one as an unscoped stylesheet only
|
||||||
|
// while no built-in `html.<scheme>` class is present, so it wins the cascade without rewriting the
|
||||||
|
// selector.
|
||||||
|
|
||||||
/** Canonical list of every `--c-*` variable every theme in `index.css` defines. */
|
/** Canonical list of every `--c-*` variable every theme in `index.css` defines. */
|
||||||
export const THEME_CSS_VARS = [
|
export const THEME_CSS_VARS = [
|
||||||
@@ -22,6 +28,8 @@ export const THEME_CSS_VARS = [
|
|||||||
'--c-accent',
|
'--c-accent',
|
||||||
'--c-accent-soft',
|
'--c-accent-soft',
|
||||||
'--c-accent-text',
|
'--c-accent-text',
|
||||||
|
'--c-accent-2',
|
||||||
|
'--c-accent-3',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type ThemeVars = Record<(typeof THEME_CSS_VARS)[number], string>
|
export type ThemeVars = Record<(typeof THEME_CSS_VARS)[number], string>
|
||||||
@@ -49,6 +57,8 @@ export const BUILTIN_THEMES: Record<string, ThemeVars> = {
|
|||||||
'--c-accent': '#0ea5e9',
|
'--c-accent': '#0ea5e9',
|
||||||
'--c-accent-soft': '#0369a1',
|
'--c-accent-soft': '#0369a1',
|
||||||
'--c-accent-text': '#7dd3fc',
|
'--c-accent-text': '#7dd3fc',
|
||||||
|
'--c-accent-2': '#a78bfa',
|
||||||
|
'--c-accent-3': '#4ade80',
|
||||||
},
|
},
|
||||||
light: {
|
light: {
|
||||||
'--c-surface-0': '#fafafa',
|
'--c-surface-0': '#fafafa',
|
||||||
@@ -71,6 +81,8 @@ export const BUILTIN_THEMES: Record<string, ThemeVars> = {
|
|||||||
'--c-accent': '#0284c7',
|
'--c-accent': '#0284c7',
|
||||||
'--c-accent-soft': '#7dd3fc',
|
'--c-accent-soft': '#7dd3fc',
|
||||||
'--c-accent-text': '#0369a1',
|
'--c-accent-text': '#0369a1',
|
||||||
|
'--c-accent-2': '#7c3aed',
|
||||||
|
'--c-accent-3': '#16a34a',
|
||||||
},
|
},
|
||||||
red: {
|
red: {
|
||||||
'--c-surface-0': 'hsl(0 32% 10%)',
|
'--c-surface-0': 'hsl(0 32% 10%)',
|
||||||
@@ -93,6 +105,8 @@ export const BUILTIN_THEMES: Record<string, ThemeVars> = {
|
|||||||
'--c-accent': 'hsl(180 70% 62%)',
|
'--c-accent': 'hsl(180 70% 62%)',
|
||||||
'--c-accent-soft': 'hsl(150 55% 34%)',
|
'--c-accent-soft': 'hsl(150 55% 34%)',
|
||||||
'--c-accent-text': 'hsl(180 75% 80%)',
|
'--c-accent-text': 'hsl(180 75% 80%)',
|
||||||
|
'--c-accent-2': 'hsl(90 65% 58%)',
|
||||||
|
'--c-accent-3': 'hsl(270 65% 68%)',
|
||||||
},
|
},
|
||||||
green: {
|
green: {
|
||||||
'--c-surface-0': 'hsl(140 32% 10%)',
|
'--c-surface-0': 'hsl(140 32% 10%)',
|
||||||
@@ -115,6 +129,8 @@ export const BUILTIN_THEMES: Record<string, ThemeVars> = {
|
|||||||
'--c-accent': 'hsl(320 70% 62%)',
|
'--c-accent': 'hsl(320 70% 62%)',
|
||||||
'--c-accent-soft': 'hsl(290 55% 34%)',
|
'--c-accent-soft': 'hsl(290 55% 34%)',
|
||||||
'--c-accent-text': 'hsl(320 75% 80%)',
|
'--c-accent-text': 'hsl(320 75% 80%)',
|
||||||
|
'--c-accent-2': 'hsl(230 65% 62%)',
|
||||||
|
'--c-accent-3': 'hsl(50 65% 58%)',
|
||||||
},
|
},
|
||||||
blue: {
|
blue: {
|
||||||
'--c-surface-0': 'hsl(210 32% 10%)',
|
'--c-surface-0': 'hsl(210 32% 10%)',
|
||||||
@@ -137,6 +153,8 @@ export const BUILTIN_THEMES: Record<string, ThemeVars> = {
|
|||||||
'--c-accent': 'hsl(30 70% 62%)',
|
'--c-accent': 'hsl(30 70% 62%)',
|
||||||
'--c-accent-soft': 'hsl(0 55% 34%)',
|
'--c-accent-soft': 'hsl(0 55% 34%)',
|
||||||
'--c-accent-text': 'hsl(30 75% 80%)',
|
'--c-accent-text': 'hsl(30 75% 80%)',
|
||||||
|
'--c-accent-2': 'hsl(300 65% 68%)',
|
||||||
|
'--c-accent-3': 'hsl(120 65% 55%)',
|
||||||
},
|
},
|
||||||
yellow: {
|
yellow: {
|
||||||
'--c-surface-0': 'hsl(50 32% 10%)',
|
'--c-surface-0': 'hsl(50 32% 10%)',
|
||||||
@@ -159,6 +177,8 @@ export const BUILTIN_THEMES: Record<string, ThemeVars> = {
|
|||||||
'--c-accent': 'hsl(230 70% 62%)',
|
'--c-accent': 'hsl(230 70% 62%)',
|
||||||
'--c-accent-soft': 'hsl(200 55% 34%)',
|
'--c-accent-soft': 'hsl(200 55% 34%)',
|
||||||
'--c-accent-text': 'hsl(230 75% 80%)',
|
'--c-accent-text': 'hsl(230 75% 80%)',
|
||||||
|
'--c-accent-2': 'hsl(140 65% 55%)',
|
||||||
|
'--c-accent-3': 'hsl(320 65% 68%)',
|
||||||
},
|
},
|
||||||
pink: {
|
pink: {
|
||||||
'--c-surface-0': 'hsl(330 32% 10%)',
|
'--c-surface-0': 'hsl(330 32% 10%)',
|
||||||
@@ -181,6 +201,8 @@ export const BUILTIN_THEMES: Record<string, ThemeVars> = {
|
|||||||
'--c-accent': 'hsl(150 70% 62%)',
|
'--c-accent': 'hsl(150 70% 62%)',
|
||||||
'--c-accent-soft': 'hsl(120 55% 34%)',
|
'--c-accent-soft': 'hsl(120 55% 34%)',
|
||||||
'--c-accent-text': 'hsl(150 75% 80%)',
|
'--c-accent-text': 'hsl(150 75% 80%)',
|
||||||
|
'--c-accent-2': 'hsl(60 65% 58%)',
|
||||||
|
'--c-accent-3': 'hsl(240 65% 68%)',
|
||||||
},
|
},
|
||||||
orange: {
|
orange: {
|
||||||
'--c-surface-0': 'hsl(30 32% 10%)',
|
'--c-surface-0': 'hsl(30 32% 10%)',
|
||||||
@@ -203,6 +225,32 @@ export const BUILTIN_THEMES: Record<string, ThemeVars> = {
|
|||||||
'--c-accent': 'hsl(210 70% 62%)',
|
'--c-accent': 'hsl(210 70% 62%)',
|
||||||
'--c-accent-soft': 'hsl(180 55% 34%)',
|
'--c-accent-soft': 'hsl(180 55% 34%)',
|
||||||
'--c-accent-text': 'hsl(210 75% 80%)',
|
'--c-accent-text': 'hsl(210 75% 80%)',
|
||||||
|
'--c-accent-2': 'hsl(120 65% 55%)',
|
||||||
|
'--c-accent-3': 'hsl(300 65% 68%)',
|
||||||
|
},
|
||||||
|
light_green: {
|
||||||
|
'--c-surface-0': 'hsl(140 40% 96%)',
|
||||||
|
'--c-surface-1': 'hsl(140 35% 92%)',
|
||||||
|
'--c-surface-2': 'hsl(140 30% 85%)',
|
||||||
|
'--c-border': 'hsl(140 25% 80%)',
|
||||||
|
'--c-border-soft': 'hsl(140 22% 70%)',
|
||||||
|
'--c-border-faint': 'hsl(140 35% 92%)',
|
||||||
|
'--c-border-strong': 'hsl(140 20% 40%)',
|
||||||
|
'--c-text': 'hsl(140 30% 12%)',
|
||||||
|
'--c-text-secondary': 'hsl(140 20% 25%)',
|
||||||
|
'--c-text-muted': 'hsl(140 15% 40%)',
|
||||||
|
'--c-text-subtle': 'hsl(140 10% 50%)',
|
||||||
|
'--c-text-faint': 'hsl(140 10% 65%)',
|
||||||
|
'--c-text-faintest': 'hsl(140 12% 80%)',
|
||||||
|
'--c-danger-bg': '#fee2e2',
|
||||||
|
'--c-danger-border': '#fca5a5',
|
||||||
|
'--c-danger-text': '#b91c1c',
|
||||||
|
'--c-danger-text-strong': '#991b1b',
|
||||||
|
'--c-accent': 'hsl(320 70% 40%)',
|
||||||
|
'--c-accent-soft': 'hsl(290 60% 85%)',
|
||||||
|
'--c-accent-text': 'hsl(320 75% 28%)',
|
||||||
|
'--c-accent-2': 'hsl(230 70% 45%)',
|
||||||
|
'--c-accent-3': 'hsl(50 70% 38%)',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,17 @@ import {
|
|||||||
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', 'red', 'green', 'blue', 'yellow', 'pink', 'orange'] as const
|
const THEME_CLASSES = [
|
||||||
|
'light',
|
||||||
|
'dark',
|
||||||
|
'red',
|
||||||
|
'green',
|
||||||
|
'blue',
|
||||||
|
'yellow',
|
||||||
|
'pink',
|
||||||
|
'orange',
|
||||||
|
'light_green',
|
||||||
|
] 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.
|
// Reflects `colorScheme` onto `<html>` as a class + `data-color-scheme`; when a custom theme is active, no built-in class is added at all, since a class selector would out-specificity its injected `:root` override (see `applyCustomThemeCss`) and mask it.
|
||||||
function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean) {
|
function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean) {
|
||||||
@@ -143,7 +153,8 @@ export function useSettingsPanel() {
|
|||||||
[guarded],
|
[guarded],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Every entry is always a selectable theme (ticket 020), so there's a single import path.
|
// Every entry is always a selectable style, on equal footing with the built-in colour schemes
|
||||||
|
// (ticket 020 unified the data model; ticket 047 unified the UI), so there's a single import path.
|
||||||
const importCssEntry = useCallback(
|
const importCssEntry = useCallback(
|
||||||
(filename: string, content: string) =>
|
(filename: string, content: string) =>
|
||||||
guarded(async () => {
|
guarded(async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user