Add Dashboards UI: PromQL-backed bar/line/pie panels saved per profile (ticket 014)

Backend: replace Profile.dashboards' opaque JSON stub with typed
Dashboard/Panel structs, add ProfileStore CRUD (create/save/list/delete
dashboard) and matching /api/settings/profiles/:name/dashboards[/:id] routes,
with tests covering the HTTP round trip and persistence across a simulated
restart.

Frontend: add recharts (lightweight, React 19-compatible) and a Dashboards
component with a dashboard builder (create dashboards, add/remove/edit panels
with a PromQL query + chart type), live per-panel previews that respect the
active time range and show malformed-query errors inline instead of crashing,
and load/save wired to the new profile-backed persistence.

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 13:29:45 +01:00
co-authored by Claude Sonnet 5
parent ae21a7a0ce
commit 41362aa3d8
9 changed files with 1445 additions and 15 deletions
+191 -7
View File
@@ -30,10 +30,13 @@
//! tickets 001/002.
#![allow(dead_code)]
use std::collections::hash_map::DefaultHasher;
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};
@@ -158,14 +161,61 @@ impl From<&MetricDeclarationRecord> for DeclareMetricRequest {
}
}
/// 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() }
}
}
/// 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.
///
/// `dashboards` is stubbed as opaque JSON: dashboards (ticket 014) don't exist
/// yet, so there's no concrete schema to store. Ticket 014 should replace this
/// with whatever concrete `Dashboard` type it introduces (or keep it loosely
/// typed, if that turns out to be the right call for that ticket).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Profile {
pub name: String,
@@ -173,9 +223,9 @@ pub struct Profile {
pub params: Vec<ParamDeclarationRecord>,
#[serde(default)]
pub metrics: Vec<MetricDeclarationRecord>,
/// Placeholder for ticket 014's dashboard storage. See the struct doc above.
/// Saved dashboards (ticket 014), each a named set of PromQL-backed panels.
#[serde(default)]
pub dashboards: Vec<serde_json::Value>,
pub dashboards: Vec<Dashboard>,
}
impl Profile {
@@ -496,6 +546,87 @@ impl ProfileStore {
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)]
@@ -764,4 +895,57 @@ mod tests {
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)
));
}
}