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
+235 -3
View File
@@ -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");
}
}