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:
co-authored by
Claude Sonnet 5
parent
ae21a7a0ce
commit
41362aa3d8
+235
-3
@@ -3,9 +3,10 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::dto::{
|
||||
BucketValuesParams, CreateProfileRequest, CssEntryDto, ErrorDto, ImportCssRequest,
|
||||
LinkCssRequest, MetricDeclarationDto, ParamDeclarationDto, LogsParams, ProfileSummaryDto,
|
||||
PromqlParams, SettingsDto, TimelineParams, UpdateSettingsRequest,
|
||||
BucketValuesParams, CreateDashboardRequest, CreateProfileRequest, CssEntryDto, DashboardDto,
|
||||
ErrorDto, ImportCssRequest, LinkCssRequest, MetricDeclarationDto, ParamDeclarationDto,
|
||||
LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest, SettingsDto,
|
||||
TimelineParams, UpdateSettingsRequest,
|
||||
};
|
||||
use crate::engine::{
|
||||
DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage, EngineQuery,
|
||||
@@ -115,6 +116,14 @@ pub fn router(
|
||||
.route("/api/settings/profiles", get(list_profiles).post(create_profile))
|
||||
.route("/api/settings/profiles/{name}", delete(delete_profile))
|
||||
.route("/api/settings/profiles/{name}/activate", post(activate_profile))
|
||||
.route(
|
||||
"/api/settings/profiles/{name}/dashboards",
|
||||
get(list_dashboards).post(create_dashboard),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/profiles/{name}/dashboards/{dashboard_id}",
|
||||
get(get_dashboard).put(save_dashboard).delete(delete_dashboard),
|
||||
)
|
||||
.with_state(state)
|
||||
.fallback_service(static_service)
|
||||
}
|
||||
@@ -585,6 +594,105 @@ async fn activate_profile(
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/settings/profiles/:name/dashboards` — lists all dashboards saved
|
||||
/// on the profile `name`, as `Vec<DashboardDto>`. Returns `404 Not Found` if
|
||||
/// no profile with that name exists.
|
||||
async fn list_dashboards(
|
||||
State(state): State<ApiState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
||||
match store.list_dashboards(&name) {
|
||||
Ok(dashboards) => {
|
||||
let dtos: Vec<DashboardDto> = dashboards.iter().map(DashboardDto::from).collect();
|
||||
Json(dtos).into_response()
|
||||
}
|
||||
Err(err) => profile_error_response(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/settings/profiles/:name/dashboards` — creates a new, empty
|
||||
/// dashboard on profile `name`. Body: `{ "name": "..." }`. Returns
|
||||
/// `201 Created` with the new dashboard as `DashboardDto`, or `404 Not
|
||||
/// Found` if no profile with that name exists.
|
||||
async fn create_dashboard(
|
||||
State(state): State<ApiState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
Json(body): Json<CreateDashboardRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
match store.create_dashboard(&store_path, &name, body.name) {
|
||||
Ok(dashboard) => (StatusCode::CREATED, Json(DashboardDto::from(&dashboard))).into_response(),
|
||||
Err(err) => profile_error_response(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/settings/profiles/:name/dashboards/:dashboard_id` — fetches one
|
||||
/// dashboard by id. Returns `404 Not Found` if the profile or dashboard
|
||||
/// doesn't exist.
|
||||
async fn get_dashboard(
|
||||
State(state): State<ApiState>,
|
||||
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
||||
match store.list_dashboards(&name) {
|
||||
Ok(dashboards) => match dashboards.iter().find(|d| d.id == dashboard_id) {
|
||||
Some(dashboard) => Json(DashboardDto::from(dashboard)).into_response(),
|
||||
None => profile_error_response(ProfileError::NotFound),
|
||||
},
|
||||
Err(err) => profile_error_response(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUT /api/settings/profiles/:name/dashboards/:dashboard_id` — replaces the
|
||||
/// dashboard's name/panels. Body: `{ "name": "...", "panels": [{ "id": "...",
|
||||
/// "title": "...", "promql": "...", "chart_type": "bar" | "line" | "pie" }] }`.
|
||||
/// Returns the updated dashboard as `DashboardDto`, or `404 Not Found` if the
|
||||
/// profile or dashboard doesn't exist.
|
||||
async fn save_dashboard(
|
||||
State(state): State<ApiState>,
|
||||
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
||||
Json(body): Json<SaveDashboardRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
let dashboard = crate::profile::Dashboard {
|
||||
id: dashboard_id.clone(),
|
||||
name: body.name,
|
||||
panels: body.panels.iter().map(|p| p.into()).collect(),
|
||||
};
|
||||
match store.save_dashboard(&store_path, &name, &dashboard_id, dashboard) {
|
||||
Ok(dashboard) => Json(DashboardDto::from(&dashboard)).into_response(),
|
||||
Err(err) => profile_error_response(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// `DELETE /api/settings/profiles/:name/dashboards/:dashboard_id` — deletes a
|
||||
/// dashboard. Returns `204 No Content`, or `404 Not Found` if the profile or
|
||||
/// dashboard doesn't exist.
|
||||
async fn delete_dashboard(
|
||||
State(state): State<ApiState>,
|
||||
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
match store.delete_dashboard(&store_path, &name, &dashboard_id) {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(err) => profile_error_response(err),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -892,4 +1000,128 @@ mod tests {
|
||||
assert_eq!(profile.params.len(), 1);
|
||||
assert_eq!(profile.params[0].name, "level");
|
||||
}
|
||||
|
||||
async fn delete_request(app: &Router, uri: &str) -> StatusCode {
|
||||
let request = Request::builder().method("DELETE").uri(uri).body(Body::empty()).unwrap();
|
||||
app.clone().oneshot(request).await.unwrap().status()
|
||||
}
|
||||
|
||||
async fn get_status(app: &Router, uri: &str) -> StatusCode {
|
||||
let request = Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap();
|
||||
app.clone().oneshot(request).await.unwrap().status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_crud_round_trips_through_http() {
|
||||
let (app, _engine, _settings, _dir) = test_app();
|
||||
|
||||
let (status, _body) = send_json(&app, "POST", "/api/settings/profiles", json!({ "name": "web" })).await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/settings/profiles/web/dashboards",
|
||||
json!({ "name": "Overview" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
assert_eq!(body["name"], "Overview");
|
||||
assert!(body["panels"].as_array().unwrap().is_empty());
|
||||
let dashboard_id = body["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Listing shows the new (empty) dashboard.
|
||||
let (status, body) = get_json(&app, "/api/settings/profiles/web/dashboards").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body.as_array().unwrap().len(), 1);
|
||||
|
||||
// Save adds a panel.
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
"PUT",
|
||||
&format!("/api/settings/profiles/web/dashboards/{dashboard_id}"),
|
||||
json!({
|
||||
"name": "Overview",
|
||||
"panels": [
|
||||
{ "id": "p1", "title": "Requests", "promql": "requests", "chart_type": "line" }
|
||||
]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["id"], dashboard_id);
|
||||
let panels = body["panels"].as_array().unwrap();
|
||||
assert_eq!(panels.len(), 1);
|
||||
assert_eq!(panels[0]["chart_type"], "line");
|
||||
|
||||
// Get by id reflects the saved panel.
|
||||
let (status, body) =
|
||||
get_json(&app, &format!("/api/settings/profiles/web/dashboards/{dashboard_id}")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["panels"].as_array().unwrap().len(), 1);
|
||||
|
||||
// Delete removes it.
|
||||
let status = delete_request(&app, &format!("/api/settings/profiles/web/dashboards/{dashboard_id}")).await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
let (status, body) = get_json(&app, "/api/settings/profiles/web/dashboards").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_routes_404_for_unknown_profile_or_dashboard() {
|
||||
let (app, _engine, _settings, _dir) = test_app();
|
||||
let status = get_status(&app, "/api/settings/profiles/ghost/dashboards").await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
|
||||
let (status, _body) = send_json(&app, "POST", "/api/settings/profiles", json!({ "name": "web" })).await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
let status = get_status(&app, "/api/settings/profiles/web/dashboards/does-not-exist").await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_dashboard_persists_across_simulated_restart() {
|
||||
let (app, _engine, settings_handle, dir) = test_app();
|
||||
|
||||
let (status, _body) = send_json(&app, "POST", "/api/settings/profiles", json!({ "name": "web" })).await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/settings/profiles/web/dashboards",
|
||||
json!({ "name": "Overview" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
let dashboard_id = body["id"].as_str().unwrap().to_string();
|
||||
|
||||
send_json(
|
||||
&app,
|
||||
"PUT",
|
||||
&format!("/api/settings/profiles/web/dashboards/{dashboard_id}"),
|
||||
json!({
|
||||
"name": "Overview",
|
||||
"panels": [
|
||||
{ "id": "p1", "title": "Requests", "promql": "requests", "chart_type": "bar" }
|
||||
]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Simulate a restart: reload the profile store from disk directly,
|
||||
// independent of the running app/engine.
|
||||
let settings_path = dir.path().join("settings.json");
|
||||
let profiles_dir = {
|
||||
let settings = settings_handle.lock().unwrap();
|
||||
profile::resolve_profiles_dir(&settings, &settings_path)
|
||||
};
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let reloaded_store = ProfileStore::load(&store_path);
|
||||
let dashboards = reloaded_store.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, "requests");
|
||||
}
|
||||
}
|
||||
|
||||
+82
-1
@@ -1,6 +1,6 @@
|
||||
use crate::data_value::{MetricPrimitiveKind, RegexToMetricPrimitiveMatcher};
|
||||
use crate::metric_primitive::MetricPrimitive;
|
||||
use crate::profile::Profile;
|
||||
use crate::profile::{ChartType, Dashboard, Panel, Profile};
|
||||
use crate::settings::{ColorScheme, CssEntry, Settings};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
@@ -241,3 +241,84 @@ impl From<&Profile> for ProfileSummaryDto {
|
||||
pub struct CreateProfileRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// One panel within a dashboard (ticket 014): a PromQL query plus how to
|
||||
/// chart its result. Mirrors `profile::Panel`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PanelDto {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub promql: String,
|
||||
pub chart_type: ChartType,
|
||||
}
|
||||
|
||||
impl From<&Panel> for PanelDto {
|
||||
fn from(panel: &Panel) -> Self {
|
||||
PanelDto {
|
||||
id: panel.id.clone(),
|
||||
title: panel.title.clone(),
|
||||
promql: panel.promql.clone(),
|
||||
chart_type: panel.chart_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&PanelDto> for Panel {
|
||||
fn from(dto: &PanelDto) -> Self {
|
||||
Panel {
|
||||
id: dto.id.clone(),
|
||||
title: dto.title.clone(),
|
||||
promql: dto.promql.clone(),
|
||||
chart_type: dto.chart_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved dashboard (ticket 014), as returned by
|
||||
/// `GET /api/settings/profiles/:name/dashboards` and friends. Mirrors
|
||||
/// `profile::Dashboard`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DashboardDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub panels: Vec<PanelDto>,
|
||||
}
|
||||
|
||||
impl From<&Dashboard> for DashboardDto {
|
||||
fn from(dashboard: &Dashboard) -> Self {
|
||||
DashboardDto {
|
||||
id: dashboard.id.clone(),
|
||||
name: dashboard.name.clone(),
|
||||
panels: dashboard.panels.iter().map(PanelDto::from).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&DashboardDto> for Dashboard {
|
||||
fn from(dto: &DashboardDto) -> Self {
|
||||
Dashboard {
|
||||
id: dto.id.clone(),
|
||||
name: dto.name.clone(),
|
||||
panels: dto.panels.iter().map(Panel::from).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body for `POST /api/settings/profiles/:name/dashboards`: creates
|
||||
/// a new, empty dashboard with this name (see `ProfileStore::create_dashboard`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateDashboardRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Request body for
|
||||
/// `PUT /api/settings/profiles/:name/dashboards/:dashboard_id`: replaces the
|
||||
/// dashboard's `name`/`panels` (see `ProfileStore::save_dashboard`). The
|
||||
/// dashboard `id` in the URL is authoritative — any `id` in the body is
|
||||
/// ignored.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SaveDashboardRequest {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub panels: Vec<PanelDto>,
|
||||
}
|
||||
|
||||
+191
-7
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user