From c0342ed987915484f9ad398481d0ee82ac2145c9 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Tue, 26 May 2026 18:40:05 +0300 Subject: [PATCH] ADMIN: Revorked settings page --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/admin/mod.rs | 7 + src/admin/v2.rs | 237 ++++++++++++++-- templates/admin/v2.html | 608 ++++++++++++++++++++++++++++++++++++---- 5 files changed, 789 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 654aec6..469cf4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.1.14" +version = "0.1.15" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index aab5697..9e04665 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.1.15" +version = "0.1.16" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 064a2fe..632b7f8 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -278,6 +278,13 @@ impl App for AdminApp { ), "admin_v2_settings", ), + Route::with_handler_and_name( + "/v2/api/settings/probe", + get(move |session: Session, db: Database| async move { + v2::settings_probe(session, db).await + }), + "admin_v2_settings_probe", + ), Route::with_handler_and_name( "/v2/api/jobs/{name}/toggle", cot::router::method::post({ diff --git a/src/admin/v2.rs b/src/admin/v2.rs index 3a20143..ae53ecf 100644 --- a/src/admin/v2.rs +++ b/src/admin/v2.rs @@ -13,8 +13,9 @@ use serde::{Deserialize, Serialize}; use sqlx::{PgPool, Postgres, QueryBuilder}; use super::BUILD_INFO; +use crate::agent; use crate::auth::{self, AuthenticatedUser, Role}; -use crate::config::{AppConfig, ConfigEntry}; +use crate::config::{AppConfig, ConfigEntry, ConfigSources}; use crate::i18n::{I18n, Translations}; use crate::scheduler::{JobRegistry, ScheduledJob}; @@ -217,13 +218,91 @@ struct MutationResponse { #[derive(Debug, Serialize, JsonSchema)] struct AdminSettingsDto { - lastfm_api_key: String, + values: AdminSettingsValues, + sources: AdminSettingsSources, lastfm_api_key_configured: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +struct AdminSettingsValues { + auth_password_enabled: bool, + auth_sso_enabled: bool, + oidc_button_text: String, + oidc_issuer: String, + oidc_client_id: String, + oidc_client_secret: String, + oidc_admin_groups: String, + oidc_user_groups: String, + swagger_enabled: bool, + lastfm_api_key: String, + agent_enabled: bool, + agent_inbox_dir: String, + agent_storage_dir: String, + agent_llm_url: String, + agent_llm_model: String, + agent_llm_auth: String, + agent_confidence_threshold: String, + agent_context_limit: String, + agent_concurrency: String, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +struct AdminSettingsSources { + auth_password_enabled: &'static str, + auth_sso_enabled: &'static str, + oidc_button_text: &'static str, + oidc_issuer: &'static str, + oidc_client_id: &'static str, + oidc_client_secret: &'static str, + oidc_admin_groups: &'static str, + oidc_user_groups: &'static str, + swagger_enabled: &'static str, + lastfm_api_key: &'static str, + agent_enabled: &'static str, + agent_inbox_dir: &'static str, + agent_storage_dir: &'static str, + agent_llm_url: &'static str, + agent_llm_model: &'static str, + agent_llm_auth: &'static str, + agent_confidence_threshold: &'static str, + agent_context_limit: &'static str, + agent_concurrency: &'static str, +} + #[derive(Debug, Deserialize)] pub(super) struct UpdateSettingsRequest { + auth_password_enabled: bool, + auth_sso_enabled: bool, + oidc_button_text: String, + oidc_issuer: String, + oidc_client_id: String, + oidc_client_secret: String, + oidc_admin_groups: String, + oidc_user_groups: String, + swagger_enabled: bool, lastfm_api_key: String, + agent_enabled: bool, + agent_inbox_dir: String, + agent_storage_dir: String, + agent_llm_url: String, + agent_llm_model: String, + agent_llm_auth: String, + agent_confidence_threshold: String, + agent_context_limit: String, + agent_concurrency: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct AgentProbeDto { + status: String, + ok: bool, + model_intro: String, + model_name: String, + prompt_tokens: Option, + completion_tokens: Option, + tokens_per_sec: Option, + latency_ms: u64, + error: String, } #[derive(Debug, Serialize, JsonSchema)] @@ -474,12 +553,8 @@ pub async fn settings(session: Session, db: Database) -> cot::Result cot::Result { + if let Err(response) = require_admin_json(&session, &db).await { + return Ok(response); + } + let (config, _) = AppConfig::load_with_db(&db).await; + let probe = if config.agent_enabled && !config.agent_llm_url.is_empty() { + agent::probe_llm( + &config.agent_llm_url, + &config.agent_llm_model, + &config.agent_llm_auth, + ) + .await + } else { + agent::AgentProbeResult::default() + }; + let status = if !config.agent_enabled { + "disabled" + } else if config.agent_llm_url.is_empty() { + "not_configured" + } else if probe.ok { + "ok" + } else { + "error" + }; + Json(AgentProbeDto { + status: status.to_string(), + ok: probe.ok, + model_intro: probe.model_intro, + model_name: probe.model_name, + prompt_tokens: probe.prompt_tokens, + completion_tokens: probe.completion_tokens, + tokens_per_sec: probe.tokens_per_sec, + latency_ms: probe.latency_ms, + error: probe.error, + }) + .into_response() +} + +fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto { + AdminSettingsDto { + lastfm_api_key_configured: !config.lastfm_api_key.trim().is_empty(), + values: AdminSettingsValues { + auth_password_enabled: config.auth_password_enabled, + auth_sso_enabled: config.auth_sso_enabled, + oidc_button_text: config.oidc_button_text, + oidc_issuer: config.oidc_issuer, + oidc_client_id: config.oidc_client_id, + oidc_client_secret: config.oidc_client_secret, + oidc_admin_groups: config.oidc_admin_groups, + oidc_user_groups: config.oidc_user_groups, + swagger_enabled: config.swagger_enabled, + lastfm_api_key: config.lastfm_api_key, + agent_enabled: config.agent_enabled, + agent_inbox_dir: config.agent_inbox_dir, + agent_storage_dir: config.agent_storage_dir, + agent_llm_url: config.agent_llm_url, + agent_llm_model: config.agent_llm_model, + agent_llm_auth: config.agent_llm_auth, + agent_confidence_threshold: config.agent_confidence_threshold.to_string(), + agent_context_limit: config.agent_context_limit.to_string(), + agent_concurrency: config.agent_concurrency.to_string(), + }, + sources: AdminSettingsSources { + auth_password_enabled: sources.auth_password_enabled.code(), + auth_sso_enabled: sources.auth_sso_enabled.code(), + oidc_button_text: sources.oidc_button_text.code(), + oidc_issuer: sources.oidc_issuer.code(), + oidc_client_id: sources.oidc_client_id.code(), + oidc_client_secret: sources.oidc_client_secret.code(), + oidc_admin_groups: sources.oidc_admin_groups.code(), + oidc_user_groups: sources.oidc_user_groups.code(), + swagger_enabled: sources.swagger_enabled.code(), + lastfm_api_key: sources.lastfm_api_key.code(), + agent_enabled: sources.agent_enabled.code(), + agent_inbox_dir: sources.agent_inbox_dir.code(), + agent_storage_dir: sources.agent_storage_dir.code(), + agent_llm_url: sources.agent_llm_url.code(), + agent_llm_model: sources.agent_llm_model.code(), + agent_llm_auth: sources.agent_llm_auth.code(), + agent_confidence_threshold: sources.agent_confidence_threshold.code(), + agent_context_limit: sources.agent_context_limit.code(), + agent_concurrency: sources.agent_concurrency.code(), + }, + } +} + pub async fn run_job( session: Session, db: Database, diff --git a/templates/admin/v2.html b/templates/admin/v2.html index f5e3a7f..17bf526 100644 --- a/templates/admin/v2.html +++ b/templates/admin/v2.html @@ -668,16 +668,129 @@ tbody tr:hover { } .settings-page { + max-width: none; +} + +.settings-layout { display: grid; - grid-template-columns: minmax(560px, 760px) minmax(260px, 1fr); + grid-template-columns: minmax(620px, 1fr) minmax(360px, 440px); gap: 14px; align-items: start; } +.settings-column { + display: grid; + gap: 14px; + align-content: start; +} + +.settings-side .settings-grid { + grid-template-columns: minmax(0, 1fr); +} + +.settings-actions { + grid-column: 1 / -1; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 14px; +} + .settings-card { padding: 14px; } +.setting-field { + min-width: 0; +} + +.setting-field label, +.setting-toggle label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + color: var(--text-secondary); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.setting-field input { + width: 100%; + height: 34px; + padding: 0 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-primary); + color: var(--text-primary); + outline: none; +} + +.setting-field input:focus { + border-color: var(--accent); +} + +.setting-toggle { + min-height: 74px; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); +} + +.setting-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.setting-toggle-row span { + color: var(--text-primary); + font-size: 13px; + font-weight: 800; +} + +.setting-toggle input { + width: 18px; + height: 18px; + accent-color: var(--accent); +} + +.setting-help { + margin-top: 6px; + color: var(--text-subdued); + font-size: 11px; + line-height: 1.4; +} + +.source-pill { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: var(--text-subdued); + font-size: 10px; + font-weight: 850; + text-transform: lowercase; +} + +.source-pill.env { background: rgba(90, 167, 255, 0.16); color: #9ccbff; } +.source-pill.database { background: rgba(29, 185, 84, 0.16); color: #8ef0b2; } +.source-pill.default { background: rgba(255, 255, 255, 0.08); color: var(--text-subdued); } + +.settings-wide { + grid-column: 1 / -1; +} + .settings-note { padding: 14px; color: var(--text-secondary); @@ -685,6 +798,30 @@ tbody tr:hover { line-height: 1.55; } +.probe-body { + padding: 14px; +} + +.probe-intro { + margin: 0 0 12px; + color: var(--text-primary); + font-size: 13px; + line-height: 1.45; +} + +.probe-table { + display: grid; + gap: 7px; + color: var(--text-secondary); + font-size: 12px; +} + +.probe-row { + display: flex; + justify-content: space-between; + gap: 10px; +} + .library-row { display: grid; grid-template-columns: 38px minmax(0, 1fr) 300px 130px; @@ -817,26 +954,26 @@ tbody tr:hover {
- - - + + +
@@ -1262,43 +1399,248 @@ tbody tr:hover {
-
-
-
- External APIs - Keys used by scheduled enrichment jobs -
- +
+
+
+
+
+ OIDC + Identity provider and group mapping +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ Agent + AI processing directories, LLM endpoint, and execution limits +
+
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
- -
- - -
+ +
+
+
+
+ Authentication + Password and SSO access switches +
+
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+
+
+ API + Developer and enrichment integrations +
+ +
+
+
+ +
+ + +
+
Interactive API docs at /swagger/ after restart.
+
+
+ + +
Used by the weekly Last.fm popularity task.
+
+
+
+ +
+
+
+ Agent Status + +
+ +
+
+

+

+
+
Model
+
Latency
+
Prompt tokens
+
Completion tokens
+
Tokens/sec
+
+
+ +
+
+
+
+ +
+ Settings are stored as database overrides unless an environment variable wins.
- -
- -
- -
-
-
- Last.fm Popularity - Weekly track rating refresh +
-
- The scheduler uses Last.fm track.getInfo for each track, stores listeners, playcount, current rating, and a history row. The job processes tracks with missing or oldest ratings first and waits between requests to avoid Last.fm API limits. -
-
+
@@ -1431,12 +1773,43 @@ function adminV2() { activeLibraryItem: null, editorOpen: false, editorDraft: { title: '', hidden: 'false' }, - settings: { lastfm_api_key: '', lastfm_api_key_configured: false }, - settingsDraft: { lastfm_api_key: '' }, + settings: { values: {}, sources: {}, lastfm_api_key_configured: false }, + settingsDraft: { + auth_password_enabled: false, + auth_sso_enabled: false, + oidc_button_text: '', + oidc_issuer: '', + oidc_client_id: '', + oidc_client_secret: '', + oidc_admin_groups: '', + oidc_user_groups: '', + swagger_enabled: false, + lastfm_api_key: '', + agent_enabled: false, + agent_inbox_dir: '', + agent_storage_dir: '', + agent_llm_url: '', + agent_llm_model: '', + agent_llm_auth: '', + agent_confidence_threshold: '', + agent_context_limit: '', + agent_concurrency: '' + }, + settingsProbe: { status: 'idle', ok: false }, + settingsProbeLoading: false, + settingsSaving: false, + routeReady: false, poller: null, async init() { + this.applyRouteFromHash(); await this.refreshAll(); + this.routeReady = true; + this.activateCurrentView(false); + window.addEventListener('hashchange', () => { + this.applyRouteFromHash(); + this.activateCurrentView(false); + }); this.poller = setInterval(() => this.poll(), 6000); this.icons(); }, @@ -1481,6 +1854,84 @@ function adminV2() { await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]); }, + applyRouteFromHash() { + const raw = (window.location.hash || '#reviews').replace(/^#\/?/, ''); + const parts = raw.split('/').filter(Boolean); + const view = parts[0] || 'reviews'; + if (view === 'reviews') { + this.activeView = 'reviews'; + this.reviewFilter.status = parts[1] || null; + } else if (view === 'jobs') { + this.activeView = 'jobs'; + if (parts[1]) this.activeJobName = decodeURIComponent(parts[1]); + } else if (view === 'library') { + this.activeView = 'library'; + this.libraryKind = ['artists', 'releases', 'playlists'].includes(parts[1]) ? parts[1] : 'artists'; + } else if (view === 'settings') { + this.activeView = 'settings'; + } else if (view === 'tools') { + this.activeView = 'tools'; + } else { + this.activeView = 'reviews'; + } + }, + + setRoute(path) { + if (!path.startsWith('#')) path = '#' + path; + if (window.location.hash !== path) { + window.history.pushState(null, '', path); + } + }, + + async activateCurrentView(updateRoute = true) { + if (this.activeView === 'reviews') { + if (updateRoute) this.setRoute(this.reviewFilter.status ? `#reviews/${this.reviewFilter.status}` : '#reviews'); + await this.loadReviews(false); + } else if (this.activeView === 'jobs') { + if (updateRoute) this.setRoute(this.activeJobName ? `#jobs/${encodeURIComponent(this.activeJobName)}` : '#jobs'); + await this.loadJobs(); + if (this.activeJobName) await this.loadRunsForJob(this.activeJobName); + } else if (this.activeView === 'library') { + if (updateRoute) this.setRoute(`#library/${this.libraryKind}`); + await this.loadLibrary(false); + } else if (this.activeView === 'settings') { + if (updateRoute) this.setRoute('#settings'); + await this.loadSettings(); + if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') { + await this.loadSettingsProbe(false); + } + } else if (this.activeView === 'tools' && updateRoute) { + this.setRoute('#tools'); + } + }, + + openReviews(status = null) { + this.activeView = 'reviews'; + this.reviewFilter.status = status; + this.setRoute(status ? `#reviews/${status}` : '#reviews'); + this.loadReviews(); + }, + + openJobs(name = this.activeJobName) { + this.activeView = 'jobs'; + if (name) this.activeJobName = name; + this.setRoute(this.activeJobName ? `#jobs/${encodeURIComponent(this.activeJobName)}` : '#jobs'); + this.loadJobs(); + if (this.activeJobName) this.loadRunsForJob(this.activeJobName); + }, + + openLibrary(kind = this.libraryKind) { + this.activeView = 'library'; + this.libraryKind = ['artists', 'releases', 'playlists'].includes(kind) ? kind : 'artists'; + this.setRoute(`#library/${this.libraryKind}`); + this.loadLibrary(); + }, + + openTools() { + this.activeView = 'tools'; + this.setRoute('#tools'); + }, + async loadReviews(resetOffset = true) { if (resetOffset) this.reviews.offset = 0; const params = new URLSearchParams(); @@ -1533,7 +1984,7 @@ function adminV2() { async loadSettings(showErrors = true) { try { this.settings = await this.request(`${this.apiBase}/settings`); - this.settingsDraft.lastfm_api_key = this.settings.lastfm_api_key || ''; + this.settingsDraft = Object.assign({}, this.settingsDraft, this.settings.values || {}); } catch (error) { if (showErrors) this.showToast(error.message); } finally { @@ -1542,23 +1993,81 @@ function adminV2() { }, async saveSettings() { + if (this.settingsSaving) return; + this.settingsSaving = true; try { await this.request(`${this.apiBase}/settings`, { method: 'POST', - body: JSON.stringify({ - lastfm_api_key: this.settingsDraft.lastfm_api_key || '' - }) + body: JSON.stringify(this.settingsDraft) }); await this.loadSettings(false); this.showToast('Settings saved'); } catch (error) { this.showToast(error.message); + } finally { + this.settingsSaving = false; + this.icons(); } }, + async openSettings() { + this.activeView = 'settings'; + this.setRoute('#settings'); + await this.loadSettings(); + if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') { + await this.loadSettingsProbe(false); + } + }, + + async loadSettingsProbe(showErrors = true) { + this.settingsProbeLoading = true; + try { + this.settingsProbe = await this.request(`${this.apiBase}/settings/probe`); + } catch (error) { + this.settingsProbe = { status: 'error', ok: false, error: error.message }; + if (showErrors) this.showToast(error.message); + } finally { + this.settingsProbeLoading = false; + this.icons(); + } + }, + + settingSource(key) { + return (this.settings.sources || {})[key] || 'default'; + }, + + sourceClass(key) { + return this.settingSource(key); + }, + + callbackUrl() { + return `${window.location.origin}/auth/oidc/callback`; + }, + + settingsProbeBadge() { + if (this.settingsProbeLoading) return 'running'; + if (this.settingsProbe.status === 'ok') return 'ok'; + if (this.settingsProbe.status === 'error') return 'failed'; + return 'disabled'; + }, + + settingsProbeSubtitle() { + if (this.settingsProbeLoading) return 'Checking LLM connection'; + if (this.settingsProbe.status === 'ok') return 'LLM connection OK'; + if (this.settingsProbe.status === 'error') return 'LLM connection error'; + if (this.settingsProbe.status === 'disabled') return 'Agent is disabled'; + if (this.settingsProbe.status === 'not_configured') return 'LLM URL is not configured'; + return 'Connection probe'; + }, + + settingsProbeText() { + if (this.settingsProbeLoading) return 'Checking connection...'; + if (this.settingsProbe.error) return this.settingsProbe.error; + return this.settingsProbeSubtitle(); + }, + setReviewStatus(status) { - this.reviewFilter.status = status; - this.loadReviews(); + this.openReviews(status); }, openReview(row) { @@ -1684,6 +2193,7 @@ function adminV2() { async selectJob(name) { this.activeJobName = name; + this.setRoute(`#jobs/${encodeURIComponent(name)}`); this.activeReview = null; this.activeRunDetail = null; await this.loadRunsForJob(name);