//! Named, persistable bundles of parsing/indexing rules ("profiles" / "presets"), //! ticket 009. //! //! A `Profile` bundles the param declarations (ticket 001) and metric //! declarations (ticket 002) needed to derive a given CLI app's structure from //! its raw log output, plus any saved dashboards for that app. Profiles are //! collected in a `ProfileStore`, persisted as a single `profiles.json` file //! under `Settings.profiles.profiles_dir` (resolved relative to the settings //! file's directory — the same convention ticket 010 uses for CSS storage). A //! single file (rather than one file per profile) is simplest and avoids //! filename-sanitization concerns for profile names. //! //! `Settings.profiles` (see `settings.rs`) only tracks lightweight pointers — //! known profile names and which one is active — while this module owns the //! actual declaration content. That mirrors how `Settings.css_entries` holds //! lightweight `CssEntry` records while the CSS content itself lives in //! separate files (ticket 010). //! //! On startup, `main.rs` loads the active profile's declarations into a fresh //! engine by replaying `EngineMessage::DeclareParam`/`DeclareMetric` for each //! stored declaration (see `load_into_engine` below) — this reproduces the //! same parsing/indexing rules the profile had when last saved, without //! requiring the user to re-declare anything by hand after a restart. //! //! Several public methods here (e.g. `ProfileStore::delete`, //! `record_param_declaration`) have no caller yet within this worktree — CRUD //! over profiles isn't exposed over HTTP until ticket 004 lands the API layer //! and calls into this module. `#![allow(dead_code)]` mirrors how //! `engine.rs`/`data_value.rs` handle the same not-yet-wired situation for //! tickets 001/002. #![allow(dead_code)] use std::collections::hash_map::DefaultHasher; use std::collections::BTreeMap; use std::fmt; use std::fs; use std::hash::{Hash, Hasher}; use std::io; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use crate::data_value::{MetricPrimitiveKind, ParamRole, RegexToMetricPrimitiveMatcher}; use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage}; use crate::settings::Settings; use tokio::sync::oneshot; /// Name of the profile that always exists and can't be deleted (see /// `ProfileStore::ensure_default`). pub const DEFAULT_PROFILE_NAME: &str = "Default"; /// Mirrors `data_value::MetricPrimitiveKind` with `Serialize`/`Deserialize` so /// profiles can round-trip through JSON. `MetricPrimitiveKind` itself only /// derives `Serialize` (it's an engine-internal type owned by tickets /// 001/002/003, out of scope for this ticket to modify), so this module keeps /// its own mirror and converts at the boundary. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProfileMetricPrimitiveKind { String, Number, Timestamp, /// Mirrors `MetricPrimitiveKind::Duration` (ticket 023). Duration, } impl From for ProfileMetricPrimitiveKind { fn from(kind: MetricPrimitiveKind) -> Self { match kind { MetricPrimitiveKind::String => ProfileMetricPrimitiveKind::String, MetricPrimitiveKind::Number => ProfileMetricPrimitiveKind::Number, MetricPrimitiveKind::Timestamp => ProfileMetricPrimitiveKind::Timestamp, MetricPrimitiveKind::Duration => ProfileMetricPrimitiveKind::Duration, } } } impl From for MetricPrimitiveKind { fn from(kind: ProfileMetricPrimitiveKind) -> Self { match kind { ProfileMetricPrimitiveKind::String => MetricPrimitiveKind::String, ProfileMetricPrimitiveKind::Number => MetricPrimitiveKind::Number, ProfileMetricPrimitiveKind::Timestamp => MetricPrimitiveKind::Timestamp, ProfileMetricPrimitiveKind::Duration => MetricPrimitiveKind::Duration, } } } /// A stored param declaration, matching `DeclareParamRequest`'s shape (ticket 001). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ParamDeclarationRecord { pub name: String, pub message_match_regex: String, pub capture_group: String, pub kind: ProfileMetricPrimitiveKind, } impl From<&DeclareParamRequest> for ParamDeclarationRecord { fn from(request: &DeclareParamRequest) -> Self { ParamDeclarationRecord { name: request.name.clone(), message_match_regex: request.message_match_regex.clone(), capture_group: request.capture_group.clone(), kind: request.kind.into(), } } } impl From<&ParamDeclarationRecord> for DeclareParamRequest { fn from(record: &ParamDeclarationRecord) -> Self { DeclareParamRequest { name: record.name.clone(), message_match_regex: record.message_match_regex.clone(), capture_group: record.capture_group.clone(), kind: record.kind.into(), } } } /// A stored metric param matcher, matching `RegexToMetricPrimitiveMatcher`'s shape. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MetricParamRecord { pub param_name: String, pub capture_group: String, pub kind: ProfileMetricPrimitiveKind, /// See `ParamRole`. `#[serde(default)]` so profiles saved before ticket /// 022 still load, with every param defaulting to `Label`. #[serde(default)] pub role: ParamRole, } /// A stored metric declaration, matching `DeclareMetricRequest`'s shape (ticket 002). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MetricDeclarationRecord { pub name: String, pub message_match_regex: String, pub params: Vec, /// See `crate::engine::DeclareMetricRequest::matched_labels` (ticket /// 025). `#[serde(default)]` so profiles saved before that ticket still /// load. #[serde(default)] pub matched_labels: Option>, /// See `crate::engine::DeclareMetricRequest::unmatched_labels` (ticket /// 025). `#[serde(default)]` so profiles saved before that ticket still /// load. #[serde(default)] pub unmatched_labels: Option>, } impl From<&DeclareMetricRequest> for MetricDeclarationRecord { fn from(request: &DeclareMetricRequest) -> Self { MetricDeclarationRecord { name: request.name.clone(), message_match_regex: request.message_match_regex.clone(), params: request .params .iter() .map(|matcher| MetricParamRecord { param_name: matcher.param_name.clone(), capture_group: matcher.capture_group.clone(), kind: matcher.kind.into(), role: matcher.role, }) .collect(), matched_labels: request.matched_labels.clone(), unmatched_labels: request.unmatched_labels.clone(), } } } impl From<&MetricDeclarationRecord> for DeclareMetricRequest { fn from(record: &MetricDeclarationRecord) -> Self { DeclareMetricRequest { name: record.name.clone(), message_match_regex: record.message_match_regex.clone(), matched_labels: record.matched_labels.clone(), unmatched_labels: record.unmatched_labels.clone(), params: record .params .iter() .map(|param| RegexToMetricPrimitiveMatcher { param_name: param.param_name.clone(), capture_group: param.capture_group.clone(), kind: param.kind.into(), role: param.role, }) .collect(), } } } /// A chart type a dashboard panel can render (ticket 014). Kept in sync with /// the frontend's `'bar' | 'line' | 'pie'` union (see `webapp/src/lib/api.ts`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ChartType { Bar, Line, Pie, } /// One panel within a dashboard: a PromQL query (ticket 013) plus how to /// chart its result. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Panel { pub id: String, pub title: String, pub promql: String, pub chart_type: ChartType, } /// A named set of panels, saved as part of a profile (ticket 014). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Dashboard { pub id: String, pub name: String, #[serde(default)] pub panels: Vec, } /// Generates an id for a newly-created dashboard/panel. Mirrors /// `settings::generate_imported_css_id`: hashes a discriminating seed /// (dashboard/panel name) together with the current time, avoiding a new /// dependency (e.g. `uuid`) for what's otherwise a random-ish opaque string. fn generate_id(seed: &str) -> String { let mut hasher = DefaultHasher::new(); seed.hash(&mut hasher); let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); nanos.hash(&mut hasher); format!("{:016x}", hasher.finish()) } impl Dashboard { /// A new dashboard named `name`, with no panels yet. pub fn new(name: String) -> Dashboard { let id = generate_id(&name); Dashboard { id, name, panels: Vec::new() } } } /// Ticket 025: name of the metric seeded into a brand-new `Default` profile, /// used to drive its starter dashboard's two panels. const SEEDED_METRIC_NAME: &str = "log_category"; const SEEDED_METRIC_LABEL: &str = "category"; /// A counter (ticket 022) that classifies every ingested line into exactly /// two buckets — `category="error"` or `category="other"` — without /// capturing any text: `matched_labels`/`unmatched_labels` (ticket 025) apply /// those fixed values directly, rather than a param capturing the literal /// matched substring, so a case-insensitive match like `"ERROR"` vs `"Error"` /// collapses into one clean series instead of fragmenting by source casing. /// A plain regex alternation can't express the "didn't match" half of this /// without lookaround, which the `regex` crate doesn't support (see /// `crate::engine::DeclareMetricRequest::unmatched_labels`) — hence going /// through the metric-declaration mechanism instead of the PromQL surface. fn seeded_error_split_metric() -> MetricDeclarationRecord { MetricDeclarationRecord { name: SEEDED_METRIC_NAME.to_string(), message_match_regex: r"(?i)error".to_string(), params: Vec::new(), matched_labels: Some(BTreeMap::from([(SEEDED_METRIC_LABEL.to_string(), "error".to_string())])), unmatched_labels: Some(BTreeMap::from([(SEEDED_METRIC_LABEL.to_string(), "other".to_string())])), } } /// The starter dashboard seeded into a brand-new `Default` profile (ticket /// 025): a line chart and a pie chart, both querying the bare /// `SEEDED_METRIC_NAME` counter so each renders both the "error" and "other" /// series/slices from a single PromQL query, same as any user-created panel. fn seeded_default_dashboard() -> Dashboard { let mut dashboard = Dashboard::new("Overview".to_string()); dashboard.panels = vec![ Panel { id: generate_id("error-volume-line"), title: "Error vs. non-error volume".to_string(), promql: SEEDED_METRIC_NAME.to_string(), chart_type: ChartType::Line, }, Panel { id: generate_id("error-volume-pie"), title: "Error vs. non-error ratio".to_string(), promql: SEEDED_METRIC_NAME.to_string(), chart_type: ChartType::Pie, }, ]; dashboard } /// A named bundle of parsing/indexing rules for a particular CLI app: which /// params and metrics to derive from its log output, plus any saved /// dashboards. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Profile { pub name: String, #[serde(default)] pub params: Vec, #[serde(default)] pub metrics: Vec, /// Saved dashboards (ticket 014), each a named set of PromQL-backed panels. #[serde(default)] pub dashboards: Vec, } impl Profile { /// A new, empty profile with the given name. pub fn new(name: String) -> Profile { Profile { name, params: Vec::new(), metrics: Vec::new(), dashboards: Vec::new(), } } } /// Sends every param/metric declaration stored in `profile` to the engine at /// `engine`, as `EngineMessage::DeclareParam`/`DeclareMetric`. Used on startup /// to reload the active profile's rules into a fresh engine (see `main.rs`), /// and in tests to simulate that reload. /// /// A stored declaration should generally already be valid — it round-tripped /// through the engine's own validation when originally declared (see /// `record_param_declaration`/`record_metric_declaration` below) — but this /// doesn't assume that: any declaration the engine rejects (or any send/recv /// failure, e.g. the engine channel closing) is logged to stderr and skipped /// rather than panicking, so one bad stored declaration can't take the whole /// process down. pub async fn load_into_engine(engine: &EngineHandle, profile: &Profile) { for record in &profile.params { let (respond_to, response) = oneshot::channel(); let request: DeclareParamRequest = record.into(); let name = request.name.clone(); if engine .send(EngineMessage::DeclareParam { request, respond_to }) .await .is_err() { eprintln!("warning: engine unavailable while loading profile '{}'", profile.name); return; } match response.await { Ok(Ok(())) => {} Ok(Err(err)) => eprintln!( "warning: profile '{}' param declaration '{name}' rejected by engine: {err}", profile.name ), Err(_) => eprintln!( "warning: engine dropped response while declaring param '{name}' from profile '{}'", profile.name ), } } for record in &profile.metrics { let (respond_to, response) = oneshot::channel(); let request: DeclareMetricRequest = record.into(); let name = request.name.clone(); if engine .send(EngineMessage::DeclareMetric { request, respond_to }) .await .is_err() { eprintln!("warning: engine unavailable while loading profile '{}'", profile.name); return; } match response.await { Ok(Ok(())) => {} Ok(Err(err)) => eprintln!( "warning: profile '{}' metric declaration '{name}' rejected by engine: {err}", profile.name ), Err(_) => eprintln!( "warning: engine dropped response while declaring metric '{name}' from profile '{}'", profile.name ), } } } /// Error returned by `ProfileStore` mutations and lookups. #[derive(Debug)] pub enum ProfileError { /// No profile with the given name exists. NotFound, /// A profile with the given name already exists. DuplicateName, /// The `Default` profile can't be deleted — there must always be at /// least one profile to fall back to. CannotDeleteDefault, /// Underlying I/O failure reading/writing the profile store or settings file. Io(io::Error), } impl fmt::Display for ProfileError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { ProfileError::NotFound => write!(f, "no profile with that name"), ProfileError::DuplicateName => write!(f, "a profile with that name already exists"), ProfileError::CannotDeleteDefault => write!(f, "the Default profile cannot be deleted"), ProfileError::Io(err) => write!(f, "I/O error: {err}"), } } } impl std::error::Error for ProfileError {} impl From for ProfileError { fn from(err: io::Error) -> Self { ProfileError::Io(err) } } /// Resolves `settings.profiles.profiles_dir` relative to `settings_path`'s /// parent directory (or treats it as absolute if it already is one) — the /// same directory convention ticket 010 uses for CSS storage. pub fn resolve_profiles_dir(settings: &Settings, settings_path: &Path) -> PathBuf { let profiles_dir = Path::new(&settings.profiles.profiles_dir); if profiles_dir.is_absolute() { profiles_dir.to_path_buf() } else { settings_path .parent() .map(|parent| parent.join(profiles_dir)) .unwrap_or_else(|| profiles_dir.to_path_buf()) } } /// Path to the single file holding all profiles, within `profiles_dir` (see /// `resolve_profiles_dir`). pub fn profiles_store_path(profiles_dir: &Path) -> PathBuf { profiles_dir.join("profiles.json") } /// On-disk collection of all known profiles, persisted as a single JSON file /// (see module docs for why one file rather than one-per-profile). #[derive(Debug, Clone, Default)] pub struct ProfileStore { profiles: Vec, } impl ProfileStore { /// Loads the store from `store_path`. A missing file is treated as an /// empty store (not an error) — there's no reason to fail startup just /// because no profiles have been created yet. Any I/O or parse error /// while reading an existing file is logged to stderr and treated as an /// empty store, mirroring `Settings::load`'s non-fatal-error handling. pub fn load(store_path: &Path) -> ProfileStore { match fs::read_to_string(store_path) { Ok(contents) => match serde_json::from_str::>(&contents) { Ok(profiles) => ProfileStore { profiles }, Err(err) => { eprintln!( "warning: failed to parse profile store at {}: {err}; using empty store", store_path.display() ); ProfileStore::default() } }, Err(err) if err.kind() == io::ErrorKind::NotFound => ProfileStore::default(), Err(err) => { eprintln!( "warning: failed to read profile store at {}: {err}; using empty store", store_path.display() ); ProfileStore::default() } } } /// Persists the store to `store_path` as pretty-printed JSON, creating /// any missing parent directories first. pub fn save(&self, store_path: &Path) -> io::Result<()> { if let Some(parent) = store_path.parent() { fs::create_dir_all(parent)?; } let contents = serde_json::to_string_pretty(&self.profiles) .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; fs::write(store_path, contents) } /// All known profiles. pub fn list(&self) -> &[Profile] { &self.profiles } /// Looks up a profile by name. pub fn get(&self, name: &str) -> Option<&Profile> { self.profiles.iter().find(|p| p.name == name) } /// Creates a new profile named `name`, persists the store to /// `store_path`, and records the name in `settings.profiles.names` /// (persisting `settings` to `settings_path` too, keeping the two files in /// sync). Fails with `ProfileError::DuplicateName` if a profile with that /// name already exists. /// /// The new profile starts as a copy of the currently active profile's /// params/metrics/dashboards (or empty, if none is active) — a preset is /// most useful as a variation on the one you were just using, not a /// blank slate. pub fn create( &mut self, store_path: &Path, settings: &mut Settings, settings_path: &Path, name: String, ) -> Result<(), ProfileError> { if self.get(&name).is_some() { return Err(ProfileError::DuplicateName); } let mut profile = Profile::new(name.clone()); if let Some(active) = settings.profiles.active_profile.as_deref().and_then(|n| self.get(n)) { profile.params = active.params.clone(); profile.metrics = active.metrics.clone(); profile.dashboards = active.dashboards.clone(); } self.profiles.push(profile); self.save(store_path)?; if !settings.profiles.names.contains(&name) { settings.profiles.names.push(name); } settings.save(settings_path)?; Ok(()) } /// Deletes the profile named `name`, persists the store to `store_path`, /// and drops it from `settings.profiles.names` (persisting `settings` to /// `settings_path` too). If the deleted profile was active, clears /// `settings.profiles.active_profile`. Fails with `ProfileError::NotFound` /// if no such profile exists, or `ProfileError::CannotDeleteDefault` for /// the `Default` profile — there must always be at least one profile to /// fall back to. pub fn delete( &mut self, store_path: &Path, settings: &mut Settings, settings_path: &Path, name: &str, ) -> Result<(), ProfileError> { if name == DEFAULT_PROFILE_NAME { return Err(ProfileError::CannotDeleteDefault); } let index = self.profiles.iter().position(|p| p.name == name).ok_or(ProfileError::NotFound)?; self.profiles.remove(index); self.save(store_path)?; settings.profiles.names.retain(|n| n != name); if settings.profiles.active_profile.as_deref() == Some(name) { settings.profiles.active_profile = None; } settings.save(settings_path)?; Ok(()) } /// Guarantees a `Default` profile exists in this store, that it's tracked /// in `settings.profiles.names`, and that `settings.profiles.active_profile` /// falls back to it when nothing else is active — so the app always has /// at least one usable profile, even on a first run with no settings file /// yet. Persists both the store and `settings` if either changed. /// /// A newly-created `Default` profile (not one that already existed) is /// seeded with a starter dashboard (ticket 025): a line chart and a pie /// chart of "error" vs. non-"error" log volume, both querying /// `seeded_error_split_metric`'s counter — editable/deletable afterward /// like any other dashboard/metric, and never re-seeded or duplicated on /// a later call once the profile exists. /// /// Called once on startup (see `main.rs`) before anything else touches /// profiles. pub fn ensure_default( &mut self, store_path: &Path, settings: &mut Settings, settings_path: &Path, ) -> Result<(), ProfileError> { if self.get(DEFAULT_PROFILE_NAME).is_none() { let mut profile = Profile::new(DEFAULT_PROFILE_NAME.to_string()); profile.metrics.push(seeded_error_split_metric()); profile.dashboards.push(seeded_default_dashboard()); self.profiles.push(profile); self.save(store_path)?; } if !settings.profiles.names.iter().any(|n| n == DEFAULT_PROFILE_NAME) { settings.profiles.names.push(DEFAULT_PROFILE_NAME.to_string()); } if settings.profiles.active_profile.is_none() { settings.profiles.active_profile = Some(DEFAULT_PROFILE_NAME.to_string()); } settings.save(settings_path)?; Ok(()) } /// Sets (or clears, with `name: None`) the active profile: updates /// `settings.profiles.active_profile` and persists `settings` to /// `settings_path`. Fails with `ProfileError::NotFound` if `name` is /// `Some` and no such profile exists in this store. /// /// This only updates `Settings`; it does not itself reload the newly /// active profile's declarations into a running engine (that happens on /// the next startup — see `load_into_engine` and `main.rs`). pub fn set_active( &self, settings: &mut Settings, settings_path: &Path, name: Option, ) -> Result<(), ProfileError> { if let Some(name) = &name { if self.get(name).is_none() { return Err(ProfileError::NotFound); } } settings.profiles.active_profile = name; settings.save(settings_path)?; Ok(()) } /// Records a newly-declared param into `profile_name`'s stored /// declarations (replacing any existing record with the same param name, /// so re-declaring updates it in place), persisting the store to /// `store_path`. /// /// **Integration hook for ticket 004 (HTTP API)**: when the HTTP API /// declares a param against the engine (sending /// `EngineMessage::DeclareParam`), it should also call this — with the /// same `DeclareParamRequest` — against the currently-active profile, so /// the declaration survives a restart (see `load_into_engine`). This /// ticket (009) cannot wire that call itself: there's no declare-param /// HTTP route yet for it to hook into. Call shape: /// /// ```ignore /// profile_store.record_param_declaration(&store_path, &active_profile_name, &request)?; /// ``` pub fn record_param_declaration( &mut self, store_path: &Path, profile_name: &str, request: &DeclareParamRequest, ) -> Result<(), ProfileError> { let profile = self .profiles .iter_mut() .find(|p| p.name == profile_name) .ok_or(ProfileError::NotFound)?; let record: ParamDeclarationRecord = request.into(); match profile.params.iter_mut().find(|r| r.name == record.name) { Some(existing) => *existing = record, None => profile.params.push(record), } self.save(store_path)?; Ok(()) } /// Records a newly-declared metric into `profile_name`'s stored /// declarations (replacing any existing record with the same metric name), /// persisting the store to `store_path`. /// /// **Integration hook for ticket 004 (HTTP API)**: same as /// `record_param_declaration` above, but for /// `EngineMessage::DeclareMetric`/`DeclareMetricRequest`. pub fn record_metric_declaration( &mut self, store_path: &Path, profile_name: &str, request: &DeclareMetricRequest, ) -> Result<(), ProfileError> { let profile = self .profiles .iter_mut() .find(|p| p.name == profile_name) .ok_or(ProfileError::NotFound)?; let record: MetricDeclarationRecord = request.into(); match profile.metrics.iter_mut().find(|r| r.name == record.name) { Some(existing) => *existing = record, None => profile.metrics.push(record), } self.save(store_path)?; Ok(()) } /// Lists the dashboards saved on `profile_name`. Fails with /// `ProfileError::NotFound` if no such profile exists. pub fn list_dashboards(&self, profile_name: &str) -> Result<&[Dashboard], ProfileError> { let profile = self.get(profile_name).ok_or(ProfileError::NotFound)?; Ok(&profile.dashboards) } /// Creates a new, empty dashboard named `name` on `profile_name`, /// persisting the store to `store_path`. Returns the new dashboard. /// Fails with `ProfileError::NotFound` if no such profile exists. pub fn create_dashboard( &mut self, store_path: &Path, profile_name: &str, name: String, ) -> Result { let profile = self .profiles .iter_mut() .find(|p| p.name == profile_name) .ok_or(ProfileError::NotFound)?; let dashboard = Dashboard::new(name); profile.dashboards.push(dashboard.clone()); self.save(store_path)?; Ok(dashboard) } /// Replaces the dashboard identified by `dashboard_id` on `profile_name` /// with `dashboard` (e.g. after adding/removing/editing panels), /// persisting the store to `store_path`. `dashboard.id` is forced to /// `dashboard_id` so a caller can't accidentally change a dashboard's /// identity through this call. Fails with `ProfileError::NotFound` if /// either the profile or the dashboard doesn't exist. pub fn save_dashboard( &mut self, store_path: &Path, profile_name: &str, dashboard_id: &str, mut dashboard: Dashboard, ) -> Result { let profile = self .profiles .iter_mut() .find(|p| p.name == profile_name) .ok_or(ProfileError::NotFound)?; let existing = profile .dashboards .iter_mut() .find(|d| d.id == dashboard_id) .ok_or(ProfileError::NotFound)?; dashboard.id = dashboard_id.to_string(); *existing = dashboard.clone(); self.save(store_path)?; Ok(dashboard) } /// Deletes the dashboard identified by `dashboard_id` from /// `profile_name`, persisting the store to `store_path`. Fails with /// `ProfileError::NotFound` if either the profile or the dashboard /// doesn't exist. pub fn delete_dashboard( &mut self, store_path: &Path, profile_name: &str, dashboard_id: &str, ) -> Result<(), ProfileError> { let profile = self .profiles .iter_mut() .find(|p| p.name == profile_name) .ok_or(ProfileError::NotFound)?; let index = profile .dashboards .iter() .position(|d| d.id == dashboard_id) .ok_or(ProfileError::NotFound)?; profile.dashboards.remove(index); self.save(store_path)?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::data_value::MetricPrimitiveKind; use crate::dto::LogsParams; use crate::engine::{EngineMessage, EngineQuery, EngineResponse, IngestedLine}; use crate::timestamp::Timestamp; use tokio::sync::mpsc; fn param_request(name: &str, pattern: &str, capture_group: &str) -> DeclareParamRequest { DeclareParamRequest { name: name.to_string(), message_match_regex: pattern.to_string(), capture_group: capture_group.to_string(), kind: MetricPrimitiveKind::String, } } fn metric_request(name: &str, pattern: &str, param_name: &str, capture_group: &str) -> DeclareMetricRequest { DeclareMetricRequest { name: name.to_string(), message_match_regex: pattern.to_string(), matched_labels: None, unmatched_labels: None, params: vec![RegexToMetricPrimitiveMatcher { param_name: param_name.to_string(), capture_group: capture_group.to_string(), kind: MetricPrimitiveKind::Number, role: ParamRole::Label, }], } } async fn ask(engine: &EngineHandle, query: EngineQuery) -> EngineResponse { let (respond_to, response) = oneshot::channel(); engine.send(EngineMessage::Query { query, respond_to }).await.unwrap(); response.await.unwrap() } #[test] fn resolve_profiles_dir_is_relative_to_settings_parent() { let settings_path = Path::new("/config/ottyl/settings.json"); let mut settings = Settings::default(); settings.profiles.profiles_dir = "profiles".to_string(); let dir = resolve_profiles_dir(&settings, settings_path); assert_eq!(dir, Path::new("/config/ottyl/profiles")); } #[test] fn resolve_profiles_dir_respects_absolute_path() { let settings_path = Path::new("/config/ottyl/settings.json"); let mut settings = Settings::default(); settings.profiles.profiles_dir = "/elsewhere/profiles".to_string(); let dir = resolve_profiles_dir(&settings, settings_path); assert_eq!(dir, Path::new("/elsewhere/profiles")); } #[test] fn create_list_get_delete_round_trip() { let dir = tempfile::tempdir().unwrap(); let store_path = dir.path().join("profiles.json"); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); let mut store = ProfileStore::load(&store_path); store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap(); store.create(&store_path, &mut settings, &settings_path, "worker".to_string()).unwrap(); assert_eq!(store.list().len(), 2); assert!(store.get("web").is_some()); assert_eq!(settings.profiles.names, vec!["web".to_string(), "worker".to_string()]); // Duplicate creation is rejected. let dup = store.create(&store_path, &mut settings, &settings_path, "web".to_string()); assert!(matches!(dup, Err(ProfileError::DuplicateName))); // Reload from disk: profiles and settings both persisted. let reloaded = ProfileStore::load(&store_path); assert_eq!(reloaded.list().len(), 2); let reloaded_settings = Settings::load(&settings_path); assert_eq!(reloaded_settings.profiles.names, settings.profiles.names); store.delete(&store_path, &mut settings, &settings_path, "worker").unwrap(); assert!(store.get("worker").is_none()); assert_eq!(settings.profiles.names, vec!["web".to_string()]); let missing = store.delete(&store_path, &mut settings, &settings_path, "worker"); assert!(matches!(missing, Err(ProfileError::NotFound))); } #[test] fn ensure_default_creates_default_and_activates_it_once() { let dir = tempfile::tempdir().unwrap(); let store_path = dir.path().join("profiles.json"); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); let mut store = ProfileStore::load(&store_path); store.ensure_default(&store_path, &mut settings, &settings_path).unwrap(); assert!(store.get(DEFAULT_PROFILE_NAME).is_some()); assert!(settings.profiles.names.contains(&DEFAULT_PROFILE_NAME.to_string())); assert_eq!(settings.profiles.active_profile, Some(DEFAULT_PROFILE_NAME.to_string())); // Idempotent: doesn't duplicate the profile, and doesn't clobber an // already-active (non-Default) profile. store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap(); store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap(); store.ensure_default(&store_path, &mut settings, &settings_path).unwrap(); assert_eq!(store.list().iter().filter(|p| p.name == DEFAULT_PROFILE_NAME).count(), 1); assert_eq!(settings.profiles.active_profile, Some("web".to_string())); } #[test] fn ensure_default_seeds_starter_dashboard_only_on_creation() { let dir = tempfile::tempdir().unwrap(); let store_path = dir.path().join("profiles.json"); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); let mut store = ProfileStore::load(&store_path); store.ensure_default(&store_path, &mut settings, &settings_path).unwrap(); let default_profile = store.get(DEFAULT_PROFILE_NAME).unwrap(); assert_eq!(default_profile.metrics.len(), 1); assert_eq!(default_profile.metrics[0].name, SEEDED_METRIC_NAME); assert_eq!( default_profile.metrics[0].matched_labels, Some(BTreeMap::from([(SEEDED_METRIC_LABEL.to_string(), "error".to_string())])) ); assert_eq!( default_profile.metrics[0].unmatched_labels, Some(BTreeMap::from([(SEEDED_METRIC_LABEL.to_string(), "other".to_string())])) ); assert_eq!(default_profile.dashboards.len(), 1); let dashboard = &default_profile.dashboards[0]; assert_eq!(dashboard.panels.len(), 2); assert!(dashboard.panels.iter().all(|p| p.promql == SEEDED_METRIC_NAME)); assert!(dashboard.panels.iter().any(|p| p.chart_type == ChartType::Line)); assert!(dashboard.panels.iter().any(|p| p.chart_type == ChartType::Pie)); // Reload (simulating a restart) and ensure_default again: must not // re-seed or duplicate the dashboard/metric. let mut store = ProfileStore::load(&store_path); store.ensure_default(&store_path, &mut settings, &settings_path).unwrap(); let default_profile = store.get(DEFAULT_PROFILE_NAME).unwrap(); assert_eq!(default_profile.metrics.len(), 1); assert_eq!(default_profile.dashboards.len(), 1); } #[tokio::test] async fn seeded_metric_renders_real_error_vs_other_data() { use crate::engine::{self, EngineMessage, EngineQuery, EngineResponse, IngestedLine}; use crate::timestamp::Timestamp; use tokio::sync::mpsc; let dir = tempfile::tempdir().unwrap(); let store_path = dir.path().join("profiles.json"); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); let mut store = ProfileStore::load(&store_path); store.ensure_default(&store_path, &mut settings, &settings_path).unwrap(); let default_profile = store.get(DEFAULT_PROFILE_NAME).unwrap().clone(); let (engine_tx, engine_rx) = mpsc::channel(128); tokio::spawn(engine::run(engine_rx)); load_into_engine(&engine_tx, &default_profile).await; for (sequence, text) in [(0u64, "all good"), (1, "an ERROR happened"), (2, "fine again")] { engine_tx .send(EngineMessage::Ingest(IngestedLine { sequence, timestamp: Timestamp::from_millis(sequence as i64), text: text.to_string(), })) .await .unwrap(); } let response = ask( &engine_tx, EngineQuery::Promql { query: SEEDED_METRIC_NAME.to_string(), start_ms: None, end_ms: None }, ) .await; let EngineResponse::Promql(Ok(mut series)) = response else { panic!("expected a successful Promql response"); }; series.sort_by(|a, b| a.labels.get(SEEDED_METRIC_LABEL).cmp(&b.labels.get(SEEDED_METRIC_LABEL))); assert_eq!(series.len(), 2); assert_eq!(series[0].labels.get(SEEDED_METRIC_LABEL).map(String::as_str), Some("error")); assert_eq!(series[0].points.len(), 1); assert_eq!(series[1].labels.get(SEEDED_METRIC_LABEL).map(String::as_str), Some("other")); assert_eq!(series[1].points.len(), 2); } #[test] fn default_profile_cannot_be_deleted() { let dir = tempfile::tempdir().unwrap(); let store_path = dir.path().join("profiles.json"); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); let mut store = ProfileStore::load(&store_path); store.ensure_default(&store_path, &mut settings, &settings_path).unwrap(); let err = store.delete(&store_path, &mut settings, &settings_path, DEFAULT_PROFILE_NAME); assert!(matches!(err, Err(ProfileError::CannotDeleteDefault))); assert!(store.get(DEFAULT_PROFILE_NAME).is_some()); } #[test] fn create_copies_the_active_profile() { let dir = tempfile::tempdir().unwrap(); let store_path = dir.path().join("profiles.json"); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); let mut store = ProfileStore::load(&store_path); store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap(); let param_req = param_request("level", r"level=(?P\w+)", "level"); store.record_param_declaration(&store_path, "web", ¶m_req).unwrap(); store.create_dashboard(&store_path, "web", "Overview".to_string()).unwrap(); store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap(); store.create(&store_path, &mut settings, &settings_path, "web-copy".to_string()).unwrap(); let copy = store.get("web-copy").unwrap(); assert_eq!(copy.params, store.get("web").unwrap().params); assert_eq!(copy.dashboards, store.get("web").unwrap().dashboards); // With no active profile, new profiles start empty as before. store.set_active(&mut settings, &settings_path, None).unwrap(); store.create(&store_path, &mut settings, &settings_path, "blank".to_string()).unwrap(); let blank = store.get("blank").unwrap(); assert!(blank.params.is_empty()); assert!(blank.dashboards.is_empty()); } #[test] fn set_active_updates_and_persists_settings() { let dir = tempfile::tempdir().unwrap(); let store_path = dir.path().join("profiles.json"); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); let mut store = ProfileStore::load(&store_path); store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap(); store.create(&store_path, &mut settings, &settings_path, "worker".to_string()).unwrap(); store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap(); assert_eq!(settings.profiles.active_profile, Some("web".to_string())); // Switching to another known profile works and persists. store.set_active(&mut settings, &settings_path, Some("worker".to_string())).unwrap(); let reloaded = Settings::load(&settings_path); assert_eq!(reloaded.profiles.active_profile, Some("worker".to_string())); // Setting an unknown profile active is rejected, leaving the previous value. let err = store.set_active(&mut settings, &settings_path, Some("ghost".to_string())); assert!(matches!(err, Err(ProfileError::NotFound))); assert_eq!(settings.profiles.active_profile, Some("worker".to_string())); // Clearing the active profile works. store.set_active(&mut settings, &settings_path, None).unwrap(); assert_eq!(settings.profiles.active_profile, None); } #[test] fn record_param_and_metric_declarations_persist_into_profile() { let dir = tempfile::tempdir().unwrap(); let store_path = dir.path().join("profiles.json"); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); let mut store = ProfileStore::load(&store_path); store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap(); let param_req = param_request("level", r"level=(?P\w+)", "level"); store.record_param_declaration(&store_path, "web", ¶m_req).unwrap(); let metric_req = metric_request( "requests", r"duration=(?P\d+)", "duration", "duration", ); store.record_metric_declaration(&store_path, "web", &metric_req).unwrap(); let profile = store.get("web").unwrap(); assert_eq!(profile.params.len(), 1); assert_eq!(profile.params[0].name, "level"); assert_eq!(profile.metrics.len(), 1); 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\w+)", "level"); store.record_param_declaration(&store_path, "web", ¶m_req_v2).unwrap(); let profile = store.get("web").unwrap(); assert_eq!(profile.params.len(), 1); assert_eq!(profile.params[0].message_match_regex, r"lvl=(?P\w+)"); // Reload from disk: recorded declarations survive a restart. let reloaded = ProfileStore::load(&store_path); let profile = reloaded.get("web").unwrap(); assert_eq!(profile.params.len(), 1); assert_eq!(profile.metrics.len(), 1); } /// Acceptance criterion: declaring params/metrics into a profile, "restarting" /// (dropping and reloading the store from disk, rather than a literal process /// restart), and replaying the saved profile against a fresh engine reproduces /// the same declarations and still ingests lines correctly — without /// re-declaring anything by hand. /// /// Note: ticket 003 (metric bucket derivation) isn't implemented yet in this /// worktree, so there's no bucket index to compare directly. What this test /// verifies instead is the part ticket 009 owns: that a profile's declarations /// round-trip through disk byte-for-byte and, once replayed via /// `load_into_engine`, produce identical engine-visible declarations (which is /// exactly the state ticket 003's derivation logic will read from) — and that /// the engine keeps ingesting lines normally afterwards. #[tokio::test] async fn profile_round_trip_reproduces_declarations_in_fresh_engine() { let dir = tempfile::tempdir().unwrap(); let store_path = dir.path().join("profiles.json"); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); // Declare directly into a profile (as ticket 004's HTTP API will do via the // `record_*_declaration` hooks once it exists). let mut store = ProfileStore::load(&store_path); store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap(); store .record_param_declaration( &store_path, "web", ¶m_request("level", r"level=(?P\w+)", "level"), ) .unwrap(); store .record_metric_declaration( &store_path, "web", &metric_request( "requests", r"request duration=(?P\d+)", "duration", "duration", ), ) .unwrap(); store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap(); // Simulate a restart: drop everything in memory, reload from disk. drop(store); drop(settings); let settings = Settings::load(&settings_path); let active_name = settings.profiles.active_profile.clone().unwrap(); let reloaded_store = ProfileStore::load(&store_path); let profile = reloaded_store.get(&active_name).unwrap().clone(); // Spawn a fresh engine and replay the profile's declarations into it. let (engine_tx, engine_rx) = mpsc::channel(16); tokio::spawn(crate::engine::run(engine_rx)); load_into_engine(&engine_tx, &profile).await; // Ingest a line and confirm the engine is functioning with the replayed // declarations in place (no re-declaration needed). engine_tx .send(EngineMessage::Ingest(IngestedLine { sequence: 0, timestamp: Timestamp::from_millis(0), text: "level=info request duration=42".to_string(), })) .await .unwrap(); let params = match ask(&engine_tx, EngineQuery::ParamDeclarations).await { EngineResponse::ParamDeclarations(params) => params, _ => panic!("unexpected response"), }; assert_eq!(params.len(), 1); assert_eq!(params[0].name, "level"); assert_eq!(params[0].message_match_regex, r"level=(?P\w+)"); assert_eq!(params[0].capture_group, "level"); assert!(matches!(params[0].kind, MetricPrimitiveKind::String)); let metrics = match ask(&engine_tx, EngineQuery::MetricDeclarations).await { EngineResponse::MetricDeclarations(metrics) => metrics, _ => panic!("unexpected response"), }; assert_eq!(metrics.len(), 1); assert_eq!(metrics[0].name, "requests"); assert_eq!(metrics[0].message_match_regex, r"request duration=(?P\d+)"); assert_eq!(metrics[0].params.len(), 1); assert_eq!(metrics[0].params[0].param_name, "duration"); let logs = match ask( &engine_tx, EngineQuery::Logs(LogsParams { since_sequence: None, start_ms: None, end_ms: None, limit: None, filter: None, }), ) .await { EngineResponse::Logs(logs) => logs, _ => panic!("unexpected response"), }; assert_eq!(logs.len(), 1); assert_eq!(logs[0].text, "level=info request duration=42"); } /// Acceptance criterion (ticket 014): creating a dashboard (with a panel), /// then reloading the profile store from disk (simulating a restart), /// still has the dashboard and its panel. #[test] fn dashboard_create_save_delete_round_trip_and_persists_across_reload() { let dir = tempfile::tempdir().unwrap(); let store_path = dir.path().join("profiles.json"); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); let mut store = ProfileStore::load(&store_path); store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap(); let dashboard = store.create_dashboard(&store_path, "web", "Overview".to_string()).unwrap(); assert_eq!(dashboard.name, "Overview"); assert!(dashboard.panels.is_empty()); let mut with_panel = dashboard.clone(); with_panel.panels.push(Panel { id: "panel-1".to_string(), title: "Requests".to_string(), promql: r#"requests{path="/a"}"#.to_string(), chart_type: ChartType::Line, }); let saved = store .save_dashboard(&store_path, "web", &dashboard.id, with_panel) .unwrap(); assert_eq!(saved.panels.len(), 1); assert_eq!(saved.id, dashboard.id); // Simulate a restart: drop and reload from disk. drop(store); let reloaded = ProfileStore::load(&store_path); let dashboards = reloaded.list_dashboards("web").unwrap(); assert_eq!(dashboards.len(), 1); assert_eq!(dashboards[0].name, "Overview"); assert_eq!(dashboards[0].panels.len(), 1); assert_eq!(dashboards[0].panels[0].promql, r#"requests{path="/a"}"#); // Deleting removes it, and that too persists. let mut reloaded = reloaded; reloaded.delete_dashboard(&store_path, "web", &dashboard.id).unwrap(); assert!(reloaded.list_dashboards("web").unwrap().is_empty()); let reloaded_again = ProfileStore::load(&store_path); assert!(reloaded_again.list_dashboards("web").unwrap().is_empty()); // Unknown profile/dashboard both fail with NotFound. assert!(matches!( reloaded_again.list_dashboards("ghost"), Err(ProfileError::NotFound) )); } }