Adds AlertRule/AlertCondition/AlertSink models with CRUD persisted on Settings (mirroring CssEntry's shape), per-line trigger evaluation hooked into the engine's ingest path (Immediate/Threshold/NewValue/Ratio conditions), and best-effort ntfy delivery spawned off the ingest hot path. Design notes: - Alert queries reuse FilterBar syntax but only support the regex()/sequence/timestamp subset (no metric[param] comparisons), since resolving those would require a full bucket rescan per alert per ingested line rather than cost proportional to new logs. - Ratio's zero-denominator case skips evaluation for that line rather than treating the ratio as infinite. - NewValue's seen-value set is in-memory only and does not survive a restart, matching the engine's other non-persisted runtime state. engine::run now takes a SettingsHandle (moved from api.rs to settings.rs so engine.rs can depend on it without a circular import) to read the current alert rules on each ingest. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
1241 lines
51 KiB
Rust
1241 lines
51 KiB
Rust
//! 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<MetricPrimitiveKind> 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<ProfileMetricPrimitiveKind> 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<MetricParamRecord>,
|
|
/// See `crate::engine::DeclareMetricRequest::matched_labels` (ticket
|
|
/// 025). `#[serde(default)]` so profiles saved before that ticket still
|
|
/// load.
|
|
#[serde(default)]
|
|
pub matched_labels: Option<std::collections::BTreeMap<String, String>>,
|
|
/// See `crate::engine::DeclareMetricRequest::unmatched_labels` (ticket
|
|
/// 025). `#[serde(default)]` so profiles saved before that ticket still
|
|
/// load.
|
|
#[serde(default)]
|
|
pub unmatched_labels: Option<std::collections::BTreeMap<String, String>>,
|
|
}
|
|
|
|
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<Panel>,
|
|
}
|
|
|
|
/// Generates an id for a newly-created dashboard/panel. Mirrors
|
|
/// `settings::generate_imported_css_id`: hashes a discriminating seed
|
|
/// (dashboard/panel name) together with the current time, avoiding a new
|
|
/// dependency (e.g. `uuid`) for what's otherwise a random-ish opaque string.
|
|
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<ParamDeclarationRecord>,
|
|
#[serde(default)]
|
|
pub metrics: Vec<MetricDeclarationRecord>,
|
|
/// Saved dashboards (ticket 014), each a named set of PromQL-backed panels.
|
|
#[serde(default)]
|
|
pub dashboards: Vec<Dashboard>,
|
|
}
|
|
|
|
impl Profile {
|
|
/// A new, empty profile with the given name.
|
|
pub fn new(name: String) -> Profile {
|
|
Profile {
|
|
name,
|
|
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<io::Error> 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<Profile>,
|
|
}
|
|
|
|
impl ProfileStore {
|
|
/// Loads the store from `store_path`. A missing file is treated as an
|
|
/// empty store (not an error) — there's no reason to fail startup just
|
|
/// because no profiles have been created yet. Any I/O or parse error
|
|
/// while reading an existing file is logged to stderr and treated as an
|
|
/// empty store, mirroring `Settings::load`'s non-fatal-error handling.
|
|
pub fn load(store_path: &Path) -> ProfileStore {
|
|
match fs::read_to_string(store_path) {
|
|
Ok(contents) => match serde_json::from_str::<Vec<Profile>>(&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<String>,
|
|
) -> 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<Dashboard, ProfileError> {
|
|
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<Dashboard, ProfileError> {
|
|
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);
|
|
let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone()));
|
|
tokio::spawn(engine::run(engine_rx, settings_handle));
|
|
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<level>\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<level>\w+)", "level");
|
|
store.record_param_declaration(&store_path, "web", ¶m_req).unwrap();
|
|
|
|
let metric_req = metric_request(
|
|
"requests",
|
|
r"duration=(?P<duration>\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<level>\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<level>\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<level>\w+)", "level"),
|
|
)
|
|
.unwrap();
|
|
store
|
|
.record_metric_declaration(
|
|
&store_path,
|
|
"web",
|
|
&metric_request(
|
|
"requests",
|
|
r"request duration=(?P<duration>\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);
|
|
let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone()));
|
|
tokio::spawn(crate::engine::run(engine_rx, settings_handle));
|
|
load_into_engine(&engine_tx, &profile).await;
|
|
|
|
// Ingest a line and confirm the engine is functioning with the replayed
|
|
// declarations in place (no re-declaration needed).
|
|
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<level>\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<duration>\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)
|
|
));
|
|
}
|
|
}
|