Strip all comments outside src/consts.rs
Removes doc comments, module comments, and inline comments across the backend so the only comments left in the codebase are the brief ones on the constants they document. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
284e3562a2
commit
b6401c1c3c
@@ -1,14 +1,3 @@
|
|||||||
//! Alert rules: definitions, trigger evaluation, and ntfy delivery.
|
|
||||||
//!
|
|
||||||
//! 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::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
@@ -41,19 +30,16 @@ pub struct AlertRule {
|
|||||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||||
pub enum AlertCondition {
|
pub enum AlertCondition {
|
||||||
Immediate { query: String },
|
Immediate { query: String },
|
||||||
/// Fires when `query`'s match count within a rolling `window_ms` crosses `threshold` (via `op`).
|
|
||||||
Threshold {
|
Threshold {
|
||||||
query: String,
|
query: String,
|
||||||
op: CompareOp,
|
op: CompareOp,
|
||||||
threshold: f64,
|
threshold: f64,
|
||||||
window_ms: i64,
|
window_ms: i64,
|
||||||
},
|
},
|
||||||
/// Seen values live only in memory — a restart forgets them, so previously-seen values can fire again; a deliberate simplicity tradeoff, not an oversight.
|
|
||||||
NewValue {
|
NewValue {
|
||||||
field_regex: String,
|
field_regex: String,
|
||||||
capture_group: String,
|
capture_group: String,
|
||||||
},
|
},
|
||||||
/// A zero `count_b` skips evaluation for that line rather than treating the ratio as infinite.
|
|
||||||
Ratio {
|
Ratio {
|
||||||
query_a: String,
|
query_a: String,
|
||||||
query_b: String,
|
query_b: String,
|
||||||
@@ -86,7 +72,6 @@ impl From<io::Error> for AlertError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hashes the name and current time rather than pulling in a `uuid` dependency.
|
|
||||||
fn generate_alert_rule_id(name: &str) -> String {
|
fn generate_alert_rule_id(name: &str) -> String {
|
||||||
let mut hasher = DefaultHasher::new();
|
let mut hasher = DefaultHasher::new();
|
||||||
name.hash(&mut hasher);
|
name.hash(&mut hasher);
|
||||||
@@ -145,13 +130,6 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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_window: RateWindow,
|
threshold_window: RateWindow,
|
||||||
@@ -163,10 +141,6 @@ pub struct AlertRuntimeState {
|
|||||||
seen_values: HashSet<String>,
|
seen_values: HashSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds a `RateWindow` seeded from already-ingested logs matching `query`, bounded to
|
|
||||||
/// `window_ms` before `now` (not the full log history) so the cost stays proportional to the
|
|
||||||
/// window rather than everything ever ingested. `before_sequence` excludes the line currently
|
|
||||||
/// being evaluated, which the caller pushes separately if it matches.
|
|
||||||
fn backfill_window(
|
fn backfill_window(
|
||||||
index: &BTreeMap<IndexKey, DataValue>,
|
index: &BTreeMap<IndexKey, DataValue>,
|
||||||
query: &str,
|
query: &str,
|
||||||
@@ -184,7 +158,6 @@ fn backfill_window(
|
|||||||
RateWindow::backfill(matching_timestamps, now, window_ms)
|
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).
|
|
||||||
fn eval_alert_query(query: &str, sequence: u64, timestamp: Timestamp, text: &str) -> Result<bool, String> {
|
fn eval_alert_query(query: &str, sequence: u64, timestamp: Timestamp, text: &str) -> Result<bool, String> {
|
||||||
let expr = filter_parser::parse(query).map_err(|err| err.to_string())?;
|
let expr = filter_parser::parse(query).map_err(|err| err.to_string())?;
|
||||||
eval_alert_expr(&expr, sequence, timestamp, text)
|
eval_alert_expr(&expr, sequence, timestamp, text)
|
||||||
@@ -237,7 +210,6 @@ fn eval_alert_expr(expr: &FilterExpr, sequence: u64, timestamp: Timestamp, text:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A malformed query (bad regex, unsupported metric comparison) is logged and simply never matches, rather than panicking.
|
|
||||||
pub fn evaluate_alerts(
|
pub fn evaluate_alerts(
|
||||||
rules: &[AlertRule],
|
rules: &[AlertRule],
|
||||||
states: &mut HashMap<String, AlertRuntimeState>,
|
states: &mut HashMap<String, AlertRuntimeState>,
|
||||||
@@ -344,7 +316,6 @@ fn evaluate_condition(
|
|||||||
let count_a = state.ratio_a_window.count() as f64;
|
let count_a = state.ratio_a_window.count() as f64;
|
||||||
let count_b = state.ratio_b_window.count() 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.
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let observed = count_a / count_b;
|
let observed = count_a / count_b;
|
||||||
@@ -357,9 +328,6 @@ fn evaluate_condition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delivers to the single, process-wide ntfy sink configured in Settings. Errors are only logged
|
|
||||||
/// (no retry queue); spawned off the ingest hot path so a slow/unreachable sink never blocks ingestion.
|
|
||||||
/// A silently no-op'd delivery (no topic configured) is expected — a rule can be defined before the sink is.
|
|
||||||
pub async fn deliver_for_rule(rule_name: String, message: String, topic: Option<String>, server: Option<String>) {
|
pub async fn deliver_for_rule(rule_name: String, message: String, topic: Option<String>, server: Option<String>) {
|
||||||
let Some(topic) = topic else {
|
let Some(topic) = topic else {
|
||||||
return;
|
return;
|
||||||
@@ -370,7 +338,6 @@ pub async fn deliver_for_rule(rule_name: String, message: String, topic: Option<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Standalone and `AlertRule`-free so a "send test notification" endpoint can call it directly.
|
|
||||||
pub async fn deliver_ntfy(server: &str, topic: &str, message: &str) -> Result<(), String> {
|
pub async fn deliver_ntfy(server: &str, topic: &str, message: &str) -> Result<(), String> {
|
||||||
let url = format!("{}/{}", server.trim_end_matches('/'), topic);
|
let url = format!("{}/{}", server.trim_end_matches('/'), topic);
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
@@ -506,7 +473,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 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.
|
|
||||||
let fired = evaluate_alerts(&[rule], &mut states, &index, 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());
|
||||||
}
|
}
|
||||||
@@ -527,7 +493,6 @@ mod tests {
|
|||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
||||||
assert!(evaluate_alerts(&[rule.clone()], &mut states, &index, 0, Timestamp::from_millis(0), "error").is_empty());
|
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.
|
|
||||||
assert!(evaluate_alerts(&[rule], &mut states, &index, 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());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,15 +533,11 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
||||||
// Only an "error" so far; no "request" yet, so denominator is 0 -> skip.
|
|
||||||
assert!(evaluate_alerts(&[rule.clone()], &mut states, &index, 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.
|
|
||||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 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.
|
|
||||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 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.
|
|
||||||
let fired = evaluate_alerts(&[rule], &mut states, &index, 3, Timestamp::from_millis(3), "request");
|
let fired = evaluate_alerts(&[rule], &mut states, &index, 3, Timestamp::from_millis(3), "request");
|
||||||
assert!(fired.is_empty());
|
assert!(fired.is_empty());
|
||||||
}
|
}
|
||||||
@@ -606,7 +567,6 @@ mod tests {
|
|||||||
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();
|
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
||||||
// Should log a warning and simply not fire, never panic.
|
|
||||||
let fired = evaluate_alerts(&[rule], &mut states, &index, 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());
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-124
@@ -26,15 +26,12 @@ use tokio::sync::oneshot;
|
|||||||
|
|
||||||
static WEBAPP_DIST: EmbedDir = fs_embed::fs_embed!("webapp/dist");
|
static WEBAPP_DIST: EmbedDir = fs_embed::fs_embed!("webapp/dist");
|
||||||
|
|
||||||
/// Embedded so an agent that has only discovered a port (no filesystem access into the ottyl
|
|
||||||
/// repo/binary) can still fetch it — see `SKILL.md` at the repo root and ticket 069.
|
|
||||||
const SKILL_MD: &str = include_str!("../SKILL.md");
|
const SKILL_MD: &str = include_str!("../SKILL.md");
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ApiState {
|
struct ApiState {
|
||||||
engine: EngineHandle,
|
engine: EngineHandle,
|
||||||
settings: SettingsHandle,
|
settings: SettingsHandle,
|
||||||
/// Imported CSS files are stored alongside this path's parent directory (see `ApiState::css_dir`).
|
|
||||||
settings_path: Arc<PathBuf>,
|
settings_path: Arc<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +43,6 @@ impl ApiState {
|
|||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 mut settings = self.settings.lock().unwrap();
|
let mut settings = self.settings.lock().unwrap();
|
||||||
let Some(profile_name) = settings.active_profile.clone() else {
|
let Some(profile_name) = settings.active_profile.clone() else {
|
||||||
@@ -60,11 +56,6 @@ impl ApiState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Counterpart to `persist_param_declaration` for `DELETE /api/params` (ticket 052): empties the
|
|
||||||
/// active profile's stored params so the just-cleared set doesn't reappear from `settings.json`
|
|
||||||
/// on next load. Same best-effort semantics — a no-op if no profile is active, I/O failures only
|
|
||||||
/// logged, since the engine-side clear (already applied by the time this runs) is the source of
|
|
||||||
/// truth for the running process.
|
|
||||||
fn persist_clear_params(&self) {
|
fn persist_clear_params(&self) {
|
||||||
let mut settings = self.settings.lock().unwrap();
|
let mut settings = self.settings.lock().unwrap();
|
||||||
let Some(profile_name) = settings.active_profile.clone() else {
|
let Some(profile_name) = settings.active_profile.clone() else {
|
||||||
@@ -77,11 +68,6 @@ impl ApiState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same as `persist_param_declaration`, for metric declarations. Also promotes any of the
|
|
||||||
/// metric's referenced params that only exist as auto-derived engine state (see
|
|
||||||
/// `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) {
|
async fn persist_metric_declaration(&self, request: &DeclareMetricRequest) {
|
||||||
let profile_name = {
|
let profile_name = {
|
||||||
let settings = self.settings.lock().unwrap();
|
let settings = self.settings.lock().unwrap();
|
||||||
@@ -109,7 +95,6 @@ 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.
|
|
||||||
async fn persist_metric_declaration_update(&self, old_name: &str, request: &DeclareMetricRequest) {
|
async fn persist_metric_declaration_update(&self, old_name: &str, request: &DeclareMetricRequest) {
|
||||||
let profile_name = {
|
let profile_name = {
|
||||||
let settings = self.settings.lock().unwrap();
|
let settings = self.settings.lock().unwrap();
|
||||||
@@ -138,9 +123,6 @@ impl ApiState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort, matching `persist_param_declaration`'s intent — a no-op if no profile is
|
|
||||||
/// active, and I/O failures are only logged, since the engine's removal is already the source
|
|
||||||
/// of truth for what the live process shows.
|
|
||||||
fn persist_metric_declaration_removal(&self, name: &str) {
|
fn persist_metric_declaration_removal(&self, name: &str) {
|
||||||
let mut settings = self.settings.lock().unwrap();
|
let mut settings = self.settings.lock().unwrap();
|
||||||
let Some(profile_name) = settings.active_profile.clone() else {
|
let Some(profile_name) = settings.active_profile.clone() else {
|
||||||
@@ -203,7 +185,6 @@ async fn get_skill_md() -> impl IntoResponse {
|
|||||||
([(header::CONTENT_TYPE, "text/markdown; charset=utf-8")], SKILL_MD)
|
([(header::CONTENT_TYPE, "text/markdown; charset=utf-8")], SKILL_MD)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Falls back to `index.html` for unmatched paths so client-side routing works.
|
|
||||||
async fn serve_webapp(uri: axum::http::Uri) -> Response {
|
async fn serve_webapp(uri: axum::http::Uri) -> Response {
|
||||||
let path = uri.path().trim_start_matches('/');
|
let path = uri.path().trim_start_matches('/');
|
||||||
let file = WEBAPP_DIST
|
let file = WEBAPP_DIST
|
||||||
@@ -234,13 +215,6 @@ fn mime_for_extension(extension: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ticket 075: every read query is served straight off a `left_right::ReadHandle` obtained from
|
|
||||||
/// `engine.reader_factory` — wait-free, and never funneled through the actor's mpsc queue, so a slow
|
|
||||||
/// query here can no longer block an unrelated cheap one. The one exception is a sorted `Logs` query
|
|
||||||
/// the first time its `sort_by` is used: no helper index exists yet for it, and only the actor (the
|
|
||||||
/// sole owner of the write side) can build one, so this sends `EnsureSortHelperIndex`, awaits the
|
|
||||||
/// ack, then re-enters a fresh `ReadHandle` (now caught up) before proceeding — every other read,
|
|
||||||
/// and every sorted query once the helper already exists, never reaches the actor at all.
|
|
||||||
async fn ask(engine: &EngineHandle, query: EngineQuery) -> Result<EngineResponse, StatusCode> {
|
async fn ask(engine: &EngineHandle, query: EngineQuery) -> Result<EngineResponse, StatusCode> {
|
||||||
let read_handle = engine.reader_factory.handle();
|
let read_handle = engine.reader_factory.handle();
|
||||||
if let Some(sort_by) = crate::engine::query_sort_by(&query) {
|
if let Some(sort_by) = crate::engine::query_sort_by(&query) {
|
||||||
@@ -262,13 +236,6 @@ async fn ask(engine: &EngineHandle, query: EngineQuery) -> Result<EngineResponse
|
|||||||
Ok(crate::engine::handle_query_ro(&guard, query))
|
Ok(crate::engine::handle_query_ro(&guard, query))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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(
|
async fn promoted_param_records(
|
||||||
engine: &EngineHandle,
|
engine: &EngineHandle,
|
||||||
existing_params: &[ParamDeclarationRecord],
|
existing_params: &[ParamDeclarationRecord],
|
||||||
@@ -342,13 +309,6 @@ async fn get_stats(State(state): State<ApiState>) -> impl IntoResponse {
|
|||||||
async fn list_params(State(state): State<ApiState>) -> impl IntoResponse {
|
async fn list_params(State(state): State<ApiState>) -> impl IntoResponse {
|
||||||
match ask(&state.engine, EngineQuery::ParamDeclarations).await {
|
match ask(&state.engine, EngineQuery::ParamDeclarations).await {
|
||||||
Ok(EngineResponse::ParamDeclarations(mut declarations)) => {
|
Ok(EngineResponse::ParamDeclarations(mut declarations)) => {
|
||||||
// `ottyl_seq`/`ottyl_ts`/`ottyl_fmt` (ticket 055) are computed line-locally rather than
|
|
||||||
// stored as index `ParamDeclaration` entries (see `crate::engine::declare_param`'s
|
|
||||||
// rejection of these names), so they're merged in here purely for discoverability
|
|
||||||
// rather than via `param_declarations_range`. This is deliberately a merge only in this
|
|
||||||
// one HTTP-facing listing, not in `EngineQuery::ParamDeclarations` itself — other
|
|
||||||
// internal callers of that query (promoting a metric's referenced params, replaying
|
|
||||||
// declarations on profile switch) must keep seeing only real, persistable entries.
|
|
||||||
declarations.extend(reserved_param_declaration_dtos());
|
declarations.extend(reserved_param_declaration_dtos());
|
||||||
Json(declarations).into_response()
|
Json(declarations).into_response()
|
||||||
}
|
}
|
||||||
@@ -357,10 +317,6 @@ async fn list_params(State(state): State<ApiState>) -> impl IntoResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/params`-only synthesized entries for the three reserved names
|
|
||||||
/// (`crate::engine::OTTYL_RESERVED_PARAM_NAMES`) — see `list_params`. `message_match_regex`/
|
|
||||||
/// `capture_group` are empty since these are never text-extracted; `reserved: true` marks them as
|
|
||||||
/// non-deletable/non-editable, distinct from `auto_derived`.
|
|
||||||
fn reserved_param_declaration_dtos() -> [ParamDeclarationDto; 3] {
|
fn reserved_param_declaration_dtos() -> [ParamDeclarationDto; 3] {
|
||||||
[
|
[
|
||||||
ParamDeclarationDto {
|
ParamDeclarationDto {
|
||||||
@@ -421,8 +377,6 @@ async fn declare_param(
|
|||||||
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,
|
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,
|
auto_derived: false,
|
||||||
reserved: false,
|
reserved: false,
|
||||||
};
|
};
|
||||||
@@ -433,11 +387,6 @@ async fn declare_param(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/params` (ticket 052): clears every declared param (manual and auto-derived) and
|
|
||||||
/// immediately re-derives whatever `auto_declare_params` can still find in log text the engine
|
|
||||||
/// still retains. The response body's `logs_considered` tells the caller how many retained lines
|
|
||||||
/// were re-scanned, so the UI can make clear that lines evicted by `max_logs` before this ran
|
|
||||||
/// can't regain their params — this is expected behavior, not a partial failure.
|
|
||||||
async fn clear_params(State(state): State<ApiState>) -> impl IntoResponse {
|
async fn clear_params(State(state): State<ApiState>) -> impl IntoResponse {
|
||||||
let (respond_to, response) = oneshot::channel();
|
let (respond_to, response) = oneshot::channel();
|
||||||
if state.engine.tx.send(EngineMessage::ClearParams { respond_to }).await.is_err() {
|
if state.engine.tx.send(EngineMessage::ClearParams { respond_to }).await.is_err() {
|
||||||
@@ -493,7 +442,6 @@ async fn declare_metric(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the metric declaration named by the URL path in place, reusing `declare_metric`'s validation (via `engine::update_metric`) rather than creating a second declaration or erroring on a duplicate-name conflict. `body.name` may differ from the path segment to rename the metric, so long as no other declaration already uses the new name.
|
|
||||||
async fn update_metric(
|
async fn update_metric(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
AxumPath(name): AxumPath<String>,
|
AxumPath(name): AxumPath<String>,
|
||||||
@@ -528,12 +476,6 @@ async fn update_metric(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes the metric declaration named by the URL path — both from the live engine index and,
|
|
||||||
/// if a profile is active, from persisted settings (ticket 051), so it doesn't reappear on
|
|
||||||
/// restart. Its recorded bucket-value history is purged along with it (see the comment on
|
|
||||||
/// `EngineMessage::RemoveMetricDeclaration`'s handler in `engine.rs`), rather than left orphaned,
|
|
||||||
/// so a dashboard panel or PromQL query still referencing the metric by name fails with a clear
|
|
||||||
/// "unknown metric" error instead of silently keeping stale data alive.
|
|
||||||
async fn delete_metric(
|
async fn delete_metric(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
AxumPath(name): AxumPath<String>,
|
AxumPath(name): AxumPath<String>,
|
||||||
@@ -558,7 +500,6 @@ async fn delete_metric(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Malformed pairs (no `=`) are skipped rather than rejected.
|
|
||||||
fn parse_labels_filter(raw: &str) -> BTreeMap<String, String> {
|
fn parse_labels_filter(raw: &str) -> BTreeMap<String, String> {
|
||||||
raw.split(',')
|
raw.split(',')
|
||||||
.filter_map(|pair| pair.split_once('='))
|
.filter_map(|pair| pair.split_once('='))
|
||||||
@@ -639,7 +580,6 @@ async fn import_css(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content is never copied, and the entry can never be deleted via `DELETE /api/settings/css/:id`.
|
|
||||||
async fn link_css(
|
async fn link_css(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
Json(body): Json<LinkCssRequest>,
|
Json(body): Json<LinkCssRequest>,
|
||||||
@@ -669,7 +609,6 @@ async fn remove_css(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rejects a linked entry with 400 — there's no local content; the frontend should use `path_or_url` directly.
|
|
||||||
async fn get_css_content(
|
async fn get_css_content(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
AxumPath(id): AxumPath<String>,
|
AxumPath(id): AxumPath<String>,
|
||||||
@@ -730,7 +669,6 @@ async fn delete_alert_rule(State(state): State<ApiState>, AxumPath(id): AxumPath
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delivers a test notification without a saved rule so the sink can be verified before saving.
|
|
||||||
async fn test_alert_notification(Json(body): Json<TestAlertNotificationRequest>) -> impl IntoResponse {
|
async fn test_alert_notification(Json(body): Json<TestAlertNotificationRequest>) -> impl IntoResponse {
|
||||||
let server = body.server.unwrap_or_else(|| "https://ntfy.sh".to_string());
|
let server = body.server.unwrap_or_else(|| "https://ntfy.sh".to_string());
|
||||||
match deliver_ntfy(&server, &body.topic, "Test notification from ottyl").await {
|
match deliver_ntfy(&server, &body.topic, "Test notification from ottyl").await {
|
||||||
@@ -739,13 +677,11 @@ async fn test_alert_notification(Json(body): Json<TestAlertNotificationRequest>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CSS entries have their own endpoint (`GET /api/settings/css`).
|
|
||||||
async fn get_settings(State(state): State<ApiState>) -> impl IntoResponse {
|
async fn get_settings(State(state): State<ApiState>) -> impl IntoResponse {
|
||||||
let settings = state.settings.lock().unwrap();
|
let settings = state.settings.lock().unwrap();
|
||||||
Json(SettingsDto::from(&*settings)).into_response()
|
Json(SettingsDto::from(&*settings)).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `max_logs: null` clears the cap; a changed value is pushed to the engine so it applies without a restart.
|
|
||||||
async fn update_settings(
|
async fn update_settings(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
Json(body): Json<UpdateSettingsRequest>,
|
Json(body): Json<UpdateSettingsRequest>,
|
||||||
@@ -780,9 +716,6 @@ async fn update_settings(
|
|||||||
.tx
|
.tx
|
||||||
.send(EngineMessage::SetMaxLogs(new_max_logs.1))
|
.send(EngineMessage::SetMaxLogs(new_max_logs.1))
|
||||||
.await;
|
.await;
|
||||||
// `SetMaxLogs` has no response to await on its own (ticket 075: it's fire-and-forget, same as
|
|
||||||
// `Ingest`), but this endpoint's caller reasonably expects a subsequent read to already
|
|
||||||
// reflect the new cap's eviction — barrier so it does.
|
|
||||||
state.engine.barrier().await;
|
state.engine.barrier().await;
|
||||||
}
|
}
|
||||||
Json(new_max_logs.0).into_response()
|
Json(new_max_logs.0).into_response()
|
||||||
@@ -817,7 +750,6 @@ async fn create_profile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If it was the active profile, `active_profile` is cleared, but the engine's already-declared params/metrics keep running until the next restart.
|
|
||||||
async fn delete_profile(
|
async fn delete_profile(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
AxumPath(name): AxumPath<String>,
|
AxumPath(name): AxumPath<String>,
|
||||||
@@ -829,10 +761,6 @@ async fn delete_profile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -972,7 +900,6 @@ mod tests {
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
/// Spins up a fresh engine + router pair backed by a temp settings file.
|
|
||||||
fn test_app() -> (Router, EngineHandle, SettingsHandle, tempfile::TempDir) {
|
fn test_app() -> (Router, EngineHandle, SettingsHandle, tempfile::TempDir) {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let settings_path = dir.path().join("settings.json");
|
let settings_path = dir.path().join("settings.json");
|
||||||
@@ -1066,7 +993,6 @@ mod tests {
|
|||||||
let status = delete_request(&app, "/api/settings/alerts/does-not-exist").await;
|
let status = delete_request(&app, "/api/settings/alerts/does-not-exist").await;
|
||||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
// AlertError::NotFound maps to a plain-text (not JSON) 404 body, so check status directly instead of via send_json.
|
|
||||||
let request = Request::builder()
|
let request = Request::builder()
|
||||||
.method("PUT")
|
.method("PUT")
|
||||||
.uri("/api/settings/alerts/does-not-exist")
|
.uri("/api/settings/alerts/does-not-exist")
|
||||||
@@ -1104,10 +1030,6 @@ mod tests {
|
|||||||
assert_eq!(body["capture_group"], "level");
|
assert_eq!(body["capture_group"], "level");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ticket 052's core acceptance criteria: `DELETE /api/params` removes every declared param
|
|
||||||
/// (manual and auto-derived), that removal survives a reload of persisted settings, and —
|
|
||||||
/// without any new line being ingested — the retained `IngestedLog` entries immediately
|
|
||||||
/// re-derive a fresh (auto-derived) param set.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn clearing_params_persists_and_reextracts_from_retained_logs() {
|
async fn clearing_params_persists_and_reextracts_from_retained_logs() {
|
||||||
let (app, engine, settings_handle, dir) = test_app();
|
let (app, engine, settings_handle, dir) = test_app();
|
||||||
@@ -1119,14 +1041,10 @@ mod tests {
|
|||||||
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
|
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// A manual declaration plus an auto-derived one (from ingesting a logfmt line before "path" is declared).
|
|
||||||
declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await;
|
declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await;
|
||||||
ingest_lines(&engine, 0, &["request path=/a", "level=info x=1"]).await;
|
ingest_lines(&engine, 0, &["request path=/a", "level=info x=1"]).await;
|
||||||
|
|
||||||
let (_, before) = get_json(&app, "/api/params").await;
|
let (_, before) = get_json(&app, "/api/params").await;
|
||||||
// Ticket 055 merges the three non-deletable `ottyl_*` reserved names into this listing
|
|
||||||
// too; `DELETE /api/params` (ticket 052) only ever clears real declarations, so the
|
|
||||||
// reserved ones must be excluded from the "everything got removed" count below.
|
|
||||||
let before_declared: Vec<&str> = before
|
let before_declared: Vec<&str> = before
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1141,7 +1059,6 @@ mod tests {
|
|||||||
let (status, result) = send_json(&app, "DELETE", "/api/params", json!({})).await;
|
let (status, result) = send_json(&app, "DELETE", "/api/params", json!({})).await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
assert_eq!(result["removed"], before_declared.len() as u64);
|
assert_eq!(result["removed"], before_declared.len() as u64);
|
||||||
// Re-derived without any new ingest — same three keys come back, now all auto-derived.
|
|
||||||
assert_eq!(result["reextracted"], 3);
|
assert_eq!(result["reextracted"], 3);
|
||||||
assert_eq!(result["logs_considered"], 2);
|
assert_eq!(result["logs_considered"], 2);
|
||||||
|
|
||||||
@@ -1151,8 +1068,6 @@ mod tests {
|
|||||||
assert_eq!(after_declared.len(), 3);
|
assert_eq!(after_declared.len(), 3);
|
||||||
assert!(after_declared.iter().all(|p| p["auto_derived"] == true));
|
assert!(after_declared.iter().all(|p| p["auto_derived"] == true));
|
||||||
|
|
||||||
// The clear persisted: "path" (the only manually-declared, profile-stored param) doesn't
|
|
||||||
// reappear from settings.json on a fresh load.
|
|
||||||
let reloaded_settings = Settings::load(&settings_path);
|
let reloaded_settings = Settings::load(&settings_path);
|
||||||
let profile = reloaded_settings.profile("web").unwrap();
|
let profile = reloaded_settings.profile("web").unwrap();
|
||||||
assert!(profile.params.is_empty());
|
assert!(profile.params.is_empty());
|
||||||
@@ -1177,9 +1092,6 @@ 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) {
|
async fn declare_test_param(app: &Router, name: &str, pattern: &str, capture_group: &str, kind: &str) {
|
||||||
let (status, _body) = send_json(
|
let (status, _body) = send_json(
|
||||||
app,
|
app,
|
||||||
@@ -1271,7 +1183,6 @@ mod tests {
|
|||||||
assert_eq!(body["name"], "requests");
|
assert_eq!(body["name"], "requests");
|
||||||
assert_eq!(body["label_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.
|
|
||||||
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();
|
||||||
@@ -1357,8 +1268,6 @@ mod tests {
|
|||||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acceptance criterion: deleting a metric persists the removal into the active profile too, so
|
|
||||||
/// it doesn't reappear from `settings.json` on the next restart.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn deleting_metric_persists_removal_and_it_does_not_reappear_after_restart() {
|
async fn deleting_metric_persists_removal_and_it_does_not_reappear_after_restart() {
|
||||||
let (app, _engine, settings_handle, dir) = test_app();
|
let (app, _engine, settings_handle, dir) = test_app();
|
||||||
@@ -1389,10 +1298,6 @@ mod tests {
|
|||||||
assert!(Settings::load(&settings_path).profile("web").unwrap().metrics.is_empty());
|
assert!(Settings::load(&settings_path).profile("web").unwrap().metrics.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acceptance criterion: a dashboard panel (PromQL query) that still references a metric by
|
|
||||||
/// name after it's been deleted fails with a readable "unknown metric" error, rather than a
|
|
||||||
/// panic or a silently-empty series — seeded here by declaring, ingesting into, deleting, then
|
|
||||||
/// re-querying the same metric name.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn promql_query_against_deleted_metric_fails_with_readable_error() {
|
async fn promql_query_against_deleted_metric_fails_with_readable_error() {
|
||||||
let (app, engine, _settings, _dir) = test_app();
|
let (app, engine, _settings, _dir) = test_app();
|
||||||
@@ -1420,7 +1325,6 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
engine.barrier().await;
|
engine.barrier().await;
|
||||||
|
|
||||||
// Sanity check: the dashboard-panel-style query works before deletion.
|
|
||||||
let (status, _body) = get_json(&app, "/api/query?promql=requests").await;
|
let (status, _body) = get_json(&app, "/api/query?promql=requests").await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
|
||||||
@@ -1478,11 +1382,9 @@ mod tests {
|
|||||||
let values = body.as_array().unwrap();
|
let values = body.as_array().unwrap();
|
||||||
assert_eq!(values.len(), 2);
|
assert_eq!(values.len(), 2);
|
||||||
for value in values {
|
for value in values {
|
||||||
// MetricPrimitive derives Serialize as an externally-tagged enum, so JSON is {"String": "/a"}.
|
|
||||||
assert_eq!(value["labels"]["path"]["String"], "/a");
|
assert_eq!(value["labels"]["path"]["String"], "/a");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bucket values group by label (BTreeMap-ordered "/a" before "/b"), not by sequence, so limit=1 picks sequence 2 from "/a" ahead of sequence 1 from "/b".
|
|
||||||
let (status, body) = get_json(&app, "/api/metrics/requests?since_sequence=0&limit=1").await;
|
let (status, body) = get_json(&app, "/api/metrics/requests?since_sequence=0&limit=1").await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
let values = body.as_array().unwrap();
|
let values = body.as_array().unwrap();
|
||||||
@@ -1548,7 +1450,6 @@ mod tests {
|
|||||||
async fn declaring_param_persists_into_active_profile() {
|
async fn declaring_param_persists_into_active_profile() {
|
||||||
let (app, _engine, settings_handle, dir) = test_app();
|
let (app, _engine, settings_handle, dir) = test_app();
|
||||||
|
|
||||||
// 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 mut settings = settings_handle.lock().unwrap();
|
let mut settings = settings_handle.lock().unwrap();
|
||||||
@@ -1576,11 +1477,6 @@ mod tests {
|
|||||||
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]
|
#[tokio::test]
|
||||||
async fn declaring_metric_against_auto_derived_param_promotes_it_into_the_profile() {
|
async fn declaring_metric_against_auto_derived_param_promotes_it_into_the_profile() {
|
||||||
let (app, engine, settings_handle, dir) = test_app();
|
let (app, engine, settings_handle, dir) = test_app();
|
||||||
@@ -1592,8 +1488,6 @@ mod tests {
|
|||||||
settings.set_active_profile(&settings_path, Some("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;
|
ingest_lines(&engine, 0, &["dx=1 moved"]).await;
|
||||||
|
|
||||||
let (status, _body) = send_json(
|
let (status, _body) = send_json(
|
||||||
@@ -1613,11 +1507,9 @@ mod tests {
|
|||||||
let profile = reloaded_settings.profile("web").unwrap();
|
let profile = reloaded_settings.profile("web").unwrap();
|
||||||
assert_eq!(profile.metrics.len(), 1);
|
assert_eq!(profile.metrics.len(), 1);
|
||||||
assert_eq!(profile.metrics[0].name, "direction_x");
|
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.len(), 1);
|
||||||
assert_eq!(profile.params[0].name, "dx");
|
assert_eq!(profile.params[0].name, "dx");
|
||||||
|
|
||||||
// Simulate a restart: replay the persisted profile into a fresh engine with no prior ingestion.
|
|
||||||
let fresh_settings_handle = std::sync::Arc::new(std::sync::Mutex::new(Settings::default()));
|
let fresh_settings_handle = std::sync::Arc::new(std::sync::Mutex::new(Settings::default()));
|
||||||
let fresh_engine_tx = crate::engine::spawn(fresh_settings_handle, 16);
|
let fresh_engine_tx = crate::engine::spawn(fresh_settings_handle, 16);
|
||||||
profile::load_into_engine(&fresh_engine_tx, profile).await;
|
profile::load_into_engine(&fresh_engine_tx, profile).await;
|
||||||
@@ -1734,7 +1626,6 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// 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 reloaded_settings = Settings::load(&settings_path);
|
let reloaded_settings = Settings::load(&settings_path);
|
||||||
let dashboards = reloaded_settings.list_dashboards("web").unwrap();
|
let dashboards = reloaded_settings.list_dashboards("web").unwrap();
|
||||||
@@ -1744,9 +1635,6 @@ mod tests {
|
|||||||
assert_eq!(dashboards[0].panels[0].promql, "requests");
|
assert_eq!(dashboards[0].panels[0].promql, "requests");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ticket 075: `Ingest` has no response to await on its own, and reads no longer share a queue
|
|
||||||
/// with it — every caller here immediately reads back what it just ingested, so this barriers
|
|
||||||
/// once at the end rather than making every call site remember to.
|
|
||||||
async fn ingest_lines(engine: &EngineHandle, start_sequence: u64, texts: &[&str]) {
|
async fn ingest_lines(engine: &EngineHandle, start_sequence: u64, texts: &[&str]) {
|
||||||
for (offset, text) in texts.iter().enumerate() {
|
for (offset, text) in texts.iter().enumerate() {
|
||||||
let sequence = start_sequence + offset as u64;
|
let sequence = start_sequence + offset as u64;
|
||||||
@@ -1776,8 +1664,6 @@ mod tests {
|
|||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
assert_eq!(body["max_logs"], 2);
|
assert_eq!(body["max_logs"], 2);
|
||||||
|
|
||||||
// Existing over-cap logs are evicted as soon as the setting change
|
|
||||||
// reaches the engine, not just on the next ingest.
|
|
||||||
let (_, body) = get_json(&app, "/api/logs").await;
|
let (_, body) = get_json(&app, "/api/logs").await;
|
||||||
assert_eq!(body.as_array().unwrap().len(), 2);
|
assert_eq!(body.as_array().unwrap().len(), 2);
|
||||||
|
|
||||||
@@ -1822,8 +1708,6 @@ mod tests {
|
|||||||
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]
|
#[tokio::test]
|
||||||
async fn get_logs_sort_by_orders_by_param_and_toggles_missing_param_handling() {
|
async fn get_logs_sort_by_orders_by_param_and_toggles_missing_param_handling() {
|
||||||
let (app, engine, _settings, _dir) = test_app();
|
let (app, engine, _settings, _dir) = test_app();
|
||||||
@@ -1834,28 +1718,24 @@ mod tests {
|
|||||||
&engine,
|
&engine,
|
||||||
0,
|
0,
|
||||||
&[
|
&[
|
||||||
"priority=3 boom", // sequence 0, has param
|
"priority=3 boom",
|
||||||
"no param here", // sequence 1, missing
|
"no param here",
|
||||||
"priority=1 boom", // sequence 2, has param
|
"priority=1 boom",
|
||||||
"also missing", // sequence 3, missing
|
"also missing",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Default order (no sort_by): unchanged, plain ingest/ottyl_seq order.
|
|
||||||
let (status, body) = get_json(&app, "/api/logs").await;
|
let (status, body) = get_json(&app, "/api/logs").await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
let sequences: Vec<u64> = body.as_array().unwrap().iter().map(|e| e["sequence"].as_u64().unwrap()).collect();
|
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]);
|
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;
|
let (status, body) = get_json(&app, "/api/logs?sort_by=priority").await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
let sequences: Vec<u64> = body.as_array().unwrap().iter().map(|e| e["sequence"].as_u64().unwrap()).collect();
|
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]);
|
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;
|
let (status, body) = get_json(&app, "/api/logs?sort_by=priority&sort_missing_fallback=false").await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
let sequences: Vec<u64> = body.as_array().unwrap().iter().map(|e| e["sequence"].as_u64().unwrap()).collect();
|
let sequences: Vec<u64> = body.as_array().unwrap().iter().map(|e| e["sequence"].as_u64().unwrap()).collect();
|
||||||
|
|||||||
@@ -3,61 +3,22 @@ use crate::timestamp::Timestamp;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
/// A named, standalone parameter: how to pull a value out of a matching log line, and what kind it
|
|
||||||
/// parses as. Ticket 037 unified this with the metric-declaration-embedded matcher that used to
|
|
||||||
/// exist alongside it (`RegexToMetricPrimitiveMatcher`) — a metric now references params by name
|
|
||||||
/// (`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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct ParamDeclaration {
|
pub struct ParamDeclaration {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub message_match_regex: 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.
|
|
||||||
/// `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`.
|
|
||||||
/// `#[serde(default)]` so profile files/requests saved before this field existed still load
|
|
||||||
/// 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,
|
||||||
/// 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 elapsed_since_last: bool,
|
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)]
|
#[serde(default)]
|
||||||
pub auto_derived: bool,
|
pub auto_derived: bool,
|
||||||
/// Set for a `log stream` column/message param declared by
|
|
||||||
/// `crate::engine::auto_declare_log_stream_params`. Those params' `message_match_regex` is a
|
|
||||||
/// pure fixed character-offset slice with no content anchor of its own (ticket 076) — it'll
|
|
||||||
/// happily "match" and extract garbage from any line at least that long, including a wrapped
|
|
||||||
/// multi-line dump's continuation line or the header row itself. When set, extraction (
|
|
||||||
/// `extract_param_raw`/`derive_log_params`) additionally requires
|
|
||||||
/// `crate::logfmt::is_log_stream_line(text)` before trusting the regex at all.
|
|
||||||
/// `#[serde(default)]` so params declared before this field existed keep extracting as before.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub log_stream_gated: bool,
|
pub log_stream_gated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads a value out of a JSON object by dot-separated field path (e.g. `"foo.bar"`), for
|
|
||||||
/// [`ParamDeclaration::json_field_path`]. Returns `None` when `text` doesn't parse as a JSON
|
|
||||||
/// object, the path doesn't resolve to a value, or the resolved value isn't a scalar
|
|
||||||
/// (string/number/bool) — there's no sensible flat text form of a nested object/array to feed into
|
|
||||||
/// `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()?;
|
||||||
let mut current = root.as_object()?;
|
let mut current = root.as_object()?;
|
||||||
@@ -77,14 +38,6 @@ pub fn extract_json_field(text: &str, field_path: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flattens a JSON object into dot-separated field paths mapped to each leaf scalar's raw text form
|
|
||||||
/// (string/number/bool), for [`crate::engine::auto_declare_params`] when a line is JSON
|
|
||||||
/// (`crate::logfmt::detect_format`) rather than logfmt — the JSON equivalent of
|
|
||||||
/// `crate::logfmt::extract_pairs`'s `key=value` scan. Nested objects recurse, dot-joining the path
|
|
||||||
/// (`"foo": {"bar": 1}` yields `"foo.bar"`); arrays and null are skipped since there's no single leaf
|
|
||||||
/// scalar to declare a param against, matching `extract_json_field`'s own restriction to scalars.
|
|
||||||
/// Returns an empty vec for anything that isn't a JSON object, same as `extract_pairs` returns empty
|
|
||||||
/// for a line with no `key=value` pairs.
|
|
||||||
pub fn flatten_json_object(text: &str) -> Vec<(String, String)> {
|
pub fn flatten_json_object(text: &str) -> Vec<(String, String)> {
|
||||||
let Ok(serde_json::Value::Object(root)) = serde_json::from_str::<serde_json::Value>(text) else {
|
let Ok(serde_json::Value::Object(root)) = serde_json::from_str::<serde_json::Value>(text) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -111,7 +64,6 @@ fn flatten_json_object_into(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `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)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -119,45 +71,25 @@ pub enum MetricPrimitiveKind {
|
|||||||
String,
|
String,
|
||||||
Number,
|
Number,
|
||||||
Timestamp,
|
Timestamp,
|
||||||
/// 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
|
|
||||||
/// simply whether `message_match_regex` matches the line, case-insensitively or per the regex's own
|
|
||||||
/// flags — see `crate::engine::derive_log_params`. Unlike other kinds it's present as `true`/`false`
|
|
||||||
/// on every line rather than omitted when unmatched.
|
|
||||||
Boolean,
|
Boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Value type paired with [`crate::index_key::IndexKey`] in the engine's primary index.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub enum DataValue {
|
pub enum DataValue {
|
||||||
/// Paired with `IndexKey::IngestedLog`.
|
|
||||||
LogLiteral { text: String },
|
LogLiteral { text: String },
|
||||||
/// Paired with `IndexKey::ParamDeclaration`.
|
|
||||||
ParamDeclaration(ParamDeclaration),
|
ParamDeclaration(ParamDeclaration),
|
||||||
/// Paired with `IndexKey::MetricDeclaration`. `label_params`/`value_param` reference standalone
|
|
||||||
/// `ParamDeclaration`s by name (resolved against the engine's param store in
|
|
||||||
/// `crate::engine::apply_metric_to_log`) rather than embedding their own extractor.
|
|
||||||
MetricDeclaration {
|
MetricDeclaration {
|
||||||
message_match_regex: String,
|
message_match_regex: String,
|
||||||
label_params: Vec<String>,
|
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>,
|
value_param: Option<String>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::matched_labels`.
|
|
||||||
matched_labels: Option<BTreeMap<String, String>>,
|
matched_labels: Option<BTreeMap<String, String>>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
|
|
||||||
unmatched_labels: Option<BTreeMap<String, String>>,
|
unmatched_labels: Option<BTreeMap<String, String>>,
|
||||||
},
|
},
|
||||||
/// Paired with `IndexKey::Bucket`.
|
|
||||||
BucketValue {
|
BucketValue {
|
||||||
values: Vec<(u64, Timestamp, MetricPrimitive)>,
|
values: Vec<(u64, Timestamp, MetricPrimitive)>,
|
||||||
},
|
},
|
||||||
/// Paired with `IndexKey::LastMetricEvent`.
|
|
||||||
LastEventTimestamp {
|
LastEventTimestamp {
|
||||||
timestamp: Timestamp,
|
timestamp: Timestamp,
|
||||||
},
|
},
|
||||||
|
|||||||
-59
@@ -11,7 +11,6 @@ pub struct LogEntryDto {
|
|||||||
pub sequence: u64,
|
pub sequence: u64,
|
||||||
pub timestamp_ms: i64,
|
pub timestamp_ms: i64,
|
||||||
pub text: String,
|
pub text: String,
|
||||||
/// Only params whose regex matched (and parsed as the declared kind) are present — omitted rather than sent as null, so the frontend can render a blank for anything missing.
|
|
||||||
pub params: BTreeMap<String, MetricPrimitive>,
|
pub params: BTreeMap<String, MetricPrimitive>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,20 +19,10 @@ 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::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,
|
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,
|
pub auto_derived: bool,
|
||||||
/// Set only for the built-in `ottyl_seq`/`ottyl_ts`/`ottyl_fmt` names
|
|
||||||
/// (`crate::engine::OTTYL_RESERVED_PARAM_NAMES`), synthesized into the `GET /api/params`
|
|
||||||
/// response by `crate::api::list_params` rather than backed by any index entry — distinct from
|
|
||||||
/// `auto_derived` (which *is* a real, if machine-discovered, `ParamDeclaration`). Always
|
|
||||||
/// `false` for every other entry. There is no removal path for these at all (not just a
|
|
||||||
/// disabled one), so the frontend should treat `reserved` as non-deletable/non-editable.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub reserved: bool,
|
pub reserved: bool,
|
||||||
}
|
}
|
||||||
@@ -42,13 +31,9 @@ pub struct ParamDeclarationDto {
|
|||||||
pub struct MetricDeclarationDto {
|
pub struct MetricDeclarationDto {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub message_match_regex: String,
|
pub message_match_regex: String,
|
||||||
/// Names of standalone `ParamDeclaration`s used as this metric's bucket labels.
|
|
||||||
pub label_params: Vec<String>,
|
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>,
|
pub value_param: Option<String>,
|
||||||
/// 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`.
|
|
||||||
pub unmatched_labels: Option<BTreeMap<String, String>>,
|
pub unmatched_labels: Option<BTreeMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,24 +50,7 @@ pub struct StatsDto {
|
|||||||
pub last_sequence: Option<u64>,
|
pub last_sequence: Option<u64>,
|
||||||
pub first_timestamp_ms: Option<i64>,
|
pub first_timestamp_ms: Option<i64>,
|
||||||
pub last_timestamp_ms: Option<i64>,
|
pub last_timestamp_ms: Option<i64>,
|
||||||
/// Ticket 011: a running, *approximate* estimate of the in-memory heap
|
|
||||||
/// held by currently-ingested logs, in bytes. "Approximate" because it
|
|
||||||
/// only counts each `IngestedLine::text`'s UTF-8 byte length — it does
|
|
||||||
/// not account for `String`'s allocator overhead/capacity slack, the
|
|
||||||
/// `BTreeMap`/`IndexKey` node overhead the text is stored under, or any
|
|
||||||
/// derived data (bucket values, params) the line produces elsewhere in
|
|
||||||
/// the index. That's judged the best cheap proxy for "how much memory is
|
|
||||||
/// this log's text costing us": it's O(1) to compute per log (`text.len()`,
|
|
||||||
/// already an interned byte count for UTF-8 `String`), moves in lockstep
|
|
||||||
/// with the dominant cost (raw log text, which is usually much larger
|
|
||||||
/// than the fixed per-entry bookkeeping), and needs no per-entry
|
|
||||||
/// bookkeeping beyond what eviction already tracks.
|
|
||||||
pub approx_bytes_total: u64,
|
pub approx_bytes_total: u64,
|
||||||
/// Ticket 073: the highest ingestion-to-engine-processing lag (`ottyl_ts` at read time vs. when
|
|
||||||
/// the engine actor actually got around to processing that line) seen within the trailing 5
|
|
||||||
/// seconds, in milliseconds — see `crate::engine::record_ingest_lag`'s rolling high-watermark.
|
|
||||||
/// Stays near zero under normal load; rises under actor backpressure (a slow query in flight, a
|
|
||||||
/// large backlog) and decays back out of the window once the stall clears.
|
|
||||||
pub ingest_lag_high_watermark_ms: i64,
|
pub ingest_lag_high_watermark_ms: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,15 +60,8 @@ 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 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>,
|
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>,
|
pub sort_missing_fallback: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,11 +70,9 @@ pub struct TimelineParams {
|
|||||||
pub start_ms: Option<i64>,
|
pub start_ms: Option<i64>,
|
||||||
pub end_ms: Option<i64>,
|
pub end_ms: Option<i64>,
|
||||||
pub bucket_ms: Option<i64>,
|
pub bucket_ms: Option<i64>,
|
||||||
/// Same FilterBar query string as `LogsParams::filter`; only matching logs are counted into timeline buckets.
|
|
||||||
pub filter: Option<String>,
|
pub filter: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One (sequence, timestamp, value) occurrence within a metric's bucket for a given label combination.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct BucketValueDto {
|
pub struct BucketValueDto {
|
||||||
pub labels: BTreeMap<String, MetricPrimitive>,
|
pub labels: BTreeMap<String, MetricPrimitive>,
|
||||||
@@ -122,7 +81,6 @@ pub struct BucketValueDto {
|
|||||||
pub value: MetricPrimitive,
|
pub value: MetricPrimitive,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `labels` is a comma-separated `key=value` list (not repeated params) since axum's `Query` extractor needs a fixed set of field names.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct BucketValuesParams {
|
pub struct BucketValuesParams {
|
||||||
pub labels: Option<String>,
|
pub labels: Option<String>,
|
||||||
@@ -132,7 +90,6 @@ pub struct BucketValuesParams {
|
|||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `labels` uses plain `String` (already Display-formatted) since charts just need something to show; `value` is always `f64` for plottability.
|
|
||||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
pub struct PromqlSeriesDto {
|
pub struct PromqlSeriesDto {
|
||||||
pub labels: BTreeMap<String, String>,
|
pub labels: BTreeMap<String, String>,
|
||||||
@@ -146,27 +103,22 @@ pub struct PromqlParams {
|
|||||||
pub end_ms: Option<i64>,
|
pub end_ms: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JSON error body returned by API handlers on a 4xx/5xx response.
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ErrorDto {
|
pub struct ErrorDto {
|
||||||
pub error: String,
|
pub error: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every imported CSS entry is a selectable theme (ticket 020) — there's no separate
|
|
||||||
/// always-on-overlay mode to opt into.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ImportCssRequest {
|
pub struct ImportCssRequest {
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A reference to an external CSS path/URL that ottyl doesn't copy or own.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct LinkCssRequest {
|
pub struct LinkCssRequest {
|
||||||
pub path_or_url: String,
|
pub path_or_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `id` is the real id for `Imported` entries or a stable id derived from the path/URL for `Linked` ones, so the frontend can key both kinds uniformly (only `Imported` entries are deletable/content-fetchable).
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct CssEntryDto {
|
pub struct CssEntryDto {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -201,18 +153,13 @@ impl From<&CssEntry> for CssEntryDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `css_entries` is omitted here since it has its own `GET /api/settings/css` endpoint.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct SettingsDto {
|
pub struct SettingsDto {
|
||||||
pub color_scheme: ColorScheme,
|
pub color_scheme: ColorScheme,
|
||||||
pub active_profile: Option<String>,
|
pub active_profile: Option<String>,
|
||||||
/// `None` means unlimited.
|
|
||||||
pub max_logs: Option<usize>,
|
pub max_logs: Option<usize>,
|
||||||
/// Id of the selected custom theme entry, if any. `None` means `color_scheme` alone determines the active palette.
|
|
||||||
pub custom_theme_id: Option<String>,
|
pub custom_theme_id: Option<String>,
|
||||||
/// Ntfy topic every alert rule delivers to; `None` until configured.
|
|
||||||
pub ntfy_topic: Option<String>,
|
pub ntfy_topic: Option<String>,
|
||||||
/// Ntfy server override; `None` means `https://ntfy.sh`.
|
|
||||||
pub ntfy_server: Option<String>,
|
pub ntfy_server: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +176,6 @@ impl From<&Settings> for SettingsDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `max_logs`/`custom_theme_id`/`ntfy_topic`/`ntfy_server` distinguish "omitted" (leave unchanged) from "present" (null clears, value sets), via `deserialize_double_option`.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateSettingsRequest {
|
pub struct UpdateSettingsRequest {
|
||||||
pub color_scheme: Option<ColorScheme>,
|
pub color_scheme: Option<ColorScheme>,
|
||||||
@@ -243,7 +189,6 @@ pub struct UpdateSettingsRequest {
|
|||||||
pub ntfy_server: Option<Option<String>>,
|
pub ntfy_server: Option<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Distinguishes a missing field (outer `None`) from present `null` (`Some(None)`) or present value (`Some(Some(value))`), which plain `Option<Option<T>>` can't do alone.
|
|
||||||
fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
|
fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
|
||||||
where
|
where
|
||||||
D: serde::Deserializer<'de>,
|
D: serde::Deserializer<'de>,
|
||||||
@@ -266,7 +211,6 @@ pub struct UpdateAlertRuleRequest {
|
|||||||
pub condition: AlertCondition,
|
pub condition: AlertCondition,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `server` defaults to `https://ntfy.sh` when absent.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct TestAlertNotificationRequest {
|
pub struct TestAlertNotificationRequest {
|
||||||
pub topic: String,
|
pub topic: String,
|
||||||
@@ -297,7 +241,6 @@ pub struct CreateProfileRequest {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mirrors `profile::Panel`.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PanelDto {
|
pub struct PanelDto {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -328,7 +271,6 @@ impl From<&PanelDto> for Panel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mirrors `profile::Dashboard`.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DashboardDto {
|
pub struct DashboardDto {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -361,7 +303,6 @@ pub struct CreateDashboardRequest {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The dashboard `id` in the URL is authoritative; any `id` in the body is ignored.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct SaveDashboardRequest {
|
pub struct SaveDashboardRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|||||||
+5
-643
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,12 @@
|
|||||||
//! Datetime parsing for the filter query language's timestamp comparisons: relative
|
|
||||||
//! humantime, time-of-day (resolved against the UTC calendar day of `now`), or a
|
|
||||||
//! full date/datetime literal.
|
|
||||||
|
|
||||||
// `#![allow(dead_code)]` stays since most of this module's surface is still only
|
|
||||||
// exercised by its own unit tests below.
|
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
|
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
|
||||||
|
|
||||||
use crate::timestamp::Timestamp;
|
use crate::timestamp::Timestamp;
|
||||||
|
|
||||||
/// Error returned when a datetime filter literal can't be resolved to a [`Timestamp`].
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum DateTimeParseError {
|
pub enum DateTimeParseError {
|
||||||
/// The input matched a supported format's shape but its value was invalid, e.g. `25:99`.
|
|
||||||
Invalid { input: String, reason: String },
|
Invalid { input: String, reason: String },
|
||||||
/// The input didn't match any supported format at all.
|
|
||||||
Unparseable(String),
|
Unparseable(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +28,6 @@ impl std::fmt::Display for DateTimeParseError {
|
|||||||
|
|
||||||
impl std::error::Error for DateTimeParseError {}
|
impl std::error::Error for DateTimeParseError {}
|
||||||
|
|
||||||
/// Parses a filter datetime literal into an absolute [`Timestamp`]; `now` is a parameter
|
|
||||||
/// (rather than calling `Timestamp::now()` internally) so callers can pass a fixed value in tests.
|
|
||||||
pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTimeParseError> {
|
pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTimeParseError> {
|
||||||
let trimmed = input.trim();
|
let trimmed = input.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -60,8 +49,6 @@ pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTime
|
|||||||
Err(DateTimeParseError::Unparseable(input.to_string()))
|
Err(DateTimeParseError::Unparseable(input.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tries to interpret `input` as a signed relative humantime duration (`5h`, `-3m`);
|
|
||||||
/// returns `None` if it doesn't look like one at all, so the caller can try other formats.
|
|
||||||
fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTimeParseError>> {
|
fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTimeParseError>> {
|
||||||
let (sign, rest) = match input.strip_prefix('-') {
|
let (sign, rest) = match input.strip_prefix('-') {
|
||||||
Some(rest) => (-1i128, rest),
|
Some(rest) => (-1i128, rest),
|
||||||
@@ -71,7 +58,6 @@ fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTim
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Require a digit followed by an alphabetic unit so date-like inputs (e.g. "2024-01-15") fall through to the date parser instead of erroring here as an invalid duration.
|
|
||||||
let looks_like_duration = rest
|
let looks_like_duration = rest
|
||||||
.chars()
|
.chars()
|
||||||
.next()
|
.next()
|
||||||
@@ -97,9 +83,7 @@ fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTim
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tries to interpret `input` as a full date or RFC3339-ish datetime literal.
|
|
||||||
fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError>> {
|
fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError>> {
|
||||||
// Only attempt this path for inputs shaped like a date, e.g. "2024-01-15".
|
|
||||||
let looks_like_date = input.len() >= 8 && input.as_bytes()[4] == b'-';
|
let looks_like_date = input.len() >= 8 && input.as_bytes()[4] == b'-';
|
||||||
if !looks_like_date {
|
if !looks_like_date {
|
||||||
return None;
|
return None;
|
||||||
@@ -123,7 +107,6 @@ fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tries to interpret `input` as a bare time-of-day, resolved against the UTC calendar day of `now`.
|
|
||||||
fn try_time_of_day(
|
fn try_time_of_day(
|
||||||
input: &str,
|
input: &str,
|
||||||
now: Timestamp,
|
now: Timestamp,
|
||||||
@@ -147,7 +130,6 @@ fn try_time_of_day(
|
|||||||
|
|
||||||
let now_millis = now.as_millis();
|
let now_millis = now.as_millis();
|
||||||
let now_dt = Utc.timestamp_millis_opt(now_millis).single().unwrap_or_else(|| {
|
let now_dt = Utc.timestamp_millis_opt(now_millis).single().unwrap_or_else(|| {
|
||||||
// Fall back to the epoch date; shouldn't happen since `Timestamp` is always a valid instant.
|
|
||||||
Utc.timestamp_millis_opt(0).single().expect("epoch is valid")
|
Utc.timestamp_millis_opt(0).single().expect("epoch is valid")
|
||||||
});
|
});
|
||||||
let today = now_dt.date_naive();
|
let today = now_dt.date_naive();
|
||||||
|
|||||||
@@ -1,15 +1,3 @@
|
|||||||
// Grammar for the FilterBar query language.
|
|
||||||
//
|
|
||||||
// Supports:
|
|
||||||
// - regex("...") raw-text regex search over log lines
|
|
||||||
// - metric_name[param] <op> value metric comparisons
|
|
||||||
// - json[field] <op> value JSON field comparisons (line parsed as JSON)
|
|
||||||
// - ottyl_seq <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`
|
|
||||||
// - parenthesized sub-expressions
|
|
||||||
|
|
||||||
WHITESPACE = _{ " " | "\t" | "\n" | "\r" }
|
WHITESPACE = _{ " " | "\t" | "\n" | "\r" }
|
||||||
|
|
||||||
file = { SOI ~ or_expr ~ EOI }
|
file = { SOI ~ or_expr ~ EOI }
|
||||||
@@ -19,11 +7,6 @@ and_expr = { term ~ (and_kw ~ term)* }
|
|||||||
|
|
||||||
term = _{ regex_expr | comparison | "(" ~ or_expr ~ ")" }
|
term = _{ regex_expr | comparison | "(" ~ or_expr ~ ")" }
|
||||||
|
|
||||||
// These must be atomic (`@`): a non-atomic rule would let pest's implicit
|
|
||||||
// whitespace insertion slip in between the keyword and its own boundary
|
|
||||||
// check below, which breaks the lookahead (it would see the first
|
|
||||||
// non-whitespace character *after* skipping the separating space, instead
|
|
||||||
// of the character immediately following the keyword).
|
|
||||||
and_kw = @{ "and" ~ !(ASCII_ALPHANUMERIC | "_") }
|
and_kw = @{ "and" ~ !(ASCII_ALPHANUMERIC | "_") }
|
||||||
or_kw = @{ "or" ~ !(ASCII_ALPHANUMERIC | "_") }
|
or_kw = @{ "or" ~ !(ASCII_ALPHANUMERIC | "_") }
|
||||||
|
|
||||||
@@ -35,8 +18,6 @@ escaped_char = @{ "\\" ~ ANY }
|
|||||||
|
|
||||||
comparison = _{ json_comparison | metric_comparison }
|
comparison = _{ json_comparison | metric_comparison }
|
||||||
|
|
||||||
// `json[field] <op> value` must be tried before `metric_comparison` since "json" is
|
|
||||||
// otherwise a syntactically valid (if never declared) metric name.
|
|
||||||
json_comparison = { "json" ~ "[" ~ param ~ "]" ~ op ~ value }
|
json_comparison = { "json" ~ "[" ~ param ~ "]" ~ op ~ value }
|
||||||
metric_comparison = { metric ~ ("[" ~ param ~ "]")? ~ op ~ value }
|
metric_comparison = { metric ~ ("[" ~ param ~ "]")? ~ op ~ value }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//! FilterBar query language: grammar (see `src/filter_parser.pest`), AST and parser.
|
|
||||||
//! Evaluating the AST against real log data is left to `engine.rs`.
|
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use pest::Parser;
|
use pest::Parser;
|
||||||
@@ -11,20 +9,15 @@ use serde::{Deserialize, Serialize};
|
|||||||
#[grammar = "filter_parser.pest"]
|
#[grammar = "filter_parser.pest"]
|
||||||
struct FilterGrammar;
|
struct FilterGrammar;
|
||||||
|
|
||||||
/// The parsed AST for a FilterBar query.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum FilterExpr {
|
pub enum FilterExpr {
|
||||||
/// `regex("...")` — raw-text regex search over log lines.
|
|
||||||
Regex(String),
|
Regex(String),
|
||||||
/// `metric[param] <op> value` — a single metric comparison.
|
|
||||||
Comparison {
|
Comparison {
|
||||||
metric: String,
|
metric: String,
|
||||||
param: Option<String>,
|
param: Option<String>,
|
||||||
op: CompareOp,
|
op: CompareOp,
|
||||||
value: FilterValue,
|
value: FilterValue,
|
||||||
},
|
},
|
||||||
/// `json[field] <op> value` — a comparison against a field of the line's parsed JSON.
|
|
||||||
/// Lines that aren't valid JSON (or lack the field) simply don't match.
|
|
||||||
JsonComparison {
|
JsonComparison {
|
||||||
field: String,
|
field: String,
|
||||||
op: CompareOp,
|
op: CompareOp,
|
||||||
@@ -34,8 +27,6 @@ pub enum FilterExpr {
|
|||||||
Or(Box<FilterExpr>, Box<FilterExpr>),
|
Or(Box<FilterExpr>, Box<FilterExpr>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Comparison operators for metric comparisons; also reused by `crate::alerts::AlertCondition`
|
|
||||||
/// for threshold/ratio comparisons (hence `Serialize`/`Deserialize`).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum CompareOp {
|
pub enum CompareOp {
|
||||||
@@ -47,15 +38,12 @@ pub enum CompareOp {
|
|||||||
Ne,
|
Ne,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The right-hand side of a comparison: values that parse as a number become
|
|
||||||
/// [`FilterValue::Number`], everything else is kept as an opaque [`FilterValue::Raw`] string.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum FilterValue {
|
pub enum FilterValue {
|
||||||
Number(f64),
|
Number(f64),
|
||||||
Raw(String),
|
Raw(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A located, human-readable parse error.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct FilterParseError {
|
pub struct FilterParseError {
|
||||||
message: String,
|
message: String,
|
||||||
@@ -79,12 +67,10 @@ impl std::error::Error for FilterParseError {}
|
|||||||
|
|
||||||
impl From<pest::error::Error<Rule>> for FilterParseError {
|
impl From<pest::error::Error<Rule>> for FilterParseError {
|
||||||
fn from(err: pest::error::Error<Rule>) -> Self {
|
fn from(err: pest::error::Error<Rule>) -> Self {
|
||||||
// pest's Display already renders a located message, so use it and add a short prefix.
|
|
||||||
FilterParseError::new(format!("failed to parse filter expression: {err}"))
|
FilterParseError::new(format!("failed to parse filter expression: {err}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a FilterBar query string into a [`FilterExpr`] AST.
|
|
||||||
pub fn parse(input: &str) -> Result<FilterExpr, FilterParseError> {
|
pub fn parse(input: &str) -> Result<FilterExpr, FilterParseError> {
|
||||||
let mut pairs = FilterGrammar::parse(Rule::file, input)?;
|
let mut pairs = FilterGrammar::parse(Rule::file, input)?;
|
||||||
|
|
||||||
@@ -102,7 +88,6 @@ pub fn parse(input: &str) -> Result<FilterExpr, FilterParseError> {
|
|||||||
|
|
||||||
fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
||||||
debug_assert_eq!(pair.as_rule(), Rule::or_expr);
|
debug_assert_eq!(pair.as_rule(), Rule::or_expr);
|
||||||
// `or_kw` is atomic so it's not silent, showing up as a sibling pair between `and_expr` operands — skip it rather than assuming a fixed alternating structure.
|
|
||||||
let mut and_exprs = pair
|
let mut and_exprs = pair
|
||||||
.into_inner()
|
.into_inner()
|
||||||
.filter(|p| p.as_rule() != Rule::or_kw);
|
.filter(|p| p.as_rule() != Rule::or_kw);
|
||||||
@@ -119,7 +104,6 @@ fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
|||||||
|
|
||||||
fn build_and_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
fn build_and_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
||||||
debug_assert_eq!(pair.as_rule(), Rule::and_expr);
|
debug_assert_eq!(pair.as_rule(), Rule::and_expr);
|
||||||
// Same reasoning as `build_or_expr` for `and_kw`.
|
|
||||||
let mut terms = pair
|
let mut terms = pair
|
||||||
.into_inner()
|
.into_inner()
|
||||||
.filter(|p| p.as_rule() != Rule::and_kw);
|
.filter(|p| p.as_rule() != Rule::and_kw);
|
||||||
@@ -237,7 +221,6 @@ fn parse_value(s: &str) -> FilterValue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unescape a regex string literal's inner contents (`\"` -> `"`, `\\` -> `\`, `\x` -> `x`).
|
|
||||||
fn unescape(s: &str) -> String {
|
fn unescape(s: &str) -> String {
|
||||||
let mut out = String::with_capacity(s.len());
|
let mut out = String::with_capacity(s.len());
|
||||||
let mut chars = s.chars();
|
let mut chars = s.chars();
|
||||||
|
|||||||
@@ -3,37 +3,30 @@ use crate::timestamp::Timestamp;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
/// Key type for the engine's primary index. Variant order matters: derived `Ord` compares the discriminant first, carving the map into contiguous ranges in declaration order (logs, params, metric declarations, buckets).
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||||
pub enum IndexKey {
|
pub enum IndexKey {
|
||||||
/// Marker for the start of the whole range. Never inserted, only navigated to.
|
|
||||||
Start,
|
Start,
|
||||||
IngestedLog {
|
IngestedLog {
|
||||||
sequence: u64,
|
sequence: u64,
|
||||||
timestamp: Timestamp,
|
timestamp: Timestamp,
|
||||||
},
|
},
|
||||||
/// Marker between the log range and the param range.
|
|
||||||
LogParamSplit,
|
LogParamSplit,
|
||||||
ParamDeclaration {
|
ParamDeclaration {
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
/// Marker between the param range and the metric declaration range.
|
|
||||||
ParamMetricSplit,
|
ParamMetricSplit,
|
||||||
MetricDeclaration {
|
MetricDeclaration {
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
/// Marker between metric declarations and their buckets.
|
|
||||||
MetricBucketSplit,
|
MetricBucketSplit,
|
||||||
Bucket {
|
Bucket {
|
||||||
metric_name: String,
|
metric_name: String,
|
||||||
labels: BTreeMap<String, MetricPrimitive>,
|
labels: BTreeMap<String, MetricPrimitive>,
|
||||||
},
|
},
|
||||||
/// Internal bookkeeping tracking the last event timestamp per metric+label, so a `Duration` param's first occurrence (which pushes no `Bucket`) still lets the next occurrence compute elapsed time.
|
|
||||||
LastMetricEvent {
|
LastMetricEvent {
|
||||||
metric_name: String,
|
metric_name: String,
|
||||||
labels: BTreeMap<String, MetricPrimitive>,
|
labels: BTreeMap<String, MetricPrimitive>,
|
||||||
},
|
},
|
||||||
/// Marker for the end of the whole range. Nothing sorts after this.
|
|
||||||
End,
|
End,
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-20
@@ -7,8 +7,6 @@ use crate::timestamp::Timestamp;
|
|||||||
use tokio::fs::File;
|
use tokio::fs::File;
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
|
||||||
/// Reads stdin line by line, sequences and timestamps each line, and forwards it
|
|
||||||
/// to the engine task.
|
|
||||||
pub async fn run(engine: EngineHandle) {
|
pub async fn run(engine: EngineHandle) {
|
||||||
let stdin = tokio::io::stdin();
|
let stdin = tokio::io::stdin();
|
||||||
let mut lines = BufReader::new(stdin).lines();
|
let mut lines = BufReader::new(stdin).lines();
|
||||||
@@ -28,7 +26,6 @@ pub async fn run(engine: EngineHandle) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identifies a specific file instance on disk, so a recreated path can be told apart from the same file simply growing.
|
|
||||||
#[derive(PartialEq, Eq, Clone, Copy)]
|
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||||
struct FileId(#[cfg(unix)] u64, #[cfg(not(unix))] ());
|
struct FileId(#[cfg(unix)] u64, #[cfg(not(unix))] ());
|
||||||
|
|
||||||
@@ -51,9 +48,6 @@ async fn open(path: &Path) -> Option<(File, FileId)> {
|
|||||||
Some((file, file_id(&meta)))
|
Some((file, file_id(&meta)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tails `path` like `tail -f`, resuming from the start if the file is deleted/recreated or
|
|
||||||
/// truncated in place rather than exiting or getting stuck; `sequence` keeps incrementing
|
|
||||||
/// across a recreation since it's ottyl's own line counter, not tied to the source file.
|
|
||||||
pub async fn run_file(engine: EngineHandle, path: PathBuf) {
|
pub async fn run_file(engine: EngineHandle, path: PathBuf) {
|
||||||
let mut sequence: u64 = 0;
|
let mut sequence: u64 = 0;
|
||||||
|
|
||||||
@@ -73,7 +67,6 @@ pub async fn run_file(engine: EngineHandle, path: PathBuf) {
|
|||||||
let mut chunk = String::new();
|
let mut chunk = String::new();
|
||||||
match reader.read_line(&mut chunk).await {
|
match reader.read_line(&mut chunk).await {
|
||||||
Ok(0) => {
|
Ok(0) => {
|
||||||
// EOF: decide whether the file was truncated/recreated or just hasn't grown yet.
|
|
||||||
match tokio::fs::metadata(&path).await {
|
match tokio::fs::metadata(&path).await {
|
||||||
Ok(meta) => {
|
Ok(meta) => {
|
||||||
let new_id = file_id(&meta);
|
let new_id = file_id(&meta);
|
||||||
@@ -82,9 +75,7 @@ pub async fn run_file(engine: EngineHandle, path: PathBuf) {
|
|||||||
}
|
}
|
||||||
id = new_id;
|
id = new_id;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {}
|
||||||
// Path gone (mid-recreation); wait for it to reappear and reopen fresh.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
tokio::time::sleep(POLL_INTERVAL).await;
|
tokio::time::sleep(POLL_INTERVAL).await;
|
||||||
}
|
}
|
||||||
@@ -108,7 +99,6 @@ pub async fn run_file(engine: EngineHandle, path: PathBuf) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// else: partial line at EOF (write in progress) — keep buffering.
|
|
||||||
}
|
}
|
||||||
Err(_) => continue 'reopen,
|
Err(_) => continue 'reopen,
|
||||||
}
|
}
|
||||||
@@ -116,9 +106,6 @@ pub async fn run_file(engine: EngineHandle, path: PathBuf) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Polls `url` on a fixed interval for a Prometheus/OpenMetrics-format response, pushing every parsed
|
|
||||||
/// sample straight into the engine. Errors (unreachable endpoint, non-2xx response) are only logged —
|
|
||||||
/// polling keeps retrying on the next tick rather than exiting.
|
|
||||||
pub async fn run_metrics_poll(engine: EngineHandle, url: String) {
|
pub async fn run_metrics_poll(engine: EngineHandle, url: String) {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
loop {
|
loop {
|
||||||
@@ -143,10 +130,6 @@ pub async fn run_metrics_poll(engine: EngineHandle, url: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses the Prometheus text exposition / OpenMetrics format: one sample per non-comment,
|
|
||||||
/// non-empty line, as `metric_name{label="value",...} value [timestamp_ms]` (labels and the
|
|
||||||
/// timestamp are both optional). A line that doesn't parse is skipped rather than aborting the
|
|
||||||
/// whole scrape.
|
|
||||||
fn parse_openmetrics(body: &str, now: Timestamp) -> Vec<MetricSample> {
|
fn parse_openmetrics(body: &str, now: Timestamp) -> Vec<MetricSample> {
|
||||||
body.lines().filter_map(|line| parse_openmetrics_line(line, now)).collect()
|
body.lines().filter_map(|line| parse_openmetrics_line(line, now)).collect()
|
||||||
}
|
}
|
||||||
@@ -180,8 +163,6 @@ fn parse_openmetrics_line(line: &str, now: Timestamp) -> Option<MetricSample> {
|
|||||||
Some(MetricSample { metric_name, labels, value, timestamp })
|
Some(MetricSample { metric_name, labels, value, timestamp })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Splits on commas without handling an escaped comma inside a quoted label value — fine for the
|
|
||||||
/// common case this targets, not a full OpenMetrics-grammar parser.
|
|
||||||
fn parse_openmetrics_labels(raw: &str) -> BTreeMap<String, String> {
|
fn parse_openmetrics_labels(raw: &str) -> BTreeMap<String, String> {
|
||||||
raw.split(',')
|
raw.split(',')
|
||||||
.filter_map(|pair| pair.split_once('='))
|
.filter_map(|pair| pair.split_once('='))
|
||||||
|
|||||||
-153
@@ -1,8 +1,3 @@
|
|||||||
//! 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::branch::alt;
|
||||||
use nom::bytes::complete::{escaped, tag, take_until, take_while1};
|
use nom::bytes::complete::{escaped, tag, take_until, take_while1};
|
||||||
use nom::character::complete::{char, none_of, one_of};
|
use nom::character::complete::{char, none_of, one_of};
|
||||||
@@ -30,20 +25,10 @@ fn bareword_value(input: &str) -> IResult<&str, &str> {
|
|||||||
take_while1(|c: char| !c.is_whitespace())(input)
|
take_while1(|c: char| !c.is_whitespace())(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A bareword value starting with a bare `=` means the raw text actually had `key==...`/`key===...` —
|
|
||||||
/// never a real logfmt pair. This is exactly what `pair` would otherwise produce when it lands
|
|
||||||
/// mid-way through a base64/hash blob whose padding (`=`/`==`) happens to sit right after a run of
|
|
||||||
/// alnum chars that `key()` greedily consumed — e.g. the `huig_dump.txt` fixture's
|
|
||||||
/// `<mask.hash: 'rKTEtG7JmnThhoKPbZiVrA=='>,`: `key()` eats `rKTEtG7JmnThhoKPbZiVrA`, the first `=` is
|
|
||||||
/// consumed as the pair's `=`, leaving a bareword value of `='>,` (ticket 050). Rejected here rather
|
|
||||||
/// than post-filtered by a caller so `extract_pairs` never reports the false positive to anyone.
|
|
||||||
fn looks_like_base64_padding_artifact(value: &str) -> bool {
|
fn looks_like_base64_padding_artifact(value: &str) -> bool {
|
||||||
value.starts_with('=')
|
value.starts_with('=')
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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)> {
|
fn pair(input: &str) -> IResult<&str, (&str, &str, bool)> {
|
||||||
let (input, k) = key(input)?;
|
let (input, k) = key(input)?;
|
||||||
let (input, _) = char('=')(input)?;
|
let (input, _) = char('=')(input)?;
|
||||||
@@ -58,9 +43,6 @@ fn pair(input: &str) -> IResult<&str, (&str, &str, bool)> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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)> {
|
pub fn extract_pairs(line: &str) -> Vec<(&str, &str, bool)> {
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
let mut rest = line;
|
let mut rest = line;
|
||||||
@@ -80,25 +62,6 @@ pub fn extract_pairs(line: &str) -> Vec<(&str, &str, bool)> {
|
|||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fixed-column shape of macOS `log stream` output (ticket 050/054): a line starts with
|
|
||||||
/// `Timestamp Thread Type Activity PID TTL` (date, time+tz, a hex thread id, a bareword type, a hex
|
|
||||||
/// activity id, a decimal pid, a decimal ttl) followed by a free-text `process: message` tail — see
|
|
||||||
/// `huig_dump.txt`'s header row and body. Anchored at the very start of the line and requires the
|
|
||||||
/// hex/decimal shape of each column, so it won't accidentally fire on ordinary logfmt/JSON/prose that
|
|
||||||
/// merely resembles it in one column. Checked ahead of JSON/logfmt in `detect_format` since it's the
|
|
||||||
/// most specific of the three (an all-columns-plus-hex-ids match is a much stronger signal than an
|
|
||||||
/// incidental `key=value` substring), and is deliberately a *format label* rather than a dedicated
|
|
||||||
/// `ottyl_fmt` value change to JSON/logfmt detection — a `log stream` line's message tail can still
|
|
||||||
/// separately contain real `key=value` pairs (see the WiFi/radio stat lines in the fixture), which
|
|
||||||
/// `crate::engine::auto_declare_params` keeps discovering via `extract_pairs` regardless of this
|
|
||||||
/// line's `ottyl_fmt` label.
|
|
||||||
/// `crate::data_value::ParamDeclaration::log_stream_gated` params (the columns/`message`
|
|
||||||
/// `auto_declare_log_stream_params` declares) require this to hold before their own fixed-offset
|
|
||||||
/// `message_match_regex` is trusted at all (ticket 076) — that regex is pure character-offset
|
|
||||||
/// arithmetic with no content anchor of its own, so it happily "matches" (and extracts garbage
|
|
||||||
/// from) any unrelated line at least that long, e.g. a wrapped multi-line dump's continuation line
|
|
||||||
/// or the header line itself. This can't be baked into `message_match_regex` as a leading lookahead
|
|
||||||
/// instead: the `regex` crate deliberately doesn't support look-around at all.
|
|
||||||
static LOG_STREAM_RE: LazyLock<Regex> = LazyLock::new(|| {
|
static LOG_STREAM_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(
|
Regex::new(
|
||||||
r"^\d{4}-\d{2}-\d{2}\s+\S+\s+0x[0-9a-fA-F]+\s+\S+\s+0x[0-9a-fA-F]+\s+\d+\s+\d+\s+[^\s:]+:\s",
|
r"^\d{4}-\d{2}-\d{2}\s+\S+\s+0x[0-9a-fA-F]+\s+\S+\s+0x[0-9a-fA-F]+\s+\d+\s+\d+\s+[^\s:]+:\s",
|
||||||
@@ -110,21 +73,6 @@ pub fn is_log_stream_line(line: &str) -> bool {
|
|||||||
LOG_STREAM_RE.is_match(line)
|
LOG_STREAM_RE.is_match(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regex fragment for one column separator under the generic whitespace-columnar rule ticket 059
|
|
||||||
/// asks for: a run of 2+ whitespace chars, or a lone tab, separates columns; a single plain space does
|
|
||||||
/// not (so a value that legitimately contains one -- e.g. `log stream`'s `date time+tz` timestamp --
|
|
||||||
/// stays intact within its own column). `is_separator_run` is the same rule applied at split time;
|
|
||||||
/// this is that rule re-expressed as a regex fragment for the trailing message field's
|
|
||||||
/// `message_match_regex`, which re-derives its value the same way whenever a caller queries by it
|
|
||||||
/// later (declarations don't cache extracted values -- see `crate::engine::extract_param_raw`).
|
|
||||||
/// Same idea as `COLUMN_VALUE_PATTERN`, but bounded to a known column `width` (in chars) -- needed for
|
|
||||||
/// every column except the last, since two adjacent `log stream` values can legitimately be separated
|
|
||||||
/// by only a single space when the first one nearly fills its column's on-screen width (e.g. the
|
|
||||||
/// `Timestamp` column: its own `date time+tz` value already contains one internal space, so a
|
|
||||||
/// content-only pattern can't tell that boundary apart from the one-space gap before the next column
|
|
||||||
/// starts -- seed this from `LogStreamColumn::start` positions instead). Greedily matches up to
|
|
||||||
/// `width` chars while requiring the match to end on a non-whitespace char, which is what excludes the
|
|
||||||
/// column's own trailing padding spaces from the capture.
|
|
||||||
fn bounded_column_value_pattern(width: usize) -> String {
|
fn bounded_column_value_pattern(width: usize) -> String {
|
||||||
if width <= 1 {
|
if width <= 1 {
|
||||||
r"\S".to_string()
|
r"\S".to_string()
|
||||||
@@ -139,12 +87,6 @@ fn is_separator_run(run: &str) -> bool {
|
|||||||
|
|
||||||
static WHITESPACE_RUN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[ \t]+").expect("static regex must compile"));
|
static WHITESPACE_RUN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[ \t]+").expect("static regex must compile"));
|
||||||
|
|
||||||
/// Splits `line` at whitespace runs that qualify as a column separator (`is_separator_run`), returning
|
|
||||||
/// each piece alongside its byte offset in `line`; a lone plain space never splits. Consumes at most
|
|
||||||
/// `max_columns.map(|n| n - 1)` separators -- the final piece returned is the untouched remainder of
|
|
||||||
/// the line, including any further whitespace runs inside it. That's what lets a trailing unlabelled
|
|
||||||
/// `log stream` message tail (whose own internal spacing can be arbitrary) come back as one field
|
|
||||||
/// instead of being chopped up by it.
|
|
||||||
fn split_columns_with_offsets(line: &str, max_columns: Option<usize>) -> Vec<(usize, &str)> {
|
fn split_columns_with_offsets(line: &str, max_columns: Option<usize>) -> Vec<(usize, &str)> {
|
||||||
let mut columns = Vec::new();
|
let mut columns = Vec::new();
|
||||||
let mut start = 0usize;
|
let mut start = 0usize;
|
||||||
@@ -168,41 +110,21 @@ fn split_columns(line: &str, max_columns: Option<usize>) -> Vec<&str> {
|
|||||||
split_columns_with_offsets(line, max_columns).into_iter().map(|(_, s)| s).collect()
|
split_columns_with_offsets(line, max_columns).into_iter().map(|(_, s)| s).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A column-name identifier a generated `message_match_regex`'s named capture group can use as-is
|
|
||||||
/// (the regex crate requires `[A-Za-z_][0-9A-Za-z_]*`).
|
|
||||||
static COLUMN_NAME: LazyLock<Regex> =
|
static COLUMN_NAME: LazyLock<Regex> =
|
||||||
LazyLock::new(|| Regex::new(r"^[A-Za-z][A-Za-z0-9_]*$").expect("static regex must compile"));
|
LazyLock::new(|| Regex::new(r"^[A-Za-z][A-Za-z0-9_]*$").expect("static regex must compile"));
|
||||||
|
|
||||||
/// One column named by a detected `log stream` header (ticket 059). `start` is that column's position
|
|
||||||
/// in `char`s (not bytes -- see `char_slice`) within the header line -- meaningful only when
|
|
||||||
/// `LogStreamHeader::tab_delimited` is `false` (see that field's doc comment).
|
|
||||||
/// `Clone` so the engine's ingest path (`crate::engine::run`) can snapshot the current header value
|
|
||||||
/// by-value before handing it to a left-right op closure — the closure must be replayable against
|
|
||||||
/// two separate `EngineSnapshot` copies without touching the run loop's own `log_stream_header` var.
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct LogStreamColumn {
|
pub struct LogStreamColumn {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
start: usize,
|
start: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A detected `log stream` header: its named columns, plus whether they were tab- or space-separated.
|
|
||||||
/// `log stream`'s real output right-pads each header name to a fixed on-screen width using runs of
|
|
||||||
/// 2+ spaces, and every data line's values land in that same fixed alignment regardless of how much
|
|
||||||
/// of that width they individually fill -- so for that shape, a data line's column boundaries are
|
|
||||||
/// best found at the header's own fixed character positions, not by re-splitting the data line's own
|
|
||||||
/// whitespace (see `split_log_stream_row`'s doc comment for why that alone isn't reliable there).
|
|
||||||
/// A tab-separated header carries no such alignment guarantee (values aren't padded to a shared
|
|
||||||
/// width), but a lone tab is itself an unambiguous separator, so that case instead re-splits each data
|
|
||||||
/// line directly.
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct LogStreamHeader {
|
pub struct LogStreamHeader {
|
||||||
pub columns: Vec<LogStreamColumn>,
|
pub columns: Vec<LogStreamColumn>,
|
||||||
tab_delimited: bool,
|
tab_delimited: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Byte-indexed `str` slicing doesn't work directly with `LogStreamColumn::start` (a *char* offset),
|
|
||||||
/// since `log stream` output is expected to stay ASCII in practice but callers shouldn't panic if a
|
|
||||||
/// message contains multi-byte chars before the slice point. `end` of `None` slices to end of string.
|
|
||||||
fn char_slice(s: &str, start: usize, end: Option<usize>) -> &str {
|
fn char_slice(s: &str, start: usize, end: Option<usize>) -> &str {
|
||||||
if end.is_some_and(|end| end <= start) {
|
if end.is_some_and(|end| end <= start) {
|
||||||
return "";
|
return "";
|
||||||
@@ -216,13 +138,6 @@ fn char_slice(s: &str, start: usize, end: Option<usize>) -> &str {
|
|||||||
&s[byte_start..byte_end.max(byte_start)]
|
&s[byte_start..byte_end.max(byte_start)]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recognizes a `log stream`-style header row generically (ticket 059), instead of assuming the fixed
|
|
||||||
/// `Timestamp Thread Type Activity PID TTL` names: a short run of bareword column names separated by
|
|
||||||
/// qualifying whitespace runs (`split_columns_with_offsets`) that is, critically, *not itself* a
|
|
||||||
/// `log stream` data line (`is_log_stream_line`) -- so a header is never mistaken for the data line
|
|
||||||
/// immediately following it. `crate::engine::auto_declare_log_stream_params` caches the result across
|
|
||||||
/// ingested lines, since `log stream` prints its header once, before any data line, and this only ever
|
|
||||||
/// sees one line at a time.
|
|
||||||
pub fn detect_log_stream_header(line: &str) -> Option<LogStreamHeader> {
|
pub fn detect_log_stream_header(line: &str) -> Option<LogStreamHeader> {
|
||||||
if is_log_stream_line(line) {
|
if is_log_stream_line(line) {
|
||||||
return None;
|
return None;
|
||||||
@@ -244,21 +159,6 @@ pub fn detect_log_stream_header(line: &str) -> Option<LogStreamHeader> {
|
|||||||
Some(LogStreamHeader { columns, tab_delimited })
|
Some(LogStreamHeader { columns, tab_delimited })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Splits a `log stream` data line into `header`'s named columns plus, when present, one trailing
|
|
||||||
/// unlabelled tail -- `crate::engine::auto_declare_log_stream_params` maps the first
|
|
||||||
/// `header.columns.len()` pieces onto the header's names and the optional last piece onto the
|
|
||||||
/// `message` param.
|
|
||||||
///
|
|
||||||
/// For a space-padded fixed-width header, every column but the last is sliced at the header's own
|
|
||||||
/// fixed character positions (`LogStreamColumn::start`) rather than by re-splitting the data line's
|
|
||||||
/// own whitespace, since two adjacent values can legitimately be joined by only a single space when
|
|
||||||
/// the first nearly fills its column's width (e.g. `log stream`'s `Timestamp` column, whose value
|
|
||||||
/// already contains one internal space). Only the last named column's end is whitespace-run-detected
|
|
||||||
/// (`split_columns`), since that's what distinguishes its own value from the free-text message tail
|
|
||||||
/// `log stream` appends after it.
|
|
||||||
///
|
|
||||||
/// For a tab-delimited header, there's no shared on-screen width to align against, but a lone tab is
|
|
||||||
/// itself unambiguous, so this instead re-splits the data line directly on qualifying separators.
|
|
||||||
pub fn split_log_stream_row<'a>(line: &'a str, header: &LogStreamHeader) -> Vec<&'a str> {
|
pub fn split_log_stream_row<'a>(line: &'a str, header: &LogStreamHeader) -> Vec<&'a str> {
|
||||||
if header.tab_delimited {
|
if header.tab_delimited {
|
||||||
return split_columns(line, Some(header.columns.len() + 1));
|
return split_columns(line, Some(header.columns.len() + 1));
|
||||||
@@ -280,17 +180,6 @@ pub fn split_log_stream_row<'a>(line: &'a str, header: &LogStreamHeader) -> Vec<
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the `message_match_regex` for the `index`-th (0-based) of `header`'s named columns.
|
|
||||||
///
|
|
||||||
/// Fixed-width case: skips exactly `header.columns[index].start` chars, then captures this column's
|
|
||||||
/// own value bounded to its known width (`bounded_column_value_pattern`) -- unbounded for the last
|
|
||||||
/// column, which has no next column to bound against.
|
|
||||||
///
|
|
||||||
/// Tab-delimited case: skips `index` preceding columns' own value+separator pairs instead, since
|
|
||||||
/// there's no shared fixed width to anchor a character-position skip against.
|
|
||||||
///
|
|
||||||
/// Anchored at the start of the line since a `log stream` line never carries this fixed-per-run
|
|
||||||
/// column prefix anywhere but the very start.
|
|
||||||
pub fn log_stream_column_pattern(header: &LogStreamHeader, index: usize, capture_group: &str) -> String {
|
pub fn log_stream_column_pattern(header: &LogStreamHeader, index: usize, capture_group: &str) -> String {
|
||||||
if header.tab_delimited {
|
if header.tab_delimited {
|
||||||
let mut pattern = String::from("^");
|
let mut pattern = String::from("^");
|
||||||
@@ -309,9 +198,6 @@ pub fn log_stream_column_pattern(header: &LogStreamHeader, index: usize, capture
|
|||||||
format!("^.{{{start}}}(?P<{capture_group}>{value_pattern})")
|
format!("^.{{{start}}}(?P<{capture_group}>{value_pattern})")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the `message_match_regex` for the trailing unlabelled tail after `header`'s last named
|
|
||||||
/// column: everything from the end of that column's own separator to end of line, verbatim -- its own
|
|
||||||
/// internal whitespace, however irregular, is never treated as a further separator.
|
|
||||||
pub fn log_stream_message_pattern(header: &LogStreamHeader, capture_group: &str) -> String {
|
pub fn log_stream_message_pattern(header: &LogStreamHeader, capture_group: &str) -> String {
|
||||||
if header.tab_delimited {
|
if header.tab_delimited {
|
||||||
let mut pattern = String::from("^");
|
let mut pattern = String::from("^");
|
||||||
@@ -326,14 +212,6 @@ pub fn log_stream_message_pattern(header: &LogStreamHeader, capture_group: &str)
|
|||||||
format!("^.{{{start}}}{COLUMN_VALUE_PATTERN}{COLUMN_SEPARATOR_PATTERN}(?P<{capture_group}>.*)$")
|
format!("^.{{{start}}}{COLUMN_VALUE_PATTERN}{COLUMN_SEPARATOR_PATTERN}(?P<{capture_group}>.*)$")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Go `log/slog` `NewJSONHandler` output (the `go-log` label): a single-line JSON object whose first
|
|
||||||
/// three fields are always `time`, `level`, `msg` in that fixed order (slog's `JSONHandler` writes the
|
|
||||||
/// built-in attrs before any caller-supplied ones), followed by zero or more arbitrary attrs and a
|
|
||||||
/// closing `}`. Parsed with nom rather than a full JSON decode since only that fixed prefix shape
|
|
||||||
/// needs verifying; the variable tail is deliberately not parsed field-by-field the way
|
|
||||||
/// `crate::engine::auto_declare_params` will separately pick up JSON leaf fields from the line.
|
|
||||||
/// Checked ahead of the generic JSON object check in `detect_format` since it's the more specific of
|
|
||||||
/// the two (see that function's doc comment for the general ordering rationale).
|
|
||||||
fn go_log_prefix(input: &str) -> IResult<&str, (&str, &str, &str)> {
|
fn go_log_prefix(input: &str) -> IResult<&str, (&str, &str, &str)> {
|
||||||
let (input, _) = tag("{\"time\":\"")(input)?;
|
let (input, _) = tag("{\"time\":\"")(input)?;
|
||||||
let (input, time) = take_until("\"")(input)?;
|
let (input, time) = take_until("\"")(input)?;
|
||||||
@@ -352,14 +230,6 @@ pub fn is_go_log_line(text: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Python `logging` module output in the shape `"%(asctime)s %(levelname)s %(name)s %(message)s"`
|
|
||||||
/// (the `py-log` label): a `YYYY-MM-DD HH:MM:SS,mmm` timestamp, an uppercase level word, a logger
|
|
||||||
/// name, then a message tail that (for `delete-py-log`, and any caller using `logger.info("event=%s
|
|
||||||
/// %s=%s", ...)`-style calls) is itself logfmt `key=value` pairs. Only the fixed prefix is parsed with
|
|
||||||
/// nom; the tail is handed to `extract_pairs` both to confirm the shape (a py-log line with no pairs
|
|
||||||
/// at all wouldn't be distinguishable from plain prose after this prefix) and because that's the same
|
|
||||||
/// extraction `crate::engine::auto_declare_params` needs anyway. Checked ahead of the generic logfmt
|
|
||||||
/// check in `detect_format` since it's the more specific of the two.
|
|
||||||
fn py_log_prefix(input: &str) -> IResult<&str, (&str, &str, &str, &str)> {
|
fn py_log_prefix(input: &str) -> IResult<&str, (&str, &str, &str, &str)> {
|
||||||
let (input, date) = take_while1(|c: char| c.is_ascii_digit() || c == '-')(input)?;
|
let (input, date) = take_while1(|c: char| c.is_ascii_digit() || c == '-')(input)?;
|
||||||
let (input, _) = char(' ')(input)?;
|
let (input, _) = char(' ')(input)?;
|
||||||
@@ -380,16 +250,6 @@ pub fn is_py_log_line(text: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The value of the `ottyl_fmt` reserved param (`crate::engine::compile_filter`/`eval_alert_expr`):
|
|
||||||
/// which format a line was recognized as when parsed. Checked most-specific-first: `log stream`'s
|
|
||||||
/// fixed-column shape (see `LOG_STREAM_RE`'s doc comment for why it's the most specific), then the two
|
|
||||||
/// named-tool shapes `go-log` (a `time`/`level`/`msg`-prefixed JSON object) and `py-log` (a
|
|
||||||
/// timestamp/level/logger prefix over logfmt pairs) — each is a strict subset of the generic JSON/logfmt
|
|
||||||
/// case checked right after it, so they'd otherwise never be reached. JSON is checked next 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
|
|
||||||
/// none of the above is `"none"`.
|
|
||||||
pub fn detect_format(text: &str) -> &'static str {
|
pub fn detect_format(text: &str) -> &'static str {
|
||||||
if is_log_stream_line(text) {
|
if is_log_stream_line(text) {
|
||||||
"log_stream"
|
"log_stream"
|
||||||
@@ -471,15 +331,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_bare_json_array_or_scalar_is_not_treated_as_json() {
|
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("[1,2,3]"), "none");
|
||||||
assert_eq!(detect_format("42"), "none");
|
assert_eq!(detect_format("42"), "none");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_base64_padding_fragment_as_a_pair() {
|
fn rejects_base64_padding_fragment_as_a_pair() {
|
||||||
// The exact false-positive shape from ticket 050's fixture: `key()` greedily consumes the
|
|
||||||
// tail of the base64 blob as if it were a key, landing right before the `==` padding.
|
|
||||||
let pairs = extract_pairs(
|
let pairs = extract_pairs(
|
||||||
"qname: <mask.hash: 'rKTEtG7JmnThhoKPbZiVrA=='>, qtype: A, client pid: 12260 (firefox)",
|
"qname: <mask.hash: 'rKTEtG7JmnThhoKPbZiVrA=='>, qtype: A, client pid: 12260 (firefox)",
|
||||||
);
|
);
|
||||||
@@ -541,7 +398,6 @@ mod tests {
|
|||||||
assert_eq!(row[3], "0x0");
|
assert_eq!(row[3], "0x0");
|
||||||
assert_eq!(row[4], "606");
|
assert_eq!(row[4], "606");
|
||||||
assert_eq!(row[5], "0");
|
assert_eq!(row[5], "0");
|
||||||
// The message tail is never chopped up by its own internal single/double spacing.
|
|
||||||
assert_eq!(row[6], "WindowServer: (HSTouchHIDService) something happened");
|
assert_eq!(row[6], "WindowServer: (HSTouchHIDService) something happened");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,8 +407,6 @@ mod tests {
|
|||||||
assert_eq!(column_names(&header), vec!["Timestamp", "Thread", "Type"]);
|
assert_eq!(column_names(&header), vec!["Timestamp", "Thread", "Type"]);
|
||||||
assert!(header.tab_delimited);
|
assert!(header.tab_delimited);
|
||||||
|
|
||||||
// Two tabs in a row still qualify as a single separator (like a run of 2+ spaces), so this
|
|
||||||
// still cleanly separates Timestamp/Thread/Type despite the doubled tab before "Default".
|
|
||||||
let spaced = split_log_stream_row("2026-09-06 01:48:54\t0x4dfed2\t\tDefault trailing message", &header);
|
let spaced = split_log_stream_row("2026-09-06 01:48:54\t0x4dfed2\t\tDefault trailing message", &header);
|
||||||
let tabbed = split_log_stream_row("2026-09-06 01:48:54\t0x4dfed2\tDefault trailing message", &header);
|
let tabbed = split_log_stream_row("2026-09-06 01:48:54\t0x4dfed2\tDefault trailing message", &header);
|
||||||
assert_eq!(spaced[0], "2026-09-06 01:48:54");
|
assert_eq!(spaced[0], "2026-09-06 01:48:54");
|
||||||
@@ -581,8 +435,6 @@ mod tests {
|
|||||||
"WindowServer: (HSTouchHIDService) something happened"
|
"WindowServer: (HSTouchHIDService) something happened"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Also exercise the Timestamp column specifically -- its own value contains one internal
|
|
||||||
// space, so a naive content-only pattern would previously over-consume into the next column.
|
|
||||||
let timestamp_re = Regex::new(&log_stream_column_pattern(&header, 0, "Timestamp")).unwrap();
|
let timestamp_re = Regex::new(&log_stream_column_pattern(&header, 0, "Timestamp")).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
timestamp_re.captures(line).unwrap().name("Timestamp").unwrap().as_str(),
|
timestamp_re.captures(line).unwrap().name("Timestamp").unwrap().as_str(),
|
||||||
@@ -592,10 +444,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_log_stream_line_rejects_non_columnar_lines_that_a_fixed_offset_column_regex_would_still_match() {
|
fn is_log_stream_line_rejects_non_columnar_lines_that_a_fixed_offset_column_regex_would_still_match() {
|
||||||
// Ticket 076: this is exactly why `ParamDeclaration::log_stream_gated` params must check
|
|
||||||
// `is_log_stream_line` before trusting their own offset-based capture at all -- the raw
|
|
||||||
// column regex has no content anchor and matches plenty on lines that clearly aren't
|
|
||||||
// columnar `log stream` output.
|
|
||||||
let header_line = LOG_STREAM_FIXTURE.lines().next().unwrap();
|
let header_line = LOG_STREAM_FIXTURE.lines().next().unwrap();
|
||||||
let header = detect_log_stream_header(header_line).unwrap();
|
let header = detect_log_stream_header(header_line).unwrap();
|
||||||
let timestamp_re = Regex::new(&log_stream_column_pattern(&header, 0, "Timestamp")).unwrap();
|
let timestamp_re = Regex::new(&log_stream_column_pattern(&header, 0, "Timestamp")).unwrap();
|
||||||
@@ -614,7 +462,6 @@ mod tests {
|
|||||||
.find(|line| line.contains("txRTSFrm="))
|
.find(|line| line.contains("txRTSFrm="))
|
||||||
.expect("fixture must contain the WiFi stat dump line");
|
.expect("fixture must contain the WiFi stat dump line");
|
||||||
assert_eq!(detect_format(line), "log_stream");
|
assert_eq!(detect_format(line), "log_stream");
|
||||||
// The embedded key=value pairs are still there for auto_declare_params to see.
|
|
||||||
assert!(!extract_pairs(line).is_empty());
|
assert!(!extract_pairs(line).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-21
@@ -26,8 +26,6 @@ use tokio::net::TcpListener;
|
|||||||
|
|
||||||
use settings::Settings;
|
use settings::Settings;
|
||||||
|
|
||||||
/// The resolved default settings path, rendered into `--help` so `--settings-path`'s help text
|
|
||||||
/// names a real, concrete location instead of only describing where to look for one.
|
|
||||||
static SETTINGS_PATH_HELP: LazyLock<String> = LazyLock::new(|| {
|
static SETTINGS_PATH_HELP: LazyLock<String> = LazyLock::new(|| {
|
||||||
format!(
|
format!(
|
||||||
"Path to the settings file. Defaults to {}.",
|
"Path to the settings file. Defaults to {}.",
|
||||||
@@ -37,50 +35,35 @@ static SETTINGS_PATH_HELP: LazyLock<String> = LazyLock::new(|| {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Observability dashboard from terminal output.
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
struct Args {
|
struct Args {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Command>,
|
command: Option<Command>,
|
||||||
|
|
||||||
/// Port to serve the API and webapp on. Defaults to 0, letting the OS assign
|
|
||||||
/// an ephemeral free port.
|
|
||||||
#[arg(long, default_value_t = 0)]
|
#[arg(long, default_value_t = 0)]
|
||||||
port: u16,
|
port: u16,
|
||||||
|
|
||||||
#[arg(long, help = SETTINGS_PATH_HELP.as_str())]
|
#[arg(long, help = SETTINGS_PATH_HELP.as_str())]
|
||||||
settings_path: Option<PathBuf>,
|
settings_path: Option<PathBuf>,
|
||||||
|
|
||||||
/// File to tail for log lines, instead of reading from stdin. Behaves like `tail -f`.
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
file: Option<PathBuf>,
|
file: Option<PathBuf>,
|
||||||
|
|
||||||
/// URL of a Prometheus/OpenMetrics-compatible endpoint to poll for metrics, in addition to log ingestion.
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
metrics_url: Option<String>,
|
metrics_url: Option<String>,
|
||||||
|
|
||||||
/// Name to register this session under, so `ottyl mcp --session <name>` or `ottyl sessions`
|
|
||||||
/// can find it later. Defaults to a random human-readable name (e.g. "comic-gazelle").
|
|
||||||
/// Starting a second instance under the same name as one still running overwrites its
|
|
||||||
/// registration (the older process keeps running, just without a discoverable session entry).
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum Command {
|
enum Command {
|
||||||
/// Start an MCP (Model Context Protocol) server over stdio, proxying tool calls to a running
|
|
||||||
/// ottyl instance's HTTP API. Register the printed command with an MCP client (e.g. Claude
|
|
||||||
/// Code's `.mcp.json`).
|
|
||||||
Mcp {
|
Mcp {
|
||||||
/// Name of a running ottyl session to connect to (see `ottyl sessions`).
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
session: Option<String>,
|
session: Option<String>,
|
||||||
/// Port of a running ottyl instance to connect to, instead of resolving `--session` by name.
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
},
|
},
|
||||||
/// List known ottyl sessions and the ports they're serving on.
|
|
||||||
Sessions,
|
Sessions,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +96,6 @@ async fn main() {
|
|||||||
let _ = engine_tx.tx.send(engine::EngineMessage::SetMaxLogs(max_logs)).await;
|
let _ = engine_tx.tx.send(engine::EngineMessage::SetMaxLogs(max_logs)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload the active profile's declarations before ingest starts: there's no way to
|
|
||||||
// reprocess early lines later, so declaring after ingest starts could miss lines that raced ahead.
|
|
||||||
{
|
{
|
||||||
let (active_profile_name, active_profile) = {
|
let (active_profile_name, active_profile) = {
|
||||||
let mut settings_guard = settings.lock().unwrap();
|
let mut settings_guard = settings.lock().unwrap();
|
||||||
@@ -184,8 +165,6 @@ async fn main() {
|
|||||||
.expect("server error");
|
.expect("server error");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves `--session`/`--port` into a target base URL and runs the MCP server over stdio until
|
|
||||||
/// the client disconnects.
|
|
||||||
async fn run_mcp(settings_path: &std::path::Path, session: Option<String>, port: Option<u16>) {
|
async fn run_mcp(settings_path: &std::path::Path, session: Option<String>, port: Option<u16>) {
|
||||||
use rmcp::ServiceExt;
|
use rmcp::ServiceExt;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
//! MCP (Model Context Protocol) server for agents (ticket 067): a short-lived process, started
|
|
||||||
//! via `ottyl mcp --session <name>` (see `crate::sessions`), that speaks MCP over stdio and
|
|
||||||
//! proxies each tool call to a *running* ottyl instance's existing HTTP API. Every tool here is a
|
|
||||||
//! thin translation onto `api.rs` routes/DTOs — no query logic is duplicated outside `engine.rs`.
|
|
||||||
//!
|
|
||||||
//! Uses the official `rmcp` SDK (https://github.com/modelcontextprotocol/rust-sdk) over the
|
|
||||||
//! `transport-io` (stdio) transport rather than hand-rolling the JSON-RPC/MCP handshake, since a
|
|
||||||
//! maintained implementation of the protocol's framing and lifecycle is worth the extra
|
|
||||||
//! dependency.
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use rmcp::handler::server::router::tool::ToolRouter;
|
use rmcp::handler::server::router::tool::ToolRouter;
|
||||||
@@ -21,13 +11,10 @@ use serde_json::Value;
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
pub struct QueryLogsParams {
|
pub struct QueryLogsParams {
|
||||||
/// FilterBar query string, e.g. `regex("error") and ottyl_seq<5`. Omitted matches every log.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub filter: Option<String>,
|
pub filter: Option<String>,
|
||||||
/// If true, return only how many logs matched instead of the log lines themselves.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub count_only: bool,
|
pub count_only: bool,
|
||||||
/// Maximum number of log lines to return. Ignored when `count_only` is set.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
}
|
}
|
||||||
@@ -35,15 +22,11 @@ pub struct QueryLogsParams {
|
|||||||
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
||||||
pub struct DeclareParamParams {
|
pub struct DeclareParamParams {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Regex matched against each log line; `capture_group` extracts this param's value from it.
|
|
||||||
pub message_match_regex: String,
|
pub message_match_regex: String,
|
||||||
pub capture_group: String,
|
pub capture_group: String,
|
||||||
/// Dotted path into a JSON-structured log line to extract from instead of `capture_group`.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub json_field_path: Option<String>,
|
pub json_field_path: Option<String>,
|
||||||
/// One of: "string", "number", "timestamp", "duration", "boolean".
|
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
/// Extract the elapsed time since this param was last seen, instead of parsing captured text.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub elapsed_since_last: bool,
|
pub elapsed_since_last: bool,
|
||||||
}
|
}
|
||||||
@@ -52,23 +35,18 @@ pub struct DeclareParamParams {
|
|||||||
pub struct DeclareMetricParams {
|
pub struct DeclareMetricParams {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub message_match_regex: String,
|
pub message_match_regex: String,
|
||||||
/// Names of already-declared params whose values become this metric's bucket labels.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub label_params: Vec<String>,
|
pub label_params: Vec<String>,
|
||||||
/// Name of an already-declared param whose value becomes this metric's per-event bucket value.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub value_param: Option<String>,
|
pub value_param: Option<String>,
|
||||||
/// Fixed labels layered on top of `label_params` for matching lines.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub matched_labels: Option<BTreeMap<String, String>>,
|
pub matched_labels: Option<BTreeMap<String, String>>,
|
||||||
/// Fixed labels recorded once for a non-matching line, instead of it being invisible to this metric.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub unmatched_labels: Option<BTreeMap<String, String>>,
|
pub unmatched_labels: Option<BTreeMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
pub struct CreateDashboardParams {
|
pub struct CreateDashboardParams {
|
||||||
/// Profile to create the dashboard in. Defaults to the currently active profile.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub profile: Option<String>,
|
pub profile: Option<String>,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -76,17 +54,13 @@ pub struct CreateDashboardParams {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
pub struct AddPanelParams {
|
pub struct AddPanelParams {
|
||||||
/// Profile the dashboard lives in. Defaults to the currently active profile.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub profile: Option<String>,
|
pub profile: Option<String>,
|
||||||
pub dashboard_id: String,
|
pub dashboard_id: String,
|
||||||
/// Id of an existing panel to update in place; omit to add a new panel.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub panel_id: Option<String>,
|
pub panel_id: Option<String>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
/// PromQL-style query backing this panel (see `crate::promql`).
|
|
||||||
pub promql: String,
|
pub promql: String,
|
||||||
/// One of: "bar", "line", "pie". Defaults to "line".
|
|
||||||
#[serde(default = "default_chart_type")]
|
#[serde(default = "default_chart_type")]
|
||||||
pub chart_type: String,
|
pub chart_type: String,
|
||||||
}
|
}
|
||||||
@@ -97,7 +71,6 @@ fn default_chart_type() -> String {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
pub struct GetDashboardParams {
|
pub struct GetDashboardParams {
|
||||||
/// Profile the dashboard lives in. Defaults to the currently active profile.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub profile: Option<String>,
|
pub profile: Option<String>,
|
||||||
pub dashboard_id: String,
|
pub dashboard_id: String,
|
||||||
@@ -107,9 +80,6 @@ pub struct GetDashboardParams {
|
|||||||
pub struct OttylMcp {
|
pub struct OttylMcp {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
// Constructed fresh per call by the `#[tool_handler]`-generated `ServerHandler` methods
|
|
||||||
// (`Self::tool_router()`), not read through this field — kept only because `#[tool_router]`
|
|
||||||
// requires a field of this name/type to exist on the struct.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
tool_router: ToolRouter<OttylMcp>,
|
tool_router: ToolRouter<OttylMcp>,
|
||||||
}
|
}
|
||||||
@@ -138,8 +108,6 @@ impl OttylMcp {
|
|||||||
format!("{}{path}", self.base_url)
|
format!("{}{path}", self.base_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the profile a dashboard tool call should operate on: the explicit `profile`
|
|
||||||
/// argument if given, otherwise the target instance's currently active profile.
|
|
||||||
async fn resolve_profile(&self, explicit: Option<String>) -> Result<String, McpError> {
|
async fn resolve_profile(&self, explicit: Option<String>) -> Result<String, McpError> {
|
||||||
if let Some(profile) = explicit {
|
if let Some(profile) = explicit {
|
||||||
return Ok(profile);
|
return Ok(profile);
|
||||||
@@ -282,10 +250,6 @@ impl OttylMcp {
|
|||||||
json_result(body)
|
json_result(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read-modify-write over HTTP (no dashboard-level locking): two concurrent `add_panel` calls
|
|
||||||
// against the same dashboard can race and one's addition can be lost, same as two browser tabs
|
|
||||||
// saving the same dashboard through the webapp today. Not addressed here — out of scope for a
|
|
||||||
// thin proxy over the existing `save_dashboard` endpoint.
|
|
||||||
#[tool(description = "Add a new panel to a dashboard, or update an existing one by panel_id.")]
|
#[tool(description = "Add a new panel to a dashboard, or update an existing one by panel_id.")]
|
||||||
async fn add_panel(
|
async fn add_panel(
|
||||||
&self,
|
&self,
|
||||||
@@ -359,8 +323,6 @@ impl OttylMcp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Percent-encodes a single path segment (profile/dashboard names/ids can contain characters that
|
|
||||||
/// aren't valid unescaped in a URL path).
|
|
||||||
fn urlencoding_path(segment: &str) -> String {
|
fn urlencoding_path(segment: &str) -> String {
|
||||||
segment
|
segment
|
||||||
.bytes()
|
.bytes()
|
||||||
@@ -373,8 +335,6 @@ fn urlencoding_path(segment: &str) -> String {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a new panel id by hashing title/promql/time, avoiding a `uuid` dependency (matches
|
|
||||||
/// `settings::generate_imported_css_id`'s approach for the same reason).
|
|
||||||
fn generate_panel_id(title: &str, promql: &str) -> String {
|
fn generate_panel_id(title: &str, promql: &str) -> String {
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
|||||||
@@ -3,16 +3,13 @@ use serde::Serialize;
|
|||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
/// A simple, self-describing value used by metrics/labels without pulling in JSON.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub enum MetricPrimitive {
|
pub enum MetricPrimitive {
|
||||||
String(String),
|
String(String),
|
||||||
Number(Number),
|
Number(Number),
|
||||||
Timestamp(Timestamp),
|
Timestamp(Timestamp),
|
||||||
/// Milliseconds elapsed; distinct from `Number` so it reads unambiguously in JSON/label form.
|
|
||||||
Duration(i64),
|
Duration(i64),
|
||||||
/// A `Boolean`-kind param's value: whether `message_match_regex` matched the line. See `MetricPrimitiveKind::Boolean`.
|
|
||||||
Boolean(bool),
|
Boolean(bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +48,6 @@ impl Ord for MetricPrimitive {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Textual form used to compare label filters (query-string values) against a formatted `MetricPrimitive` rather than parsing them into one.
|
|
||||||
impl fmt::Display for MetricPrimitive {
|
impl fmt::Display for MetricPrimitive {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
@@ -65,7 +61,6 @@ impl fmt::Display for MetricPrimitive {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Numbers are kept as a tagged int/float pair so exact integers stay exact.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Copy, Serialize)]
|
#[derive(Debug, Clone, Copy, Serialize)]
|
||||||
pub enum Number {
|
pub enum Number {
|
||||||
@@ -74,7 +69,6 @@ pub enum Number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Number {
|
impl Number {
|
||||||
/// Widens to `f64` for cross-kind numeric comparisons; `pub(crate)` since the filter evaluator in `engine.rs` needs it.
|
|
||||||
pub(crate) fn as_f64(&self) -> f64 {
|
pub(crate) fn as_f64(&self) -> f64 {
|
||||||
match self {
|
match self {
|
||||||
Number::Int(v) => *v as f64,
|
Number::Int(v) => *v as f64,
|
||||||
|
|||||||
-205
@@ -1,8 +1,3 @@
|
|||||||
//! Named, persistable bundles of parsing/indexing rules and dashboards for a CLI app.
|
|
||||||
//! Ticket 049 folded profile storage inline into `Settings` (`settings.json`) instead of a separate
|
|
||||||
//! `profiles.json`; this module still owns the `Profile`/`Dashboard`/declaration data model, plus the
|
|
||||||
//! CRUD methods on `Settings` (`impl Settings` below `ProfileError`) that used to live on the
|
|
||||||
//! now-removed `ProfileStore`.
|
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
@@ -22,16 +17,13 @@ use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, Eng
|
|||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
/// Mirrors `data_value::MetricPrimitiveKind` with `Serialize`/`Deserialize` so profiles can round-trip through JSON, since the engine-internal type only derives `Serialize`.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum ProfileMetricPrimitiveKind {
|
pub enum ProfileMetricPrimitiveKind {
|
||||||
String,
|
String,
|
||||||
Number,
|
Number,
|
||||||
Timestamp,
|
Timestamp,
|
||||||
/// Mirrors `MetricPrimitiveKind::Duration`.
|
|
||||||
Duration,
|
Duration,
|
||||||
/// Mirrors `MetricPrimitiveKind::Boolean`.
|
|
||||||
Boolean,
|
Boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,20 +51,14 @@ impl From<ProfileMetricPrimitiveKind> for MetricPrimitiveKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A stored param declaration, matching `DeclareParamRequest`'s shape (== `data_value::ParamDeclaration`).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ParamDeclarationRecord {
|
pub struct ParamDeclarationRecord {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub message_match_regex: String,
|
pub message_match_regex: String,
|
||||||
pub capture_group: String,
|
pub capture_group: String,
|
||||||
/// See `crate::data_value::ParamDeclaration::json_field_path`; `#[serde(default)]`
|
|
||||||
/// so profiles saved before this field existed still load as plain regex-capture-group params.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub json_field_path: Option<String>,
|
pub json_field_path: Option<String>,
|
||||||
pub kind: ProfileMetricPrimitiveKind,
|
pub kind: ProfileMetricPrimitiveKind,
|
||||||
/// See `crate::data_value::ParamDeclaration::elapsed_since_last`; `#[serde(default)]` so
|
|
||||||
/// profiles saved before ticket 038 (where a `Duration`-kind value param implicitly meant this)
|
|
||||||
/// still load — `migrate_duration_value_params` below converts those.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub elapsed_since_last: bool,
|
pub elapsed_since_last: bool,
|
||||||
}
|
}
|
||||||
@@ -99,17 +85,12 @@ impl From<&ParamDeclarationRecord> for DeclareParamRequest {
|
|||||||
json_field_path: record.json_field_path.clone(),
|
json_field_path: record.json_field_path.clone(),
|
||||||
kind: record.kind.into(),
|
kind: record.kind.into(),
|
||||||
elapsed_since_last: record.elapsed_since_last,
|
elapsed_since_last: record.elapsed_since_last,
|
||||||
// A profile only ever stores manually-declared params (see `record_param_declaration`).
|
|
||||||
auto_derived: false,
|
auto_derived: false,
|
||||||
log_stream_gated: false,
|
log_stream_gated: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pre-037 role of an inline metric param matcher — a label identifying a bucket, or the (at most
|
|
||||||
/// one) value that becomes the event's captured value. Kept only so `LegacyMetricParamRecord` can
|
|
||||||
/// still deserialize a profile saved before params were unified into standalone `ParamDeclaration`s
|
|
||||||
/// referenced by name; see `migrate_legacy_metric_params`.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
enum LegacyParamRole {
|
enum LegacyParamRole {
|
||||||
@@ -118,10 +99,6 @@ enum LegacyParamRole {
|
|||||||
Value,
|
Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pre-037 inline metric param matcher shape (formerly `data_value::RegexToMetricPrimitiveMatcher`),
|
|
||||||
/// kept only to support migrating profiles saved before ticket 037 unified params — see
|
|
||||||
/// `migrate_legacy_metric_params`. Never produced when serializing a record built from a
|
|
||||||
/// `DeclareMetricRequest`.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
struct LegacyMetricParamRecord {
|
struct LegacyMetricParamRecord {
|
||||||
param_name: String,
|
param_name: String,
|
||||||
@@ -133,25 +110,16 @@ struct LegacyMetricParamRecord {
|
|||||||
role: LegacyParamRole,
|
role: LegacyParamRole,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A stored metric declaration, matching `DeclareMetricRequest`'s shape. `legacy_params` is only
|
|
||||||
/// ever populated by deserializing a pre-037 profile file (old key name `"params"`) — see
|
|
||||||
/// `migrate_legacy_metric_params`, which converts it into `label_params`/`value_param` plus any
|
|
||||||
/// implicitly-created standalone params, and empties it back out so a subsequent save never writes
|
|
||||||
/// the old shape again.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct MetricDeclarationRecord {
|
pub struct MetricDeclarationRecord {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub message_match_regex: String,
|
pub message_match_regex: String,
|
||||||
/// Names of standalone `ParamDeclarationRecord`s used as this metric's bucket labels.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub label_params: Vec<String>,
|
pub label_params: Vec<String>,
|
||||||
/// Name of the standalone `ParamDeclarationRecord` (if any) whose value becomes this metric's bucket value.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub value_param: Option<String>,
|
pub value_param: Option<String>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::matched_labels`; `#[serde(default)]` so older profiles still load.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub matched_labels: Option<std::collections::BTreeMap<String, String>>,
|
pub matched_labels: Option<std::collections::BTreeMap<String, String>>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`; `#[serde(default)]` so older profiles still load.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub unmatched_labels: Option<std::collections::BTreeMap<String, String>>,
|
pub unmatched_labels: Option<std::collections::BTreeMap<String, String>>,
|
||||||
#[serde(default, rename = "params", skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, rename = "params", skip_serializing_if = "Vec::is_empty")]
|
||||||
@@ -185,15 +153,6 @@ impl From<&MetricDeclarationRecord> for DeclareMetricRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Migrates every metric in `profile` still carrying pre-037 inline matchers
|
|
||||||
/// (`MetricDeclarationRecord::legacy_params`, deserialized from an old profile file's `"params"`
|
|
||||||
/// key) into the unified shape: each matcher becomes a reference to a standalone
|
|
||||||
/// `ParamDeclarationRecord`, resolved by reusing an existing declared param of the exact same name
|
|
||||||
/// and shape (regex/capture-group-or-json-field-path/kind) when one exists, or else creating a new
|
|
||||||
/// one implicitly named `<metric_name>.<param_name>` (added to `profile.params`). Deterministic
|
|
||||||
/// naming means re-running this on an already-migrated profile is a no-op, and running it twice on
|
|
||||||
/// the same legacy data produces the same result. Returns whether anything changed, so the caller
|
|
||||||
/// can decide whether to persist the migration.
|
|
||||||
fn migrate_legacy_metric_params(profile: &mut Profile) -> bool {
|
fn migrate_legacy_metric_params(profile: &mut Profile) -> bool {
|
||||||
let mut migrated = false;
|
let mut migrated = false;
|
||||||
for metric_index in 0..profile.metrics.len() {
|
for metric_index in 0..profile.metrics.len() {
|
||||||
@@ -221,8 +180,6 @@ fn migrate_legacy_metric_params(profile: &mut Profile) -> bool {
|
|||||||
migrated
|
migrated
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves one legacy matcher to a standalone param name, reusing an existing declared param of
|
|
||||||
/// the same bare name and shape, or else creating `<metric_name>.<param_name>` on `profile.params`.
|
|
||||||
fn resolve_or_create_legacy_param(
|
fn resolve_or_create_legacy_param(
|
||||||
profile: &mut Profile,
|
profile: &mut Profile,
|
||||||
metric_name: &str,
|
metric_name: &str,
|
||||||
@@ -247,9 +204,6 @@ fn resolve_or_create_legacy_param(
|
|||||||
capture_group: matcher.capture_group.clone(),
|
capture_group: matcher.capture_group.clone(),
|
||||||
json_field_path: matcher.json_field_path.clone(),
|
json_field_path: matcher.json_field_path.clone(),
|
||||||
kind: matcher.kind,
|
kind: matcher.kind,
|
||||||
// Pre-037, a Value-role Duration matcher implicitly meant "elapsed time since the
|
|
||||||
// previous same-labeled event" (ticket 038's now-explicit `elapsed_since_last`) — carry
|
|
||||||
// that meaning forward rather than dropping it into a meaningless literal-parse matcher.
|
|
||||||
elapsed_since_last: matcher.role == LegacyParamRole::Value
|
elapsed_since_last: matcher.role == LegacyParamRole::Value
|
||||||
&& matcher.kind == ProfileMetricPrimitiveKind::Duration,
|
&& matcher.kind == ProfileMetricPrimitiveKind::Duration,
|
||||||
});
|
});
|
||||||
@@ -257,18 +211,6 @@ fn resolve_or_create_legacy_param(
|
|||||||
implicit_name
|
implicit_name
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Migrates every already-declared `Duration`-kind param that's referenced as some metric's
|
|
||||||
/// `value_param` into the explicit `elapsed_since_last` extractor (ticket 038). Before ticket 038, a
|
|
||||||
/// `Duration`-kind value param implicitly meant "elapsed time since the previous same-labeled
|
|
||||||
/// event" (see `crate::engine::apply_metric_to_log`'s pre-038 history) — this migration makes that
|
|
||||||
/// meaning explicit so `Duration` kind no longer has a role-dependent special case. Only params
|
|
||||||
/// actually referenced as *some* metric's value param are converted: a `Duration`-kind param used
|
|
||||||
/// purely as a label param already meant a literal parsed millisecond count, and keeps meaning that.
|
|
||||||
/// Runs after `migrate_legacy_metric_params` (which populates `value_param` from pre-037
|
|
||||||
/// `legacy_params` and already sets `elapsed_since_last` correctly for those via
|
|
||||||
/// `resolve_or_create_legacy_param`), so this only has new work to do for profiles saved between
|
|
||||||
/// ticket 037 and ticket 038 landing, where `value_param` already pointed at a bare `Duration`-kind
|
|
||||||
/// param straight from the unified shape. Returns whether anything changed.
|
|
||||||
fn migrate_duration_value_params(profile: &mut Profile) -> bool {
|
fn migrate_duration_value_params(profile: &mut Profile) -> bool {
|
||||||
let value_param_names: std::collections::HashSet<&str> =
|
let value_param_names: std::collections::HashSet<&str> =
|
||||||
profile.metrics.iter().filter_map(|metric| metric.value_param.as_deref()).collect();
|
profile.metrics.iter().filter_map(|metric| metric.value_param.as_deref()).collect();
|
||||||
@@ -285,7 +227,6 @@ fn migrate_duration_value_params(profile: &mut Profile) -> bool {
|
|||||||
migrated
|
migrated
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A chart type a dashboard panel can render; kept in sync with the frontend's `'bar' | 'line' | 'pie'` union (see `webapp/src/lib/api.ts`).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum ChartType {
|
pub enum ChartType {
|
||||||
@@ -294,7 +235,6 @@ pub enum ChartType {
|
|||||||
Pie,
|
Pie,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One panel within a dashboard: a PromQL query plus how to chart its result.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Panel {
|
pub struct Panel {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -303,7 +243,6 @@ pub struct Panel {
|
|||||||
pub chart_type: ChartType,
|
pub chart_type: ChartType,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A named set of panels, saved as part of a profile.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Dashboard {
|
pub struct Dashboard {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -312,7 +251,6 @@ pub struct Dashboard {
|
|||||||
pub panels: Vec<Panel>,
|
pub panels: Vec<Panel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates an id by hashing a seed (dashboard/panel name) with the current time, avoiding a `uuid` dependency for what's otherwise an opaque string.
|
|
||||||
fn generate_id(seed: &str) -> String {
|
fn generate_id(seed: &str) -> String {
|
||||||
let mut hasher = DefaultHasher::new();
|
let mut hasher = DefaultHasher::new();
|
||||||
seed.hash(&mut hasher);
|
seed.hash(&mut hasher);
|
||||||
@@ -331,7 +269,6 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A counter that splits every line into `category="error"`/`"other"` via `matched_labels`/`unmatched_labels` rather than a captured param, so case variants like "ERROR" collapse into one series; the `regex` crate lacks the lookaround needed to express "didn't match" any other way.
|
|
||||||
fn seeded_error_split_metric() -> MetricDeclarationRecord {
|
fn seeded_error_split_metric() -> MetricDeclarationRecord {
|
||||||
MetricDeclarationRecord {
|
MetricDeclarationRecord {
|
||||||
name: SEEDED_METRIC_NAME.to_string(),
|
name: SEEDED_METRIC_NAME.to_string(),
|
||||||
@@ -344,7 +281,6 @@ fn seeded_error_split_metric() -> MetricDeclarationRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The starter dashboard seeded into a brand-new `Default` profile: a line chart and a pie chart, both querying the seeded error-split counter.
|
|
||||||
fn seeded_default_dashboard() -> Dashboard {
|
fn seeded_default_dashboard() -> Dashboard {
|
||||||
let mut dashboard = Dashboard::new("Overview".to_string());
|
let mut dashboard = Dashboard::new("Overview".to_string());
|
||||||
dashboard.panels = vec![
|
dashboard.panels = vec![
|
||||||
@@ -364,7 +300,6 @@ fn seeded_default_dashboard() -> Dashboard {
|
|||||||
dashboard
|
dashboard
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A named bundle of parsing/indexing rules for a particular CLI app, plus any saved dashboards.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Profile {
|
pub struct Profile {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -387,7 +322,6 @@ impl Profile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replays every param/metric declaration stored in `profile` into `engine` (used on startup and in tests); any rejected declaration or send/recv failure is logged to stderr and skipped rather than panicking.
|
|
||||||
pub async fn load_into_engine(engine: &EngineHandle, profile: &Profile) {
|
pub async fn load_into_engine(engine: &EngineHandle, profile: &Profile) {
|
||||||
for record in &profile.params {
|
for record in &profile.params {
|
||||||
let (respond_to, response) = oneshot::channel();
|
let (respond_to, response) = oneshot::channel();
|
||||||
@@ -442,16 +376,11 @@ pub async fn load_into_engine(engine: &EngineHandle, profile: &Profile) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error returned by `Settings`'s profile-related mutations and lookups.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ProfileError {
|
pub enum ProfileError {
|
||||||
/// No profile with the given name exists.
|
|
||||||
NotFound,
|
NotFound,
|
||||||
/// A profile with the given name already exists.
|
|
||||||
DuplicateName,
|
DuplicateName,
|
||||||
/// The `Default` profile can't be deleted — there must always be at least one profile to fall back to.
|
|
||||||
CannotDeleteDefault,
|
CannotDeleteDefault,
|
||||||
/// Underlying I/O failure reading/writing the profile store or settings file.
|
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,10 +403,6 @@ impl From<io::Error> for ProfileError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads a legacy pre-049 `profiles.json` file (back when profiles lived in a separate file rather
|
|
||||||
/// than inline in `settings.json`) for `Settings::migrate_legacy_profiles_file`. Returns `None` if
|
|
||||||
/// the file doesn't exist or can't be read/parsed — treated as "nothing to migrate" rather than
|
|
||||||
/// failing settings load.
|
|
||||||
pub(crate) fn load_legacy_profile_store(path: &Path) -> Option<Vec<Profile>> {
|
pub(crate) fn load_legacy_profile_store(path: &Path) -> Option<Vec<Profile>> {
|
||||||
let contents = fs::read_to_string(path).ok()?;
|
let contents = fs::read_to_string(path).ok()?;
|
||||||
match serde_json::from_str::<Vec<Profile>>(&contents) {
|
match serde_json::from_str::<Vec<Profile>>(&contents) {
|
||||||
@@ -492,23 +417,6 @@ pub(crate) fn load_legacy_profile_store(path: &Path) -> Option<Vec<Profile>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turns any pre-unification/pre-explicit-duration metric or param shapes still present in
|
|
||||||
/// `profiles` into their current form; used by `Settings::load` on every startup (previously done
|
|
||||||
/// by the now-removed `ProfileStore::load`, before ticket 049 folded profile storage into
|
|
||||||
/// `settings.json`). Returns whether anything changed, so the caller can decide whether to
|
|
||||||
/// re-persist.
|
|
||||||
///
|
|
||||||
/// Ticket 037: turn any pre-unification inline metric matchers into standalone param references.
|
|
||||||
/// Deterministic naming makes this idempotent, so it's safe to run on every load; persisting the
|
|
||||||
/// result isn't required for correctness (migration re-runs identically next time), but it's done
|
|
||||||
/// here anyway so the on-disk file only carries the legacy `"params"` shape once, on the very next
|
|
||||||
/// save.
|
|
||||||
///
|
|
||||||
/// Ticket 038: follows on immediately after — once `migrate_legacy_metric_params` has resolved
|
|
||||||
/// every metric's `value_param`, convert any `Duration`-kind param still referenced that way into
|
|
||||||
/// the explicit `elapsed_since_last` extractor, so a profile saved either before 037 (inline
|
|
||||||
/// matchers) or between 037 and 038 (bare `value_param` reference) ends up behaving identically
|
|
||||||
/// under the new, explicit shape.
|
|
||||||
pub(crate) fn migrate_legacy_profile_shapes(profiles: &mut [Profile]) -> bool {
|
pub(crate) fn migrate_legacy_profile_shapes(profiles: &mut [Profile]) -> bool {
|
||||||
let mut any_migrated = false;
|
let mut any_migrated = false;
|
||||||
for profile in profiles {
|
for profile in profiles {
|
||||||
@@ -521,17 +429,11 @@ pub(crate) fn migrate_legacy_profile_shapes(profiles: &mut [Profile]) -> bool {
|
|||||||
any_migrated
|
any_migrated
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Profile CRUD, previously on the standalone `ProfileStore` (removed by ticket 049): every mutation
|
|
||||||
/// here reads/writes `self.profiles` directly and persists via `Settings::save`, so `settings.json` is
|
|
||||||
/// the single source of truth instead of a separate file kept in sync alongside it.
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
pub fn profile(&self, name: &str) -> Option<&Profile> {
|
pub fn profile(&self, name: &str) -> Option<&Profile> {
|
||||||
self.profiles.iter().find(|p| p.name == name)
|
self.profiles.iter().find(|p| p.name == name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new profile named `name`, persisting settings, copying the active profile's
|
|
||||||
/// params/metrics/dashboards as a starting point rather than starting blank. Fails with
|
|
||||||
/// `DuplicateName` if the name exists.
|
|
||||||
pub fn create_profile(&mut self, settings_path: &Path, name: String) -> Result<(), ProfileError> {
|
pub fn create_profile(&mut self, settings_path: &Path, name: String) -> Result<(), ProfileError> {
|
||||||
if self.profile(&name).is_some() {
|
if self.profile(&name).is_some() {
|
||||||
return Err(ProfileError::DuplicateName);
|
return Err(ProfileError::DuplicateName);
|
||||||
@@ -547,8 +449,6 @@ impl Settings {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes the profile named `name`, persisting settings, clearing `active_profile` if it was
|
|
||||||
/// active. Fails with `NotFound`, or `CannotDeleteDefault` for the `Default` profile.
|
|
||||||
pub fn delete_profile(&mut self, settings_path: &Path, name: &str) -> Result<(), ProfileError> {
|
pub fn delete_profile(&mut self, settings_path: &Path, name: &str) -> Result<(), ProfileError> {
|
||||||
if name == DEFAULT_PROFILE_NAME {
|
if name == DEFAULT_PROFILE_NAME {
|
||||||
return Err(ProfileError::CannotDeleteDefault);
|
return Err(ProfileError::CannotDeleteDefault);
|
||||||
@@ -562,9 +462,6 @@ impl Settings {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensures a `Default` profile exists (seeding a starter dashboard/metric only on first
|
|
||||||
/// creation), and falls back to active if nothing else is. Called once on startup before anything
|
|
||||||
/// else touches profiles.
|
|
||||||
pub fn ensure_default_profile(&mut self, settings_path: &Path) -> Result<(), ProfileError> {
|
pub fn ensure_default_profile(&mut self, settings_path: &Path) -> Result<(), ProfileError> {
|
||||||
if self.profile(DEFAULT_PROFILE_NAME).is_none() {
|
if self.profile(DEFAULT_PROFILE_NAME).is_none() {
|
||||||
let mut profile = Profile::new(DEFAULT_PROFILE_NAME.to_string());
|
let mut profile = Profile::new(DEFAULT_PROFILE_NAME.to_string());
|
||||||
@@ -579,9 +476,6 @@ impl Settings {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets (or clears, with `None`) the active profile. Fails with `NotFound` if `name` is `Some`
|
|
||||||
/// and no such profile exists. Doesn't itself reload declarations into a running engine — that
|
|
||||||
/// happens on next startup via `load_into_engine`.
|
|
||||||
pub fn set_active_profile(
|
pub fn set_active_profile(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
@@ -597,11 +491,6 @@ impl Settings {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Empties `profile_name`'s stored param declarations, persisting settings — the counterpart to
|
|
||||||
/// `record_param_declaration` for `DELETE /api/params` (ticket 052). A profile only ever stores
|
|
||||||
/// manually-declared params (auto-derived ones live only in the engine, never persisted here), so
|
|
||||||
/// this is enough to keep the whole set from reappearing on next load; the engine-side clear
|
|
||||||
/// re-derives whatever auto params the retained log text still supports.
|
|
||||||
pub fn clear_param_declarations(
|
pub fn clear_param_declarations(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
@@ -617,9 +506,6 @@ impl Settings {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records/updates a declared param into `profile_name`'s stored declarations (matched by param
|
|
||||||
/// name), persisting settings. Intended to be called by the HTTP API alongside declaring against
|
|
||||||
/// the engine, so declarations survive a restart.
|
|
||||||
pub fn record_param_declaration(
|
pub fn record_param_declaration(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
@@ -640,17 +526,6 @@ impl Settings {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records/updates a declared metric into `profile_name`'s stored declarations (matched by metric
|
|
||||||
/// name), persisting settings; same integration intent as `record_param_declaration`.
|
|
||||||
///
|
|
||||||
/// `promoted_params` is any of the metric's referenced `label_params`/`value_param` that live
|
|
||||||
/// only as auto-derived engine state (see `crate::engine::auto_declare_params`) rather than a
|
|
||||||
/// persisted profile param — the caller (`ApiState::persist_metric_declaration`) resolves these
|
|
||||||
/// against the live engine before calling in. Without persisting them alongside the metric, a
|
|
||||||
/// restart would replay the metric before any log line re-derives its auto params, and the
|
|
||||||
/// engine would reject it as referencing an undeclared param. Each is added only if `profile
|
|
||||||
/// .params` doesn't already have an entry of that name, so an existing (e.g. manually-declared)
|
|
||||||
/// param is never clobbered.
|
|
||||||
pub fn record_metric_declaration(
|
pub fn record_metric_declaration(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
@@ -677,10 +552,6 @@ impl Settings {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same as `record_metric_declaration`, for an in-place edit that may rename the metric: drops
|
|
||||||
/// any persisted record under `old_name` first (when it differs from the new name) so a rename
|
|
||||||
/// doesn't leave a stale duplicate declaration behind, then upserts by the new name as usual.
|
|
||||||
/// `promoted_params` has the same meaning as in `record_metric_declaration`.
|
|
||||||
pub fn record_metric_declaration_update(
|
pub fn record_metric_declaration_update(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
@@ -711,11 +582,6 @@ impl Settings {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes `name`'s persisted metric declaration from `profile_name` (ticket 051), so it doesn't
|
|
||||||
/// reappear from `settings.json` on the next restart. A no-op (not an error) if the profile has
|
|
||||||
/// no record under that name — matches `Settings::remove_css`'s sibling calls in spirit, but
|
|
||||||
/// deleting an already-gone declaration is fine here since the caller (the API layer) has
|
|
||||||
/// already confirmed against the live engine index whether the metric existed at all.
|
|
||||||
pub fn remove_metric_declaration(
|
pub fn remove_metric_declaration(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
@@ -732,14 +598,11 @@ impl Settings {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lists the dashboards saved on `profile_name`; fails with `NotFound` if it doesn't exist.
|
|
||||||
pub fn list_dashboards(&self, profile_name: &str) -> Result<&[Dashboard], ProfileError> {
|
pub fn list_dashboards(&self, profile_name: &str) -> Result<&[Dashboard], ProfileError> {
|
||||||
let profile = self.profile(profile_name).ok_or(ProfileError::NotFound)?;
|
let profile = self.profile(profile_name).ok_or(ProfileError::NotFound)?;
|
||||||
Ok(&profile.dashboards)
|
Ok(&profile.dashboards)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new, empty dashboard named `name` on `profile_name`, persisting settings; fails
|
|
||||||
/// with `NotFound` if the profile doesn't exist.
|
|
||||||
pub fn create_dashboard(
|
pub fn create_dashboard(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
@@ -757,9 +620,6 @@ impl Settings {
|
|||||||
Ok(dashboard)
|
Ok(dashboard)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces the dashboard identified by `dashboard_id` with `dashboard`, persisting settings;
|
|
||||||
/// `dashboard.id` is forced to `dashboard_id` so a caller can't change a dashboard's identity
|
|
||||||
/// through this call.
|
|
||||||
pub fn save_dashboard(
|
pub fn save_dashboard(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
@@ -783,8 +643,6 @@ impl Settings {
|
|||||||
Ok(dashboard)
|
Ok(dashboard)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes the dashboard identified by `dashboard_id` from `profile_name`, persisting settings;
|
|
||||||
/// fails with `NotFound` if either doesn't exist.
|
|
||||||
pub fn delete_dashboard(
|
pub fn delete_dashboard(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
@@ -839,10 +697,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ticket 075: reads no longer go through `EngineHandle::tx` — this reads straight off
|
|
||||||
/// `EngineHandle::reader_factory`, same as the real API layer. Only used here for
|
|
||||||
/// `ParamDeclarations`/`MetricDeclarations` lookups, neither of which ever needs the
|
|
||||||
/// `EnsureSortHelperIndex` round-trip a sorted `Logs` query would (see `api.rs`'s own `ask`).
|
|
||||||
async fn ask(engine: &EngineHandle, query: EngineQuery) -> EngineResponse {
|
async fn ask(engine: &EngineHandle, query: EngineQuery) -> EngineResponse {
|
||||||
let read_handle = engine.reader_factory.handle();
|
let read_handle = engine.reader_factory.handle();
|
||||||
let guard = read_handle.enter().expect("engine unavailable");
|
let guard = read_handle.enter().expect("engine unavailable");
|
||||||
@@ -864,7 +718,6 @@ mod tests {
|
|||||||
let dup = settings.create_profile(&settings_path, "web".to_string());
|
let dup = settings.create_profile(&settings_path, "web".to_string());
|
||||||
assert!(matches!(dup, Err(ProfileError::DuplicateName)));
|
assert!(matches!(dup, Err(ProfileError::DuplicateName)));
|
||||||
|
|
||||||
// Reload from disk: profiles persisted.
|
|
||||||
let reloaded_settings = Settings::load(&settings_path);
|
let reloaded_settings = Settings::load(&settings_path);
|
||||||
assert_eq!(reloaded_settings.profiles.len(), 2);
|
assert_eq!(reloaded_settings.profiles.len(), 2);
|
||||||
assert!(reloaded_settings.profile("web").is_some());
|
assert!(reloaded_settings.profile("web").is_some());
|
||||||
@@ -889,7 +742,6 @@ mod tests {
|
|||||||
assert!(settings.profile(DEFAULT_PROFILE_NAME).is_some());
|
assert!(settings.profile(DEFAULT_PROFILE_NAME).is_some());
|
||||||
assert_eq!(settings.active_profile, Some(DEFAULT_PROFILE_NAME.to_string()));
|
assert_eq!(settings.active_profile, Some(DEFAULT_PROFILE_NAME.to_string()));
|
||||||
|
|
||||||
// Idempotent: doesn't duplicate the profile or clobber an already-active (non-Default) profile.
|
|
||||||
settings.create_profile(&settings_path, "web".to_string()).unwrap();
|
settings.create_profile(&settings_path, "web".to_string()).unwrap();
|
||||||
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
|
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
|
||||||
settings.ensure_default_profile(&settings_path).unwrap();
|
settings.ensure_default_profile(&settings_path).unwrap();
|
||||||
@@ -926,7 +778,6 @@ mod tests {
|
|||||||
.any(|p| p.chart_type == ChartType::Line && p.promql.contains(SEEDED_METRIC_NAME)));
|
.any(|p| p.chart_type == ChartType::Line && p.promql.contains(SEEDED_METRIC_NAME)));
|
||||||
assert!(dashboard.panels.iter().any(|p| p.chart_type == ChartType::Pie && p.promql == SEEDED_METRIC_NAME));
|
assert!(dashboard.panels.iter().any(|p| p.chart_type == ChartType::Pie && p.promql == SEEDED_METRIC_NAME));
|
||||||
|
|
||||||
// Reload (simulating a restart) and ensure_default again: must not re-seed or duplicate.
|
|
||||||
let mut reloaded = Settings::load(&settings_path);
|
let mut reloaded = Settings::load(&settings_path);
|
||||||
reloaded.ensure_default_profile(&settings_path).unwrap();
|
reloaded.ensure_default_profile(&settings_path).unwrap();
|
||||||
let default_profile = reloaded.profile(DEFAULT_PROFILE_NAME).unwrap();
|
let default_profile = reloaded.profile(DEFAULT_PROFILE_NAME).unwrap();
|
||||||
@@ -1009,7 +860,6 @@ mod tests {
|
|||||||
assert_eq!(copy.params, settings.profile("web").unwrap().params);
|
assert_eq!(copy.params, settings.profile("web").unwrap().params);
|
||||||
assert_eq!(copy.dashboards, settings.profile("web").unwrap().dashboards);
|
assert_eq!(copy.dashboards, settings.profile("web").unwrap().dashboards);
|
||||||
|
|
||||||
// With no active profile, new profiles start empty as before.
|
|
||||||
settings.set_active_profile(&settings_path, None).unwrap();
|
settings.set_active_profile(&settings_path, None).unwrap();
|
||||||
settings.create_profile(&settings_path, "blank".to_string()).unwrap();
|
settings.create_profile(&settings_path, "blank".to_string()).unwrap();
|
||||||
let blank = settings.profile("blank").unwrap();
|
let blank = settings.profile("blank").unwrap();
|
||||||
@@ -1029,17 +879,14 @@ mod tests {
|
|||||||
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
|
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
|
||||||
assert_eq!(settings.active_profile, Some("web".to_string()));
|
assert_eq!(settings.active_profile, Some("web".to_string()));
|
||||||
|
|
||||||
// Switching to another known profile works and persists.
|
|
||||||
settings.set_active_profile(&settings_path, Some("worker".to_string())).unwrap();
|
settings.set_active_profile(&settings_path, Some("worker".to_string())).unwrap();
|
||||||
let reloaded = Settings::load(&settings_path);
|
let reloaded = Settings::load(&settings_path);
|
||||||
assert_eq!(reloaded.active_profile, Some("worker".to_string()));
|
assert_eq!(reloaded.active_profile, Some("worker".to_string()));
|
||||||
|
|
||||||
// Setting an unknown profile active is rejected, leaving the previous value.
|
|
||||||
let err = settings.set_active_profile(&settings_path, Some("ghost".to_string()));
|
let err = settings.set_active_profile(&settings_path, Some("ghost".to_string()));
|
||||||
assert!(matches!(err, Err(ProfileError::NotFound)));
|
assert!(matches!(err, Err(ProfileError::NotFound)));
|
||||||
assert_eq!(settings.active_profile, Some("worker".to_string()));
|
assert_eq!(settings.active_profile, Some("worker".to_string()));
|
||||||
|
|
||||||
// Clearing the active profile works.
|
|
||||||
settings.set_active_profile(&settings_path, None).unwrap();
|
settings.set_active_profile(&settings_path, None).unwrap();
|
||||||
assert_eq!(settings.active_profile, None);
|
assert_eq!(settings.active_profile, None);
|
||||||
}
|
}
|
||||||
@@ -1064,14 +911,12 @@ mod tests {
|
|||||||
assert_eq!(profile.metrics.len(), 1);
|
assert_eq!(profile.metrics.len(), 1);
|
||||||
assert_eq!(profile.metrics[0].name, "requests");
|
assert_eq!(profile.metrics[0].name, "requests");
|
||||||
|
|
||||||
// Re-declaring the same name updates in place rather than duplicating.
|
|
||||||
let param_req_v2 = param_request("level", r"lvl=(?P<level>\w+)", "level");
|
let param_req_v2 = param_request("level", r"lvl=(?P<level>\w+)", "level");
|
||||||
settings.record_param_declaration(&settings_path, "web", ¶m_req_v2).unwrap();
|
settings.record_param_declaration(&settings_path, "web", ¶m_req_v2).unwrap();
|
||||||
let profile = settings.profile("web").unwrap();
|
let profile = settings.profile("web").unwrap();
|
||||||
assert_eq!(profile.params.len(), 1);
|
assert_eq!(profile.params.len(), 1);
|
||||||
assert_eq!(profile.params[0].message_match_regex, r"lvl=(?P<level>\w+)");
|
assert_eq!(profile.params[0].message_match_regex, r"lvl=(?P<level>\w+)");
|
||||||
|
|
||||||
// Reload from disk: recorded declarations survive a restart.
|
|
||||||
let reloaded = Settings::load(&settings_path);
|
let reloaded = Settings::load(&settings_path);
|
||||||
let profile = reloaded.profile("web").unwrap();
|
let profile = reloaded.profile("web").unwrap();
|
||||||
assert_eq!(profile.params.len(), 1);
|
assert_eq!(profile.params.len(), 1);
|
||||||
@@ -1092,22 +937,18 @@ mod tests {
|
|||||||
settings.remove_metric_declaration(&settings_path, "web", "requests").unwrap();
|
settings.remove_metric_declaration(&settings_path, "web", "requests").unwrap();
|
||||||
assert!(settings.profile("web").unwrap().metrics.is_empty());
|
assert!(settings.profile("web").unwrap().metrics.is_empty());
|
||||||
|
|
||||||
// Removing an unknown metric name is a harmless no-op, not an error.
|
|
||||||
settings.remove_metric_declaration(&settings_path, "web", "never-declared").unwrap();
|
settings.remove_metric_declaration(&settings_path, "web", "never-declared").unwrap();
|
||||||
|
|
||||||
// Reload from disk: the removal itself survives a restart (ticket 051's acceptance criterion).
|
|
||||||
let reloaded = Settings::load(&settings_path);
|
let reloaded = Settings::load(&settings_path);
|
||||||
assert!(reloaded.profile("web").unwrap().metrics.is_empty());
|
assert!(reloaded.profile("web").unwrap().metrics.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acceptance criterion: declaring params/metrics, "restarting" (reloading settings from disk), and replaying into a fresh engine reproduces the same declarations and still ingests lines, without re-declaring by hand.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn profile_round_trip_reproduces_declarations_in_fresh_engine() {
|
async fn profile_round_trip_reproduces_declarations_in_fresh_engine() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let settings_path = dir.path().join("settings.json");
|
let settings_path = dir.path().join("settings.json");
|
||||||
let mut settings = Settings::default();
|
let mut settings = Settings::default();
|
||||||
|
|
||||||
// Declare directly into a profile, as the HTTP API will do via the `record_*_declaration` hooks.
|
|
||||||
settings.create_profile(&settings_path, "web".to_string()).unwrap();
|
settings.create_profile(&settings_path, "web".to_string()).unwrap();
|
||||||
settings
|
settings
|
||||||
.record_param_declaration(
|
.record_param_declaration(
|
||||||
@@ -1116,8 +957,6 @@ mod tests {
|
|||||||
¶m_request("level", r"level=(?P<level>\w+)", "level"),
|
¶m_request("level", r"level=(?P<level>\w+)", "level"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// A metric now references params by name (ticket 037), so "duration" must be declared as a
|
|
||||||
// standalone param before the metric below can reference it as a label param.
|
|
||||||
settings
|
settings
|
||||||
.record_param_declaration(
|
.record_param_declaration(
|
||||||
&settings_path,
|
&settings_path,
|
||||||
@@ -1135,18 +974,15 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
|
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
|
||||||
|
|
||||||
// Simulate a restart: drop everything in memory, reload from disk.
|
|
||||||
drop(settings);
|
drop(settings);
|
||||||
let settings = Settings::load(&settings_path);
|
let settings = Settings::load(&settings_path);
|
||||||
let active_name = settings.active_profile.clone().unwrap();
|
let active_name = settings.active_profile.clone().unwrap();
|
||||||
let profile = settings.profile(&active_name).unwrap().clone();
|
let profile = settings.profile(&active_name).unwrap().clone();
|
||||||
|
|
||||||
// Spawn a fresh engine and replay the profile's declarations into it.
|
|
||||||
let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone()));
|
let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone()));
|
||||||
let engine_tx = crate::engine::spawn(settings_handle, 16);
|
let engine_tx = crate::engine::spawn(settings_handle, 16);
|
||||||
load_into_engine(&engine_tx, &profile).await;
|
load_into_engine(&engine_tx, &profile).await;
|
||||||
|
|
||||||
// Ingest a line and confirm the engine works with the replayed declarations (no re-declaration needed).
|
|
||||||
engine_tx
|
engine_tx
|
||||||
.tx
|
.tx
|
||||||
.send(EngineMessage::Ingest(IngestedLine {
|
.send(EngineMessage::Ingest(IngestedLine {
|
||||||
@@ -1198,7 +1034,6 @@ mod tests {
|
|||||||
assert_eq!(logs[0].text, "level=info request duration=42");
|
assert_eq!(logs[0].text, "level=info request duration=42");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acceptance criterion: a dashboard (with a panel) survives reloading settings from disk.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dashboard_create_save_delete_round_trip_and_persists_across_reload() {
|
fn dashboard_create_save_delete_round_trip_and_persists_across_reload() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -1224,7 +1059,6 @@ mod tests {
|
|||||||
assert_eq!(saved.panels.len(), 1);
|
assert_eq!(saved.panels.len(), 1);
|
||||||
assert_eq!(saved.id, dashboard.id);
|
assert_eq!(saved.id, dashboard.id);
|
||||||
|
|
||||||
// Simulate a restart: drop and reload from disk.
|
|
||||||
drop(settings);
|
drop(settings);
|
||||||
let mut reloaded = Settings::load(&settings_path);
|
let mut reloaded = Settings::load(&settings_path);
|
||||||
let dashboards = reloaded.list_dashboards("web").unwrap();
|
let dashboards = reloaded.list_dashboards("web").unwrap();
|
||||||
@@ -1233,23 +1067,17 @@ mod tests {
|
|||||||
assert_eq!(dashboards[0].panels.len(), 1);
|
assert_eq!(dashboards[0].panels.len(), 1);
|
||||||
assert_eq!(dashboards[0].panels[0].promql, r#"requests{path="/a"}"#);
|
assert_eq!(dashboards[0].panels[0].promql, r#"requests{path="/a"}"#);
|
||||||
|
|
||||||
// Deleting removes it, and that too persists.
|
|
||||||
reloaded.delete_dashboard(&settings_path, "web", &dashboard.id).unwrap();
|
reloaded.delete_dashboard(&settings_path, "web", &dashboard.id).unwrap();
|
||||||
assert!(reloaded.list_dashboards("web").unwrap().is_empty());
|
assert!(reloaded.list_dashboards("web").unwrap().is_empty());
|
||||||
let reloaded_again = Settings::load(&settings_path);
|
let reloaded_again = Settings::load(&settings_path);
|
||||||
assert!(reloaded_again.list_dashboards("web").unwrap().is_empty());
|
assert!(reloaded_again.list_dashboards("web").unwrap().is_empty());
|
||||||
|
|
||||||
// Unknown profile/dashboard both fail with NotFound.
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
reloaded_again.list_dashboards("ghost"),
|
reloaded_again.list_dashboards("ghost"),
|
||||||
Err(ProfileError::NotFound)
|
Err(ProfileError::NotFound)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes a pre-037 `settings.json` (with an inline `profiles` array whose metrics carry legacy
|
|
||||||
/// `"params"` matchers rather than `label_params`/`value_param`) directly to disk, bypassing
|
|
||||||
/// `Settings`/`Profile` entirely so this doesn't accidentally exercise the current
|
|
||||||
/// (post-migration) shape. `profiles_json` is the raw JSON array of profiles.
|
|
||||||
fn write_legacy_settings_json(settings_path: &Path, profiles_json: &str) {
|
fn write_legacy_settings_json(settings_path: &Path, profiles_json: &str) {
|
||||||
fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
|
fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
|
||||||
let contents = format!(
|
let contents = format!(
|
||||||
@@ -1258,9 +1086,6 @@ mod tests {
|
|||||||
fs::write(settings_path, contents).unwrap();
|
fs::write(settings_path, contents).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acceptance criterion: a profile saved before ticket 037 (inline `param_name`/`capture_group`/
|
|
||||||
/// `kind`/`role` matchers embedded in the metric) loads with those matchers turned into
|
|
||||||
/// standalone param declarations referenced by name, and the metric still ingests identically.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migrates_legacy_inline_metric_params_into_standalone_declarations() {
|
fn migrates_legacy_inline_metric_params_into_standalone_declarations() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -1289,7 +1114,6 @@ mod tests {
|
|||||||
let settings = Settings::load(&settings_path);
|
let settings = Settings::load(&settings_path);
|
||||||
let profile = settings.profile("legacy").unwrap();
|
let profile = settings.profile("legacy").unwrap();
|
||||||
|
|
||||||
// No pre-existing bare "path"/"duration" param, so implicit ones were created, named after the metric.
|
|
||||||
assert_eq!(profile.params.len(), 2);
|
assert_eq!(profile.params.len(), 2);
|
||||||
let path_param = profile.params.iter().find(|p| p.name == "requests.path").unwrap();
|
let path_param = profile.params.iter().find(|p| p.name == "requests.path").unwrap();
|
||||||
assert_eq!(path_param.message_match_regex, profile.metrics[0].message_match_regex);
|
assert_eq!(path_param.message_match_regex, profile.metrics[0].message_match_regex);
|
||||||
@@ -1302,11 +1126,9 @@ mod tests {
|
|||||||
assert_eq!(profile.metrics[0].label_params, vec!["requests.path".to_string()]);
|
assert_eq!(profile.metrics[0].label_params, vec!["requests.path".to_string()]);
|
||||||
assert_eq!(profile.metrics[0].value_param, Some("requests.duration".to_string()));
|
assert_eq!(profile.metrics[0].value_param, Some("requests.duration".to_string()));
|
||||||
|
|
||||||
// The migration is persisted, so the on-disk file no longer carries the legacy "params" shape.
|
|
||||||
let reloaded_raw = fs::read_to_string(&settings_path).unwrap();
|
let reloaded_raw = fs::read_to_string(&settings_path).unwrap();
|
||||||
assert!(!reloaded_raw.contains("param_name"));
|
assert!(!reloaded_raw.contains("param_name"));
|
||||||
|
|
||||||
// Replaying into a fresh engine behaves identically to before migration: the metric still ingests and buckets.
|
|
||||||
let profile = profile.clone();
|
let profile = profile.clone();
|
||||||
let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(Settings::default()));
|
let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(Settings::default()));
|
||||||
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||||
@@ -1332,10 +1154,6 @@ mod tests {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acceptance criterion (ticket 038): a pre-037 legacy profile whose inline `Value`-role matcher
|
|
||||||
/// used `kind: "duration"` (the old "elapsed time since previous event" special case) migrates
|
|
||||||
/// into a standalone param with the new explicit `elapsed_since_last: true` extractor, not just a
|
|
||||||
/// bare `Duration`-kind param that would now (post-038) mean a literal parsed millisecond count.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migrates_legacy_value_role_duration_matcher_into_elapsed_since_last_param() {
|
fn migrates_legacy_value_role_duration_matcher_into_elapsed_since_last_param() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -1370,11 +1188,6 @@ mod tests {
|
|||||||
assert_eq!(profile.metrics[0].value_param, Some("login_gap.gap".to_string()));
|
assert_eq!(profile.metrics[0].value_param, Some("login_gap.gap".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acceptance criterion (ticket 038): a profile saved after ticket 037 landed but before ticket
|
|
||||||
/// 038 — so it already carries the unified `label_params`/`value_param` shape rather than legacy
|
|
||||||
/// inline matchers, with a bare `Duration`-kind param referenced as a metric's `value_param` — is
|
|
||||||
/// migrated the same way: the param gains `elapsed_since_last: true` so its behavior is unchanged
|
|
||||||
/// (elapsed time since the previous same-labeled event) under the new explicit extractor.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migrates_post_037_bare_duration_value_param_into_elapsed_since_last() {
|
fn migrates_post_037_bare_duration_value_param_into_elapsed_since_last() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -1406,13 +1219,9 @@ mod tests {
|
|||||||
|
|
||||||
let gap_param = profile.params.iter().find(|p| p.name == "gap").unwrap();
|
let gap_param = profile.params.iter().find(|p| p.name == "gap").unwrap();
|
||||||
assert!(gap_param.elapsed_since_last);
|
assert!(gap_param.elapsed_since_last);
|
||||||
// A Duration-kind param not referenced as any value_param is left untouched (still a literal parse).
|
|
||||||
assert!(!profile.params.iter().find(|p| p.name == "user").unwrap().elapsed_since_last);
|
assert!(!profile.params.iter().find(|p| p.name == "user").unwrap().elapsed_since_last);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When a bare-named param already declared on the profile matches a legacy matcher's shape
|
|
||||||
/// exactly (same regex/capture-group/kind), migration reuses it by name instead of creating a
|
|
||||||
/// redundant `<metric>.<param>` declaration.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_reuses_an_existing_matching_bare_named_param() {
|
fn migration_reuses_an_existing_matching_bare_named_param() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -1442,14 +1251,11 @@ mod tests {
|
|||||||
let settings = Settings::load(&settings_path);
|
let settings = Settings::load(&settings_path);
|
||||||
let profile = settings.profile("legacy").unwrap();
|
let profile = settings.profile("legacy").unwrap();
|
||||||
|
|
||||||
// Reused the existing "path" declaration rather than creating "requests.path".
|
|
||||||
assert_eq!(profile.params.len(), 1);
|
assert_eq!(profile.params.len(), 1);
|
||||||
assert_eq!(profile.params[0].name, "path");
|
assert_eq!(profile.params[0].name, "path");
|
||||||
assert_eq!(profile.metrics[0].label_params, vec!["path".to_string()]);
|
assert_eq!(profile.metrics[0].label_params, vec!["path".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Migration is idempotent: loading an already-migrated (or never-legacy) profile a second time
|
|
||||||
/// doesn't change anything or re-trigger a save.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_is_a_noop_on_an_already_migrated_profile() {
|
fn migration_is_a_noop_on_an_already_migrated_profile() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -1473,11 +1279,6 @@ mod tests {
|
|||||||
assert_eq!(before, after);
|
assert_eq!(before, after);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acceptance criterion: a metric declared against a param that only exists as auto-derived
|
|
||||||
/// engine state (not yet a persisted profile param) still ingests correctly after a simulated
|
|
||||||
/// restart, because `record_metric_declaration`'s `promoted_params` persists that param into the
|
|
||||||
/// profile alongside the metric. Without this, `load_into_engine` would reject the metric on
|
|
||||||
/// restart since declarations replay before any log line re-derives the auto param.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn metric_referencing_auto_derived_param_survives_restart_via_promotion() {
|
async fn metric_referencing_auto_derived_param_survives_restart_via_promotion() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -1487,8 +1288,6 @@ mod tests {
|
|||||||
settings.create_profile(&settings_path, "web".to_string()).unwrap();
|
settings.create_profile(&settings_path, "web".to_string()).unwrap();
|
||||||
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
|
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
|
||||||
|
|
||||||
// Simulate the auto-derive path: ingest a line carrying a logfmt key before any param is
|
|
||||||
// manually declared, so `dx` only exists as engine-internal auto-derived state.
|
|
||||||
let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone()));
|
let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone()));
|
||||||
let engine_tx = crate::engine::spawn(settings_handle, 16);
|
let engine_tx = crate::engine::spawn(settings_handle, 16);
|
||||||
engine_tx
|
engine_tx
|
||||||
@@ -1501,8 +1300,6 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Declare a metric referencing the auto-derived "dx" param — this succeeds live (the param
|
|
||||||
// exists in the engine), mirroring the interactive-declare flow that hit this bug.
|
|
||||||
let metric_req = metric_request("direction_x", r"dx=(?P<dx>\S+) moved", "dx");
|
let metric_req = metric_request("direction_x", r"dx=(?P<dx>\S+) moved", "dx");
|
||||||
let (respond_to, response) = oneshot::channel();
|
let (respond_to, response) = oneshot::channel();
|
||||||
engine_tx
|
engine_tx
|
||||||
@@ -1512,7 +1309,6 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
response.await.unwrap().unwrap();
|
response.await.unwrap().unwrap();
|
||||||
|
|
||||||
// Promote "dx" from its live (auto-derived) engine state, as `ApiState::persist_metric_declaration` does.
|
|
||||||
let EngineResponse::ParamDeclarations(live_params) =
|
let EngineResponse::ParamDeclarations(live_params) =
|
||||||
ask(&engine_tx, EngineQuery::ParamDeclarations).await
|
ask(&engine_tx, EngineQuery::ParamDeclarations).await
|
||||||
else {
|
else {
|
||||||
@@ -1530,7 +1326,6 @@ mod tests {
|
|||||||
};
|
};
|
||||||
settings.record_metric_declaration(&settings_path, "web", &metric_req, &[promoted]).unwrap();
|
settings.record_metric_declaration(&settings_path, "web", &metric_req, &[promoted]).unwrap();
|
||||||
|
|
||||||
// Simulate a restart: drop everything, reload from disk into a *fresh* engine with no prior ingestion.
|
|
||||||
drop(settings);
|
drop(settings);
|
||||||
let reloaded_settings = Settings::load(&settings_path);
|
let reloaded_settings = Settings::load(&settings_path);
|
||||||
let profile = reloaded_settings.profile("web").unwrap().clone();
|
let profile = reloaded_settings.profile("web").unwrap().clone();
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
//! 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.
|
|
||||||
//! `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;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
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,
|
Count,
|
||||||
Rate,
|
Rate,
|
||||||
/// Buckets counter values into fixed-width buckets; requires `bucket_width`.
|
|
||||||
Histogram,
|
Histogram,
|
||||||
/// The most-recent counter value per label combination.
|
|
||||||
Gauge,
|
Gauge,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,25 +21,17 @@ pub struct PromqlQuery {
|
|||||||
pub agg: Option<AggOp>,
|
pub agg: Option<AggOp>,
|
||||||
pub metric_name: String,
|
pub metric_name: String,
|
||||||
pub matchers: Vec<LabelMatcher>,
|
pub matchers: Vec<LabelMatcher>,
|
||||||
/// Duration range selector, e.g. `[5m]`; only meaningful for `rate()`.
|
|
||||||
pub range: Option<Duration>,
|
pub range: Option<Duration>,
|
||||||
/// Bare-number range selector, e.g. `[10]`; only meaningful for `histogram()`, where it's the bucket width.
|
|
||||||
pub bucket_width: Option<f64>,
|
pub bucket_width: Option<f64>,
|
||||||
/// Set when `agg` is `Sum`/`Avg` and it wraps an inner `rate(metric[range])` rather than a bare vector
|
|
||||||
/// selector, e.g. `sum(rate(requests[5m]))`. `range` above carries the inner `rate()`'s duration in that case.
|
|
||||||
pub inner_rate: bool,
|
pub inner_rate: bool,
|
||||||
/// Labels named in a trailing `by (...)` clause; empty means "collapse to a single series" (the historical
|
|
||||||
/// behavior of `sum()`/`avg()`). When non-empty, one series is kept per distinct combination of these labels.
|
|
||||||
pub by_labels: Vec<String>,
|
pub by_labels: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a `[...]` range selector turned out to hold: a duration, or a bare width.
|
|
||||||
enum RangeSelector {
|
enum RangeSelector {
|
||||||
Duration(Duration),
|
Duration(Duration),
|
||||||
BareWidth(f64),
|
BareWidth(f64),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses a PromQL query string, returning a human-readable error message on malformed input.
|
|
||||||
pub fn parse_query(input: &str) -> Result<PromqlQuery, String> {
|
pub fn parse_query(input: &str) -> Result<PromqlQuery, String> {
|
||||||
let mut parser = Parser::new(input);
|
let mut parser = Parser::new(input);
|
||||||
let query = parser.parse_query()?;
|
let query = parser.parse_query()?;
|
||||||
@@ -177,7 +160,6 @@ impl<'a> Parser<'a> {
|
|||||||
Ok(matchers)
|
Ok(matchers)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses `[<digits><unit>]` (a duration, e.g. `[5m]`) or a bare `[<digits>]` (a bucket width).
|
|
||||||
fn parse_range_selector(&mut self) -> Result<Option<RangeSelector>, String> {
|
fn parse_range_selector(&mut self) -> Result<Option<RangeSelector>, String> {
|
||||||
self.skip_whitespace();
|
self.skip_whitespace();
|
||||||
if self.peek() != Some('[') {
|
if self.peek() != Some('[') {
|
||||||
@@ -239,7 +221,6 @@ impl<'a> Parser<'a> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses a trailing `by (label, ...)` clause, if present; returns an empty vec otherwise.
|
|
||||||
fn parse_by_clause(&mut self) -> Result<Vec<String>, String> {
|
fn parse_by_clause(&mut self) -> Result<Vec<String>, String> {
|
||||||
self.skip_whitespace();
|
self.skip_whitespace();
|
||||||
let checkpoint = self.pos;
|
let checkpoint = self.pos;
|
||||||
@@ -289,14 +270,11 @@ impl<'a> Parser<'a> {
|
|||||||
_ => 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() };
|
let prefix_by_labels = if agg.is_some() { self.parse_by_clause()? } else { Vec::new() };
|
||||||
self.skip_whitespace();
|
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();
|
||||||
// `sum()`/`avg()` may wrap `rate(metric[range])` directly instead of a bare vector selector.
|
|
||||||
let mut inner = if matches!(agg, Some(AggOp::Sum) | Some(AggOp::Avg)) {
|
let mut inner = if matches!(agg, Some(AggOp::Sum) | Some(AggOp::Avg)) {
|
||||||
let inner_checkpoint = self.pos;
|
let inner_checkpoint = self.pos;
|
||||||
let took_inner_rate = self.parse_identifier().is_ok_and(|id| id == "rate");
|
let took_inner_rate = self.parse_identifier().is_ok_and(|id| id == "rate");
|
||||||
@@ -338,7 +316,6 @@ impl<'a> Parser<'a> {
|
|||||||
if prefix_by_labels.is_empty() { trailing_by_labels } else { prefix_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.
|
|
||||||
self.pos = checkpoint;
|
self.pos = checkpoint;
|
||||||
self.parse_vector_selector()
|
self.parse_vector_selector()
|
||||||
}
|
}
|
||||||
@@ -432,7 +409,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_aggregation() {
|
fn rejects_unknown_aggregation() {
|
||||||
// `median` isn't a supported agg_op, so this parses as a bare metric name and trips on the trailing `(...)`.
|
|
||||||
let err = parse_query("median(requests)").unwrap_err();
|
let err = parse_query("median(requests)").unwrap_err();
|
||||||
assert!(err.contains("unexpected trailing input"));
|
assert!(err.contains("unexpected trailing input"));
|
||||||
}
|
}
|
||||||
|
|||||||
-35
@@ -1,24 +1,7 @@
|
|||||||
//! 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 std::collections::VecDeque;
|
||||||
|
|
||||||
use crate::timestamp::Timestamp;
|
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 {
|
fn count_in_window(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64) -> usize {
|
||||||
let now_ms = now.as_millis();
|
let now_ms = now.as_millis();
|
||||||
let window_start = now_ms - window_ms;
|
let window_start = now_ms - window_ms;
|
||||||
@@ -31,33 +14,21 @@ fn count_in_window(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64)
|
|||||||
.count()
|
.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 {
|
pub fn rate_at(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64) -> f64 {
|
||||||
let window_seconds = window_ms as f64 / 1000.0;
|
let window_seconds = window_ms as f64 / 1000.0;
|
||||||
count_in_window(points, now, window_ms) as f64 / window_seconds
|
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)> {
|
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()
|
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)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct RateWindow {
|
pub struct RateWindow {
|
||||||
timestamps: VecDeque<Timestamp>,
|
timestamps: VecDeque<Timestamp>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RateWindow {
|
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 {
|
pub fn backfill(timestamps: impl IntoIterator<Item = Timestamp>, now: Timestamp, window_ms: i64) -> Self {
|
||||||
let mut window = Self::default();
|
let mut window = Self::default();
|
||||||
for ts in timestamps {
|
for ts in timestamps {
|
||||||
@@ -67,14 +38,11 @@ impl RateWindow {
|
|||||||
window
|
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) {
|
pub fn push(&mut self, timestamp: Timestamp, window_ms: i64) {
|
||||||
self.timestamps.push_back(timestamp);
|
self.timestamps.push_back(timestamp);
|
||||||
self.prune(timestamp, window_ms);
|
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) {
|
fn prune(&mut self, now: Timestamp, window_ms: i64) {
|
||||||
let window_start = now.as_millis() - window_ms;
|
let window_start = now.as_millis() - window_ms;
|
||||||
while let Some(&front) = self.timestamps.front() {
|
while let Some(&front) = self.timestamps.front() {
|
||||||
@@ -102,9 +70,7 @@ mod tests {
|
|||||||
(Timestamp::from_millis(1000), 1.0),
|
(Timestamp::from_millis(1000), 1.0),
|
||||||
(Timestamp::from_millis(2000), 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);
|
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);
|
assert_eq!(rate_at(&points, Timestamp::from_millis(2000), 2000), 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +102,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn rate_window_backfill_seeds_from_history_within_the_window() {
|
fn rate_window_backfill_seeds_from_history_within_the_window() {
|
||||||
let history = [0, 500, 2000, 2500].map(Timestamp::from_millis);
|
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);
|
let window = RateWindow::backfill(history, Timestamp::from_millis(2500), 1000);
|
||||||
assert_eq!(window.count(), 2);
|
assert_eq!(window.count(), 2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
//! Named session registry (ticket 068): each running `ottyl` process registers itself under a
|
|
||||||
//! name by writing a small file to `<settings dir>/sessions/<name>.json`, so a second CLI
|
|
||||||
//! invocation (e.g. `ottyl mcp --session <name>`, ticket 067) can resolve a name to a port without
|
|
||||||
//! the user copying one around by hand.
|
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -10,7 +5,6 @@ use std::process;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// On-disk shape of a `sessions/<name>.json` file.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct SessionInfo {
|
pub struct SessionInfo {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -18,8 +12,6 @@ pub struct SessionInfo {
|
|||||||
pub pid: u32,
|
pub pid: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `sessions/` directory for a given settings file, following the same
|
|
||||||
/// sibling-directory convention as `profiles/` (see `settings::migrate_legacy_profiles_file`).
|
|
||||||
pub fn sessions_dir(settings_path: &Path) -> PathBuf {
|
pub fn sessions_dir(settings_path: &Path) -> PathBuf {
|
||||||
settings_path
|
settings_path
|
||||||
.parent()
|
.parent()
|
||||||
@@ -31,10 +23,6 @@ fn session_file_path(dir: &Path, name: &str) -> PathBuf {
|
|||||||
dir.join(format!("{name}.json"))
|
dir.join(format!("{name}.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort liveness check for a PID. Shells out to `kill -0` on unix (no signal is delivered,
|
|
||||||
/// only permission/existence is checked); assumed alive on other platforms, which just means a
|
|
||||||
/// stale session file there waits for an explicit name collision to be overwritten instead of
|
|
||||||
/// being proactively cleaned up.
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn is_pid_alive(pid: u32) -> bool {
|
fn is_pid_alive(pid: u32) -> bool {
|
||||||
process::Command::new("kill")
|
process::Command::new("kill")
|
||||||
@@ -51,9 +39,6 @@ fn is_pid_alive(_pid: u32) -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes session files whose owning process is no longer running. Best-effort: there's no
|
|
||||||
/// shutdown hook to unregister a session on graceful exit, so this runs on every startup instead.
|
|
||||||
/// Any I/O error reading a single entry is ignored — a future call gets another chance.
|
|
||||||
pub fn cleanup_stale(dir: &Path) {
|
pub fn cleanup_stale(dir: &Path) {
|
||||||
let Ok(entries) = fs::read_dir(dir) else {
|
let Ok(entries) = fs::read_dir(dir) else {
|
||||||
return;
|
return;
|
||||||
@@ -75,16 +60,10 @@ pub fn cleanup_stale(dir: &Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a random human-readable session name (e.g. `comic-gazelle`) for when `--name` isn't
|
|
||||||
/// given explicitly.
|
|
||||||
pub fn generate_name() -> String {
|
pub fn generate_name() -> String {
|
||||||
petname::petname(2, "-").unwrap_or_else(|| format!("session-{}", process::id()))
|
petname::petname(2, "-").unwrap_or_else(|| format!("session-{}", process::id()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers this process as session `name` on `port`: cleans up stale entries, then writes
|
|
||||||
/// `sessions/<name>.json`, overwriting any existing file for that name (documented behavior — a
|
|
||||||
/// second instance started under the same explicit `--name` as a still-running one simply
|
|
||||||
/// replaces its registration; the older process keeps running, just without a discoverable entry).
|
|
||||||
pub fn register_session(settings_path: &Path, name: &str, port: u16) -> io::Result<PathBuf> {
|
pub fn register_session(settings_path: &Path, name: &str, port: u16) -> io::Result<PathBuf> {
|
||||||
let dir = sessions_dir(settings_path);
|
let dir = sessions_dir(settings_path);
|
||||||
fs::create_dir_all(&dir)?;
|
fs::create_dir_all(&dir)?;
|
||||||
@@ -99,9 +78,6 @@ pub fn register_session(settings_path: &Path, name: &str, port: u16) -> io::Resu
|
|||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Looks up a session by name. Returns `None` if no such file exists, it fails to parse, or its
|
|
||||||
/// process is no longer alive (a stale entry left by a process that didn't exit through the normal
|
|
||||||
/// next-`register_session` cleanup path).
|
|
||||||
pub fn resolve_session(settings_path: &Path, name: &str) -> Option<SessionInfo> {
|
pub fn resolve_session(settings_path: &Path, name: &str) -> Option<SessionInfo> {
|
||||||
let dir = sessions_dir(settings_path);
|
let dir = sessions_dir(settings_path);
|
||||||
let contents = fs::read_to_string(session_file_path(&dir, name)).ok()?;
|
let contents = fs::read_to_string(session_file_path(&dir, name)).ok()?;
|
||||||
@@ -109,8 +85,6 @@ pub fn resolve_session(settings_path: &Path, name: &str) -> Option<SessionInfo>
|
|||||||
is_pid_alive(info.pid).then_some(info)
|
is_pid_alive(info.pid).then_some(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lists every session file under `dir`, live or stale (callers that only want live sessions
|
|
||||||
/// should filter on `is_pid_alive`-backed `resolve_session` instead). Used by `ottyl sessions`.
|
|
||||||
pub fn list_sessions(dir: &Path) -> Vec<SessionInfo> {
|
pub fn list_sessions(dir: &Path) -> Vec<SessionInfo> {
|
||||||
let Ok(entries) = fs::read_dir(dir) else {
|
let Ok(entries) = fs::read_dir(dir) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -157,8 +131,6 @@ mod tests {
|
|||||||
let sessions_dir_path = sessions_dir(&settings_path);
|
let sessions_dir_path = sessions_dir(&settings_path);
|
||||||
fs::create_dir_all(&sessions_dir_path).unwrap();
|
fs::create_dir_all(&sessions_dir_path).unwrap();
|
||||||
|
|
||||||
// A PID essentially guaranteed not to be alive (PID 1 exists but a huge, unlikely-assigned
|
|
||||||
// PID stands in as "not alive" without depending on system PID-reuse specifics).
|
|
||||||
let stale = SessionInfo { name: "foo".to_string(), port: 1111, pid: 999_999 };
|
let stale = SessionInfo { name: "foo".to_string(), port: 1111, pid: 999_999 };
|
||||||
fs::write(
|
fs::write(
|
||||||
session_file_path(&sessions_dir_path, "foo"),
|
session_file_path(&sessions_dir_path, "foo"),
|
||||||
@@ -166,8 +138,6 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Registering a live session under the same name succeeds and the stale cleanup pass (run
|
|
||||||
// as part of registration) doesn't error out on the bogus pre-existing entry.
|
|
||||||
register_session(&settings_path, "foo", 5555).unwrap();
|
register_session(&settings_path, "foo", 5555).unwrap();
|
||||||
let info = resolve_session(&settings_path, "foo").unwrap();
|
let info = resolve_session(&settings_path, "foo").unwrap();
|
||||||
assert_eq!(info.port, 5555);
|
assert_eq!(info.port, 5555);
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
//! 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;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -16,15 +13,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::alerts::AlertRule;
|
use crate::alerts::AlertRule;
|
||||||
use crate::profile::Profile;
|
use crate::profile::Profile;
|
||||||
|
|
||||||
/// Shared, process-wide settings handle. Cheap to clone.
|
|
||||||
pub type SettingsHandle = Arc<Mutex<Settings>>;
|
pub type SettingsHandle = Arc<Mutex<Settings>>;
|
||||||
|
|
||||||
/// User's preferred colour scheme for the webapp.
|
|
||||||
///
|
|
||||||
/// `Dark` accepts the legacy `"grey"` serde value (ticket 004: "grey" was a
|
|
||||||
/// near-duplicate of "dark" and has been removed as a selectable option) so a
|
|
||||||
/// settings file saved before that change still loads cleanly instead of
|
|
||||||
/// falling back to `System`.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum ColorScheme {
|
pub enum ColorScheme {
|
||||||
@@ -37,33 +27,22 @@ pub enum ColorScheme {
|
|||||||
Yellow,
|
Yellow,
|
||||||
Pink,
|
Pink,
|
||||||
Orange,
|
Orange,
|
||||||
/// Brighter, light-surfaced counterpart to `Green` (ticket 048).
|
|
||||||
#[serde(rename = "light_green")]
|
#[serde(rename = "light_green")]
|
||||||
LightGreen,
|
LightGreen,
|
||||||
/// Brighter, light-surfaced counterpart to `Red` (ticket 056).
|
|
||||||
#[serde(rename = "light_red")]
|
#[serde(rename = "light_red")]
|
||||||
LightRed,
|
LightRed,
|
||||||
/// Brighter, light-surfaced counterpart to `Blue` (ticket 056).
|
|
||||||
#[serde(rename = "light_blue")]
|
#[serde(rename = "light_blue")]
|
||||||
LightBlue,
|
LightBlue,
|
||||||
/// Brighter, light-surfaced counterpart to `Yellow` (ticket 056).
|
|
||||||
#[serde(rename = "light_yellow")]
|
#[serde(rename = "light_yellow")]
|
||||||
LightYellow,
|
LightYellow,
|
||||||
/// Brighter, light-surfaced counterpart to `Pink` (ticket 056).
|
|
||||||
#[serde(rename = "light_pink")]
|
#[serde(rename = "light_pink")]
|
||||||
LightPink,
|
LightPink,
|
||||||
/// Brighter, light-surfaced counterpart to `Orange` (ticket 056).
|
|
||||||
#[serde(rename = "light_orange")]
|
#[serde(rename = "light_orange")]
|
||||||
LightOrange,
|
LightOrange,
|
||||||
#[default]
|
#[default]
|
||||||
System,
|
System,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single CSS entry: content ottyl stores itself ("imported") or a reference to an external
|
|
||||||
/// path/URL ("linked", not deletable via the API). Every entry is always a selectable
|
|
||||||
/// colour-scheme theme (ticket 020 removed the "use as theme"/always-on-overlay distinction);
|
|
||||||
/// an entry persisted before that change with a stored `is_theme: false` still loads fine (the
|
|
||||||
/// field is simply ignored) and becomes selectable like any other entry.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||||
pub enum CssEntry {
|
pub enum CssEntry {
|
||||||
@@ -71,25 +50,15 @@ pub enum CssEntry {
|
|||||||
Linked { path_or_url: String },
|
Linked { path_or_url: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Legacy on-disk shape of the (pre-049) `Profiles` pointer struct: only names/active-profile/dir
|
|
||||||
/// pointers, with the actual profile content living in a separate `profiles.json`. Kept only so an
|
|
||||||
/// old `settings.json` still parses; see `ProfilesOnDisk` and `Settings::migrate_legacy_profiles_file`.
|
|
||||||
#[derive(Debug, Clone, Deserialize, Default)]
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
struct LegacyProfilesPointer {
|
struct LegacyProfilesPointer {
|
||||||
// `names` is intentionally not captured: it was always redundant with the legacy
|
|
||||||
// `profiles.json`'s actual profile list, which is what migration reads instead.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
active_profile: Option<String>,
|
active_profile: Option<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)]
|
#[serde(default)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
profiles_dir: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
enum ProfilesOnDisk {
|
enum ProfilesOnDisk {
|
||||||
@@ -103,9 +72,6 @@ impl Default for ProfilesOnDisk {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
struct SettingsOnDisk {
|
struct SettingsOnDisk {
|
||||||
color_scheme: ColorScheme,
|
color_scheme: ColorScheme,
|
||||||
@@ -147,40 +113,25 @@ impl From<SettingsOnDisk> for Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The single, process-wide settings instance. Only `PartialEq` (not `Eq`): `alert_rules` carries `f64` fields, which aren't `Eq`.
|
|
||||||
///
|
|
||||||
/// `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)]
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(from = "SettingsOnDisk")]
|
#[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.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub custom_theme_id: Option<String>,
|
pub custom_theme_id: Option<String>,
|
||||||
/// Every known profile, inline (ticket 049).
|
|
||||||
pub profiles: Vec<Profile>,
|
pub profiles: Vec<Profile>,
|
||||||
/// Name of the currently active profile, if any.
|
|
||||||
pub active_profile: Option<String>,
|
pub active_profile: Option<String>,
|
||||||
/// 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>,
|
||||||
/// Alert rule definitions.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub alert_rules: Vec<AlertRule>,
|
pub alert_rules: Vec<AlertRule>,
|
||||||
/// Ntfy topic every alert rule delivers to; `None` until configured in Settings.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ntfy_topic: Option<String>,
|
pub ntfy_topic: Option<String>,
|
||||||
/// Ntfy server override; `None` means `https://ntfy.sh`.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ntfy_server: Option<String>,
|
pub ntfy_server: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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(
|
ProjectDirs::from(
|
||||||
crate::consts::APP_QUALIFIER,
|
crate::consts::APP_QUALIFIER,
|
||||||
@@ -191,7 +142,6 @@ pub fn default_settings_path() -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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. 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::<Settings>(&contents) {
|
Ok(contents) => match serde_json::from_str::<Settings>(&contents) {
|
||||||
@@ -229,7 +179,6 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persists these settings to `path` as pretty-printed JSON, creating any missing parent directories first.
|
|
||||||
pub fn save(&self, path: &Path) -> io::Result<()> {
|
pub fn save(&self, path: &Path) -> io::Result<()> {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
@@ -239,14 +188,6 @@ impl Settings {
|
|||||||
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) {
|
fn migrate_legacy_profiles_file(&mut self, settings_path: &Path) {
|
||||||
if !self.profiles.is_empty() {
|
if !self.profiles.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -276,12 +217,6 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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) {
|
fn migrate_legacy_profile_shapes(&mut self, settings_path: &Path) {
|
||||||
if crate::profile::migrate_legacy_profile_shapes(&mut self.profiles)
|
if crate::profile::migrate_legacy_profile_shapes(&mut self.profiles)
|
||||||
&& let Err(err) = self.save(settings_path)
|
&& let Err(err) = self.save(settings_path)
|
||||||
@@ -294,14 +229,10 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error returned by CSS-entry mutations and lookups.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum CssError {
|
pub enum CssError {
|
||||||
/// No entry with the given id exists.
|
|
||||||
NotFound,
|
NotFound,
|
||||||
/// The entry is a `Linked` entry, not `Imported` — ottyl doesn't own a linked entry's content, so there's nothing to delete or serve back.
|
|
||||||
NotImported,
|
NotImported,
|
||||||
/// Underlying I/O failure: writing/reading/removing the CSS file, or persisting `settings.json`.
|
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +254,6 @@ impl From<io::Error> for CssError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computes the id used to reference a `CssEntry` over the API: the stored id for `Imported`, or a hash of `path_or_url` for `Linked` (which has no stored id), so the frontend can key both the same way.
|
|
||||||
pub fn css_entry_id(entry: &CssEntry) -> String {
|
pub fn css_entry_id(entry: &CssEntry) -> String {
|
||||||
match entry {
|
match entry {
|
||||||
CssEntry::Imported { id, .. } => id.clone(),
|
CssEntry::Imported { id, .. } => id.clone(),
|
||||||
@@ -337,7 +267,6 @@ fn linked_entry_id(path_or_url: &str) -> String {
|
|||||||
format!("linked-{:016x}", hasher.finish())
|
format!("linked-{:016x}", hasher.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates an id for a newly-imported CSS entry by hashing filename, content, and time, avoiding a `uuid` dependency for what's otherwise an opaque string.
|
|
||||||
fn generate_imported_css_id(filename: &str, content: &str) -> String {
|
fn generate_imported_css_id(filename: &str, content: &str) -> String {
|
||||||
let mut hasher = DefaultHasher::new();
|
let mut hasher = DefaultHasher::new();
|
||||||
filename.hash(&mut hasher);
|
filename.hash(&mut hasher);
|
||||||
@@ -355,7 +284,6 @@ fn imported_css_file_path(css_dir: &Path, id: &str) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
/// Imports raw CSS `content`: writes it to `<css_dir>/<id>.css`, records a `CssEntry::Imported` in `css_entries`, and persists settings. Returns the generated id. `css_dir` is expected to be the settings file's parent directory.
|
|
||||||
pub fn import_css(
|
pub fn import_css(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
@@ -374,14 +302,12 @@ impl Settings {
|
|||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers a reference to an external CSS path/URL without copying its content, and persists settings.
|
|
||||||
pub fn link_css(&mut self, settings_path: &Path, path_or_url: String) -> Result<(), CssError> {
|
pub fn link_css(&mut self, settings_path: &Path, path_or_url: String) -> Result<(), CssError> {
|
||||||
self.css_entries.push(CssEntry::Linked { path_or_url });
|
self.css_entries.push(CssEntry::Linked { path_or_url });
|
||||||
self.save(settings_path)?;
|
self.save(settings_path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes an imported CSS entry identified by `id`: deletes its file under `css_dir`, drops it from `css_entries`, and persists settings. Fails with `NotFound` or `NotImported` (linked entries aren't deletable this way).
|
|
||||||
pub fn remove_css(
|
pub fn remove_css(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
@@ -398,7 +324,6 @@ impl Settings {
|
|||||||
Some(CssEntry::Imported { .. }) => {
|
Some(CssEntry::Imported { .. }) => {
|
||||||
let idx = index.unwrap();
|
let idx = index.unwrap();
|
||||||
self.css_entries.remove(idx);
|
self.css_entries.remove(idx);
|
||||||
// Best-effort: the settings entry is already removed even if the file is gone.
|
|
||||||
let _ = fs::remove_file(imported_css_file_path(css_dir, id));
|
let _ = fs::remove_file(imported_css_file_path(css_dir, id));
|
||||||
self.save(settings_path)?;
|
self.save(settings_path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -406,7 +331,6 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads back the raw content of an imported CSS entry identified by `id`. Fails with `NotFound` or `NotImported` (linked entries have no local content — the frontend fetches `path_or_url` directly).
|
|
||||||
pub fn css_content(&self, css_dir: &Path, id: &str) -> Result<String, CssError> {
|
pub fn css_content(&self, css_dir: &Path, id: &str) -> Result<String, CssError> {
|
||||||
match self.css_entries.iter().find(|entry| css_entry_id(entry) == id) {
|
match self.css_entries.iter().find(|entry| css_entry_id(entry) == id) {
|
||||||
None => Err(CssError::NotFound),
|
None => Err(CssError::NotFound),
|
||||||
@@ -424,7 +348,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_settings_path_ends_with_settings_json() {
|
fn default_settings_path_ends_with_settings_json() {
|
||||||
// Can't assert the exact OS path without touching real config dirs, so just check the shape.
|
|
||||||
if let Some(path) = default_settings_path() {
|
if let Some(path) = default_settings_path() {
|
||||||
assert_eq!(path.file_name().unwrap(), "settings.json");
|
assert_eq!(path.file_name().unwrap(), "settings.json");
|
||||||
assert!(path.parent().is_some());
|
assert!(path.parent().is_some());
|
||||||
@@ -471,10 +394,6 @@ 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]
|
#[test]
|
||||||
fn legacy_profiles_pointer_shape_deserializes_with_empty_inline_profiles() {
|
fn legacy_profiles_pointer_shape_deserializes_with_empty_inline_profiles() {
|
||||||
let settings: Settings = serde_json::from_str(
|
let settings: Settings = serde_json::from_str(
|
||||||
@@ -548,7 +467,6 @@ mod tests {
|
|||||||
assert!(css_file.exists());
|
assert!(css_file.exists());
|
||||||
assert_eq!(fs::read_to_string(&css_file).unwrap(), "body { color: red; }");
|
assert_eq!(fs::read_to_string(&css_file).unwrap(), "body { color: red; }");
|
||||||
|
|
||||||
// Drop and reload settings from disk (simulates a restart).
|
|
||||||
drop(settings);
|
drop(settings);
|
||||||
let reloaded = Settings::load(&settings_path);
|
let reloaded = Settings::load(&settings_path);
|
||||||
assert!(reloaded.css_entries.iter().any(|entry| matches!(
|
assert!(reloaded.css_entries.iter().any(|entry| matches!(
|
||||||
@@ -598,7 +516,6 @@ mod tests {
|
|||||||
|
|
||||||
let result = settings.remove_css(&settings_path, &css_dir, &linked_id);
|
let result = settings.remove_css(&settings_path, &css_dir, &linked_id);
|
||||||
assert!(matches!(result, Err(CssError::NotImported)));
|
assert!(matches!(result, Err(CssError::NotImported)));
|
||||||
// Entry must still be there — rejection shouldn't silently drop it.
|
|
||||||
assert_eq!(settings.css_entries.len(), 1);
|
assert_eq!(settings.css_entries.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,9 +546,6 @@ mod tests {
|
|||||||
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]
|
#[test]
|
||||||
fn load_migrates_legacy_profiles_file_into_inline_settings() {
|
fn load_migrates_legacy_profiles_file_into_inline_settings() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -644,8 +558,6 @@ mod tests {
|
|||||||
r#"[{"name":"web","params":[],"metrics":[],"dashboards":[]}]"#,
|
r#"[{"name":"web","params":[],"metrics":[],"dashboards":[]}]"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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(
|
fs::write(
|
||||||
&settings_path,
|
&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}"#,
|
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}"#,
|
||||||
@@ -657,19 +569,15 @@ mod tests {
|
|||||||
assert_eq!(settings.profiles[0].name, "web");
|
assert_eq!(settings.profiles[0].name, "web");
|
||||||
assert_eq!(settings.active_profile, Some("web".to_string()));
|
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());
|
assert!(!legacy_path.exists());
|
||||||
let migrated_path = legacy_path.with_extension("json.migrated");
|
let migrated_path = legacy_path.with_extension("json.migrated");
|
||||||
assert!(migrated_path.exists());
|
assert!(migrated_path.exists());
|
||||||
|
|
||||||
// Reload from disk: the migrated settings.json now carries profiles inline directly.
|
|
||||||
let reloaded = Settings::load(&settings_path);
|
let reloaded = Settings::load(&settings_path);
|
||||||
assert_eq!(reloaded.profiles.len(), 1);
|
assert_eq!(reloaded.profiles.len(), 1);
|
||||||
assert_eq!(reloaded.profiles[0].name, "web");
|
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]
|
#[test]
|
||||||
fn load_without_legacy_file_leaves_profiles_empty() {
|
fn load_without_legacy_file_leaves_profiles_empty() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
/// Wall-clock instant stored as nanoseconds since the Unix epoch (not `std::time::Instant`, which has no epoch and can't be serialized).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||||
pub struct Timestamp(i64);
|
pub struct Timestamp(i64);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user