Seed a default dashboard with 2 starter panels (ticket 025)

ensure_default now seeds a brand-new Default profile (not one that
already existed) with a "log_category" counter metric and an
"Overview" dashboard containing a line and a pie panel, both querying
that metric so each renders both series from a single PromQL query
like any user-created panel.

The counter classifies every line into exactly "category=error" or
"category=other" via two new fixed-label fields on metric
declarations: DeclareMetricRequest::matched_labels/unmatched_labels.
These apply literal label values instead of parsing captured text,
which is what keeps a case-insensitive match (e.g. "ERROR" vs "Error")
collapsed into one clean series instead of fragmenting by source
casing, and is also the only way to express the "line does NOT match"
side at all: plain regex alternation can't do it without lookaround,
which the `regex` crate doesn't support (a catch-all alternation
branch always wins at the search's leftmost position). Both fields
default to None and are backward compatible with existing profiles.

Seeded panels are ordinary editable/deletable dashboard state,
persisted through the normal profile save path; ensure_default never
re-seeds or duplicates them on a later call once the profile exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 15:38:27 +01:00
co-authored by Claude Sonnet 5
parent 8faf12f819
commit 1da00e3586
6 changed files with 377 additions and 22 deletions
+164 -7
View File
@@ -31,6 +31,7 @@
#![allow(dead_code)]
use std::collections::hash_map::DefaultHasher;
use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::hash::{Hash, Hasher};
@@ -135,6 +136,16 @@ 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 {
@@ -152,6 +163,8 @@ impl From<&DeclareMetricRequest> for MetricDeclarationRecord {
role: matcher.role,
})
.collect(),
matched_labels: request.matched_labels.clone(),
unmatched_labels: request.unmatched_labels.clone(),
}
}
}
@@ -161,6 +174,8 @@ impl From<&MetricDeclarationRecord> for DeclareMetricRequest {
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()
@@ -227,6 +242,54 @@ impl Dashboard {
}
}
/// 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.
@@ -494,12 +557,18 @@ impl ProfileStore {
Ok(())
}
/// Guarantees a `Default` profile exists in this store (creating an empty
/// one if not), 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.
/// 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.
@@ -510,7 +579,10 @@ impl ProfileStore {
settings_path: &Path,
) -> Result<(), ProfileError> {
if self.get(DEFAULT_PROFILE_NAME).is_none() {
self.profiles.push(Profile::new(DEFAULT_PROFILE_NAME.to_string()));
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) {
@@ -714,6 +786,8 @@ mod tests {
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(),
@@ -806,6 +880,89 @@ mod tests {
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();