Add Dashboards UI: PromQL-backed bar/line/pie panels saved per profile (ticket 014)
Backend: replace Profile.dashboards' opaque JSON stub with typed Dashboard/Panel structs, add ProfileStore CRUD (create/save/list/delete dashboard) and matching /api/settings/profiles/:name/dashboards[/:id] routes, with tests covering the HTTP round trip and persistence across a simulated restart. Frontend: add recharts (lightweight, React 19-compatible) and a Dashboards component with a dashboard builder (create dashboards, add/remove/edit panels with a PromQL query + chart type), live per-panel previews that respect the active time range and show malformed-query errors inline instead of crashing, and load/save wired to the new profile-backed persistence. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ae21a7a0ce
commit
41362aa3d8
+235
-3
@@ -3,9 +3,10 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::dto::{
|
||||
BucketValuesParams, CreateProfileRequest, CssEntryDto, ErrorDto, ImportCssRequest,
|
||||
LinkCssRequest, MetricDeclarationDto, ParamDeclarationDto, LogsParams, ProfileSummaryDto,
|
||||
PromqlParams, SettingsDto, TimelineParams, UpdateSettingsRequest,
|
||||
BucketValuesParams, CreateDashboardRequest, CreateProfileRequest, CssEntryDto, DashboardDto,
|
||||
ErrorDto, ImportCssRequest, LinkCssRequest, MetricDeclarationDto, ParamDeclarationDto,
|
||||
LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest, SettingsDto,
|
||||
TimelineParams, UpdateSettingsRequest,
|
||||
};
|
||||
use crate::engine::{
|
||||
DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage, EngineQuery,
|
||||
@@ -115,6 +116,14 @@ pub fn router(
|
||||
.route("/api/settings/profiles", get(list_profiles).post(create_profile))
|
||||
.route("/api/settings/profiles/{name}", delete(delete_profile))
|
||||
.route("/api/settings/profiles/{name}/activate", post(activate_profile))
|
||||
.route(
|
||||
"/api/settings/profiles/{name}/dashboards",
|
||||
get(list_dashboards).post(create_dashboard),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/profiles/{name}/dashboards/{dashboard_id}",
|
||||
get(get_dashboard).put(save_dashboard).delete(delete_dashboard),
|
||||
)
|
||||
.with_state(state)
|
||||
.fallback_service(static_service)
|
||||
}
|
||||
@@ -585,6 +594,105 @@ async fn activate_profile(
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/settings/profiles/:name/dashboards` — lists all dashboards saved
|
||||
/// on the profile `name`, as `Vec<DashboardDto>`. Returns `404 Not Found` if
|
||||
/// no profile with that name exists.
|
||||
async fn list_dashboards(
|
||||
State(state): State<ApiState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
||||
match store.list_dashboards(&name) {
|
||||
Ok(dashboards) => {
|
||||
let dtos: Vec<DashboardDto> = dashboards.iter().map(DashboardDto::from).collect();
|
||||
Json(dtos).into_response()
|
||||
}
|
||||
Err(err) => profile_error_response(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/settings/profiles/:name/dashboards` — creates a new, empty
|
||||
/// dashboard on profile `name`. Body: `{ "name": "..." }`. Returns
|
||||
/// `201 Created` with the new dashboard as `DashboardDto`, or `404 Not
|
||||
/// Found` if no profile with that name exists.
|
||||
async fn create_dashboard(
|
||||
State(state): State<ApiState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
Json(body): Json<CreateDashboardRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
match store.create_dashboard(&store_path, &name, body.name) {
|
||||
Ok(dashboard) => (StatusCode::CREATED, Json(DashboardDto::from(&dashboard))).into_response(),
|
||||
Err(err) => profile_error_response(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/settings/profiles/:name/dashboards/:dashboard_id` — fetches one
|
||||
/// dashboard by id. Returns `404 Not Found` if the profile or dashboard
|
||||
/// doesn't exist.
|
||||
async fn get_dashboard(
|
||||
State(state): State<ApiState>,
|
||||
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
||||
match store.list_dashboards(&name) {
|
||||
Ok(dashboards) => match dashboards.iter().find(|d| d.id == dashboard_id) {
|
||||
Some(dashboard) => Json(DashboardDto::from(dashboard)).into_response(),
|
||||
None => profile_error_response(ProfileError::NotFound),
|
||||
},
|
||||
Err(err) => profile_error_response(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUT /api/settings/profiles/:name/dashboards/:dashboard_id` — replaces the
|
||||
/// dashboard's name/panels. Body: `{ "name": "...", "panels": [{ "id": "...",
|
||||
/// "title": "...", "promql": "...", "chart_type": "bar" | "line" | "pie" }] }`.
|
||||
/// Returns the updated dashboard as `DashboardDto`, or `404 Not Found` if the
|
||||
/// profile or dashboard doesn't exist.
|
||||
async fn save_dashboard(
|
||||
State(state): State<ApiState>,
|
||||
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
||||
Json(body): Json<SaveDashboardRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
let dashboard = crate::profile::Dashboard {
|
||||
id: dashboard_id.clone(),
|
||||
name: body.name,
|
||||
panels: body.panels.iter().map(|p| p.into()).collect(),
|
||||
};
|
||||
match store.save_dashboard(&store_path, &name, &dashboard_id, dashboard) {
|
||||
Ok(dashboard) => Json(DashboardDto::from(&dashboard)).into_response(),
|
||||
Err(err) => profile_error_response(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// `DELETE /api/settings/profiles/:name/dashboards/:dashboard_id` — deletes a
|
||||
/// dashboard. Returns `204 No Content`, or `404 Not Found` if the profile or
|
||||
/// dashboard doesn't exist.
|
||||
async fn delete_dashboard(
|
||||
State(state): State<ApiState>,
|
||||
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
match store.delete_dashboard(&store_path, &name, &dashboard_id) {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(err) => profile_error_response(err),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -892,4 +1000,128 @@ mod tests {
|
||||
assert_eq!(profile.params.len(), 1);
|
||||
assert_eq!(profile.params[0].name, "level");
|
||||
}
|
||||
|
||||
async fn delete_request(app: &Router, uri: &str) -> StatusCode {
|
||||
let request = Request::builder().method("DELETE").uri(uri).body(Body::empty()).unwrap();
|
||||
app.clone().oneshot(request).await.unwrap().status()
|
||||
}
|
||||
|
||||
async fn get_status(app: &Router, uri: &str) -> StatusCode {
|
||||
let request = Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap();
|
||||
app.clone().oneshot(request).await.unwrap().status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_crud_round_trips_through_http() {
|
||||
let (app, _engine, _settings, _dir) = test_app();
|
||||
|
||||
let (status, _body) = send_json(&app, "POST", "/api/settings/profiles", json!({ "name": "web" })).await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/settings/profiles/web/dashboards",
|
||||
json!({ "name": "Overview" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
assert_eq!(body["name"], "Overview");
|
||||
assert!(body["panels"].as_array().unwrap().is_empty());
|
||||
let dashboard_id = body["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Listing shows the new (empty) dashboard.
|
||||
let (status, body) = get_json(&app, "/api/settings/profiles/web/dashboards").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body.as_array().unwrap().len(), 1);
|
||||
|
||||
// Save adds a panel.
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
"PUT",
|
||||
&format!("/api/settings/profiles/web/dashboards/{dashboard_id}"),
|
||||
json!({
|
||||
"name": "Overview",
|
||||
"panels": [
|
||||
{ "id": "p1", "title": "Requests", "promql": "requests", "chart_type": "line" }
|
||||
]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["id"], dashboard_id);
|
||||
let panels = body["panels"].as_array().unwrap();
|
||||
assert_eq!(panels.len(), 1);
|
||||
assert_eq!(panels[0]["chart_type"], "line");
|
||||
|
||||
// Get by id reflects the saved panel.
|
||||
let (status, body) =
|
||||
get_json(&app, &format!("/api/settings/profiles/web/dashboards/{dashboard_id}")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["panels"].as_array().unwrap().len(), 1);
|
||||
|
||||
// Delete removes it.
|
||||
let status = delete_request(&app, &format!("/api/settings/profiles/web/dashboards/{dashboard_id}")).await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
let (status, body) = get_json(&app, "/api/settings/profiles/web/dashboards").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_routes_404_for_unknown_profile_or_dashboard() {
|
||||
let (app, _engine, _settings, _dir) = test_app();
|
||||
let status = get_status(&app, "/api/settings/profiles/ghost/dashboards").await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
|
||||
let (status, _body) = send_json(&app, "POST", "/api/settings/profiles", json!({ "name": "web" })).await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
let status = get_status(&app, "/api/settings/profiles/web/dashboards/does-not-exist").await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_dashboard_persists_across_simulated_restart() {
|
||||
let (app, _engine, settings_handle, dir) = test_app();
|
||||
|
||||
let (status, _body) = send_json(&app, "POST", "/api/settings/profiles", json!({ "name": "web" })).await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/settings/profiles/web/dashboards",
|
||||
json!({ "name": "Overview" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
let dashboard_id = body["id"].as_str().unwrap().to_string();
|
||||
|
||||
send_json(
|
||||
&app,
|
||||
"PUT",
|
||||
&format!("/api/settings/profiles/web/dashboards/{dashboard_id}"),
|
||||
json!({
|
||||
"name": "Overview",
|
||||
"panels": [
|
||||
{ "id": "p1", "title": "Requests", "promql": "requests", "chart_type": "bar" }
|
||||
]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Simulate a restart: reload the profile store from disk directly,
|
||||
// independent of the running app/engine.
|
||||
let settings_path = dir.path().join("settings.json");
|
||||
let profiles_dir = {
|
||||
let settings = settings_handle.lock().unwrap();
|
||||
profile::resolve_profiles_dir(&settings, &settings_path)
|
||||
};
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let reloaded_store = ProfileStore::load(&store_path);
|
||||
let dashboards = reloaded_store.list_dashboards("web").unwrap();
|
||||
assert_eq!(dashboards.len(), 1);
|
||||
assert_eq!(dashboards[0].name, "Overview");
|
||||
assert_eq!(dashboards[0].panels.len(), 1);
|
||||
assert_eq!(dashboards[0].panels[0].promql, "requests");
|
||||
}
|
||||
}
|
||||
|
||||
+82
-1
@@ -1,6 +1,6 @@
|
||||
use crate::data_value::{MetricPrimitiveKind, RegexToMetricPrimitiveMatcher};
|
||||
use crate::metric_primitive::MetricPrimitive;
|
||||
use crate::profile::Profile;
|
||||
use crate::profile::{ChartType, Dashboard, Panel, Profile};
|
||||
use crate::settings::{ColorScheme, CssEntry, Settings};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
@@ -241,3 +241,84 @@ impl From<&Profile> for ProfileSummaryDto {
|
||||
pub struct CreateProfileRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// One panel within a dashboard (ticket 014): a PromQL query plus how to
|
||||
/// chart its result. Mirrors `profile::Panel`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PanelDto {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub promql: String,
|
||||
pub chart_type: ChartType,
|
||||
}
|
||||
|
||||
impl From<&Panel> for PanelDto {
|
||||
fn from(panel: &Panel) -> Self {
|
||||
PanelDto {
|
||||
id: panel.id.clone(),
|
||||
title: panel.title.clone(),
|
||||
promql: panel.promql.clone(),
|
||||
chart_type: panel.chart_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&PanelDto> for Panel {
|
||||
fn from(dto: &PanelDto) -> Self {
|
||||
Panel {
|
||||
id: dto.id.clone(),
|
||||
title: dto.title.clone(),
|
||||
promql: dto.promql.clone(),
|
||||
chart_type: dto.chart_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved dashboard (ticket 014), as returned by
|
||||
/// `GET /api/settings/profiles/:name/dashboards` and friends. Mirrors
|
||||
/// `profile::Dashboard`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DashboardDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub panels: Vec<PanelDto>,
|
||||
}
|
||||
|
||||
impl From<&Dashboard> for DashboardDto {
|
||||
fn from(dashboard: &Dashboard) -> Self {
|
||||
DashboardDto {
|
||||
id: dashboard.id.clone(),
|
||||
name: dashboard.name.clone(),
|
||||
panels: dashboard.panels.iter().map(PanelDto::from).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&DashboardDto> for Dashboard {
|
||||
fn from(dto: &DashboardDto) -> Self {
|
||||
Dashboard {
|
||||
id: dto.id.clone(),
|
||||
name: dto.name.clone(),
|
||||
panels: dto.panels.iter().map(Panel::from).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body for `POST /api/settings/profiles/:name/dashboards`: creates
|
||||
/// a new, empty dashboard with this name (see `ProfileStore::create_dashboard`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateDashboardRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Request body for
|
||||
/// `PUT /api/settings/profiles/:name/dashboards/:dashboard_id`: replaces the
|
||||
/// dashboard's `name`/`panels` (see `ProfileStore::save_dashboard`). The
|
||||
/// dashboard `id` in the URL is authoritative — any `id` in the body is
|
||||
/// ignored.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SaveDashboardRequest {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub panels: Vec<PanelDto>,
|
||||
}
|
||||
|
||||
+191
-7
@@ -30,10 +30,13 @@
|
||||
//! tickets 001/002.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -158,14 +161,61 @@ impl From<&MetricDeclarationRecord> for DeclareMetricRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// A chart type a dashboard panel can render (ticket 014). Kept in sync with
|
||||
/// the frontend's `'bar' | 'line' | 'pie'` union (see `webapp/src/lib/api.ts`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ChartType {
|
||||
Bar,
|
||||
Line,
|
||||
Pie,
|
||||
}
|
||||
|
||||
/// One panel within a dashboard: a PromQL query (ticket 013) plus how to
|
||||
/// chart its result.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Panel {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub promql: String,
|
||||
pub chart_type: ChartType,
|
||||
}
|
||||
|
||||
/// A named set of panels, saved as part of a profile (ticket 014).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Dashboard {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub panels: Vec<Panel>,
|
||||
}
|
||||
|
||||
/// Generates an id for a newly-created dashboard/panel. Mirrors
|
||||
/// `settings::generate_imported_css_id`: hashes a discriminating seed
|
||||
/// (dashboard/panel name) together with the current time, avoiding a new
|
||||
/// dependency (e.g. `uuid`) for what's otherwise a random-ish opaque string.
|
||||
fn generate_id(seed: &str) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
seed.hash(&mut hasher);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
nanos.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
impl Dashboard {
|
||||
/// A new dashboard named `name`, with no panels yet.
|
||||
pub fn new(name: String) -> Dashboard {
|
||||
let id = generate_id(&name);
|
||||
Dashboard { id, name, panels: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A named bundle of parsing/indexing rules for a particular CLI app: which
|
||||
/// params and metrics to derive from its log output, plus any saved
|
||||
/// dashboards.
|
||||
///
|
||||
/// `dashboards` is stubbed as opaque JSON: dashboards (ticket 014) don't exist
|
||||
/// yet, so there's no concrete schema to store. Ticket 014 should replace this
|
||||
/// with whatever concrete `Dashboard` type it introduces (or keep it loosely
|
||||
/// typed, if that turns out to be the right call for that ticket).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Profile {
|
||||
pub name: String,
|
||||
@@ -173,9 +223,9 @@ pub struct Profile {
|
||||
pub params: Vec<ParamDeclarationRecord>,
|
||||
#[serde(default)]
|
||||
pub metrics: Vec<MetricDeclarationRecord>,
|
||||
/// Placeholder for ticket 014's dashboard storage. See the struct doc above.
|
||||
/// Saved dashboards (ticket 014), each a named set of PromQL-backed panels.
|
||||
#[serde(default)]
|
||||
pub dashboards: Vec<serde_json::Value>,
|
||||
pub dashboards: Vec<Dashboard>,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
@@ -496,6 +546,87 @@ impl ProfileStore {
|
||||
self.save(store_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lists the dashboards saved on `profile_name`. Fails with
|
||||
/// `ProfileError::NotFound` if no such profile exists.
|
||||
pub fn list_dashboards(&self, profile_name: &str) -> Result<&[Dashboard], ProfileError> {
|
||||
let profile = self.get(profile_name).ok_or(ProfileError::NotFound)?;
|
||||
Ok(&profile.dashboards)
|
||||
}
|
||||
|
||||
/// Creates a new, empty dashboard named `name` on `profile_name`,
|
||||
/// persisting the store to `store_path`. Returns the new dashboard.
|
||||
/// Fails with `ProfileError::NotFound` if no such profile exists.
|
||||
pub fn create_dashboard(
|
||||
&mut self,
|
||||
store_path: &Path,
|
||||
profile_name: &str,
|
||||
name: String,
|
||||
) -> Result<Dashboard, ProfileError> {
|
||||
let profile = self
|
||||
.profiles
|
||||
.iter_mut()
|
||||
.find(|p| p.name == profile_name)
|
||||
.ok_or(ProfileError::NotFound)?;
|
||||
let dashboard = Dashboard::new(name);
|
||||
profile.dashboards.push(dashboard.clone());
|
||||
self.save(store_path)?;
|
||||
Ok(dashboard)
|
||||
}
|
||||
|
||||
/// Replaces the dashboard identified by `dashboard_id` on `profile_name`
|
||||
/// with `dashboard` (e.g. after adding/removing/editing panels),
|
||||
/// persisting the store to `store_path`. `dashboard.id` is forced to
|
||||
/// `dashboard_id` so a caller can't accidentally change a dashboard's
|
||||
/// identity through this call. Fails with `ProfileError::NotFound` if
|
||||
/// either the profile or the dashboard doesn't exist.
|
||||
pub fn save_dashboard(
|
||||
&mut self,
|
||||
store_path: &Path,
|
||||
profile_name: &str,
|
||||
dashboard_id: &str,
|
||||
mut dashboard: Dashboard,
|
||||
) -> Result<Dashboard, ProfileError> {
|
||||
let profile = self
|
||||
.profiles
|
||||
.iter_mut()
|
||||
.find(|p| p.name == profile_name)
|
||||
.ok_or(ProfileError::NotFound)?;
|
||||
let existing = profile
|
||||
.dashboards
|
||||
.iter_mut()
|
||||
.find(|d| d.id == dashboard_id)
|
||||
.ok_or(ProfileError::NotFound)?;
|
||||
dashboard.id = dashboard_id.to_string();
|
||||
*existing = dashboard.clone();
|
||||
self.save(store_path)?;
|
||||
Ok(dashboard)
|
||||
}
|
||||
|
||||
/// Deletes the dashboard identified by `dashboard_id` from
|
||||
/// `profile_name`, persisting the store to `store_path`. Fails with
|
||||
/// `ProfileError::NotFound` if either the profile or the dashboard
|
||||
/// doesn't exist.
|
||||
pub fn delete_dashboard(
|
||||
&mut self,
|
||||
store_path: &Path,
|
||||
profile_name: &str,
|
||||
dashboard_id: &str,
|
||||
) -> Result<(), ProfileError> {
|
||||
let profile = self
|
||||
.profiles
|
||||
.iter_mut()
|
||||
.find(|p| p.name == profile_name)
|
||||
.ok_or(ProfileError::NotFound)?;
|
||||
let index = profile
|
||||
.dashboards
|
||||
.iter()
|
||||
.position(|d| d.id == dashboard_id)
|
||||
.ok_or(ProfileError::NotFound)?;
|
||||
profile.dashboards.remove(index);
|
||||
self.save(store_path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -764,4 +895,57 @@ mod tests {
|
||||
assert_eq!(logs.len(), 1);
|
||||
assert_eq!(logs[0].text, "level=info request duration=42");
|
||||
}
|
||||
|
||||
/// Acceptance criterion (ticket 014): creating a dashboard (with a panel),
|
||||
/// then reloading the profile store from disk (simulating a restart),
|
||||
/// still has the dashboard and its panel.
|
||||
#[test]
|
||||
fn dashboard_create_save_delete_round_trip_and_persists_across_reload() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store_path = dir.path().join("profiles.json");
|
||||
let settings_path = dir.path().join("settings.json");
|
||||
let mut settings = Settings::default();
|
||||
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
|
||||
|
||||
let dashboard = store.create_dashboard(&store_path, "web", "Overview".to_string()).unwrap();
|
||||
assert_eq!(dashboard.name, "Overview");
|
||||
assert!(dashboard.panels.is_empty());
|
||||
|
||||
let mut with_panel = dashboard.clone();
|
||||
with_panel.panels.push(Panel {
|
||||
id: "panel-1".to_string(),
|
||||
title: "Requests".to_string(),
|
||||
promql: r#"requests{path="/a"}"#.to_string(),
|
||||
chart_type: ChartType::Line,
|
||||
});
|
||||
let saved = store
|
||||
.save_dashboard(&store_path, "web", &dashboard.id, with_panel)
|
||||
.unwrap();
|
||||
assert_eq!(saved.panels.len(), 1);
|
||||
assert_eq!(saved.id, dashboard.id);
|
||||
|
||||
// Simulate a restart: drop and reload from disk.
|
||||
drop(store);
|
||||
let reloaded = ProfileStore::load(&store_path);
|
||||
let dashboards = reloaded.list_dashboards("web").unwrap();
|
||||
assert_eq!(dashboards.len(), 1);
|
||||
assert_eq!(dashboards[0].name, "Overview");
|
||||
assert_eq!(dashboards[0].panels.len(), 1);
|
||||
assert_eq!(dashboards[0].panels[0].promql, r#"requests{path="/a"}"#);
|
||||
|
||||
// Deleting removes it, and that too persists.
|
||||
let mut reloaded = reloaded;
|
||||
reloaded.delete_dashboard(&store_path, "web", &dashboard.id).unwrap();
|
||||
assert!(reloaded.list_dashboards("web").unwrap().is_empty());
|
||||
let reloaded_again = ProfileStore::load(&store_path);
|
||||
assert!(reloaded_again.list_dashboards("web").unwrap().is_empty());
|
||||
|
||||
// Unknown profile/dashboard both fail with NotFound.
|
||||
assert!(matches!(
|
||||
reloaded_again.list_dashboards("ghost"),
|
||||
Err(ProfileError::NotFound)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+401
-2
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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() {
|
||||
<LogTimeline buckets={buckets} range={range} onSelectRange={setRange} />
|
||||
<LogPreview logs={logs} />
|
||||
<Metrics range={range} />
|
||||
<ComingSoon title="Dashboards" />
|
||||
<Dashboards range={range} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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<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> {
|
||||
const res = await fetch(path)
|
||||
if (!res.ok) {
|
||||
@@ -265,3 +288,46 @@ export function deleteProfile(name: string): Promise<void> {
|
||||
export function activateProfile(name: string): Promise<Settings> {
|
||||
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',
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user