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 std::sync::{Arc, Mutex};
use crate::dto::{ use crate::dto::{
BucketValuesParams, CreateProfileRequest, CssEntryDto, ErrorDto, ImportCssRequest, BucketValuesParams, CreateDashboardRequest, CreateProfileRequest, CssEntryDto, DashboardDto,
LinkCssRequest, MetricDeclarationDto, ParamDeclarationDto, LogsParams, ProfileSummaryDto, ErrorDto, ImportCssRequest, LinkCssRequest, MetricDeclarationDto, ParamDeclarationDto,
PromqlParams, SettingsDto, TimelineParams, UpdateSettingsRequest, LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest, SettingsDto,
TimelineParams, UpdateSettingsRequest,
}; };
use crate::engine::{ use crate::engine::{
DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage, EngineQuery, 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", get(list_profiles).post(create_profile))
.route("/api/settings/profiles/{name}", delete(delete_profile)) .route("/api/settings/profiles/{name}", delete(delete_profile))
.route("/api/settings/profiles/{name}/activate", post(activate_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) .with_state(state)
.fallback_service(static_service) .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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -892,4 +1000,128 @@ mod tests {
assert_eq!(profile.params.len(), 1); assert_eq!(profile.params.len(), 1);
assert_eq!(profile.params[0].name, "level"); 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
View File
@@ -1,6 +1,6 @@
use crate::data_value::{MetricPrimitiveKind, RegexToMetricPrimitiveMatcher}; use crate::data_value::{MetricPrimitiveKind, RegexToMetricPrimitiveMatcher};
use crate::metric_primitive::MetricPrimitive; use crate::metric_primitive::MetricPrimitive;
use crate::profile::Profile; use crate::profile::{ChartType, Dashboard, Panel, Profile};
use crate::settings::{ColorScheme, CssEntry, Settings}; use crate::settings::{ColorScheme, CssEntry, Settings};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -241,3 +241,84 @@ impl From<&Profile> for ProfileSummaryDto {
pub struct CreateProfileRequest { pub struct CreateProfileRequest {
pub name: String, 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
View File
@@ -30,10 +30,13 @@
//! tickets 001/002. //! tickets 001/002.
#![allow(dead_code)] #![allow(dead_code)]
use std::collections::hash_map::DefaultHasher;
use std::fmt; use std::fmt;
use std::fs; use std::fs;
use std::hash::{Hash, Hasher};
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize}; 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 /// 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 /// params and metrics to derive from its log output, plus any saved
/// dashboards. /// 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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Profile { pub struct Profile {
pub name: String, pub name: String,
@@ -173,9 +223,9 @@ pub struct Profile {
pub params: Vec<ParamDeclarationRecord>, pub params: Vec<ParamDeclarationRecord>,
#[serde(default)] #[serde(default)]
pub metrics: Vec<MetricDeclarationRecord>, 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)] #[serde(default)]
pub dashboards: Vec<serde_json::Value>, pub dashboards: Vec<Dashboard>,
} }
impl Profile { impl Profile {
@@ -496,6 +546,87 @@ impl ProfileStore {
self.save(store_path)?; self.save(store_path)?;
Ok(()) 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)] #[cfg(test)]
@@ -764,4 +895,57 @@ mod tests {
assert_eq!(logs.len(), 1); assert_eq!(logs.len(), 1);
assert_eq!(logs[0].text, "level=info request duration=42"); 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)
));
}
} }
+401 -2
View File
@@ -11,6 +11,7 @@
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"recharts": "^3.10.1",
"tailwindcss": "^4.3.3" "tailwindcss": "^4.3.3"
}, },
"devDependencies": { "devDependencies": {
@@ -424,6 +425,32 @@
"node": "^20.19.0 || >=22.12.0" "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": { "node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", "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==", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT" "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": { "node_modules/@tailwindcss/node": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -1218,6 +1257,69 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8" "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": { "node_modules/@types/node": {
"version": "24.13.3", "version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@@ -1232,7 +1334,7 @@
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -1248,6 +1350,12 @@
"@types/react": "^19.2.0" "@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": { "node_modules/@vitejs/plugin-react": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", "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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "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" "license": "MIT"
}, },
"node_modules/detect-libc": { "node_modules/detect-libc": {
@@ -1307,6 +1551,24 @@
"node": ">=10.13.0" "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "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==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "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": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -1757,6 +2038,87 @@
"react": "^19.2.8" "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": { "node_modules/rolldown": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz",
@@ -1824,6 +2186,12 @@
"url": "https://opencollective.com/webpack" "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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1861,6 +2229,37 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/vite": {
"version": "8.2.2", "version": "8.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
+1
View File
@@ -13,6 +13,7 @@
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"recharts": "^3.10.1",
"tailwindcss": "^4.3.3" "tailwindcss": "^4.3.3"
}, },
"devDependencies": { "devDependencies": {
+2 -2
View File
@@ -3,8 +3,8 @@ import { NavMenu } from './components/NavMenu'
import { FilterBar } from './components/FilterBar' import { FilterBar } from './components/FilterBar'
import { LogTimeline, type TimeRange } from './components/LogTimeline' import { LogTimeline, type TimeRange } from './components/LogTimeline'
import { LogPreview } from './components/LogPreview' import { LogPreview } from './components/LogPreview'
import { ComingSoon } from './components/ComingSoon'
import { Metrics } from './components/Metrics' import { Metrics } from './components/Metrics'
import { Dashboards } from './components/Dashboards'
import { fetchLogs, fetchStats, fetchTimeline, type LogEntry, type Stats, type TimelineBucket } from './lib/api' import { fetchLogs, fetchStats, fetchTimeline, type LogEntry, type Stats, type TimelineBucket } from './lib/api'
const POLL_MS = 2000 const POLL_MS = 2000
@@ -56,7 +56,7 @@ export default function App() {
<LogTimeline buckets={buckets} range={range} onSelectRange={setRange} /> <LogTimeline buckets={buckets} range={range} onSelectRange={setRange} />
<LogPreview logs={logs} /> <LogPreview logs={logs} />
<Metrics range={range} /> <Metrics range={range} />
<ComingSoon title="Dashboards" /> <Dashboards range={range} />
</div> </div>
</div> </div>
) )
+306
View File
@@ -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<PromqlSeries[]>([])
const [error, setError] = useState<string | null>(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 <div className="rounded border border-red-900 bg-red-950/50 px-2 py-1 text-xs text-red-400">{error}</div>
}
if (loading && series.length === 0) {
return <div className="flex h-48 items-center justify-center text-xs text-neutral-600">Loading…</div>
}
return <PanelChart series={series} chartType={panel.chart_type} />
}
export function Dashboards({ range }: { range: TimeRange | null }) {
const [activeProfile, setActiveProfile] = useState<string | null>(null)
const [dashboards, setDashboards] = useState<Dashboard[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [draft, setDraft] = useState<Dashboard | null>(null)
const [newDashboardName, setNewDashboardName] = useState('')
const [error, setError] = useState<string | null>(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<Panel>) {
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 (
<div className="border-b border-neutral-800 p-3 text-xs text-neutral-600">
Dashboards — activate a profile (see the settings menu) to create and save dashboards.
</div>
)
}
return (
<div className="border-b border-neutral-800 p-3 text-sm">
<div className="mb-2 text-xs text-neutral-500">Dashboards — profile "{activeProfile}"</div>
{error && (
<div className="mb-2 rounded border border-red-900 bg-red-950/50 px-2 py-1 text-xs text-red-400">{error}</div>
)}
<div className="flex gap-4">
<div className="w-56 shrink-0 space-y-3">
<form onSubmit={handleCreateDashboard} className="flex gap-1">
<input
className="w-full min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
value={newDashboardName}
onChange={(e) => setNewDashboardName(e.target.value)}
placeholder="New dashboard name"
/>
<button
type="submit"
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-300 hover:bg-neutral-800"
>
+ Add
</button>
</form>
<div className="space-y-1">
{dashboards.length === 0 && <div className="text-xs text-neutral-600">No dashboards saved yet.</div>}
{dashboards.map((d) => (
<div
key={d.id}
className={`flex items-center justify-between rounded border px-2 py-1 text-xs ${
selectedId === d.id
? 'border-sky-500 bg-sky-500/10 text-sky-300'
: 'border-neutral-700 text-neutral-300 hover:bg-neutral-800'
}`}
>
<button type="button" className="min-w-0 flex-1 truncate text-left" onClick={() => setSelectedId(d.id)}>
{d.name} ({d.panels.length})
</button>
<button
type="button"
onClick={() => handleDeleteDashboard(d.id)}
className="ml-1 shrink-0 text-neutral-500 hover:text-red-400"
title="Delete dashboard"
>
×
</button>
</div>
))}
</div>
</div>
<div className="min-w-0 flex-1">
{!draft ? (
<div className="text-xs text-neutral-600">Select or create a dashboard to edit its panels.</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<input
className="w-64 rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-sm text-neutral-100 focus:border-neutral-500 focus:outline-none"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
<div className="flex gap-1">
<button
type="button"
onClick={addPanel}
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-300 hover:bg-neutral-800"
>
+ Add panel
</button>
<button
type="button"
onClick={handleSaveDraft}
disabled={saving}
className="rounded border border-sky-700 bg-sky-500/10 px-2 py-1 text-xs text-sky-300 hover:bg-sky-500/20 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save dashboard'}
</button>
</div>
</div>
{draft.panels.length === 0 && (
<div className="text-xs text-neutral-600">No panels yet — add one above.</div>
)}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{draft.panels.map((panel) => (
<div key={panel.id} className="rounded border border-neutral-800 p-2">
<div className="mb-2 flex items-center gap-1">
<input
className="w-28 min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-900 px-1.5 py-1 text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
value={panel.title}
onChange={(e) => updatePanel(panel.id, { title: e.target.value })}
placeholder="Panel title"
/>
<input
className="w-40 min-w-0 flex-[2] rounded border border-neutral-700 bg-neutral-900 px-1.5 py-1 font-mono text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
value={panel.promql}
onChange={(e) => updatePanel(panel.id, { promql: e.target.value })}
placeholder={String.raw`requests{path="/a"}`}
/>
<select
className="rounded border border-neutral-700 bg-neutral-900 px-1 py-1 text-xs text-neutral-100 focus:border-neutral-500 focus:outline-none"
value={panel.chart_type}
onChange={(e) => updatePanel(panel.id, { chart_type: e.target.value as ChartType })}
>
{CHART_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<button
type="button"
onClick={() => removePanel(panel.id)}
className="rounded border border-neutral-700 px-1.5 py-1 text-xs text-neutral-400 hover:bg-neutral-800"
>
×
</button>
</div>
{panel.title && <div className="mb-1 text-xs text-neutral-500">{panel.title}</div>}
<PanelPreview panel={panel} range={range} />
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}
+161
View File
@@ -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, string>): 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<string, number | string>[]; keys: string[] } {
const keys = series.map((s) => seriesLabel(s.labels))
const byTimestamp = new Map<number, Record<string, number | string>>()
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 <div className="flex h-48 items-center justify-center text-xs text-neutral-600">No data in range.</div>
}
if (chartType === 'line') {
const { rows, keys } = toLineData(series)
return (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={rows} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#262626" />
<XAxis
dataKey="timestamp_ms"
tickFormatter={formatTimestamp}
stroke="#737373"
tick={{ fontSize: 11, fill: '#a3a3a3' }}
/>
<YAxis stroke="#737373" tick={{ fontSize: 11, fill: '#a3a3a3' }} width={48} />
<Tooltip
labelFormatter={(ts) => formatTimestamp(Number(ts))}
contentStyle={{ background: '#171717', border: '1px solid #404040', fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
{keys.map((key, i) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={COLORS[i % COLORS.length]}
dot={false}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
)
}
if (chartType === 'bar') {
const rows = series.map((s) => ({ name: seriesLabel(s.labels), value: latestValue(s) }))
return (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={rows} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#262626" />
<XAxis dataKey="name" stroke="#737373" tick={{ fontSize: 11, fill: '#a3a3a3' }} />
<YAxis stroke="#737373" tick={{ fontSize: 11, fill: '#a3a3a3' }} width={48} />
<Tooltip contentStyle={{ background: '#171717', border: '1px solid #404040', fontSize: 12 }} />
<Bar dataKey="value" isAnimationActive={false}>
{rows.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)
}
// 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 <div className="flex h-48 items-center justify-center text-xs text-neutral-600">No non-zero values in range.</div>
}
return (
<ResponsiveContainer width="100%" height={220}>
<PieChart margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<Pie data={rows} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} isAnimationActive={false}>
{rows.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip
formatter={(value) => {
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 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
</PieChart>
</ResponsiveContainer>
)
}
+66
View File
@@ -98,6 +98,29 @@ export interface ProfileSummary {
dashboard_count: number 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<string, string>
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<T>(path: string): Promise<T> { async function getJson<T>(path: string): Promise<T> {
const res = await fetch(path) const res = await fetch(path)
if (!res.ok) { if (!res.ok) {
@@ -265,3 +288,46 @@ export function deleteProfile(name: string): Promise<void> {
export function activateProfile(name: string): Promise<Settings> { export function activateProfile(name: string): Promise<Settings> {
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST') 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<PromqlSeries[]> {
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<Dashboard[]> {
return getJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`)
}
export function createDashboard(profileName: string, name: string): Promise<Dashboard> {
return sendJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`, 'POST', { name })
}
export function saveDashboard(
profileName: string,
dashboardId: string,
update: { name: string; panels: Panel[] },
): Promise<Dashboard> {
return sendJson(
`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`,
'PUT',
update,
)
}
export function deleteDashboard(profileName: string, dashboardId: string): Promise<void> {
return sendJson(
`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`,
'DELETE',
)
}