Add DELETE /api/metrics/{name} to allow deleting a declared metric (ticket 051)

Wires the engine's existing RemoveMetricDeclaration message (previously
only used internally by delete_profile's cleanup) up to a new DELETE
route, persists the removal into the active profile so it survives a
restart, and adds a delete affordance in the Metrics UI.

Decisions:
- Bucket-value history under the deleted metric's name is purged, not
  left orphaned (engine.rs's new remove_metric_declaration reuses
  update_metric's clear_metric_data helper). This is what lets a
  dashboard panel or PromQL query still referencing the metric by name
  fail with a readable "unknown metric" error instead of silently
  returning stale data.
- evaluate_promql now errors when a metric has neither a declaration nor
  any bucket data, since a metric ingested via --metrics-url scraping
  never gets a MetricDeclaration but must stay queryable. Verified with
  a seeded fixture (promql_query_against_deleted_metric_fails_with_readable_error
  in src/api.rs).

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-06 09:41:07 +01:00
co-authored by Claude Sonnet 5
parent d17cfe9b83
commit 466c81fa94
6 changed files with 312 additions and 72 deletions
+43
View File
@@ -694,6 +694,27 @@ impl Settings {
Ok(())
}
/// Removes `name`'s persisted metric declaration from `profile_name` (ticket 051), so it doesn't
/// reappear from `settings.json` on the next restart. A no-op (not an error) if the profile has
/// no record under that name — matches `Settings::remove_css`'s sibling calls in spirit, but
/// deleting an already-gone declaration is fine here since the caller (the API layer) has
/// already confirmed against the live engine index whether the metric existed at all.
pub fn remove_metric_declaration(
&mut self,
settings_path: &Path,
profile_name: &str,
name: &str,
) -> Result<(), ProfileError> {
let profile = self
.profiles
.iter_mut()
.find(|p| p.name == profile_name)
.ok_or(ProfileError::NotFound)?;
profile.metrics.retain(|r| r.name != name);
self.save(settings_path)?;
Ok(())
}
/// Lists the dashboards saved on `profile_name`; fails with `NotFound` if it doesn't exist.
pub fn list_dashboards(&self, profile_name: &str) -> Result<&[Dashboard], ProfileError> {
let profile = self.profile(profile_name).ok_or(ProfileError::NotFound)?;
@@ -1036,6 +1057,28 @@ mod tests {
assert_eq!(profile.metrics.len(), 1);
}
#[test]
fn remove_metric_declaration_drops_it_and_does_not_reappear_after_restart() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default();
settings.create_profile(&settings_path, "web".to_string()).unwrap();
let metric_req = metric_request("requests", r"duration=(?P<duration>\d+)", "duration");
settings.record_metric_declaration(&settings_path, "web", &metric_req, &[]).unwrap();
assert_eq!(settings.profile("web").unwrap().metrics.len(), 1);
settings.remove_metric_declaration(&settings_path, "web", "requests").unwrap();
assert!(settings.profile("web").unwrap().metrics.is_empty());
// Removing an unknown metric name is a harmless no-op, not an error.
settings.remove_metric_declaration(&settings_path, "web", "never-declared").unwrap();
// Reload from disk: the removal itself survives a restart (ticket 051's acceptance criterion).
let reloaded = Settings::load(&settings_path);
assert!(reloaded.profile("web").unwrap().metrics.is_empty());
}
/// Acceptance criterion: declaring params/metrics, "restarting" (reloading settings from disk), and replaying into a fresh engine reproduces the same declarations and still ingests lines, without re-declaring by hand.
#[tokio::test]
async fn profile_round_trip_reproduces_declarations_in_fresh_engine() {