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
+82 -1
View File
@@ -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>,
}