Remove uploading css functionality. Themes are hard-coded. Change the code
This commit is contained in:
+4
-53
@@ -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
@@ -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(¶ms).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(¶ms).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(¶ms.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(¶ms.id))))
|
||||
.get(self.url(&format!("/api/themes/{}", urlencoding_path(¶ms.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
@@ -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;
|
||||
}
|
||||
|
||||
+40
-288
@@ -1,13 +1,9 @@
|
||||
pub mod profile;
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -17,39 +13,10 @@ use crate::config::profile::Profile;
|
||||
|
||||
pub type SettingsHandle = Arc<Mutex<Settings>>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ColorScheme {
|
||||
Light,
|
||||
#[serde(alias = "grey")]
|
||||
Dark,
|
||||
Red,
|
||||
Green,
|
||||
Blue,
|
||||
Yellow,
|
||||
Pink,
|
||||
Orange,
|
||||
#[serde(rename = "light_green")]
|
||||
LightGreen,
|
||||
#[serde(rename = "light_red")]
|
||||
LightRed,
|
||||
#[serde(rename = "light_blue")]
|
||||
LightBlue,
|
||||
#[serde(rename = "light_yellow")]
|
||||
LightYellow,
|
||||
#[serde(rename = "light_pink")]
|
||||
LightPink,
|
||||
#[serde(rename = "light_orange")]
|
||||
LightOrange,
|
||||
#[default]
|
||||
System,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum CssEntry {
|
||||
Imported { id: String, filename: String },
|
||||
Linked { path_or_url: String },
|
||||
/// Name of the selected theme file in `themes/` (see `THEMES` in `src/api/mod.rs`), or
|
||||
/// `"system"` to follow the OS light/dark preference between the `dark`/`light` themes.
|
||||
fn default_theme() -> String {
|
||||
"system".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
@@ -76,11 +43,13 @@ impl Default for ProfilesOnDisk {
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SettingsOnDisk {
|
||||
color_scheme: ColorScheme,
|
||||
#[serde(default)]
|
||||
css_entries: Vec<CssEntry>,
|
||||
theme: Option<String>,
|
||||
// Pre-theme-directory settings stored the selection as `color_scheme` (a fixed enum); a
|
||||
// separate `custom_theme_id` pointed at an uploaded/linked CSS entry (both removed) and can't
|
||||
// be mapped onto a theme file, so it's left out here and just ignored by serde on load.
|
||||
#[serde(default)]
|
||||
custom_theme_id: Option<String>,
|
||||
color_scheme: Option<String>,
|
||||
#[serde(default)]
|
||||
profiles: ProfilesOnDisk,
|
||||
#[serde(default)]
|
||||
@@ -101,10 +70,15 @@ impl From<SettingsOnDisk> for Settings {
|
||||
ProfilesOnDisk::Inline(profiles) => (profiles, None),
|
||||
ProfilesOnDisk::Legacy(pointer) => (Vec::new(), pointer.active_profile),
|
||||
};
|
||||
let theme = on_disk.theme.unwrap_or_else(|| {
|
||||
match on_disk.color_scheme.as_deref() {
|
||||
Some("grey") => "dark".to_string(),
|
||||
Some(scheme) => scheme.to_string(),
|
||||
None => default_theme(),
|
||||
}
|
||||
});
|
||||
Settings {
|
||||
color_scheme: on_disk.color_scheme,
|
||||
css_entries: on_disk.css_entries,
|
||||
custom_theme_id: on_disk.custom_theme_id,
|
||||
theme,
|
||||
profiles,
|
||||
active_profile: on_disk.active_profile.or(legacy_active_profile),
|
||||
max_logs: on_disk.max_logs,
|
||||
@@ -115,13 +89,11 @@ impl From<SettingsOnDisk> for Settings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(from = "SettingsOnDisk")]
|
||||
pub struct Settings {
|
||||
pub color_scheme: ColorScheme,
|
||||
pub css_entries: Vec<CssEntry>,
|
||||
#[serde(default)]
|
||||
pub custom_theme_id: Option<String>,
|
||||
#[serde(default = "default_theme")]
|
||||
pub theme: String,
|
||||
pub profiles: Vec<Profile>,
|
||||
pub active_profile: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -134,6 +106,20 @@ pub struct Settings {
|
||||
pub ntfy_server: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings {
|
||||
theme: default_theme(),
|
||||
profiles: Vec::new(),
|
||||
active_profile: None,
|
||||
max_logs: None,
|
||||
alert_rules: Vec::new(),
|
||||
ntfy_topic: None,
|
||||
ntfy_server: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_settings_path() -> Option<PathBuf> {
|
||||
ProjectDirs::from(
|
||||
crate::consts::APP_QUALIFIER,
|
||||
@@ -231,119 +217,6 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CssError {
|
||||
NotFound,
|
||||
NotImported,
|
||||
Io(io::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for CssError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
CssError::NotFound => write!(f, "no CSS entry with that id"),
|
||||
CssError::NotImported => write!(f, "CSS entry is linked, not imported"),
|
||||
CssError::Io(err) => write!(f, "I/O error: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CssError {}
|
||||
|
||||
impl From<io::Error> for CssError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
CssError::Io(err)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn css_entry_id(entry: &CssEntry) -> String {
|
||||
match entry {
|
||||
CssEntry::Imported { id, .. } => id.clone(),
|
||||
CssEntry::Linked { path_or_url, .. } => linked_entry_id(path_or_url),
|
||||
}
|
||||
}
|
||||
|
||||
fn linked_entry_id(path_or_url: &str) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
path_or_url.hash(&mut hasher);
|
||||
format!("linked-{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
fn generate_imported_css_id(filename: &str, content: &str) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
filename.hash(&mut hasher);
|
||||
content.hash(&mut hasher);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
nanos.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
fn imported_css_file_path(css_dir: &Path, id: &str) -> PathBuf {
|
||||
css_dir.join(format!("{id}.css"))
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn import_css(
|
||||
&mut self,
|
||||
settings_path: &Path,
|
||||
css_dir: &Path,
|
||||
filename: String,
|
||||
content: &str,
|
||||
) -> Result<String, CssError> {
|
||||
fs::create_dir_all(css_dir)?;
|
||||
let id = generate_imported_css_id(&filename, content);
|
||||
fs::write(imported_css_file_path(css_dir, &id), content)?;
|
||||
self.css_entries.push(CssEntry::Imported {
|
||||
id: id.clone(),
|
||||
filename,
|
||||
});
|
||||
self.save(settings_path)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn link_css(&mut self, settings_path: &Path, path_or_url: String) -> Result<(), CssError> {
|
||||
self.css_entries.push(CssEntry::Linked { path_or_url });
|
||||
self.save(settings_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_css(
|
||||
&mut self,
|
||||
settings_path: &Path,
|
||||
css_dir: &Path,
|
||||
id: &str,
|
||||
) -> Result<(), CssError> {
|
||||
let index = self
|
||||
.css_entries
|
||||
.iter()
|
||||
.position(|entry| css_entry_id(entry) == id);
|
||||
match index.map(|i| &self.css_entries[i]) {
|
||||
None => Err(CssError::NotFound),
|
||||
Some(CssEntry::Linked { .. }) => Err(CssError::NotImported),
|
||||
Some(CssEntry::Imported { .. }) => {
|
||||
let idx = index.unwrap();
|
||||
self.css_entries.remove(idx);
|
||||
let _ = fs::remove_file(imported_css_file_path(css_dir, id));
|
||||
self.save(settings_path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn css_content(&self, css_dir: &Path, id: &str) -> Result<String, CssError> {
|
||||
match self.css_entries.iter().find(|entry| css_entry_id(entry) == id) {
|
||||
None => Err(CssError::NotFound),
|
||||
Some(CssEntry::Linked { .. }) => Err(CssError::NotImported),
|
||||
Some(CssEntry::Imported { .. }) => {
|
||||
Ok(fs::read_to_string(imported_css_file_path(css_dir, id))?)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -371,29 +244,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_variant_color_schemes_round_trip_through_serde() {
|
||||
for (scheme, rename) in [
|
||||
(ColorScheme::LightGreen, "light_green"),
|
||||
(ColorScheme::LightRed, "light_red"),
|
||||
(ColorScheme::LightBlue, "light_blue"),
|
||||
(ColorScheme::LightYellow, "light_yellow"),
|
||||
(ColorScheme::LightPink, "light_pink"),
|
||||
(ColorScheme::LightOrange, "light_orange"),
|
||||
] {
|
||||
let json = serde_json::to_string(&scheme).unwrap();
|
||||
assert_eq!(json, format!("\"{rename}\""));
|
||||
let deserialized: ColorScheme = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized, scheme);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_grey_color_scheme_deserializes_as_dark() {
|
||||
fn legacy_grey_color_scheme_migrates_to_dark_theme() {
|
||||
let settings: Settings = serde_json::from_str(
|
||||
r#"{"color_scheme":"grey","css_entries":[],"custom_theme_id":null,"profiles":{"names":[],"active_profile":null,"profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(settings.color_scheme, ColorScheme::Dark);
|
||||
assert_eq!(settings.theme, "dark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -411,15 +267,10 @@ mod tests {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("settings.json");
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings.color_scheme = ColorScheme::Dark;
|
||||
settings.css_entries.push(CssEntry::Imported {
|
||||
id: "abc123".to_string(),
|
||||
filename: "theme.css".to_string(),
|
||||
});
|
||||
settings.css_entries.push(CssEntry::Linked {
|
||||
path_or_url: "https://example.com/style.css".to_string(),
|
||||
});
|
||||
let mut settings = Settings {
|
||||
theme: "dark".to_string(),
|
||||
..Settings::default()
|
||||
};
|
||||
settings.profiles.push(crate::config::profile::Profile::new("default".to_string()));
|
||||
settings.active_profile = Some("default".to_string());
|
||||
settings.alert_rules.push(crate::engine::alerts::AlertRule {
|
||||
@@ -449,105 +300,6 @@ mod tests {
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_css_persists_file_and_entry_across_reload() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings_path = dir.path().join("settings.json");
|
||||
let css_dir = dir.path().to_path_buf();
|
||||
|
||||
let mut settings = Settings::load(&settings_path);
|
||||
let id = settings
|
||||
.import_css(
|
||||
&settings_path,
|
||||
&css_dir,
|
||||
"theme.css".to_string(),
|
||||
"body { color: red; }",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let css_file = css_dir.join(format!("{id}.css"));
|
||||
assert!(css_file.exists());
|
||||
assert_eq!(fs::read_to_string(&css_file).unwrap(), "body { color: red; }");
|
||||
|
||||
drop(settings);
|
||||
let reloaded = Settings::load(&settings_path);
|
||||
assert!(reloaded.css_entries.iter().any(|entry| matches!(
|
||||
entry,
|
||||
CssEntry::Imported { id: entry_id, filename, .. } if entry_id == &id && filename == "theme.css"
|
||||
)));
|
||||
assert!(css_file.exists());
|
||||
assert_eq!(
|
||||
reloaded.css_content(&css_dir, &id).unwrap(),
|
||||
"body { color: red; }"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_css_deletes_file_and_entry() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings_path = dir.path().join("settings.json");
|
||||
let css_dir = dir.path().to_path_buf();
|
||||
|
||||
let mut settings = Settings::load(&settings_path);
|
||||
let id = settings
|
||||
.import_css(&settings_path, &css_dir, "a.css".to_string(), "a{}")
|
||||
.unwrap();
|
||||
let css_file = css_dir.join(format!("{id}.css"));
|
||||
assert!(css_file.exists());
|
||||
|
||||
settings.remove_css(&settings_path, &css_dir, &id).unwrap();
|
||||
|
||||
assert!(!css_file.exists());
|
||||
assert!(!settings.css_entries.iter().any(|e| css_entry_id(e) == id));
|
||||
|
||||
let reloaded = Settings::load(&settings_path);
|
||||
assert!(!reloaded.css_entries.iter().any(|e| css_entry_id(e) == id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_css_rejects_linked_entry() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings_path = dir.path().join("settings.json");
|
||||
let css_dir = dir.path().to_path_buf();
|
||||
|
||||
let mut settings = Settings::load(&settings_path);
|
||||
settings
|
||||
.link_css(&settings_path, "https://example.com/style.css".to_string())
|
||||
.unwrap();
|
||||
let linked_id = css_entry_id(&settings.css_entries[0]);
|
||||
|
||||
let result = settings.remove_css(&settings_path, &css_dir, &linked_id);
|
||||
assert!(matches!(result, Err(CssError::NotImported)));
|
||||
assert_eq!(settings.css_entries.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_css_rejects_unknown_id() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings_path = dir.path().join("settings.json");
|
||||
let css_dir = dir.path().to_path_buf();
|
||||
|
||||
let mut settings = Settings::load(&settings_path);
|
||||
let result = settings.remove_css(&settings_path, &css_dir, "does-not-exist");
|
||||
assert!(matches!(result, Err(CssError::NotFound)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_content_rejects_linked_entry() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings_path = dir.path().join("settings.json");
|
||||
let css_dir = dir.path().to_path_buf();
|
||||
|
||||
let mut settings = Settings::load(&settings_path);
|
||||
settings
|
||||
.link_css(&settings_path, "https://example.com/style.css".to_string())
|
||||
.unwrap();
|
||||
let linked_id = css_entry_id(&settings.css_entries[0]);
|
||||
|
||||
let result = settings.css_content(&css_dir, &linked_id);
|
||||
assert!(matches!(result, Err(CssError::NotImported)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_migrates_legacy_profiles_file_into_inline_settings() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user