From 801ac36b780d0fc0585b1ac41e0f0aed9010e435 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sat, 5 Sep 2026 13:21:31 +0100 Subject: [PATCH] Add NavMenu settings/presets UI and profile HTTP routes (ticket 011) Adds thin HTTP routes for profile CRUD and colour-scheme updates (GET/PATCH /api/settings, GET/POST /api/settings/profiles, DELETE /api/settings/profiles/:name, POST /api/settings/profiles/:name/activate) following the existing CSS-route pattern in api.rs, with activate hot-swapping the running engine's declared params/metrics so switching profiles needs no restart. Builds out the NavMenu frontend with Settings (colour scheme picker, CSS import/list/delete) and Presets (list/create/switch/delete profiles with declaration counts) panels, and a useSettingsPanel hook that applies the active colour scheme and imported CSS to the document live. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o --- src/api.rs | 170 ++++++++++++++++- src/dto.rs | 60 +++++- webapp/src/components/NavMenu.tsx | 290 ++++++++++++++++++++++++++--- webapp/src/index.css | 14 ++ webapp/src/lib/api.ts | 83 +++++++++ webapp/src/lib/useSettingsPanel.ts | 181 ++++++++++++++++++ 6 files changed, 772 insertions(+), 26 deletions(-) create mode 100644 webapp/src/lib/useSettingsPanel.ts diff --git a/src/api.rs b/src/api.rs index b79a88a..83a2783 100644 --- a/src/api.rs +++ b/src/api.rs @@ -3,14 +3,15 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use crate::dto::{ - BucketValuesParams, CssEntryDto, ErrorDto, ImportCssRequest, LinkCssRequest, - MetricDeclarationDto, ParamDeclarationDto, LogsParams, PromqlParams, TimelineParams, + BucketValuesParams, CreateProfileRequest, CssEntryDto, ErrorDto, ImportCssRequest, + LinkCssRequest, MetricDeclarationDto, ParamDeclarationDto, LogsParams, ProfileSummaryDto, + PromqlParams, SettingsDto, TimelineParams, UpdateSettingsRequest, }; use crate::engine::{ DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage, EngineQuery, EngineResponse, }; -use crate::profile::{self, ProfileStore}; +use crate::profile::{self, Profile, ProfileError, ProfileStore}; use crate::settings::{CssError, Settings}; use axum::extract::{Path as AxumPath, Query, State}; use axum::http::{header, StatusCode}; @@ -106,10 +107,14 @@ pub fn router( .route("/api/metrics", get(list_metrics).post(declare_metric)) .route("/api/metrics/{name}", get(get_metric_bucket_values)) .route("/api/query", get(run_promql_query)) + .route("/api/settings", get(get_settings).patch(update_settings)) .route("/api/settings/css", get(list_css).post(import_css)) .route("/api/settings/css/link", post(link_css)) .route("/api/settings/css/{id}", delete(remove_css)) .route("/api/settings/css/{id}/content", get(get_css_content)) + .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)) .with_state(state) .fallback_service(static_service) } @@ -421,6 +426,165 @@ async fn get_css_content( } } +/// `GET /api/settings` — returns the subset of `Settings` the NavMenu UI +/// needs (colour scheme, active profile name) as `SettingsDto`. CSS entries +/// have their own dedicated list endpoint (`GET /api/settings/css`). +async fn get_settings(State(state): State) -> impl IntoResponse { + let settings = state.settings.lock().unwrap(); + Json(SettingsDto::from(&*settings)).into_response() +} + +/// `PATCH /api/settings` — updates process-wide settings. Body: +/// `{ "color_scheme": "light" | "dark" | "system" }` (omit the field, or +/// send `null`, to leave it unchanged). Persists to `settings_path` and +/// returns the updated settings as `SettingsDto`. +async fn update_settings( + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + let mut settings = state.settings.lock().unwrap(); + if let Some(color_scheme) = body.color_scheme { + settings.color_scheme = color_scheme; + } + match settings.save(&state.settings_path) { + Ok(()) => Json(SettingsDto::from(&*settings)).into_response(), + Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), + } +} + +/// Maps a `ProfileError` to a response status/message: `NotFound` -> 404, +/// `DuplicateName` -> 400, `Io` -> 500. Mirrors `css_error_response` above. +fn profile_error_response(err: ProfileError) -> axum::response::Response { + match err { + ProfileError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(), + ProfileError::DuplicateName => (StatusCode::BAD_REQUEST, err.to_string()).into_response(), + ProfileError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), + } +} + +/// `GET /api/settings/profiles` — lists all known profiles with their +/// param/metric declaration and dashboard counts, as `Vec`. +/// Use `GET /api/settings`'s `active_profile` field to know which one (if +/// any) is currently active. +async fn list_profiles(State(state): State) -> 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)); + let summaries: Vec = store.list().iter().map(ProfileSummaryDto::from).collect(); + Json(summaries).into_response() +} + +/// `POST /api/settings/profiles` — creates a new, empty profile. Body: +/// `{ "name": "..." }`. Returns `201 Created` with the new profile as +/// `ProfileSummaryDto`, or `400 Bad Request` if a profile with that name +/// already exists. +async fn create_profile( + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + let mut 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(&store_path, &mut settings, &state.settings_path, body.name.clone()) { + Ok(()) => { + let profile = store.get(&body.name).expect("profile was just created by store.create"); + (StatusCode::CREATED, Json(ProfileSummaryDto::from(profile))).into_response() + } + Err(err) => profile_error_response(err), + } +} + +/// `DELETE /api/settings/profiles/:name` — deletes a profile. If it was the +/// active profile, `active_profile` is cleared (but the engine's currently +/// declared params/metrics are left as-is — they simply won't be reloaded on +/// the next restart). Returns `204 No Content`, or `404 Not Found` if no +/// profile with that name exists. +async fn delete_profile( + State(state): State, + AxumPath(name): AxumPath, +) -> impl IntoResponse { + let mut 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(&store_path, &mut settings, &state.settings_path, &name) { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => profile_error_response(err), + } +} + +/// Removes every currently-declared param/metric from the running engine +/// (via `EngineMessage::RemoveParamDeclaration`/`RemoveMetricDeclaration`), +/// then replays `profile`'s stored declarations into it (see +/// `profile::load_into_engine`). Used by `activate_profile` so switching the +/// active profile takes effect on the running engine immediately, without +/// requiring a process restart. +async fn reload_engine_declarations(engine: &EngineHandle, profile: &Profile) { + if let Ok(EngineResponse::ParamDeclarations(params)) = + ask(engine, EngineQuery::ParamDeclarations).await + { + for param in params { + let (respond_to, response) = oneshot::channel(); + if engine + .send(EngineMessage::RemoveParamDeclaration { name: param.name, respond_to }) + .await + .is_ok() + { + let _ = response.await; + } + } + } + if let Ok(EngineResponse::MetricDeclarations(metrics)) = + ask(engine, EngineQuery::MetricDeclarations).await + { + for metric in metrics { + let (respond_to, response) = oneshot::channel(); + if engine + .send(EngineMessage::RemoveMetricDeclaration { name: metric.name, respond_to }) + .await + .is_ok() + { + let _ = response.await; + } + } + } + profile::load_into_engine(engine, profile).await; +} + +/// `POST /api/settings/profiles/:name/activate` — sets `name` as the active +/// profile (persisted, see `ProfileStore::set_active`) and hot-swaps the +/// running engine's declared params/metrics to match it (see +/// `reload_engine_declarations`), so the switch is visible without a +/// restart. Returns the updated settings as `SettingsDto`, or `404 Not +/// Found` if no profile with that name exists. +async fn activate_profile( + State(state): State, + AxumPath(name): AxumPath, +) -> impl IntoResponse { + let activated_profile = { + let mut 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 store = ProfileStore::load(&store_path); + match store.set_active(&mut settings, &state.settings_path, Some(name.clone())) { + Ok(()) => Ok(store.get(&name).cloned()), + Err(err) => Err(err), + } + }; + + match activated_profile { + Ok(profile) => { + if let Some(profile) = profile { + reload_engine_declarations(&state.engine, &profile).await; + } + let settings = state.settings.lock().unwrap(); + Json(SettingsDto::from(&*settings)).into_response() + } + Err(err) => profile_error_response(err), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/dto.rs b/src/dto.rs index a65f278..3e3fc8a 100644 --- a/src/dto.rs +++ b/src/dto.rs @@ -1,6 +1,7 @@ use crate::data_value::{MetricPrimitiveKind, RegexToMetricPrimitiveMatcher}; use crate::metric_primitive::MetricPrimitive; -use crate::settings::CssEntry; +use crate::profile::Profile; +use crate::settings::{ColorScheme, CssEntry, Settings}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -183,3 +184,60 @@ impl From<&CssEntry> for CssEntryDto { } } } + +/// Response shape for `GET /api/settings` and `PATCH /api/settings` (ticket +/// 011): the subset of `Settings` the NavMenu UI needs to render a colour +/// scheme picker and know which profile is active. `css_entries` is +/// deliberately omitted here since it already has its own dedicated +/// `GET /api/settings/css` endpoint (ticket 010). +#[derive(Debug, Clone, Serialize)] +pub struct SettingsDto { + pub color_scheme: ColorScheme, + pub active_profile: Option, +} + +impl From<&Settings> for SettingsDto { + fn from(settings: &Settings) -> Self { + SettingsDto { + color_scheme: settings.color_scheme, + active_profile: settings.profiles.active_profile.clone(), + } + } +} + +/// Request body for `PATCH /api/settings`. Only `color_scheme` is +/// updatable for now; `None` leaves the current value untouched (so a +/// partial body doesn't reset other fields). +#[derive(Debug, Deserialize)] +pub struct UpdateSettingsRequest { + pub color_scheme: Option, +} + +/// Summary of one profile for `GET /api/settings/profiles` (ticket 011): +/// just the name and declaration/dashboard counts the Presets panel needs to +/// render a list, rather than the full param/metric declaration bodies. +#[derive(Debug, Clone, Serialize)] +pub struct ProfileSummaryDto { + pub name: String, + pub param_count: usize, + pub metric_count: usize, + pub dashboard_count: usize, +} + +impl From<&Profile> for ProfileSummaryDto { + fn from(profile: &Profile) -> Self { + ProfileSummaryDto { + name: profile.name.clone(), + param_count: profile.params.len(), + metric_count: profile.metrics.len(), + dashboard_count: profile.dashboards.len(), + } + } +} + +/// Request body for `POST /api/settings/profiles`: creates a new, empty +/// profile with this name (see `ProfileStore::create`). +#[derive(Debug, Deserialize)] +pub struct CreateProfileRequest { + pub name: String, +} diff --git a/webapp/src/components/NavMenu.tsx b/webapp/src/components/NavMenu.tsx index 0f70426..dc539ff 100644 --- a/webapp/src/components/NavMenu.tsx +++ b/webapp/src/components/NavMenu.tsx @@ -1,30 +1,276 @@ +import { useState } from 'react' import type { Stats } from '../lib/api' +import type { ColorScheme } from '../lib/api' +import { useSettingsPanel } from '../lib/useSettingsPanel' + +type PanelName = 'settings' | 'presets' | null + +const COLOR_SCHEMES: ColorScheme[] = ['light', 'dark', 'system'] export function NavMenu({ stats }: { stats: Stats | null }) { + const [openPanel, setOpenPanel] = useState(null) + const panel = useSettingsPanel() + + function togglePanel(name: PanelName) { + setOpenPanel((current) => (current === name ? null : name)) + } + return ( -
-
-
ottyl
- -
-
- {stats ? ( - <> -
{stats.log_count} logs
-
seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0}
- - ) : ( -
connecting…
- )} +
+
+
+
ottyl
+ +
+
+ {stats ? ( + <> +
{stats.log_count} logs
+
+ seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0} +
+ + ) : ( +
connecting…
+ )} +
+ + {openPanel && ( +
+
+

+ {openPanel === 'settings' ? 'Settings' : 'Presets'} +

+ +
+ {panel.error && ( +
+ {panel.error} +
+ )} + {openPanel === 'settings' ? : } +
+ )} +
+ ) +} + +function SettingsPanel({ panel }: { panel: ReturnType }) { + const [filename, setFilename] = useState('') + const [content, setContent] = useState('') + + async function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + setFilename(file.name) + setContent(await file.text()) + } + + async function handleImport(e: React.FormEvent) { + e.preventDefault() + if (!filename.trim() || !content.trim()) return + await panel.importCssEntry(filename.trim(), content) + setFilename('') + setContent('') + } + + return ( +
+
+

Colour scheme

+
+ {COLOR_SCHEMES.map((scheme) => ( + + ))} +
+

+ "Light" only recolours the base page for now — most components still use fixed dark colours. +

+
+ +
+

Import CSS

+
+ + setFilename(e.target.value)} + className="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" + /> +