Remove uploading css functionality. Themes are hard-coded. Change the code

This commit is contained in:
Hugh Kaznowski
2026-09-07 01:02:41 +01:00
parent 901dcf0a3f
commit 82fa0dfeb4
24 changed files with 578 additions and 1702 deletions
+4 -53
View File
@@ -2,7 +2,7 @@ use crate::engine::alerts::AlertCondition;
use crate::engine::data_value::MetricPrimitiveKind;
use crate::engine::metric_primitive::MetricPrimitive;
use crate::config::profile::{ChartType, Dashboard, Panel, Profile};
use crate::config::{ColorScheme, CssEntry, Settings};
use crate::config::Settings;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
@@ -108,57 +108,11 @@ pub struct ErrorDto {
pub error: String,
}
#[derive(Debug, Deserialize)]
pub struct ImportCssRequest {
pub filename: String,
pub content: String,
}
#[derive(Debug, Deserialize)]
pub struct LinkCssRequest {
pub path_or_url: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct CssEntryDto {
pub id: String,
pub kind: CssEntryKind,
pub filename: Option<String>,
pub path_or_url: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CssEntryKind {
Imported,
Linked,
}
impl From<&CssEntry> for CssEntryDto {
fn from(entry: &CssEntry) -> Self {
match entry {
CssEntry::Imported { id, filename } => CssEntryDto {
id: id.clone(),
kind: CssEntryKind::Imported,
filename: Some(filename.clone()),
path_or_url: None,
},
CssEntry::Linked { path_or_url } => CssEntryDto {
id: crate::config::css_entry_id(entry),
kind: CssEntryKind::Linked,
filename: None,
path_or_url: Some(path_or_url.clone()),
},
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SettingsDto {
pub color_scheme: ColorScheme,
pub theme: String,
pub active_profile: Option<String>,
pub max_logs: Option<usize>,
pub custom_theme_id: Option<String>,
pub ntfy_topic: Option<String>,
pub ntfy_server: Option<String>,
}
@@ -166,10 +120,9 @@ pub struct SettingsDto {
impl From<&Settings> for SettingsDto {
fn from(settings: &Settings) -> Self {
SettingsDto {
color_scheme: settings.color_scheme,
theme: settings.theme.clone(),
active_profile: settings.active_profile.clone(),
max_logs: settings.max_logs,
custom_theme_id: settings.custom_theme_id.clone(),
ntfy_topic: settings.ntfy_topic.clone(),
ntfy_server: settings.ntfy_server.clone(),
}
@@ -178,12 +131,10 @@ impl From<&Settings> for SettingsDto {
#[derive(Debug, Deserialize)]
pub struct UpdateSettingsRequest {
pub color_scheme: Option<ColorScheme>,
pub theme: Option<String>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub max_logs: Option<Option<usize>>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub custom_theme_id: Option<Option<String>>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub ntfy_topic: Option<Option<String>>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub ntfy_server: Option<Option<String>>,
+14 -60
View File
@@ -186,36 +186,23 @@ pub struct DeleteDashboardParams {
pub dashboard_id: String,
}
// ---- settings / css ----
// ---- settings / theming ----
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct UpdateSettingsParams {
#[serde(default)]
pub color_scheme: Option<String>,
pub theme: Option<String>,
#[serde(default)]
pub max_logs: Option<Option<usize>>,
#[serde(default)]
pub custom_theme_id: Option<Option<String>>,
#[serde(default)]
pub ntfy_topic: Option<Option<String>>,
#[serde(default)]
pub ntfy_server: Option<Option<String>>,
}
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ImportCssParams {
pub filename: String,
pub content: String,
}
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct LinkCssParams {
pub path_or_url: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct CssIdParams {
pub id: String,
pub struct GetThemeContentParams {
pub name: String,
}
// ---- alerts ----
@@ -734,7 +721,7 @@ impl OttylMcp {
// ---- settings / theming ----
#[tool(description = "Read the running instance's global settings (color scheme, active profile, max_logs, ntfy config).")]
#[tool(description = "Read the running instance's global settings (theme, active profile, max_logs, ntfy config).")]
async fn get_settings(&self) -> Result<CallToolResult, McpError> {
json_result(self.get_json("/api/settings", &[]).await?)
}
@@ -748,52 +735,19 @@ impl OttylMcp {
json_result(self.send_json(reqwest::Method::PATCH, "/api/settings", Some(&body)).await?)
}
#[tool(description = "List imported/linked custom CSS theme entries.")]
async fn list_css(&self) -> Result<CallToolResult, McpError> {
json_result(self.get_json("/api/settings/css", &[]).await?)
#[tool(description = "List the names of the themes embedded in this build (see /api/themes).")]
async fn list_themes(&self) -> Result<CallToolResult, McpError> {
json_result(self.get_json("/api/themes", &[]).await?)
}
#[tool(description = "Import a custom CSS theme file by content.")]
async fn import_css(
#[tool(description = "Read the raw CSS content of a theme by name.")]
async fn get_theme_content(
&self,
Parameters(params): Parameters<ImportCssParams>,
) -> Result<CallToolResult, McpError> {
let body = serde_json::to_value(&params).map_err(|err| mcp_error("failed to encode params", err))?;
json_result(self.send_json(reqwest::Method::POST, "/api/settings/css", Some(&body)).await?)
}
#[tool(description = "Link an external CSS file or URL as a custom theme, instead of importing its content.")]
async fn link_css(
&self,
Parameters(params): Parameters<LinkCssParams>,
) -> Result<CallToolResult, McpError> {
let body = serde_json::to_value(&params).map_err(|err| mcp_error("failed to encode params", err))?;
json_result(self.send_json(reqwest::Method::POST, "/api/settings/css/link", Some(&body)).await?)
}
#[tool(description = "Remove a custom CSS entry by id.")]
async fn remove_css(
&self,
Parameters(params): Parameters<CssIdParams>,
) -> Result<CallToolResult, McpError> {
json_result(
self.send_json(
reqwest::Method::DELETE,
&format!("/api/settings/css/{}", urlencoding_path(&params.id)),
None,
)
.await?,
)
}
#[tool(description = "Read the raw content of an imported CSS entry by id.")]
async fn get_css_content(
&self,
Parameters(params): Parameters<CssIdParams>,
Parameters(params): Parameters<GetThemeContentParams>,
) -> Result<CallToolResult, McpError> {
let response = self
.client
.get(self.url(&format!("/api/settings/css/{}/content", urlencoding_path(&params.id))))
.get(self.url(&format!("/api/themes/{}", urlencoding_path(&params.name))))
.send()
.await
.map_err(|err| mcp_error("failed to reach ottyl", err))?;
@@ -801,7 +755,7 @@ impl OttylMcp {
let text = response
.text()
.await
.map_err(|err| mcp_error("failed to read css content response", err))?;
.map_err(|err| mcp_error("failed to read theme content response", err))?;
if !status.is_success() {
return Err(mcp_error("ottyl rejected the request", text));
}
@@ -962,7 +916,7 @@ impl ServerHandler for OttylMcp {
"Proxies to a running ottyl instance's HTTP API. Covers everything the web UI can do: \
logs/timeline/stats, param & metric declarations, PromQL queries, dashboards & panels \
(including render_panel/render_dashboard, which return a chart's underlying series data \
as JSON so an agent can reason about it without a browser), settings & custom CSS themes, \
as JSON so an agent can reason about it without a browser), settings & themes, \
alert rules, and profile management. See the embedded SKILL.md (GET /SKILL.md on the \
target instance) for examples."
.to_string(),
+32 -96
View File
@@ -2,12 +2,12 @@ pub mod dto;
pub mod mcp_server;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::sync::Arc;
use crate::api::dto::{
BucketValuesParams, CreateAlertRuleRequest, CreateDashboardRequest, CreateProfileRequest,
CssEntryDto, DashboardDto, ErrorDto, ImportCssRequest, LinkCssRequest, MetricDeclarationDto,
DashboardDto, ErrorDto, MetricDeclarationDto,
ParamDeclarationDto, LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest,
SettingsDto, TestAlertNotificationRequest, TimelineParams, UpdateAlertRuleRequest, UpdateSettingsRequest,
};
@@ -18,7 +18,7 @@ use crate::engine::{
};
use crate::engine::alerts::{deliver_ntfy, AlertError};
use crate::config::profile::{Dashboard, ParamDeclarationRecord, Profile, ProfileError};
use crate::config::{CssError, SettingsHandle};
use crate::config::SettingsHandle;
use axum::extract::{Path as AxumPath, Query, State};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Json, Response};
@@ -29,6 +29,11 @@ use tokio::sync::oneshot;
static WEBAPP_DIST: EmbedDir = fs_embed::fs_embed!("webapp/dist");
// Every file here is a selectable theme: a complete stylesheet (colors, spacing, fonts, ...)
// applied on top of the base app shell. Add a new theme by dropping another `.css` file in
// `themes/` and rebuilding — no code changes needed (see `webapp/src/index.css`).
static THEMES: EmbedDir = fs_embed::fs_embed!("themes");
const SKILL_MD: &str = include_str!("../../SKILL.md");
#[derive(Clone)]
@@ -39,13 +44,6 @@ struct ApiState {
}
impl ApiState {
fn css_dir(&self) -> PathBuf {
self.settings_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."))
}
fn persist_param_declaration(&self, request: &DeclareParamRequest) {
let mut settings = self.settings.lock().unwrap();
let Some(profile_name) = settings.active_profile.clone() else {
@@ -159,10 +157,8 @@ pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: Pat
)
.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/themes", get(list_themes))
.route("/api/themes/{name}", get(get_theme_content))
.route("/api/settings/alerts", get(list_alert_rules).post(create_alert_rule))
.route(
"/api/settings/alerts/{id}",
@@ -553,84 +549,27 @@ async fn run_promql_query(
}
}
impl CssError {
fn into_response(self) -> axum::response::Response {
match self {
CssError::NotFound => (StatusCode::NOT_FOUND, self.to_string()).into_response(),
CssError::NotImported => (StatusCode::BAD_REQUEST, self.to_string()).into_response(),
CssError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response(),
async fn list_themes() -> impl IntoResponse {
let mut names: Vec<String> = THEMES
.walk()
.filter_map(|file| file.file_name().map(|name| name.trim_end_matches(".css").to_string()))
.collect();
names.sort();
Json(names).into_response()
}
async fn get_theme_content(AxumPath(name): AxumPath<String>) -> impl IntoResponse {
// Themes are just filenames within a flat embedded dir, not filesystem paths, so reject
// anything that isn't a plain name to rule out path traversal.
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
return StatusCode::NOT_FOUND.into_response();
}
match THEMES.get_file(&format!("{name}.css")) {
Some(file) => {
let bytes = file.read_bytes().unwrap_or_default();
([(header::CONTENT_TYPE, "text/css; charset=utf-8")], bytes).into_response()
}
}
}
async fn list_css(State(state): State<ApiState>) -> impl IntoResponse {
let settings = state.settings.lock().unwrap();
let entries: Vec<CssEntryDto> = settings.css_entries.iter().map(CssEntryDto::from).collect();
Json(entries).into_response()
}
async fn import_css(
State(state): State<ApiState>,
Json(body): Json<ImportCssRequest>,
) -> impl IntoResponse {
let css_dir = state.css_dir();
let mut settings = state.settings.lock().unwrap();
match settings.import_css(&state.settings_path, &css_dir, body.filename, &body.content) {
Ok(id) => {
let entry = settings
.css_entries
.iter()
.find(|entry| crate::config::css_entry_id(entry) == id)
.expect("entry was just inserted by import_css");
(StatusCode::CREATED, Json(CssEntryDto::from(entry))).into_response()
}
Err(err) => err.into_response(),
}
}
async fn link_css(
State(state): State<ApiState>,
Json(body): Json<LinkCssRequest>,
) -> impl IntoResponse {
let mut settings = state.settings.lock().unwrap();
match settings.link_css(&state.settings_path, body.path_or_url.clone()) {
Ok(()) => {
let entry = settings
.css_entries
.last()
.expect("entry was just pushed by link_css");
(StatusCode::CREATED, Json(CssEntryDto::from(entry))).into_response()
}
Err(err) => err.into_response(),
}
}
async fn remove_css(
State(state): State<ApiState>,
AxumPath(id): AxumPath<String>,
) -> impl IntoResponse {
let css_dir = state.css_dir();
let mut settings = state.settings.lock().unwrap();
match settings.remove_css(&state.settings_path, &css_dir, &id) {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => err.into_response(),
}
}
async fn get_css_content(
State(state): State<ApiState>,
AxumPath(id): AxumPath<String>,
) -> impl IntoResponse {
let css_dir = state.css_dir();
let settings = state.settings.lock().unwrap();
match settings.css_content(&css_dir, &id) {
Ok(content) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "text/css; charset=utf-8")],
content,
)
.into_response(),
Err(err) => err.into_response(),
None => StatusCode::NOT_FOUND.into_response(),
}
}
@@ -699,15 +638,12 @@ async fn update_settings(
let max_logs_changed = body.max_logs.is_some();
let new_max_logs = {
let mut settings = state.settings.lock().unwrap();
if let Some(color_scheme) = body.color_scheme {
settings.color_scheme = color_scheme;
if let Some(theme) = body.theme {
settings.theme = theme;
}
if let Some(max_logs) = body.max_logs {
settings.max_logs = max_logs;
}
if let Some(custom_theme_id) = body.custom_theme_id {
settings.custom_theme_id = custom_theme_id;
}
if let Some(ntfy_topic) = body.ntfy_topic {
settings.ntfy_topic = ntfy_topic;
}