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:
co-authored by
Claude Sonnet 5
parent
57d23c62e9
commit
801ac36b78
+167
-3
@@ -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::*;
|
||||
|
||||
+59
-1
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<ColorScheme>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user