From 705424edb331d096a6296358818e071e6794d443 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Fri, 1 May 2026 18:37:32 +0530 Subject: [PATCH 1/2] feat(dashboard): persistent settings key/value store Add Storage::get_setting / set_setting / delete_setting / list_settings across SQLite, Postgres, and Redis backends, exposed via PyQueue, QueueSettingsMixin, and REST endpoints under /api/settings (list, get, put, delete). Storage values are opaque strings; the dashboard handler JSON-encodes non-string PUT bodies for ergonomic structured config. Includes per-backend SQLite tests, cross-backend contract test, and HTTP-level tests covering CRUD, validation, and persistence across Queue instances. --- crates/taskito-core/src/storage/mod.rs | 26 +++ crates/taskito-core/src/storage/models.rs | 16 +- .../storage/postgres/dashboard_settings.rs | 56 +++++ .../taskito-core/src/storage/postgres/mod.rs | 11 + .../redis_backend/dashboard_settings.rs | 39 ++++ .../src/storage/redis_backend/mod.rs | 1 + crates/taskito-core/src/storage/schema.rs | 8 + .../src/storage/sqlite/dashboard_settings.rs | 49 +++++ crates/taskito-core/src/storage/sqlite/mod.rs | 11 + .../taskito-core/src/storage/sqlite/tests.rs | 56 +++++ crates/taskito-core/src/storage/traits.rs | 11 + .../taskito-core/tests/rust/storage_tests.rs | 29 +++ crates/taskito-python/src/py_queue/mod.rs | 28 +++ py_src/taskito/_taskito.pyi | 4 + py_src/taskito/app.py | 2 + py_src/taskito/dashboard/handlers/settings.py | 69 ++++++ py_src/taskito/dashboard/routes.py | 18 ++ py_src/taskito/dashboard/server.py | 83 ++++++++ py_src/taskito/mixins/__init__.py | 2 + py_src/taskito/mixins/settings.py | 34 +++ tests/python/test_dashboard_settings.py | 199 ++++++++++++++++++ 21 files changed, 749 insertions(+), 3 deletions(-) create mode 100644 crates/taskito-core/src/storage/postgres/dashboard_settings.rs create mode 100644 crates/taskito-core/src/storage/redis_backend/dashboard_settings.rs create mode 100644 crates/taskito-core/src/storage/sqlite/dashboard_settings.rs create mode 100644 py_src/taskito/dashboard/handlers/settings.py create mode 100644 py_src/taskito/mixins/settings.py create mode 100644 tests/python/test_dashboard_settings.py diff --git a/crates/taskito-core/src/storage/mod.rs b/crates/taskito-core/src/storage/mod.rs index 22ef377..fb52d36 100644 --- a/crates/taskito-core/src/storage/mod.rs +++ b/crates/taskito-core/src/storage/mod.rs @@ -514,6 +514,20 @@ macro_rules! impl_storage { namespace, ) } + fn get_setting(&self, key: &str) -> $crate::error::Result> { + self.get_setting(key) + } + fn set_setting(&self, key: &str, value: &str) -> $crate::error::Result<()> { + self.set_setting(key, value) + } + fn delete_setting(&self, key: &str) -> $crate::error::Result { + self.delete_setting(key) + } + fn list_settings( + &self, + ) -> $crate::error::Result> { + self.list_settings() + } } }; } @@ -881,4 +895,16 @@ impl Storage for StorageBackend { namespace ) } + fn get_setting(&self, key: &str) -> Result> { + delegate!(self, get_setting, key) + } + fn set_setting(&self, key: &str, value: &str) -> Result<()> { + delegate!(self, set_setting, key, value) + } + fn delete_setting(&self, key: &str) -> Result { + delegate!(self, delete_setting, key) + } + fn list_settings(&self) -> Result> { + delegate!(self, list_settings) + } } diff --git a/crates/taskito-core/src/storage/models.rs b/crates/taskito-core/src/storage/models.rs index ce64bf3..0b6fa02 100644 --- a/crates/taskito-core/src/storage/models.rs +++ b/crates/taskito-core/src/storage/models.rs @@ -2,9 +2,9 @@ use diesel::prelude::*; use serde::{Deserialize, Serialize}; use super::schema::{ - archived_jobs, circuit_breakers, dead_letter, distributed_locks, execution_claims, - job_dependencies, job_errors, jobs, periodic_tasks, queue_state, rate_limits, replay_history, - task_logs, task_metrics, workers, + archived_jobs, circuit_breakers, dashboard_settings, dead_letter, distributed_locks, + execution_claims, job_dependencies, job_errors, jobs, periodic_tasks, queue_state, rate_limits, + replay_history, task_logs, task_metrics, workers, }; /// A row in the `jobs` table (for SELECT queries). @@ -340,6 +340,16 @@ pub struct QueueStateRow { pub paused_at: Option, } +// ── Dashboard Settings ────────────────────────────────────────── + +#[derive(Queryable, Selectable, Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = dashboard_settings)] +pub struct DashboardSettingRow { + pub key: String, + pub value: String, + pub updated_at: i64, +} + // ── Distributed Locks ─────────────────────────────────────────── #[derive(Queryable, Selectable, QueryableByName, Debug, Clone)] diff --git a/crates/taskito-core/src/storage/postgres/dashboard_settings.rs b/crates/taskito-core/src/storage/postgres/dashboard_settings.rs new file mode 100644 index 0000000..15027fe --- /dev/null +++ b/crates/taskito-core/src/storage/postgres/dashboard_settings.rs @@ -0,0 +1,56 @@ +use std::collections::HashMap; + +use diesel::prelude::*; + +use super::super::models::DashboardSettingRow; +use super::super::schema::dashboard_settings; +use super::PostgresStorage; +use crate::error::Result; +use crate::job::now_millis; + +impl PostgresStorage { + pub fn get_setting(&self, key: &str) -> Result> { + let mut conn = self.conn()?; + let row: Option = dashboard_settings::table + .filter(dashboard_settings::key.eq(key)) + .first::(&mut conn) + .optional()?; + Ok(row.map(|r| r.value)) + } + + pub fn set_setting(&self, key: &str, value: &str) -> Result<()> { + let mut conn = self.conn()?; + let now = now_millis(); + let row = DashboardSettingRow { + key: key.to_string(), + value: value.to_string(), + updated_at: now, + }; + diesel::insert_into(dashboard_settings::table) + .values(&row) + .on_conflict(dashboard_settings::key) + .do_update() + .set(( + dashboard_settings::value.eq(value), + dashboard_settings::updated_at.eq(now), + )) + .execute(&mut conn)?; + Ok(()) + } + + pub fn delete_setting(&self, key: &str) -> Result { + let mut conn = self.conn()?; + let deleted = + diesel::delete(dashboard_settings::table.filter(dashboard_settings::key.eq(key))) + .execute(&mut conn)?; + Ok(deleted > 0) + } + + pub fn list_settings(&self) -> Result> { + let mut conn = self.conn()?; + let rows: Vec = dashboard_settings::table + .select(DashboardSettingRow::as_select()) + .load(&mut conn)?; + Ok(rows.into_iter().map(|r| (r.key, r.value)).collect()) + } +} diff --git a/crates/taskito-core/src/storage/postgres/mod.rs b/crates/taskito-core/src/storage/postgres/mod.rs index 57f7975..14f6ee6 100644 --- a/crates/taskito-core/src/storage/postgres/mod.rs +++ b/crates/taskito-core/src/storage/postgres/mod.rs @@ -1,5 +1,6 @@ mod archival; mod circuit_breakers; +mod dashboard_settings; mod dead_letter; mod jobs; mod locks; @@ -491,6 +492,16 @@ impl PostgresStorage { ) .execute(&mut conn)?; + // ── Dashboard Settings ─────────────────────────── + diesel::sql_query( + "CREATE TABLE IF NOT EXISTS dashboard_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at BIGINT NOT NULL + )", + ) + .execute(&mut conn)?; + Ok(()) } } diff --git a/crates/taskito-core/src/storage/redis_backend/dashboard_settings.rs b/crates/taskito-core/src/storage/redis_backend/dashboard_settings.rs new file mode 100644 index 0000000..c238507 --- /dev/null +++ b/crates/taskito-core/src/storage/redis_backend/dashboard_settings.rs @@ -0,0 +1,39 @@ +use std::collections::HashMap; + +use redis::Commands; + +use super::{map_err, RedisStorage}; +use crate::error::Result; + +/// Redis key for the dashboard settings hash. All keys are stored under +/// a single hash so atomic ``HGETALL`` returns the full snapshot. +fn settings_key(storage: &RedisStorage) -> String { + storage.key(&["dashboard", "settings"]) +} + +impl RedisStorage { + pub fn get_setting(&self, key: &str) -> Result> { + let mut conn = self.conn()?; + let value: Option = conn.hget(settings_key(self), key).map_err(map_err)?; + Ok(value) + } + + pub fn set_setting(&self, key: &str, value: &str) -> Result<()> { + let mut conn = self.conn()?; + conn.hset::<_, _, _, ()>(settings_key(self), key, value) + .map_err(map_err)?; + Ok(()) + } + + pub fn delete_setting(&self, key: &str) -> Result { + let mut conn = self.conn()?; + let removed: i64 = conn.hdel(settings_key(self), key).map_err(map_err)?; + Ok(removed > 0) + } + + pub fn list_settings(&self) -> Result> { + let mut conn = self.conn()?; + let map: HashMap = conn.hgetall(settings_key(self)).map_err(map_err)?; + Ok(map) + } +} diff --git a/crates/taskito-core/src/storage/redis_backend/mod.rs b/crates/taskito-core/src/storage/redis_backend/mod.rs index cbe6039..20d0261 100644 --- a/crates/taskito-core/src/storage/redis_backend/mod.rs +++ b/crates/taskito-core/src/storage/redis_backend/mod.rs @@ -1,5 +1,6 @@ mod archival; mod circuit_breakers; +mod dashboard_settings; mod dead_letter; mod jobs; mod locks; diff --git a/crates/taskito-core/src/storage/schema.rs b/crates/taskito-core/src/storage/schema.rs index 27d982c..3ecdf11 100644 --- a/crates/taskito-core/src/storage/schema.rs +++ b/crates/taskito-core/src/storage/schema.rs @@ -212,4 +212,12 @@ diesel::table! { } } +diesel::table! { + dashboard_settings (key) { + key -> Text, + value -> Text, + updated_at -> BigInt, + } +} + diesel::allow_tables_to_appear_in_same_query!(jobs, job_dependencies); diff --git a/crates/taskito-core/src/storage/sqlite/dashboard_settings.rs b/crates/taskito-core/src/storage/sqlite/dashboard_settings.rs new file mode 100644 index 0000000..bc98930 --- /dev/null +++ b/crates/taskito-core/src/storage/sqlite/dashboard_settings.rs @@ -0,0 +1,49 @@ +use std::collections::HashMap; + +use diesel::prelude::*; + +use super::super::models::DashboardSettingRow; +use super::super::schema::dashboard_settings; +use super::SqliteStorage; +use crate::error::Result; +use crate::job::now_millis; + +impl SqliteStorage { + pub fn get_setting(&self, key: &str) -> Result> { + let mut conn = self.conn()?; + let row: Option = dashboard_settings::table + .filter(dashboard_settings::key.eq(key)) + .first::(&mut conn) + .optional()?; + Ok(row.map(|r| r.value)) + } + + pub fn set_setting(&self, key: &str, value: &str) -> Result<()> { + let mut conn = self.conn()?; + let row = DashboardSettingRow { + key: key.to_string(), + value: value.to_string(), + updated_at: now_millis(), + }; + diesel::replace_into(dashboard_settings::table) + .values(&row) + .execute(&mut conn)?; + Ok(()) + } + + pub fn delete_setting(&self, key: &str) -> Result { + let mut conn = self.conn()?; + let deleted = + diesel::delete(dashboard_settings::table.filter(dashboard_settings::key.eq(key))) + .execute(&mut conn)?; + Ok(deleted > 0) + } + + pub fn list_settings(&self) -> Result> { + let mut conn = self.conn()?; + let rows: Vec = dashboard_settings::table + .select(DashboardSettingRow::as_select()) + .load(&mut conn)?; + Ok(rows.into_iter().map(|r| (r.key, r.value)).collect()) + } +} diff --git a/crates/taskito-core/src/storage/sqlite/mod.rs b/crates/taskito-core/src/storage/sqlite/mod.rs index 1e94ce8..a0bbe8f 100644 --- a/crates/taskito-core/src/storage/sqlite/mod.rs +++ b/crates/taskito-core/src/storage/sqlite/mod.rs @@ -1,5 +1,6 @@ mod archival; mod circuit_breakers; +mod dashboard_settings; mod dead_letter; mod jobs; mod locks; @@ -480,6 +481,16 @@ impl SqliteStorage { ) .execute(&mut conn)?; + // ── Dashboard Settings ─────────────────────────── + diesel::sql_query( + "CREATE TABLE IF NOT EXISTS dashboard_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + )", + ) + .execute(&mut conn)?; + Ok(()) } } diff --git a/crates/taskito-core/src/storage/sqlite/tests.rs b/crates/taskito-core/src/storage/sqlite/tests.rs index c0bb741..3959d57 100644 --- a/crates/taskito-core/src/storage/sqlite/tests.rs +++ b/crates/taskito-core/src/storage/sqlite/tests.rs @@ -408,3 +408,59 @@ fn test_enqueue_rejects_missing_dependency() { let result = storage.enqueue(dep_job); assert!(result.is_err()); } + +#[test] +fn test_setting_get_returns_none_when_unset() { + let storage = test_storage(); + assert_eq!(storage.get_setting("missing").unwrap(), None); +} + +#[test] +fn test_setting_set_and_get() { + let storage = test_storage(); + storage.set_setting("dashboard.title", "My Queue").unwrap(); + assert_eq!( + storage.get_setting("dashboard.title").unwrap(), + Some("My Queue".to_string()) + ); +} + +#[test] +fn test_setting_set_overwrites() { + let storage = test_storage(); + storage.set_setting("k", "v1").unwrap(); + storage.set_setting("k", "v2").unwrap(); + assert_eq!(storage.get_setting("k").unwrap(), Some("v2".to_string())); +} + +#[test] +fn test_setting_delete() { + let storage = test_storage(); + storage.set_setting("k", "v").unwrap(); + assert!(storage.delete_setting("k").unwrap()); + assert_eq!(storage.get_setting("k").unwrap(), None); + // Deleting non-existent returns false. + assert!(!storage.delete_setting("k").unwrap()); +} + +#[test] +fn test_setting_list_returns_all() { + let storage = test_storage(); + storage.set_setting("a", "1").unwrap(); + storage.set_setting("b", "2").unwrap(); + let all = storage.list_settings().unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all.get("a"), Some(&"1".to_string())); + assert_eq!(all.get("b"), Some(&"2".to_string())); +} + +#[test] +fn test_setting_preserves_unicode_and_json() { + let storage = test_storage(); + let payload = r#"{"label":"Grafana ⏱️","url":"https://grafana.example/dash"}"#; + storage.set_setting("dashboard.links.0", payload).unwrap(); + assert_eq!( + storage.get_setting("dashboard.links.0").unwrap(), + Some(payload.to_string()) + ); +} diff --git a/crates/taskito-core/src/storage/traits.rs b/crates/taskito-core/src/storage/traits.rs index aed2535..89d653b 100644 --- a/crates/taskito-core/src/storage/traits.rs +++ b/crates/taskito-core/src/storage/traits.rs @@ -199,4 +199,15 @@ pub trait Storage: Send + Sync + Clone { offset: i64, namespace: Option<&str>, ) -> Result>; + + // ── Dashboard settings (key/value store) ───────────────────── + + /// Fetch a single setting value by key, or ``None`` if unset. + fn get_setting(&self, key: &str) -> Result>; + /// Insert or update a setting. + fn set_setting(&self, key: &str, value: &str) -> Result<()>; + /// Delete a setting. Returns ``true`` if a row was removed. + fn delete_setting(&self, key: &str) -> Result; + /// Return all settings as a key→value map. + fn list_settings(&self) -> Result>; } diff --git a/crates/taskito-core/tests/rust/storage_tests.rs b/crates/taskito-core/tests/rust/storage_tests.rs index 60796e4..8fce93b 100644 --- a/crates/taskito-core/tests/rust/storage_tests.rs +++ b/crates/taskito-core/tests/rust/storage_tests.rs @@ -242,6 +242,34 @@ fn test_execution_claims_purge(s: &impl Storage) { s.complete_execution(fresh_job).unwrap(); } +fn test_dashboard_settings(s: &impl Storage) { + // get on missing key + assert!(s.get_setting("settings-nonexistent").unwrap().is_none()); + + // set then get + s.set_setting("settings-key", "settings-value").unwrap(); + assert_eq!( + s.get_setting("settings-key").unwrap(), + Some("settings-value".to_string()) + ); + + // overwrite + s.set_setting("settings-key", "settings-new").unwrap(); + assert_eq!( + s.get_setting("settings-key").unwrap(), + Some("settings-new".to_string()) + ); + + // list contains the key + let all = s.list_settings().unwrap(); + assert_eq!(all.get("settings-key"), Some(&"settings-new".to_string())); + + // delete returns true once, false the second time + assert!(s.delete_setting("settings-key").unwrap()); + assert!(!s.delete_setting("settings-key").unwrap()); + assert!(s.get_setting("settings-key").unwrap().is_none()); +} + fn test_circuit_breakers(s: &impl Storage) { let task = "cb-test-task"; let cb = s.get_circuit_breaker(task).unwrap(); @@ -288,6 +316,7 @@ fn run_storage_tests(s: &impl Storage) { test_pause_resume_queue(s); test_circuit_breakers(s); test_execution_claims_purge(s); + test_dashboard_settings(s); } // ── Backend-specific wiring ────────────────────────────────────────── diff --git a/crates/taskito-python/src/py_queue/mod.rs b/crates/taskito-python/src/py_queue/mod.rs index 2a6ca41..2b9d09d 100644 --- a/crates/taskito-python/src/py_queue/mod.rs +++ b/crates/taskito-python/src/py_queue/mod.rs @@ -495,6 +495,34 @@ impl PyQueue { .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } + /// Get a single dashboard setting value, or ``None`` if unset. + pub fn get_setting(&self, key: &str) -> PyResult> { + self.storage + .get_setting(key) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + + /// Set a dashboard setting (insert or update). + pub fn set_setting(&self, key: &str, value: &str) -> PyResult<()> { + self.storage + .set_setting(key, value) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + + /// Delete a dashboard setting. Returns ``True`` if the key existed. + pub fn delete_setting(&self, key: &str) -> PyResult { + self.storage + .delete_setting(key) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + + /// Return all dashboard settings as a ``{key: value}`` dict. + pub fn list_settings(&self) -> PyResult> { + self.storage + .list_settings() + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + /// Cancel all pending jobs for a task name. Returns count cancelled. pub fn revoke_task(&self, task_name: &str) -> PyResult { self.storage diff --git a/py_src/taskito/_taskito.pyi b/py_src/taskito/_taskito.pyi index 564240a..aea0751 100644 --- a/py_src/taskito/_taskito.pyi +++ b/py_src/taskito/_taskito.pyi @@ -193,6 +193,10 @@ class PyQueue: def list_paused_queues(self) -> list[str]: ... def purge_queue(self, queue_name: str) -> int: ... def revoke_task(self, task_name: str) -> int: ... + def get_setting(self, key: str) -> str | None: ... + def set_setting(self, key: str, value: str) -> None: ... + def delete_setting(self, key: str) -> bool: ... + def list_settings(self) -> dict[str, str]: ... def archive_old_jobs(self, older_than_seconds: int) -> int: ... def list_archived(self, limit: int = 50, offset: int = 0) -> list[PyJob]: ... def get_metrics( diff --git a/py_src/taskito/app.py b/py_src/taskito/app.py index 3976671..1d54a72 100644 --- a/py_src/taskito/app.py +++ b/py_src/taskito/app.py @@ -37,6 +37,7 @@ QueueLockMixin, QueueOperationsMixin, QueueResourceMixin, + QueueSettingsMixin, ) from taskito.proxies import ProxyRegistry, cleanup_proxies, reconstruct_proxies from taskito.proxies.built_in import register_builtin_handlers @@ -75,6 +76,7 @@ class Queue( QueueInspectionMixin, QueueOperationsMixin, QueueLockMixin, + QueueSettingsMixin, QueueWorkflowMixin, AsyncQueueMixin, ): diff --git a/py_src/taskito/dashboard/handlers/settings.py b/py_src/taskito/dashboard/handlers/settings.py new file mode 100644 index 0000000..412c93f --- /dev/null +++ b/py_src/taskito/dashboard/handlers/settings.py @@ -0,0 +1,69 @@ +"""Dashboard settings (key/value config) route handlers. + +Settings are persisted via :class:`Storage` (one row per key) so every +worker and dashboard instance pointed at the same backend reads the same +values. The dashboard frontend stores JSON-encoded blobs (lists of +external links, integration URLs, branding overrides, etc.); the storage +layer treats them as opaque strings. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from taskito.dashboard.errors import _BadRequest, _NotFound + +if TYPE_CHECKING: + from taskito.app import Queue + + +_MAX_KEY_LENGTH = 256 +_MAX_VALUE_LENGTH = 64 * 1024 # 64 KiB — enough for any realistic dashboard config blob + + +def _validate_key(key: str) -> None: + """Reject empty / oversized / control-character keys.""" + if not key: + raise _BadRequest("setting key must not be empty") + if len(key) > _MAX_KEY_LENGTH: + raise _BadRequest(f"setting key exceeds {_MAX_KEY_LENGTH} characters") + if any(ord(c) < 32 or ord(c) == 127 for c in key): + raise _BadRequest("setting key must not contain control characters") + + +def _validate_value(value: str) -> None: + if len(value) > _MAX_VALUE_LENGTH: + raise _BadRequest(f"setting value exceeds {_MAX_VALUE_LENGTH} bytes") + + +def _handle_list_settings(queue: Queue, _qs: dict) -> dict[str, str]: + """Return all settings as a ``{key: value}`` dict.""" + return queue.list_settings() + + +def _handle_get_setting(queue: Queue, _qs: dict, key: str) -> dict[str, Any]: + """Return a single setting, or 404 if it does not exist.""" + value = queue.get_setting(key) + if value is None: + raise _NotFound(f"setting '{key}' not found") + return {"key": key, "value": value} + + +def _handle_set_setting(queue: Queue, body: dict, key: str) -> dict[str, Any]: + """Insert or update a setting from a ``PUT`` body of ``{"value": ...}``.""" + _validate_key(key) + if not isinstance(body, dict) or "value" not in body: + raise _BadRequest("body must be a JSON object with a 'value' field") + raw = body["value"] + # Accept any JSON-serialisable type — re-encode for storage so callers + # don't need to stringify themselves. + value = raw if isinstance(raw, str) else json.dumps(raw, separators=(",", ":")) + _validate_value(value) + queue.set_setting(key, value) + return {"key": key, "value": value} + + +def _handle_delete_setting(queue: Queue, key: str) -> dict[str, bool]: + """Delete a setting. Returns ``{deleted: bool}``.""" + return {"deleted": queue.delete_setting(key)} diff --git a/py_src/taskito/dashboard/routes.py b/py_src/taskito/dashboard/routes.py index 82f5a0e..1b45981 100644 --- a/py_src/taskito/dashboard/routes.py +++ b/py_src/taskito/dashboard/routes.py @@ -21,6 +21,12 @@ from taskito.dashboard.handlers.metrics import _handle_metrics, _handle_metrics_timeseries from taskito.dashboard.handlers.queues import _handle_stats_queues from taskito.dashboard.handlers.scaler import build_scaler_response +from taskito.dashboard.handlers.settings import ( + _handle_delete_setting, + _handle_get_setting, + _handle_list_settings, + _handle_set_setting, +) # ── Exact-match GET routes: path → handler(queue, qs) → JSON data ── GET_ROUTES: dict[str, Any] = { @@ -38,6 +44,7 @@ "/api/queues/paused": lambda q, qs: q.paused_queues(), "/api/stats/queues": _handle_stats_queues, "/api/scaler": lambda q, qs: build_scaler_response(q, queue_name=qs.get("queue", [None])[0]), + "/api/settings": _handle_list_settings, } # ── Parameterized GET routes: regex → handler(queue, qs, captured_id) ── @@ -51,6 +58,7 @@ ), (re.compile(r"^/api/jobs/([^/]+)/dag$"), lambda q, qs, jid: q.job_dag(jid)), (re.compile(r"^/api/jobs/([^/]+)$"), _handle_get_job), + (re.compile(r"^/api/settings/(.+)$"), _handle_get_setting), ] # ── Exact-match POST routes: path → handler(queue) → JSON data ── @@ -75,3 +83,13 @@ lambda q, n: (q.resume(n), {"resumed": n})[1], ), ] + +# ── Parameterized PUT routes: regex → handler(queue, body, captured_id) ── +PUT_PARAM_ROUTES: list[tuple[re.Pattern, Any]] = [ + (re.compile(r"^/api/settings/(.+)$"), _handle_set_setting), +] + +# ── Parameterized DELETE routes: regex → handler(queue, captured_id) ── +DELETE_PARAM_ROUTES: list[tuple[re.Pattern, Any]] = [ + (re.compile(r"^/api/settings/(.+)$"), _handle_delete_setting), +] diff --git a/py_src/taskito/dashboard/server.py b/py_src/taskito/dashboard/server.py index 541e9fa..2b4f1d9 100644 --- a/py_src/taskito/dashboard/server.py +++ b/py_src/taskito/dashboard/server.py @@ -10,10 +10,12 @@ from taskito.dashboard.errors import _BadRequest, _NotFound from taskito.dashboard.routes import ( + DELETE_PARAM_ROUTES, GET_PARAM_ROUTES, GET_ROUTES, POST_PARAM_ROUTES, POST_ROUTES, + PUT_PARAM_ROUTES, ) from taskito.dashboard.static import ( IMMUTABLE_PREFIX, @@ -38,6 +40,11 @@ _LOG_UNSAFE_CHARS[127] = None _LOG_PATH_MAX = 256 +# Hard cap on the request body we'll parse for PUT requests. Settings and +# other config writes are tiny; anything larger is almost certainly an +# attacker probing for memory exhaustion. +_MAX_BODY_BYTES = 1 * 1024 * 1024 # 1 MiB + def _safe_path(path: str) -> str: """Return ``path`` with control characters stripped and length capped. @@ -164,6 +171,82 @@ def _handle_post(self) -> None: self._json_response({"error": "Not found"}, status=404) + def do_PUT(self) -> None: + try: + self._handle_put() + except BrokenPipeError: + pass + except Exception: + logger.exception("Error handling PUT %s", _safe_path(self.path)) + self._json_response({"error": "Internal server error"}, status=500) + + def _handle_put(self) -> None: + path = urlparse(self.path).path + for pattern, param_handler in PUT_PARAM_ROUTES: + m = pattern.match(path) + if m: + body = self._read_json_body() + if body is None: + return + try: + self._json_response(param_handler(queue, body, m.group(1))) + except _BadRequest as e: + self._json_response({"error": e.message}, status=400) + except _NotFound as e: + self._json_response({"error": e.message}, status=404) + return + self._json_response({"error": "Not found"}, status=404) + + def do_DELETE(self) -> None: + try: + self._handle_delete() + except BrokenPipeError: + pass + except Exception: + logger.exception("Error handling DELETE %s", _safe_path(self.path)) + self._json_response({"error": "Internal server error"}, status=500) + + def _handle_delete(self) -> None: + path = urlparse(self.path).path + for pattern, param_handler in DELETE_PARAM_ROUTES: + m = pattern.match(path) + if m: + try: + self._json_response(param_handler(queue, m.group(1))) + except _BadRequest as e: + self._json_response({"error": e.message}, status=400) + except _NotFound as e: + self._json_response({"error": e.message}, status=404) + return + self._json_response({"error": "Not found"}, status=404) + + def _read_json_body(self) -> Any | None: + """Read and parse the request body as JSON. + + Returns ``None`` after writing the appropriate error response + (400/413) when the body is missing, malformed, or oversized. + """ + length_header = self.headers.get("Content-Length") + try: + length = int(length_header) if length_header is not None else 0 + except ValueError: + self._json_response({"error": "invalid Content-Length"}, status=400) + return None + if length < 0: + self._json_response({"error": "invalid Content-Length"}, status=400) + return None + if length > _MAX_BODY_BYTES: + self._json_response({"error": "request body too large"}, status=413) + return None + raw = self.rfile.read(length) if length else b"" + if not raw: + return {} + try: + return json.loads(raw) + except json.JSONDecodeError as e: + self._json_response({"error": f"invalid JSON: {e.msg}"}, status=400) + return None + def _json_response(self, data: Any, status: int = 200) -> None: body = json.dumps(data, default=str).encode() self.send_response(status) diff --git a/py_src/taskito/mixins/__init__.py b/py_src/taskito/mixins/__init__.py index 0b0828a..cad14a0 100644 --- a/py_src/taskito/mixins/__init__.py +++ b/py_src/taskito/mixins/__init__.py @@ -7,6 +7,7 @@ from taskito.mixins.locks import QueueLockMixin from taskito.mixins.operations import QueueOperationsMixin from taskito.mixins.resources import QueueResourceMixin +from taskito.mixins.settings import QueueSettingsMixin __all__ = [ "QueueDecoratorMixin", @@ -16,4 +17,5 @@ "QueueLockMixin", "QueueOperationsMixin", "QueueResourceMixin", + "QueueSettingsMixin", ] diff --git a/py_src/taskito/mixins/settings.py b/py_src/taskito/mixins/settings.py new file mode 100644 index 0000000..a4a6c9a --- /dev/null +++ b/py_src/taskito/mixins/settings.py @@ -0,0 +1,34 @@ +"""Dashboard settings (key/value store) accessor methods for the Queue.""" + +from __future__ import annotations + +from typing import Any + + +class QueueSettingsMixin: + """Persistent key/value settings backing the dashboard configuration page. + + Values are opaque strings as far as storage is concerned — callers that + need structured data (lists, dicts, booleans) should ``json.dumps`` / + ``json.loads`` around these methods. Settings are deployment-wide; + every worker and dashboard instance pointed at the same backend sees + the same values. + """ + + _inner: Any + + def get_setting(self, key: str) -> str | None: + """Return the value for ``key``, or ``None`` if not set.""" + return self._inner.get_setting(key) # type: ignore[no-any-return] + + def set_setting(self, key: str, value: str) -> None: + """Insert or update a setting.""" + self._inner.set_setting(key, value) + + def delete_setting(self, key: str) -> bool: + """Delete a setting. Returns ``True`` if the key existed.""" + return self._inner.delete_setting(key) # type: ignore[no-any-return] + + def list_settings(self) -> dict[str, str]: + """Return all settings as a ``{key: value}`` dict.""" + return self._inner.list_settings() # type: ignore[no-any-return] diff --git a/tests/python/test_dashboard_settings.py b/tests/python/test_dashboard_settings.py new file mode 100644 index 0000000..ee9888d --- /dev/null +++ b/tests/python/test_dashboard_settings.py @@ -0,0 +1,199 @@ +"""Tests for the dashboard settings key/value store. + +Covers: +- ``Queue.get_setting`` / ``set_setting`` / ``delete_setting`` / ``list_settings`` +- HTTP endpoints under ``/api/settings`` +""" + +from __future__ import annotations + +import json +import threading +import urllib.error +import urllib.request +from collections.abc import Generator +from pathlib import Path +from typing import Any + +import pytest + +from taskito import Queue + + +@pytest.fixture +def queue(tmp_path: Path) -> Queue: + return Queue(db_path=str(tmp_path / "settings.db")) + + +def _put(url: str, body: dict) -> Any: + req = urllib.request.Request( + url, + method="PUT", + data=json.dumps(body).encode(), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) + + +def _delete(url: str) -> Any: + req = urllib.request.Request(url, method="DELETE") + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) + + +def _get(url: str) -> Any: + with urllib.request.urlopen(url) as resp: + return json.loads(resp.read()) + + +# ── Python API ────────────────────────────────────────── + + +def test_get_setting_returns_none_when_unset(queue: Queue) -> None: + assert queue.get_setting("missing") is None + + +def test_set_and_get_setting(queue: Queue) -> None: + queue.set_setting("dashboard.title", "My Queue") + assert queue.get_setting("dashboard.title") == "My Queue" + + +def test_set_setting_overwrites(queue: Queue) -> None: + queue.set_setting("k", "v1") + queue.set_setting("k", "v2") + assert queue.get_setting("k") == "v2" + + +def test_delete_setting(queue: Queue) -> None: + queue.set_setting("k", "v") + assert queue.delete_setting("k") is True + assert queue.get_setting("k") is None + # Delete on missing key is a no-op returning False. + assert queue.delete_setting("k") is False + + +def test_list_settings_returns_all(queue: Queue) -> None: + queue.set_setting("a", "1") + queue.set_setting("b", "2") + snapshot = queue.list_settings() + assert snapshot == {"a": "1", "b": "2"} + + +def test_setting_preserves_unicode(queue: Queue) -> None: + queue.set_setting("greeting", "안녕하세요 🌏") + assert queue.get_setting("greeting") == "안녕하세요 🌏" + + +def test_setting_preserves_json(queue: Queue) -> None: + payload = json.dumps({"label": "Grafana", "url": "https://example/dash"}) + queue.set_setting("dashboard.links.0", payload) + assert json.loads(queue.get_setting("dashboard.links.0") or "") == { + "label": "Grafana", + "url": "https://example/dash", + } + + +# ── HTTP endpoints ────────────────────────────────────── + + +@pytest.fixture +def dashboard_server(queue: Queue) -> Generator[tuple[str, Queue]]: + from http.server import ThreadingHTTPServer + + from taskito.dashboard import _make_handler + + handler = _make_handler(queue) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{port}", queue + finally: + server.shutdown() + + +def test_get_settings_returns_empty_dict(dashboard_server: tuple[str, Queue]) -> None: + base, _ = dashboard_server + assert _get(f"{base}/api/settings") == {} + + +def test_put_then_get_setting(dashboard_server: tuple[str, Queue]) -> None: + base, _ = dashboard_server + _put(f"{base}/api/settings/dashboard.title", {"value": "My Queue"}) + + data = _get(f"{base}/api/settings/dashboard.title") + assert data == {"key": "dashboard.title", "value": "My Queue"} + + snapshot = _get(f"{base}/api/settings") + assert snapshot == {"dashboard.title": "My Queue"} + + +def test_put_setting_with_json_value(dashboard_server: tuple[str, Queue]) -> None: + """Non-string ``value`` is JSON-encoded before persistence.""" + base, queue = dashboard_server + payload = [ + {"label": "Grafana", "url": "https://grafana.example/d/abc"}, + {"label": "Sentry", "url": "https://sentry.example/issues"}, + ] + _put(f"{base}/api/settings/dashboard.external_links", {"value": payload}) + + stored = queue.get_setting("dashboard.external_links") + assert stored is not None + assert json.loads(stored) == payload + + +def test_get_unknown_setting_returns_404(dashboard_server: tuple[str, Queue]) -> None: + base, _ = dashboard_server + with pytest.raises(urllib.error.HTTPError) as exc_info: + _get(f"{base}/api/settings/missing.key") + assert exc_info.value.code == 404 + + +def test_put_setting_with_missing_value_field_returns_400( + dashboard_server: tuple[str, Queue], +) -> None: + base, _ = dashboard_server + with pytest.raises(urllib.error.HTTPError) as exc_info: + _put(f"{base}/api/settings/k", {"not_value": 1}) + assert exc_info.value.code == 400 + + +def test_put_setting_rejects_invalid_json_body(dashboard_server: tuple[str, Queue]) -> None: + base, _ = dashboard_server + req = urllib.request.Request( + f"{base}/api/settings/k", + method="PUT", + data=b"{not json", + headers={"Content-Type": "application/json"}, + ) + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(req) + assert exc_info.value.code == 400 + + +def test_delete_setting_returns_true_when_exists( + dashboard_server: tuple[str, Queue], +) -> None: + base, queue = dashboard_server + queue.set_setting("k", "v") + assert _delete(f"{base}/api/settings/k") == {"deleted": True} + assert queue.get_setting("k") is None + + +def test_delete_missing_setting_returns_false( + dashboard_server: tuple[str, Queue], +) -> None: + base, _ = dashboard_server + assert _delete(f"{base}/api/settings/missing") == {"deleted": False} + + +def test_settings_persist_across_queue_instances(tmp_path: Path) -> None: + """A fresh Queue instance pointed at the same DB sees prior writes.""" + db = str(tmp_path / "persist.db") + q1 = Queue(db_path=db) + q1.set_setting("k", "v") + + q2 = Queue(db_path=db) + assert q2.get_setting("k") == "v" From 859bf96a5edb151fa7fddca0a27dce62277fa337 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Fri, 1 May 2026 18:59:54 +0530 Subject: [PATCH 2/2] feat(dashboard): settings page with branding, links, integrations Add a /settings route that surfaces three sections backed by the new /api/settings endpoints: - Branding: dashboard title and accent color overrides - External links: deployment-wide list of {label, url} sidebar shortcuts - Integrations: Grafana / Sentry / OTel base URLs Each section persists optimistically via TanStack Query mutations with rollback on error. Sidebar (and mobile menu) gain a Configuration group linking to the new page. API client extended with put/delete helpers. --- .../src/components/layout/mobile-menu.tsx | 5 + dashboard/src/components/layout/sidebar.tsx | 5 + dashboard/src/features/settings/api.ts | 19 +++ .../settings/components/branding-section.tsx | 86 ++++++++++ .../components/external-links-section.tsx | 148 ++++++++++++++++++ .../components/integrations-section.tsx | 104 ++++++++++++ .../settings/components/setting-row.tsx | 32 ++++ dashboard/src/features/settings/hooks.ts | 72 +++++++++ dashboard/src/features/settings/index.ts | 6 + dashboard/src/features/settings/types.ts | 44 ++++++ dashboard/src/lib/api-client.ts | 23 +++ dashboard/src/routes/settings.tsx | 48 ++++++ 12 files changed, 592 insertions(+) create mode 100644 dashboard/src/features/settings/api.ts create mode 100644 dashboard/src/features/settings/components/branding-section.tsx create mode 100644 dashboard/src/features/settings/components/external-links-section.tsx create mode 100644 dashboard/src/features/settings/components/integrations-section.tsx create mode 100644 dashboard/src/features/settings/components/setting-row.tsx create mode 100644 dashboard/src/features/settings/hooks.ts create mode 100644 dashboard/src/features/settings/index.ts create mode 100644 dashboard/src/features/settings/types.ts create mode 100644 dashboard/src/routes/settings.tsx diff --git a/dashboard/src/components/layout/mobile-menu.tsx b/dashboard/src/components/layout/mobile-menu.tsx index 8e314ea..e1fbf31 100644 --- a/dashboard/src/components/layout/mobile-menu.tsx +++ b/dashboard/src/components/layout/mobile-menu.tsx @@ -4,6 +4,7 @@ import { BarChart3, Box, CircuitBoard, + Cog, LayoutDashboard, ListTree, type LucideIcon, @@ -57,6 +58,10 @@ const NAV: Array<{ title: string; items: NavItem[] }> = [ { to: "/system", label: "System", icon: Settings2 }, ], }, + { + title: "Configuration", + items: [{ to: "/settings", label: "Settings", icon: Cog }], + }, ]; export function MobileMenu() { diff --git a/dashboard/src/components/layout/sidebar.tsx b/dashboard/src/components/layout/sidebar.tsx index 30910a7..d4a4955 100644 --- a/dashboard/src/components/layout/sidebar.tsx +++ b/dashboard/src/components/layout/sidebar.tsx @@ -5,6 +5,7 @@ import { BarChart3, Box, CircuitBoard, + Cog, LayoutDashboard, ListTree, type LucideIcon, @@ -53,6 +54,10 @@ const NAV: NavGroup[] = [ { to: "/system", label: "System", icon: Settings2 }, ], }, + { + title: "Configuration", + items: [{ to: "/settings", label: "Settings", icon: Cog }], + }, ]; export function Sidebar() { diff --git a/dashboard/src/features/settings/api.ts b/dashboard/src/features/settings/api.ts new file mode 100644 index 0000000..65b84bd --- /dev/null +++ b/dashboard/src/features/settings/api.ts @@ -0,0 +1,19 @@ +import { api } from "@/lib/api-client"; +import type { SettingsSnapshot } from "./types"; + +export function fetchSettings(signal?: AbortSignal): Promise { + return api.get("/api/settings", { signal }); +} + +export interface SettingResponse { + key: string; + value: string; +} + +export function setSetting(key: string, value: unknown): Promise { + return api.put(`/api/settings/${encodeURIComponent(key)}`, { value }); +} + +export function deleteSetting(key: string): Promise<{ deleted: boolean }> { + return api.delete<{ deleted: boolean }>(`/api/settings/${encodeURIComponent(key)}`); +} diff --git a/dashboard/src/features/settings/components/branding-section.tsx b/dashboard/src/features/settings/components/branding-section.tsx new file mode 100644 index 0000000..be583eb --- /dev/null +++ b/dashboard/src/features/settings/components/branding-section.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from "react"; +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, +} from "@/components/ui"; +import { useDeleteSetting, useUpdateSetting } from "../hooks"; +import { SETTING_KEYS, type SettingsSnapshot } from "../types"; +import { SettingRow } from "./setting-row"; + +/** + * Branding overrides — the dashboard title shown in the sidebar and the + * accent color CSS variable. Empty inputs revert to the bundled defaults + * by deleting the underlying setting key. + */ +export function BrandingSection({ settings }: { settings: SettingsSnapshot }) { + const update = useUpdateSetting(); + const remove = useDeleteSetting(); + + const [title, setTitle] = useState(""); + const [accent, setAccent] = useState(""); + + useEffect(() => { + setTitle(settings[SETTING_KEYS.brandTitle] ?? ""); + setAccent(settings[SETTING_KEYS.brandAccent] ?? ""); + }, [settings]); + + const onSave = () => { + if (title) update.mutate({ key: SETTING_KEYS.brandTitle, value: title }); + else remove.mutate(SETTING_KEYS.brandTitle); + if (accent) update.mutate({ key: SETTING_KEYS.brandAccent, value: accent }); + else remove.mutate(SETTING_KEYS.brandAccent); + }; + + const dirty = + title !== (settings[SETTING_KEYS.brandTitle] ?? "") || + accent !== (settings[SETTING_KEYS.brandAccent] ?? ""); + + return ( + + + Branding + + Override the dashboard name and accent color for this deployment. + + + + + setTitle(e.target.value)} + maxLength={64} + /> + + + setAccent(e.target.value)} + maxLength={9} + /> + +
+ +
+
+
+ ); +} diff --git a/dashboard/src/features/settings/components/external-links-section.tsx b/dashboard/src/features/settings/components/external-links-section.tsx new file mode 100644 index 0000000..566d740 --- /dev/null +++ b/dashboard/src/features/settings/components/external-links-section.tsx @@ -0,0 +1,148 @@ +import { Plus, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, +} from "@/components/ui"; +import { useDeleteSetting, useUpdateSetting } from "../hooks"; +import { type ExternalLink, SETTING_KEYS, type SettingsSnapshot } from "../types"; + +/** Editable link item with a stable client-side id for React keys. */ +interface DraftLink extends ExternalLink { + id: string; +} + +/** Generate a stable client-side id for a draft row. Used only as a React + * key — never persisted, never sent to the server. */ +function draftId(): string { + return crypto.randomUUID(); +} + +/** + * Parse the JSON-encoded ``external_links`` setting into a typed list, + * tolerating malformed values (returns ``[]``) so a bad write never + * breaks the page. + */ +function parseLinks(raw: string | undefined): ExternalLink[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .filter( + (item): item is ExternalLink => + typeof item === "object" && + item !== null && + typeof (item as ExternalLink).label === "string" && + typeof (item as ExternalLink).url === "string", + ) + .map((item) => ({ label: item.label, url: item.url })); + } catch { + return []; + } +} + +/** + * User-defined links rendered in the sidebar (e.g. wiki, runbook, status + * page). Stored as a single JSON-encoded array under + * ``dashboard.external_links``. + */ +export function ExternalLinksSection({ settings }: { settings: SettingsSnapshot }) { + const update = useUpdateSetting(); + const remove = useDeleteSetting(); + + const initial = useMemo(() => parseLinks(settings[SETTING_KEYS.externalLinks]), [settings]); + const [links, setLinks] = useState(() => + initial.map((link) => ({ ...link, id: draftId() })), + ); + + // When the server snapshot changes, reset the local list to match. + useEffect(() => { + setLinks(initial.map((link) => ({ ...link, id: draftId() }))); + }, [initial]); + + const dirty = useMemo(() => { + const stripped = links.map(({ label, url }) => ({ label, url })); + return JSON.stringify(stripped) !== JSON.stringify(initial); + }, [links, initial]); + + const updateAt = (id: string, patch: Partial) => + setLinks((current) => current.map((link) => (link.id === id ? { ...link, ...patch } : link))); + + const removeAt = (id: string) => setLinks((current) => current.filter((link) => link.id !== id)); + + const addLink = () => setLinks((current) => [...current, { id: draftId(), label: "", url: "" }]); + + const onSave = () => { + const cleaned = links + .map((link) => ({ label: link.label.trim(), url: link.url.trim() })) + .filter((link) => link.label && link.url); + if (cleaned.length === 0) { + remove.mutate(SETTING_KEYS.externalLinks); + } else { + update.mutate({ key: SETTING_KEYS.externalLinks, value: cleaned }); + } + }; + + return ( + + + External links + + Custom shortcuts shown in the sidebar — runbooks, dashboards, status pages. + + + + {links.length === 0 ? ( +

No external links configured.

+ ) : ( +
    + {links.map((link) => ( +
  • + updateAt(link.id, { label: e.target.value })} + maxLength={64} + /> + updateAt(link.id, { url: e.target.value })} + /> + +
  • + ))} +
