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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 13:21:31 +01:00
co-authored by Claude Sonnet 5
parent 57d23c62e9
commit 801ac36b78
6 changed files with 772 additions and 26 deletions
+167 -3
View File
@@ -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<ApiState>) -> 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<ApiState>,
Json(body): Json<UpdateSettingsRequest>,
) -> 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<ProfileSummaryDto>`.
/// Use `GET /api/settings`'s `active_profile` field to know which one (if
/// any) is currently active.
async fn list_profiles(State(state): State<ApiState>) -> 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<ProfileSummaryDto> = 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<ApiState>,
Json(body): Json<CreateProfileRequest>,
) -> 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<ApiState>,
AxumPath(name): AxumPath<String>,
) -> 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<ApiState>,
AxumPath(name): AxumPath<String>,
) -> 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::*;