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 std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use crate::dto::{
|
use crate::dto::{
|
||||||
BucketValuesParams, CssEntryDto, ErrorDto, ImportCssRequest, LinkCssRequest,
|
BucketValuesParams, CreateProfileRequest, CssEntryDto, ErrorDto, ImportCssRequest,
|
||||||
MetricDeclarationDto, ParamDeclarationDto, LogsParams, PromqlParams, TimelineParams,
|
LinkCssRequest, MetricDeclarationDto, ParamDeclarationDto, LogsParams, ProfileSummaryDto,
|
||||||
|
PromqlParams, SettingsDto, TimelineParams, UpdateSettingsRequest,
|
||||||
};
|
};
|
||||||
use crate::engine::{
|
use crate::engine::{
|
||||||
DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage, EngineQuery,
|
DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage, EngineQuery,
|
||||||
EngineResponse,
|
EngineResponse,
|
||||||
};
|
};
|
||||||
use crate::profile::{self, ProfileStore};
|
use crate::profile::{self, Profile, ProfileError, ProfileStore};
|
||||||
use crate::settings::{CssError, Settings};
|
use crate::settings::{CssError, Settings};
|
||||||
use axum::extract::{Path as AxumPath, Query, State};
|
use axum::extract::{Path as AxumPath, Query, State};
|
||||||
use axum::http::{header, StatusCode};
|
use axum::http::{header, StatusCode};
|
||||||
@@ -106,10 +107,14 @@ pub fn router(
|
|||||||
.route("/api/metrics", get(list_metrics).post(declare_metric))
|
.route("/api/metrics", get(list_metrics).post(declare_metric))
|
||||||
.route("/api/metrics/{name}", get(get_metric_bucket_values))
|
.route("/api/metrics/{name}", get(get_metric_bucket_values))
|
||||||
.route("/api/query", get(run_promql_query))
|
.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", get(list_css).post(import_css))
|
||||||
.route("/api/settings/css/link", post(link_css))
|
.route("/api/settings/css/link", post(link_css))
|
||||||
.route("/api/settings/css/{id}", delete(remove_css))
|
.route("/api/settings/css/{id}", delete(remove_css))
|
||||||
.route("/api/settings/css/{id}/content", get(get_css_content))
|
.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)
|
.with_state(state)
|
||||||
.fallback_service(static_service)
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+59
-1
@@ -1,6 +1,7 @@
|
|||||||
use crate::data_value::{MetricPrimitiveKind, RegexToMetricPrimitiveMatcher};
|
use crate::data_value::{MetricPrimitiveKind, RegexToMetricPrimitiveMatcher};
|
||||||
use crate::metric_primitive::MetricPrimitive;
|
use crate::metric_primitive::MetricPrimitive;
|
||||||
use crate::settings::CssEntry;
|
use crate::profile::Profile;
|
||||||
|
use crate::settings::{ColorScheme, CssEntry, Settings};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +1,276 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import type { Stats } from '../lib/api'
|
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 }) {
|
export function NavMenu({ stats }: { stats: Stats | null }) {
|
||||||
|
const [openPanel, setOpenPanel] = useState<PanelName>(null)
|
||||||
|
const panel = useSettingsPanel()
|
||||||
|
|
||||||
|
function togglePanel(name: PanelName) {
|
||||||
|
setOpenPanel((current) => (current === name ? null : name))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-neutral-800 p-3">
|
<div className="flex h-full">
|
||||||
<div>
|
<div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-neutral-800 p-3">
|
||||||
<div className="mb-4 text-lg font-semibold text-neutral-100">ottyl</div>
|
<div>
|
||||||
<nav className="flex flex-col gap-1 text-sm text-neutral-400">
|
<div className="mb-4 text-lg font-semibold text-neutral-100">ottyl</div>
|
||||||
<span className="rounded bg-neutral-800 px-2 py-1 text-neutral-100">Dashboard</span>
|
<nav className="flex flex-col gap-1 text-sm text-neutral-400">
|
||||||
<span className="cursor-not-allowed px-2 py-1 opacity-50" title="Coming soon">
|
<span className="rounded bg-neutral-800 px-2 py-1 text-neutral-100">Dashboard</span>
|
||||||
Presets
|
<button
|
||||||
</span>
|
type="button"
|
||||||
<span className="cursor-not-allowed px-2 py-1 opacity-50" title="Coming soon">
|
onClick={() => togglePanel('presets')}
|
||||||
Settings
|
className={`rounded px-2 py-1 text-left hover:bg-neutral-800 ${
|
||||||
</span>
|
openPanel === 'presets' ? 'bg-neutral-800 text-neutral-100' : ''
|
||||||
</nav>
|
}`}
|
||||||
</div>
|
>
|
||||||
<div className="text-xs text-neutral-500">
|
Presets
|
||||||
{stats ? (
|
</button>
|
||||||
<>
|
<button
|
||||||
<div>{stats.log_count} logs</div>
|
type="button"
|
||||||
<div>seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0}</div>
|
onClick={() => togglePanel('settings')}
|
||||||
</>
|
className={`rounded px-2 py-1 text-left hover:bg-neutral-800 ${
|
||||||
) : (
|
openPanel === 'settings' ? 'bg-neutral-800 text-neutral-100' : ''
|
||||||
<div>connecting…</div>
|
}`}
|
||||||
)}
|
>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500">
|
||||||
|
{stats ? (
|
||||||
|
<>
|
||||||
|
<div>{stats.log_count} logs</div>
|
||||||
|
<div>
|
||||||
|
seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div>connecting…</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{openPanel && (
|
||||||
|
<div className="fixed inset-y-0 left-48 z-20 flex w-80 flex-col overflow-y-auto border-r border-neutral-800 bg-neutral-900 p-4 shadow-xl">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-100">
|
||||||
|
{openPanel === 'settings' ? 'Settings' : 'Presets'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenPanel(null)}
|
||||||
|
className="rounded px-1.5 text-neutral-500 hover:bg-neutral-800 hover:text-neutral-200"
|
||||||
|
aria-label="Close panel"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{panel.error && (
|
||||||
|
<div className="mb-3 rounded border border-red-900 bg-red-950/50 px-2 py-1 text-xs text-red-300">
|
||||||
|
{panel.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{openPanel === 'settings' ? <SettingsPanel panel={panel} /> : <PresetsPanel panel={panel} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
|
||||||
|
const [filename, setFilename] = useState('')
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
|
||||||
|
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-5 text-sm text-neutral-300">
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">Colour scheme</h3>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{COLOR_SCHEMES.map((scheme) => (
|
||||||
|
<button
|
||||||
|
key={scheme}
|
||||||
|
type="button"
|
||||||
|
onClick={() => panel.setColorScheme(scheme)}
|
||||||
|
className={`flex-1 rounded border px-2 py-1 text-xs capitalize ${
|
||||||
|
panel.settings?.color_scheme === scheme
|
||||||
|
? 'border-neutral-400 bg-neutral-800 text-neutral-100'
|
||||||
|
: 'border-neutral-700 text-neutral-400 hover:bg-neutral-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{scheme}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] text-neutral-600">
|
||||||
|
"Light" only recolours the base page for now — most components still use fixed dark colours.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">Import CSS</h3>
|
||||||
|
<form onSubmit={handleImport} className="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".css,text/css"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="text-xs text-neutral-400 file:mr-2 file:rounded file:border file:border-neutral-700 file:bg-neutral-800 file:px-2 file:py-1 file:text-neutral-200"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="filename.css"
|
||||||
|
value={filename}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="Paste CSS content here"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="resize-y rounded border border-neutral-700 bg-neutral-900 px-2 py-1 font-mono text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-200 hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">Configured CSS</h3>
|
||||||
|
{panel.cssEntries.length === 0 ? (
|
||||||
|
<div className="text-xs text-neutral-600">No CSS configured.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{panel.cssEntries.map((entry) => (
|
||||||
|
<li
|
||||||
|
key={entry.id}
|
||||||
|
className="flex items-center justify-between rounded border border-neutral-800 px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-neutral-200">{entry.filename ?? entry.path_or_url}</div>
|
||||||
|
<div className="text-[10px] uppercase text-neutral-600">{entry.kind}</div>
|
||||||
|
</div>
|
||||||
|
{entry.kind === 'imported' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => panel.removeCssEntry(entry.id)}
|
||||||
|
className="ml-2 shrink-0 rounded px-1.5 text-neutral-500 hover:bg-neutral-800 hover:text-red-400"
|
||||||
|
aria-label={`Delete ${entry.filename}`}
|
||||||
|
>
|
||||||
|
delete
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="ml-2 shrink-0 text-neutral-700" title="Linked CSS isn't deletable here">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PresetsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
|
||||||
|
async function handleCreate(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!newName.trim()) return
|
||||||
|
await panel.addProfile(newName.trim())
|
||||||
|
setNewName('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 text-sm text-neutral-300">
|
||||||
|
<form onSubmit={handleCreate} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="new profile name"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
className="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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-200 hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{panel.profiles.length === 0 ? (
|
||||||
|
<div className="text-xs text-neutral-600">No profiles yet.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{panel.profiles.map((profile) => {
|
||||||
|
const isActive = panel.settings?.active_profile === profile.name
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={profile.name}
|
||||||
|
className={`rounded border px-2 py-1.5 text-xs ${
|
||||||
|
isActive ? 'border-neutral-500 bg-neutral-800' : 'border-neutral-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => !isActive && panel.switchProfile(profile.name)}
|
||||||
|
disabled={isActive}
|
||||||
|
className={`truncate text-left font-medium ${
|
||||||
|
isActive ? 'cursor-default text-neutral-100' : 'text-neutral-200 hover:underline'
|
||||||
|
}`}
|
||||||
|
title={isActive ? 'Active profile' : `Switch to ${profile.name}`}
|
||||||
|
>
|
||||||
|
{profile.name}
|
||||||
|
{isActive && <span className="ml-1 text-[10px] uppercase text-neutral-400">active</span>}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => panel.removeProfile(profile.name)}
|
||||||
|
className="ml-2 shrink-0 rounded px-1.5 text-neutral-500 hover:bg-neutral-800 hover:text-red-400"
|
||||||
|
aria-label={`Delete ${profile.name}`}
|
||||||
|
>
|
||||||
|
delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[10px] text-neutral-500">
|
||||||
|
{profile.param_count} params · {profile.metric_count} metrics · {profile.dashboard_count} dashboards
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-neutral-600">
|
||||||
|
Switching profiles hot-swaps the running engine's declared params/metrics immediately — no restart needed.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,17 @@ body {
|
|||||||
@apply bg-neutral-950 text-neutral-200;
|
@apply bg-neutral-950 text-neutral-200;
|
||||||
font: 14px/1.5 ui-monospace, Consolas, monospace;
|
font: 14px/1.5 ui-monospace, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ticket 011: colour-scheme picker support. Most of the app's Tailwind
|
||||||
|
* classes are hardcoded to a dark palette (e.g. `bg-neutral-950` above)
|
||||||
|
* rather than using `dark:`-variant classes, so there isn't a full light
|
||||||
|
* theme to switch to yet. This override (keyed off the `light` class
|
||||||
|
* `useSettingsPanel` toggles on `<html>`) gives "Light" a real, if minimal,
|
||||||
|
* visible effect on the base page colours; a proper light theme would need
|
||||||
|
* the rest of the app's components reworked to use theme-aware classes.
|
||||||
|
*/
|
||||||
|
html.light body {
|
||||||
|
background-color: #f5f5f4;
|
||||||
|
color: #1c1917;
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,29 @@ export interface Stats {
|
|||||||
last_timestamp_ms: number | null
|
last_timestamp_ms: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ColorScheme = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
color_scheme: ColorScheme
|
||||||
|
active_profile: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CssEntryKind = 'imported' | 'linked'
|
||||||
|
|
||||||
|
export interface CssEntry {
|
||||||
|
id: string
|
||||||
|
kind: CssEntryKind
|
||||||
|
filename: string | null
|
||||||
|
path_or_url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileSummary {
|
||||||
|
name: string
|
||||||
|
param_count: number
|
||||||
|
metric_count: number
|
||||||
|
dashboard_count: number
|
||||||
|
}
|
||||||
|
|
||||||
async function getJson<T>(path: string): Promise<T> {
|
async function getJson<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(path)
|
const res = await fetch(path)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -51,6 +74,26 @@ async function getJson<T>(path: string): Promise<T> {
|
|||||||
return res.json() as Promise<T>
|
return res.json() as Promise<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendJson<T>(path: string, method: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method,
|
||||||
|
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = `${path} failed: ${res.status}`
|
||||||
|
try {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data?.error) message = data.error
|
||||||
|
} catch {
|
||||||
|
// response body wasn't JSON (or was empty) — fall back to the generic message
|
||||||
|
}
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchLogs(params: {
|
export function fetchLogs(params: {
|
||||||
sinceSequence?: number
|
sinceSequence?: number
|
||||||
startMs?: number
|
startMs?: number
|
||||||
@@ -84,3 +127,43 @@ export function fetchStats(): Promise<Stats> {
|
|||||||
export function fetchParamDeclarations(): Promise<ParamDeclaration[]> {
|
export function fetchParamDeclarations(): Promise<ParamDeclaration[]> {
|
||||||
return getJson('/api/params')
|
return getJson('/api/params')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchSettings(): Promise<Settings> {
|
||||||
|
return getJson('/api/settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSettings(update: { colorScheme?: ColorScheme }): Promise<Settings> {
|
||||||
|
return sendJson('/api/settings', 'PATCH', { color_scheme: update.colorScheme })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCssEntries(): Promise<CssEntry[]> {
|
||||||
|
return getJson('/api/settings/css')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importCss(filename: string, content: string): Promise<CssEntry> {
|
||||||
|
return sendJson('/api/settings/css', 'POST', { filename, content })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCssEntry(id: string): Promise<void> {
|
||||||
|
return sendJson(`/api/settings/css/${encodeURIComponent(id)}`, 'DELETE')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cssContentUrl(id: string): string {
|
||||||
|
return `/api/settings/css/${encodeURIComponent(id)}/content`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchProfiles(): Promise<ProfileSummary[]> {
|
||||||
|
return getJson('/api/settings/profiles')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProfile(name: string): Promise<ProfileSummary> {
|
||||||
|
return sendJson('/api/settings/profiles', 'POST', { name })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteProfile(name: string): Promise<void> {
|
||||||
|
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}`, 'DELETE')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activateProfile(name: string): Promise<Settings> {
|
||||||
|
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST')
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
activateProfile,
|
||||||
|
createProfile,
|
||||||
|
cssContentUrl,
|
||||||
|
deleteCssEntry,
|
||||||
|
deleteProfile,
|
||||||
|
fetchCssEntries,
|
||||||
|
fetchProfiles,
|
||||||
|
fetchSettings,
|
||||||
|
importCss,
|
||||||
|
updateSettings,
|
||||||
|
type ColorScheme,
|
||||||
|
type CssEntry,
|
||||||
|
type ProfileSummary,
|
||||||
|
type Settings,
|
||||||
|
} from './api'
|
||||||
|
|
||||||
|
/** `data-*` marker used to find/remove previously-injected CSS tags on re-apply. */
|
||||||
|
const INJECTED_CSS_ATTR = 'data-ottyl-css-id'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves `colorScheme` to an effective `light`/`dark` value (following the
|
||||||
|
* OS preference for `system`) and reflects it onto `<html>` as both a class
|
||||||
|
* (`light`/`dark`) and a `data-color-scheme` attribute. The app's Tailwind
|
||||||
|
* classes are mostly hardcoded to a dark palette (see `index.css`) rather
|
||||||
|
* than using `dark:` variants, so "Light" mode is necessarily limited to
|
||||||
|
* whatever `index.css` explicitly overrides for `.light` — this is a known,
|
||||||
|
* documented limitation rather than a full theme system.
|
||||||
|
*/
|
||||||
|
function applyColorScheme(colorScheme: ColorScheme) {
|
||||||
|
const root = document.documentElement
|
||||||
|
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true
|
||||||
|
const effective = colorScheme === 'system' ? (prefersDark ? 'dark' : 'light') : colorScheme
|
||||||
|
root.classList.remove('light', 'dark')
|
||||||
|
root.classList.add(effective)
|
||||||
|
root.dataset.colorScheme = colorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects a `<link>` tag per CSS entry into `<head>` (imported entries point
|
||||||
|
* at `GET /api/settings/css/:id/content`, linked entries point straight at
|
||||||
|
* their `path_or_url`), after removing any tags this hook previously
|
||||||
|
* injected — so the rendered app reflects the current entry list immediately
|
||||||
|
* on every change, without a page reload.
|
||||||
|
*/
|
||||||
|
function applyCssEntries(entries: CssEntry[]) {
|
||||||
|
document.querySelectorAll(`link[${INJECTED_CSS_ATTR}]`).forEach((el) => el.remove())
|
||||||
|
for (const entry of entries) {
|
||||||
|
const href = entry.kind === 'imported' ? cssContentUrl(entry.id) : entry.path_or_url
|
||||||
|
if (!href) continue
|
||||||
|
const link = document.createElement('link')
|
||||||
|
link.rel = 'stylesheet'
|
||||||
|
link.href = href
|
||||||
|
link.setAttribute(INJECTED_CSS_ATTR, entry.id)
|
||||||
|
document.head.appendChild(link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns fetching + mutating settings/CSS/profile state for the NavMenu panels
|
||||||
|
* (ticket 011), and applies the colour scheme / imported CSS to the document
|
||||||
|
* as a side effect whenever that state changes — so toggling either is
|
||||||
|
* visible in the running app immediately, with no reload.
|
||||||
|
*/
|
||||||
|
export function useSettingsPanel() {
|
||||||
|
const [settings, setSettings] = useState<Settings | null>(null)
|
||||||
|
const [cssEntries, setCssEntries] = useState<CssEntry[]>([])
|
||||||
|
const [profiles, setProfiles] = useState<ProfileSummary[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const refreshSettings = useCallback(async () => {
|
||||||
|
const next = await fetchSettings()
|
||||||
|
setSettings(next)
|
||||||
|
return next
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshCss = useCallback(async () => {
|
||||||
|
const next = await fetchCssEntries()
|
||||||
|
setCssEntries(next)
|
||||||
|
return next
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshProfiles = useCallback(async () => {
|
||||||
|
const next = await fetchProfiles()
|
||||||
|
setProfiles(next)
|
||||||
|
return next
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshSettings().catch(() => {})
|
||||||
|
refreshCss().catch(() => {})
|
||||||
|
refreshProfiles().catch(() => {})
|
||||||
|
}, [refreshSettings, refreshCss, refreshProfiles])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings) applyColorScheme(settings.color_scheme)
|
||||||
|
}, [settings])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyCssEntries(cssEntries)
|
||||||
|
}, [cssEntries])
|
||||||
|
|
||||||
|
const guarded = useCallback((action: () => Promise<void>) => {
|
||||||
|
return async () => {
|
||||||
|
try {
|
||||||
|
setError(null)
|
||||||
|
await action()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setColorScheme = useCallback(
|
||||||
|
(colorScheme: ColorScheme) =>
|
||||||
|
guarded(async () => {
|
||||||
|
const next = await updateSettings({ colorScheme })
|
||||||
|
setSettings(next)
|
||||||
|
})(),
|
||||||
|
[guarded],
|
||||||
|
)
|
||||||
|
|
||||||
|
const importCssEntry = useCallback(
|
||||||
|
(filename: string, content: string) =>
|
||||||
|
guarded(async () => {
|
||||||
|
await importCss(filename, content)
|
||||||
|
await refreshCss()
|
||||||
|
})(),
|
||||||
|
[guarded, refreshCss],
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeCssEntry = useCallback(
|
||||||
|
(id: string) =>
|
||||||
|
guarded(async () => {
|
||||||
|
await deleteCssEntry(id)
|
||||||
|
await refreshCss()
|
||||||
|
})(),
|
||||||
|
[guarded, refreshCss],
|
||||||
|
)
|
||||||
|
|
||||||
|
const addProfile = useCallback(
|
||||||
|
(name: string) =>
|
||||||
|
guarded(async () => {
|
||||||
|
await createProfile(name)
|
||||||
|
await refreshProfiles()
|
||||||
|
})(),
|
||||||
|
[guarded, refreshProfiles],
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeProfile = useCallback(
|
||||||
|
(name: string) =>
|
||||||
|
guarded(async () => {
|
||||||
|
await deleteProfile(name)
|
||||||
|
await Promise.all([refreshProfiles(), refreshSettings()])
|
||||||
|
})(),
|
||||||
|
[guarded, refreshProfiles, refreshSettings],
|
||||||
|
)
|
||||||
|
|
||||||
|
const switchProfile = useCallback(
|
||||||
|
(name: string) =>
|
||||||
|
guarded(async () => {
|
||||||
|
const next = await activateProfile(name)
|
||||||
|
setSettings(next)
|
||||||
|
})(),
|
||||||
|
[guarded],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
cssEntries,
|
||||||
|
profiles,
|
||||||
|
error,
|
||||||
|
setColorScheme,
|
||||||
|
importCssEntry,
|
||||||
|
removeCssEntry,
|
||||||
|
addProfile,
|
||||||
|
removeProfile,
|
||||||
|
switchProfile,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user