+ )} +
+ + +
+
+
+ ); +} diff --git a/dashboard/src/features/settings/components/integrations-section.tsx b/dashboard/src/features/settings/components/integrations-section.tsx new file mode 100644 index 0000000..440d660 --- /dev/null +++ b/dashboard/src/features/settings/components/integrations-section.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from "react"; +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, +} from "@/components/ui"; +import { useDeleteSetting, useUpdateSetting } from "../hooks"; +import { SETTING_KEYS, type SettingsSnapshot } from "../types"; +import { SettingRow } from "./setting-row"; + +/** + * Single-URL integration shortcuts. When set, the dashboard surfaces + * "View in Grafana / Sentry / OTel" links on the relevant pages + * (job detail, metrics, etc.). + */ +export function IntegrationsSection({ settings }: { settings: SettingsSnapshot }) { + const update = useUpdateSetting(); + const remove = useDeleteSetting(); + + const [grafana, setGrafana] = useState(""); + const [sentry, setSentry] = useState(""); + const [otel, setOtel] = useState(""); + + useEffect(() => { + setGrafana(settings[SETTING_KEYS.integrationGrafana] ?? ""); + setSentry(settings[SETTING_KEYS.integrationSentry] ?? ""); + setOtel(settings[SETTING_KEYS.integrationOtel] ?? ""); + }, [settings]); + + const dirty = + grafana !== (settings[SETTING_KEYS.integrationGrafana] ?? "") || + sentry !== (settings[SETTING_KEYS.integrationSentry] ?? "") || + otel !== (settings[SETTING_KEYS.integrationOtel] ?? ""); + + const persist = (key: string, value: string) => { + if (value) update.mutate({ key, value }); + else remove.mutate(key); + }; + + const onSave = () => { + persist(SETTING_KEYS.integrationGrafana, grafana); + persist(SETTING_KEYS.integrationSentry, sentry); + persist(SETTING_KEYS.integrationOtel, otel); + }; + + return ( + + + Integrations + External observability tools. URLs are deployment-wide. + + + + setGrafana(e.target.value)} + /> + + + setSentry(e.target.value)} + /> + + + setOtel(e.target.value)} + /> + +
+ +
+
+
+ ); +} diff --git a/dashboard/src/features/settings/components/setting-row.tsx b/dashboard/src/features/settings/components/setting-row.tsx new file mode 100644 index 0000000..b514409 --- /dev/null +++ b/dashboard/src/features/settings/components/setting-row.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; + +/** + * Two-column row with a label/description on the left and a form control + * on the right. Used across all settings sections so spacing stays + * consistent. + */ +export function SettingRow({ + label, + description, + htmlFor, + children, +}: { + label: string; + description?: string; + htmlFor?: string; + children: ReactNode; +}) { + return ( +
+
+ + {description ? ( +

{description}

+ ) : null} +
+
{children}
+
+ ); +} diff --git a/dashboard/src/features/settings/hooks.ts b/dashboard/src/features/settings/hooks.ts new file mode 100644 index 0000000..b56d5e1 --- /dev/null +++ b/dashboard/src/features/settings/hooks.ts @@ -0,0 +1,72 @@ +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { deleteSetting, fetchSettings, setSetting } from "./api"; +import type { SettingsSnapshot } from "./types"; + +const KEY = ["settings"] as const; + +export function settingsQuery() { + return queryOptions({ + queryKey: KEY, + queryFn: ({ signal }) => fetchSettings(signal), + }); +} + +export function useSettings() { + return useQuery(settingsQuery()); +} + +interface UpdateInput { + key: string; + value: unknown; +} + +/** + * Optimistically update a setting in the cache, then sync with the server. + * Rolls back on error to keep the UI consistent. + */ +export function useUpdateSetting() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ key, value }: UpdateInput) => setSetting(key, value), + onMutate: async ({ key, value }) => { + await qc.cancelQueries({ queryKey: KEY }); + const prev = qc.getQueryData(KEY); + const encoded = typeof value === "string" ? value : JSON.stringify(value); + qc.setQueryData(KEY, { ...(prev ?? {}), [key]: encoded }); + return { prev }; + }, + onError: (error, _input, ctx) => { + if (ctx?.prev) qc.setQueryData(KEY, ctx.prev); + toast.error("Couldn't save setting", { + description: error instanceof Error ? error.message : String(error), + }); + }, + onSuccess: () => toast.success("Setting saved"), + onSettled: () => qc.invalidateQueries({ queryKey: KEY }), + }); +} + +export function useDeleteSetting() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (key: string) => deleteSetting(key), + onMutate: async (key) => { + await qc.cancelQueries({ queryKey: KEY }); + const prev = qc.getQueryData(KEY); + if (prev) { + const next = { ...prev }; + delete next[key]; + qc.setQueryData(KEY, next); + } + return { prev }; + }, + onError: (error, _key, ctx) => { + if (ctx?.prev) qc.setQueryData(KEY, ctx.prev); + toast.error("Couldn't delete setting", { + description: error instanceof Error ? error.message : String(error), + }); + }, + onSettled: () => qc.invalidateQueries({ queryKey: KEY }), + }); +} diff --git a/dashboard/src/features/settings/index.ts b/dashboard/src/features/settings/index.ts new file mode 100644 index 0000000..46eea67 --- /dev/null +++ b/dashboard/src/features/settings/index.ts @@ -0,0 +1,6 @@ +export { BrandingSection } from "./components/branding-section"; +export { ExternalLinksSection } from "./components/external-links-section"; +export { IntegrationsSection } from "./components/integrations-section"; +export { settingsQuery, useDeleteSetting, useSettings, useUpdateSetting } from "./hooks"; +export type { ExternalLink, IntegrationUrls, SettingsSnapshot } from "./types"; +export { SETTING_KEYS } from "./types"; diff --git a/dashboard/src/features/settings/types.ts b/dashboard/src/features/settings/types.ts new file mode 100644 index 0000000..429fc03 --- /dev/null +++ b/dashboard/src/features/settings/types.ts @@ -0,0 +1,44 @@ +/** + * Dashboard settings types. + * + * Settings are stored server-side as a flat key→string map. The UI groups + * them by category and JSON-encodes structured values (lists, integration + * blobs) before persisting; the server stores them as opaque strings. + * + * See `py_src/taskito/dashboard/handlers/settings.py` for the REST API. + */ + +/** Settings keys used by the dashboard UI. Keep them in one place so the + * UI and any server-side defaults stay in sync. */ +export const SETTING_KEYS = { + brandTitle: "dashboard.brand.title", + brandAccent: "dashboard.brand.accent", + externalLinks: "dashboard.external_links", + integrationGrafana: "dashboard.integrations.grafana_url", + integrationSentry: "dashboard.integrations.sentry_url", + integrationOtel: "dashboard.integrations.otel_url", +} as const; + +export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS]; + +/** A user-defined external link rendered in the sidebar. */ +export interface ExternalLink { + label: string; + url: string; +} + +/** Single-URL integration shortcuts surfaced on relevant pages. */ +export interface IntegrationUrls { + grafana: string; + sentry: string; + otel: string; +} + +/** Branding overrides — empty strings mean "use the default". */ +export interface BrandOverrides { + title: string; + accent: string; +} + +/** Raw key→value snapshot returned by ``GET /api/settings``. */ +export type SettingsSnapshot = Record; diff --git a/dashboard/src/lib/api-client.ts b/dashboard/src/lib/api-client.ts index 153327a..8624c30 100644 --- a/dashboard/src/lib/api-client.ts +++ b/dashboard/src/lib/api-client.ts @@ -71,4 +71,27 @@ export const api = { }); return parse(response); }, + + async put(path: string, body?: unknown, options: RequestOptions = {}): Promise { + const response = await fetch(buildUrl(path, options.params), { + method: "PUT", + signal: options.signal, + headers: { + Accept: "application/json", + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + ...options.headers, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return parse(response); + }, + + async delete(path: string, options: RequestOptions = {}): Promise { + const response = await fetch(buildUrl(path, options.params), { + method: "DELETE", + signal: options.signal, + headers: { Accept: "application/json", ...options.headers }, + }); + return parse(response); + }, }; diff --git a/dashboard/src/routes/settings.tsx b/dashboard/src/routes/settings.tsx new file mode 100644 index 0000000..4e9c611 --- /dev/null +++ b/dashboard/src/routes/settings.tsx @@ -0,0 +1,48 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { PageHeader } from "@/components/layout"; +import { ErrorState, Skeleton } from "@/components/ui"; +import { + BrandingSection, + ExternalLinksSection, + IntegrationsSection, + settingsQuery, + useSettings, +} from "@/features/settings"; + +export const Route = createFileRoute("/settings")({ + loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(settingsQuery()), + component: SettingsPage, +}); + +function SettingsPage() { + const { data, isLoading, error, refetch } = useSettings(); + + return ( + <> + + + {isLoading || !data ? ( +
+ + + +
+ ) : error ? ( + refetch()} + /> + ) : ( +
+ + + +
+ )} + + ); +}