From 41362aa3d8c670054517bf29062596c27c1d2926 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sat, 5 Sep 2026 13:29:45 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o --- src/api.rs | 238 +++++++++++++++- src/dto.rs | 83 +++++- src/profile.rs | 198 ++++++++++++- webapp/package-lock.json | 403 ++++++++++++++++++++++++++- webapp/package.json | 1 + webapp/src/App.tsx | 4 +- webapp/src/components/Dashboards.tsx | 306 ++++++++++++++++++++ webapp/src/components/PanelChart.tsx | 161 +++++++++++ webapp/src/lib/api.ts | 66 +++++ 9 files changed, 1445 insertions(+), 15 deletions(-) create mode 100644 webapp/src/components/Dashboards.tsx create mode 100644 webapp/src/components/PanelChart.tsx diff --git a/src/api.rs b/src/api.rs index 83a2783..d534c9b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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`. Returns `404 Not Found` if +/// no profile with that name exists. +async fn list_dashboards( + State(state): State, + AxumPath(name): AxumPath, +) -> 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 = 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, + AxumPath(name): AxumPath, + Json(body): Json, +) -> 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, + 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, + AxumPath((name, dashboard_id)): AxumPath<(String, String)>, + Json(body): Json, +) -> 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, + 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"); + } } diff --git a/src/dto.rs b/src/dto.rs index 3e3fc8a..2dbfe0b 100644 --- a/src/dto.rs +++ b/src/dto.rs @@ -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, +} + +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, +} diff --git a/src/profile.rs b/src/profile.rs index 470d9d0..13a6dca 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -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, +} + +/// 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, #[serde(default)] pub metrics: Vec, - /// 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, + pub dashboards: Vec, } 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 { + 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 { + 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) + )); + } } diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 86a094c..2c253a1 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -11,6 +11,7 @@ "@tailwindcss/vite": "^4.3.3", "react": "^19.2.8", "react-dom": "^19.2.8", + "recharts": "^3.10.1", "tailwindcss": "^4.3.3" }, "devDependencies": { @@ -424,6 +425,32 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", @@ -688,6 +715,18 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -1218,6 +1257,69 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -1232,7 +1334,7 @@ "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1248,6 +1350,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", @@ -1278,11 +1386,147 @@ } } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, "node_modules/detect-libc": { @@ -1307,6 +1551,24 @@ "node": ">=10.13.0" } }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1344,6 +1606,25 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1757,6 +2038,87 @@ "react": "^19.2.8" } }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", @@ -1824,6 +2186,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1861,6 +2229,37 @@ "devOptional": true, "license": "MIT" }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", diff --git a/webapp/package.json b/webapp/package.json index f523e3b..5c5273b 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -13,6 +13,7 @@ "@tailwindcss/vite": "^4.3.3", "react": "^19.2.8", "react-dom": "^19.2.8", + "recharts": "^3.10.1", "tailwindcss": "^4.3.3" }, "devDependencies": { diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 04e96cd..0680619 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -3,8 +3,8 @@ import { NavMenu } from './components/NavMenu' import { FilterBar } from './components/FilterBar' import { LogTimeline, type TimeRange } from './components/LogTimeline' import { LogPreview } from './components/LogPreview' -import { ComingSoon } from './components/ComingSoon' import { Metrics } from './components/Metrics' +import { Dashboards } from './components/Dashboards' import { fetchLogs, fetchStats, fetchTimeline, type LogEntry, type Stats, type TimelineBucket } from './lib/api' const POLL_MS = 2000 @@ -56,7 +56,7 @@ export default function App() { - + ) diff --git a/webapp/src/components/Dashboards.tsx b/webapp/src/components/Dashboards.tsx new file mode 100644 index 0000000..55c43d3 --- /dev/null +++ b/webapp/src/components/Dashboards.tsx @@ -0,0 +1,306 @@ +import { useEffect, useState } from 'react' +import type { TimeRange } from './LogTimeline' +import { PanelChart } from './PanelChart' +import { + ApiError, + createDashboard, + deleteDashboard, + fetchDashboards, + fetchPromqlSeries, + fetchSettings, + saveDashboard, + type ChartType, + type Dashboard, + type Panel, + type PromqlSeries, +} from '../lib/api' + +const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie'] + +function newPanelId(): string { + // Client-side scratch id for a not-yet-saved panel; the backend treats + // panel ids as opaque strings, so this only needs to be unique within one + // dashboard's editing session. + return `panel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +/// One panel's live preview: fetches its PromQL series whenever the query, +/// chart type, or active time range changes, and renders either the chart, a +/// loading state, or an inline error (a malformed query must not crash the +/// page — see ticket 014's acceptance criteria). +function PanelPreview({ panel, range }: { panel: Panel; range: TimeRange | null }) { + const [series, setSeries] = useState([]) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!panel.promql.trim()) { + setSeries([]) + setError(null) + return + } + let cancelled = false + setLoading(true) + fetchPromqlSeries({ promql: panel.promql, startMs: range?.startMs, endMs: range?.endMs }) + .then((result) => { + if (cancelled) return + setSeries(result) + setError(null) + }) + .catch((err) => { + if (cancelled) return + setSeries([]) + setError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [panel.promql, panel.chart_type, range?.startMs, range?.endMs]) + + if (error) { + return
{error}
+ } + if (loading && series.length === 0) { + return
Loading…
+ } + return +} + +export function Dashboards({ range }: { range: TimeRange | null }) { + const [activeProfile, setActiveProfile] = useState(null) + const [dashboards, setDashboards] = useState([]) + const [selectedId, setSelectedId] = useState(null) + const [draft, setDraft] = useState(null) + const [newDashboardName, setNewDashboardName] = useState('') + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + + async function loadDashboards(profileName: string, selectAfterLoad?: string) { + try { + const loaded = await fetchDashboards(profileName) + setDashboards(loaded) + const toSelect = selectAfterLoad ?? selectedId ?? loaded[0]?.id ?? null + setSelectedId(toSelect) + setDraft(loaded.find((d) => d.id === toSelect) ?? null) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + useEffect(() => { + fetchSettings() + .then((settings) => { + setActiveProfile(settings.active_profile) + if (settings.active_profile) { + loadDashboards(settings.active_profile) + } + }) + .catch(() => setActiveProfile(null)) + // Only load once on mount; switching the active profile elsewhere in the + // app (NavMenu) requires a reload for now, same as other panels don't + // watch for profile changes mid-session. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + setDraft(dashboards.find((d) => d.id === selectedId) ?? null) + }, [selectedId, dashboards]) + + async function handleCreateDashboard(e: React.FormEvent) { + e.preventDefault() + if (!activeProfile || !newDashboardName.trim()) return + try { + const created = await createDashboard(activeProfile, newDashboardName.trim()) + setNewDashboardName('') + await loadDashboards(activeProfile, created.id) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to create dashboard') + } + } + + async function handleDeleteDashboard(id: string) { + if (!activeProfile) return + try { + await deleteDashboard(activeProfile, id) + await loadDashboards(activeProfile, undefined) + setSelectedId((prev) => (prev === id ? null : prev)) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to delete dashboard') + } + } + + function addPanel() { + if (!draft) return + setDraft({ + ...draft, + panels: [...draft.panels, { id: newPanelId(), title: '', promql: '', chart_type: 'line' }], + }) + } + + function updatePanel(id: string, patch: Partial) { + if (!draft) return + setDraft({ ...draft, panels: draft.panels.map((p) => (p.id === id ? { ...p, ...patch } : p)) }) + } + + function removePanel(id: string) { + if (!draft) return + setDraft({ ...draft, panels: draft.panels.filter((p) => p.id !== id) }) + } + + async function handleSaveDraft() { + if (!activeProfile || !draft) return + setSaving(true) + try { + const saved = await saveDashboard(activeProfile, draft.id, { name: draft.name, panels: draft.panels }) + setDashboards((prev) => prev.map((d) => (d.id === saved.id ? saved : d))) + setDraft(saved) + setError(null) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to save dashboard') + } finally { + setSaving(false) + } + } + + if (!activeProfile) { + return ( +
+ Dashboards — activate a profile (see the settings menu) to create and save dashboards. +
+ ) + } + + return ( +
+
Dashboards — profile "{activeProfile}"
+ {error && ( +
{error}
+ )} +
+
+
+ setNewDashboardName(e.target.value)} + placeholder="New dashboard name" + /> + +
+
+ {dashboards.length === 0 &&
No dashboards saved yet.
} + {dashboards.map((d) => ( +
+ + +
+ ))} +
+
+ +
+ {!draft ? ( +
Select or create a dashboard to edit its panels.
+ ) : ( +
+
+ setDraft({ ...draft, name: e.target.value })} + /> +
+ + +
+
+ + {draft.panels.length === 0 && ( +
No panels yet — add one above.
+ )} + +
+ {draft.panels.map((panel) => ( +
+
+ updatePanel(panel.id, { title: e.target.value })} + placeholder="Panel title" + /> + updatePanel(panel.id, { promql: e.target.value })} + placeholder={String.raw`requests{path="/a"}`} + /> + + +
+ {panel.title &&
{panel.title}
} + +
+ ))} +
+
+ )} +
+
+
+ ) +} diff --git a/webapp/src/components/PanelChart.tsx b/webapp/src/components/PanelChart.tsx new file mode 100644 index 0000000..e3239b0 --- /dev/null +++ b/webapp/src/components/PanelChart.tsx @@ -0,0 +1,161 @@ +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Legend, + Line, + LineChart, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import type { ChartType, PromqlSeries } from '../lib/api' + +/// Renders a label combination as a short, stable string for use as a series +/// key/legend entry — e.g. `{"path":"/a"}` -> `path=/a`. A series with no +/// labels at all (a bare metric name with no matchers) renders as `value`. +export function seriesLabel(labels: Record): string { + const entries = Object.entries(labels) + if (entries.length === 0) return 'value' + return entries.map(([k, v]) => `${k}=${v}`).join(', ') +} + +// Fixed, small categorical palette (rather than pulling in a colour-scale +// dependency) — cycles if there are more series than colours. +const COLORS = ['#38bdf8', '#f472b6', '#a78bfa', '#4ade80', '#fbbf24', '#fb7185', '#2dd4bf', '#f97316'] + +/// A line chart with time on the x-axis, value on the y-axis, and one line +/// per label combination (series). Points across series are merged into one +/// row-per-timestamp dataset (recharts wants a single array of objects keyed +/// by series name) — timestamps that only one series has are left `undefined` +/// for the others, which recharts simply skips when drawing that line. +function toLineData(series: PromqlSeries[]): { rows: Record[]; keys: string[] } { + const keys = series.map((s) => seriesLabel(s.labels)) + const byTimestamp = new Map>() + series.forEach((s, i) => { + const key = keys[i] + for (const [ts, value] of s.points) { + let row = byTimestamp.get(ts) + if (!row) { + row = { timestamp_ms: ts } + byTimestamp.set(ts, row) + } + row[key] = value + } + }) + const rows = Array.from(byTimestamp.values()).sort( + (a, b) => (a.timestamp_ms as number) - (b.timestamp_ms as number), + ) + return { rows, keys } +} + +function formatTimestamp(ts: number): string { + const date = new Date(ts) + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} + +/// Bar/pie charts need a single scalar per series, but the input is a time +/// series — inherently lossy. Aggregation choices (documented here rather +/// than hidden in the code): +/// - bar: the *latest* point per series (a snapshot of "where things are +/// now" — the common Grafana single-stat-per-series bar use case). +/// - pie: the *sum* of all points per series, shown as a share of the total +/// across all series (proportion of total volume, the common Grafana pie +/// use case — e.g. "which path got the most requests over this range"). +function latestValue(series: PromqlSeries): number { + if (series.points.length === 0) return 0 + return series.points[series.points.length - 1][1] +} + +function sumValue(series: PromqlSeries): number { + return series.points.reduce((total, [, value]) => total + value, 0) +} + +export function PanelChart({ series, chartType }: { series: PromqlSeries[]; chartType: ChartType }) { + if (series.length === 0) { + return
No data in range.
+ } + + if (chartType === 'line') { + const { rows, keys } = toLineData(series) + return ( + + + + + + formatTimestamp(Number(ts))} + contentStyle={{ background: '#171717', border: '1px solid #404040', fontSize: 12 }} + /> + + {keys.map((key, i) => ( + + ))} + + + ) + } + + if (chartType === 'bar') { + const rows = series.map((s) => ({ name: seriesLabel(s.labels), value: latestValue(s) })) + return ( + + + + + + + + {rows.map((_, i) => ( + + ))} + + + + ) + } + + // Pie: share of total across label combinations (see aggregation note above). + const rows = series.map((s) => ({ name: seriesLabel(s.labels), value: sumValue(s) })) + const total = rows.reduce((sum, r) => sum + r.value, 0) + if (total === 0) { + return
No non-zero values in range.
+ } + return ( + + + + {rows.map((_, i) => ( + + ))} + + { + const num = typeof value === 'number' ? value : Number(value ?? 0) + return `${num} (${((num / total) * 100).toFixed(1)}%)` + }} + contentStyle={{ background: '#171717', border: '1px solid #404040', fontSize: 12 }} + /> + + + + ) +} diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts index 7583265..154e859 100644 --- a/webapp/src/lib/api.ts +++ b/webapp/src/lib/api.ts @@ -98,6 +98,29 @@ export interface ProfileSummary { dashboard_count: number } +/// One chart-ready time series returned by `GET /api/query` (ticket 013): +/// a label combination plus its `(timestamp_ms, value)` points, sorted by +/// timestamp. See `PromqlSeriesDto` on the backend. +export interface PromqlSeries { + labels: Record + points: [number, number][] +} + +export type ChartType = 'bar' | 'line' | 'pie' + +export interface Panel { + id: string + title: string + promql: string + chart_type: ChartType +} + +export interface Dashboard { + id: string + name: string + panels: Panel[] +} + async function getJson(path: string): Promise { const res = await fetch(path) if (!res.ok) { @@ -265,3 +288,46 @@ export function deleteProfile(name: string): Promise { export function activateProfile(name: string): Promise { return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST') } + +/// Evaluates a PromQL query (ticket 013) via `GET /api/query`. Throws on a +/// malformed query (`400` with `{ "error": "..." }`), so callers should catch +/// and surface the message inline (e.g. per-panel) rather than letting it +/// crash the page. +export function fetchPromqlSeries(params: { + promql: string + startMs?: number + endMs?: number +}): Promise { + const search = new URLSearchParams() + search.set('promql', params.promql) + if (params.startMs !== undefined) search.set('start_ms', String(params.startMs)) + if (params.endMs !== undefined) search.set('end_ms', String(params.endMs)) + return getJson(`/api/query?${search.toString()}`) +} + +export function fetchDashboards(profileName: string): Promise { + return getJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`) +} + +export function createDashboard(profileName: string, name: string): Promise { + return sendJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`, 'POST', { name }) +} + +export function saveDashboard( + profileName: string, + dashboardId: string, + update: { name: string; panels: Panel[] }, +): Promise { + return sendJson( + `/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`, + 'PUT', + update, + ) +} + +export function deleteDashboard(profileName: string, dashboardId: string): Promise { + return sendJson( + `/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`, + 'DELETE', + ) +}