ADMIN: Revorked settings page
Build and Publish / Build and Publish Docker Image (push) Successful in 3m4s
Build and Publish / Build and Publish Docker Image (push) Successful in 3m4s
This commit is contained in:
Generated
+1
-1
@@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.1.14"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.1.15"
|
version = "0.1.16"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||||
|
|
||||||
|
|||||||
@@ -278,6 +278,13 @@ impl App for AdminApp {
|
|||||||
),
|
),
|
||||||
"admin_v2_settings",
|
"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(
|
Route::with_handler_and_name(
|
||||||
"/v2/api/jobs/{name}/toggle",
|
"/v2/api/jobs/{name}/toggle",
|
||||||
cot::router::method::post({
|
cot::router::method::post({
|
||||||
|
|||||||
+221
-16
@@ -13,8 +13,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
use sqlx::{PgPool, Postgres, QueryBuilder};
|
use sqlx::{PgPool, Postgres, QueryBuilder};
|
||||||
|
|
||||||
use super::BUILD_INFO;
|
use super::BUILD_INFO;
|
||||||
|
use crate::agent;
|
||||||
use crate::auth::{self, AuthenticatedUser, Role};
|
use crate::auth::{self, AuthenticatedUser, Role};
|
||||||
use crate::config::{AppConfig, ConfigEntry};
|
use crate::config::{AppConfig, ConfigEntry, ConfigSources};
|
||||||
use crate::i18n::{I18n, Translations};
|
use crate::i18n::{I18n, Translations};
|
||||||
use crate::scheduler::{JobRegistry, ScheduledJob};
|
use crate::scheduler::{JobRegistry, ScheduledJob};
|
||||||
|
|
||||||
@@ -217,13 +218,91 @@ struct MutationResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
struct AdminSettingsDto {
|
struct AdminSettingsDto {
|
||||||
lastfm_api_key: String,
|
values: AdminSettingsValues,
|
||||||
|
sources: AdminSettingsSources,
|
||||||
lastfm_api_key_configured: bool,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct UpdateSettingsRequest {
|
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,
|
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<u32>,
|
||||||
|
completion_tokens: Option<u32>,
|
||||||
|
tokens_per_sec: Option<f64>,
|
||||||
|
latency_ms: u64,
|
||||||
|
error: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -474,12 +553,8 @@ pub async fn settings(session: Session, db: Database) -> cot::Result<cot::respon
|
|||||||
if let Err(response) = require_admin_json(&session, &db).await {
|
if let Err(response) = require_admin_json(&session, &db).await {
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
let (config, sources) = AppConfig::load_with_db(&db).await;
|
||||||
Json(AdminSettingsDto {
|
Json(settings_dto(config, sources)).into_response()
|
||||||
lastfm_api_key_configured: !config.lastfm_api_key.trim().is_empty(),
|
|
||||||
lastfm_api_key: config.lastfm_api_key,
|
|
||||||
})
|
|
||||||
.into_response()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_settings(
|
pub async fn update_settings(
|
||||||
@@ -490,17 +565,147 @@ pub async fn update_settings(
|
|||||||
if let Err(response) = require_admin_json(&session, &db).await {
|
if let Err(response) = require_admin_json(&session, &db).await {
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
let mut entry = ConfigEntry::new(
|
let fields = [
|
||||||
"lastfm_api_key".to_string(),
|
(
|
||||||
body.lastfm_api_key.trim().to_string(),
|
"auth_password_enabled",
|
||||||
);
|
body.auth_password_enabled.to_string(),
|
||||||
entry
|
),
|
||||||
.save(&db)
|
("auth_sso_enabled", body.auth_sso_enabled.to_string()),
|
||||||
.await
|
("oidc_button_text", body.oidc_button_text.trim().to_string()),
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
("oidc_issuer", body.oidc_issuer.trim().to_string()),
|
||||||
|
("oidc_client_id", body.oidc_client_id.trim().to_string()),
|
||||||
|
(
|
||||||
|
"oidc_client_secret",
|
||||||
|
body.oidc_client_secret.trim().to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"oidc_admin_groups",
|
||||||
|
body.oidc_admin_groups.trim().to_string(),
|
||||||
|
),
|
||||||
|
("oidc_user_groups", body.oidc_user_groups.trim().to_string()),
|
||||||
|
("swagger_enabled", body.swagger_enabled.to_string()),
|
||||||
|
("lastfm_api_key", body.lastfm_api_key.trim().to_string()),
|
||||||
|
("agent_enabled", body.agent_enabled.to_string()),
|
||||||
|
("agent_inbox_dir", body.agent_inbox_dir.trim().to_string()),
|
||||||
|
(
|
||||||
|
"agent_storage_dir",
|
||||||
|
body.agent_storage_dir.trim().to_string(),
|
||||||
|
),
|
||||||
|
("agent_llm_url", body.agent_llm_url.trim().to_string()),
|
||||||
|
("agent_llm_model", body.agent_llm_model.trim().to_string()),
|
||||||
|
("agent_llm_auth", body.agent_llm_auth.trim().to_string()),
|
||||||
|
(
|
||||||
|
"agent_confidence_threshold",
|
||||||
|
body.agent_confidence_threshold.trim().to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"agent_context_limit",
|
||||||
|
body.agent_context_limit.trim().to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"agent_concurrency",
|
||||||
|
body.agent_concurrency.trim().to_string(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (key, value) in fields {
|
||||||
|
let mut entry = ConfigEntry::new(key.to_string(), value);
|
||||||
|
entry
|
||||||
|
.save(&db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
}
|
||||||
Json(serde_json::json!({ "ok": true })).into_response()
|
Json(serde_json::json!({ "ok": true })).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn settings_probe(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
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(
|
pub async fn run_job(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
|
|||||||
+559
-49
@@ -668,16 +668,129 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-page {
|
.settings-page {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(560px, 760px) minmax(260px, 1fr);
|
grid-template-columns: minmax(620px, 1fr) minmax(360px, 440px);
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
align-items: start;
|
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 {
|
.settings-card {
|
||||||
padding: 14px;
|
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 {
|
.settings-note {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -685,6 +798,30 @@ tbody tr:hover {
|
|||||||
line-height: 1.55;
|
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 {
|
.library-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 38px minmax(0, 1fr) 300px 130px;
|
grid-template-columns: 38px minmax(0, 1fr) 300px 130px;
|
||||||
@@ -817,26 +954,26 @@ tbody tr:hover {
|
|||||||
|
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-label">Operations</div>
|
<div class="nav-label">Operations</div>
|
||||||
<button class="nav-btn" :class="{active: activeView === 'reviews'}" @click="activeView = 'reviews'">
|
<button class="nav-btn" :class="{active: activeView === 'reviews'}" @click="openReviews()">
|
||||||
<i data-lucide="inbox"></i>
|
<i data-lucide="inbox"></i>
|
||||||
<span>Review Queue</span>
|
<span>Review Queue</span>
|
||||||
<span class="nav-count" x-text="reviews.total || 0"></span>
|
<span class="nav-count" x-text="reviews.total || 0"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" :class="{active: activeView === 'jobs'}" @click="activeView = 'jobs'; loadJobs()">
|
<button class="nav-btn" :class="{active: activeView === 'jobs'}" @click="openJobs()">
|
||||||
<i data-lucide="calendar-clock"></i>
|
<i data-lucide="calendar-clock"></i>
|
||||||
<span>Tasks</span>
|
<span>Tasks</span>
|
||||||
<span class="nav-count" x-text="jobs.length || 0"></span>
|
<span class="nav-count" x-text="jobs.length || 0"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" :class="{active: activeView === 'library'}" @click="activeView = 'library'; loadLibrary()">
|
<button class="nav-btn" :class="{active: activeView === 'library'}" @click="openLibrary(libraryKind)">
|
||||||
<i data-lucide="library"></i>
|
<i data-lucide="library"></i>
|
||||||
<span>Library Workbench</span>
|
<span>Library Workbench</span>
|
||||||
<span class="nav-count" x-text="fmt(stats.tracks || 0)"></span>
|
<span class="nav-count" x-text="fmt(stats.tracks || 0)"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" :class="{active: activeView === 'tools'}" @click="activeView = 'tools'">
|
<button class="nav-btn" :class="{active: activeView === 'tools'}" @click="openTools()">
|
||||||
<i data-lucide="wrench"></i>
|
<i data-lucide="wrench"></i>
|
||||||
<span>Future Tools</span>
|
<span>Future Tools</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" :class="{active: activeView === 'settings'}" @click="activeView = 'settings'; loadSettings()">
|
<button class="nav-btn" :class="{active: activeView === 'settings'}" @click="openSettings()">
|
||||||
<i data-lucide="settings"></i>
|
<i data-lucide="settings"></i>
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
<span class="nav-count" x-text="settings.lastfm_api_key_configured ? 'ok' : ''"></span>
|
<span class="nav-count" x-text="settings.lastfm_api_key_configured ? 'ok' : ''"></span>
|
||||||
@@ -845,17 +982,17 @@ tbody tr:hover {
|
|||||||
|
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-label">Entities</div>
|
<div class="nav-label">Entities</div>
|
||||||
<button class="nav-btn" @click="activeView = 'library'; libraryKind = 'artists'; loadLibrary()">
|
<button class="nav-btn" @click="openLibrary('artists')">
|
||||||
<i data-lucide="mic-2"></i>
|
<i data-lucide="mic-2"></i>
|
||||||
<span>Artists</span>
|
<span>Artists</span>
|
||||||
<span class="nav-count" x-text="fmt(libraryOverview.artists || 0)"></span>
|
<span class="nav-count" x-text="fmt(libraryOverview.artists || 0)"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" @click="activeView = 'library'; libraryKind = 'releases'; loadLibrary()">
|
<button class="nav-btn" @click="openLibrary('releases')">
|
||||||
<i data-lucide="disc-3"></i>
|
<i data-lucide="disc-3"></i>
|
||||||
<span>Releases</span>
|
<span>Releases</span>
|
||||||
<span class="nav-count" x-text="fmt(libraryOverview.releases || 0)"></span>
|
<span class="nav-count" x-text="fmt(libraryOverview.releases || 0)"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" @click="activeView = 'library'; libraryKind = 'playlists'; loadLibrary()">
|
<button class="nav-btn" @click="openLibrary('playlists')">
|
||||||
<i data-lucide="list-music"></i>
|
<i data-lucide="list-music"></i>
|
||||||
<span>Playlists</span>
|
<span>Playlists</span>
|
||||||
<span class="nav-count" x-text="fmt(libraryOverview.playlists || 0)"></span>
|
<span class="nav-count" x-text="fmt(libraryOverview.playlists || 0)"></span>
|
||||||
@@ -1153,9 +1290,9 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="segmented">
|
<div class="segmented">
|
||||||
<button class="seg-btn" :class="{active: libraryKind === 'artists'}" @click="libraryKind = 'artists'; loadLibrary()">Artists</button>
|
<button class="seg-btn" :class="{active: libraryKind === 'artists'}" @click="openLibrary('artists')">Artists</button>
|
||||||
<button class="seg-btn" :class="{active: libraryKind === 'releases'}" @click="libraryKind = 'releases'; loadLibrary()">Releases</button>
|
<button class="seg-btn" :class="{active: libraryKind === 'releases'}" @click="openLibrary('releases')">Releases</button>
|
||||||
<button class="seg-btn" :class="{active: libraryKind === 'playlists'}" @click="libraryKind = 'playlists'; loadLibrary()">Playlists</button>
|
<button class="seg-btn" :class="{active: libraryKind === 'playlists'}" @click="openLibrary('playlists')">Playlists</button>
|
||||||
</div>
|
</div>
|
||||||
<input class="search" placeholder="Search library" x-model="librarySearch" @input.debounce.350ms="loadLibrary()" />
|
<input class="search" placeholder="Search library" x-model="librarySearch" @input.debounce.350ms="loadLibrary()" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1262,43 +1399,248 @@ tbody tr:hover {
|
|||||||
|
|
||||||
<div class="content" x-show="activeView === 'settings'">
|
<div class="content" x-show="activeView === 'settings'">
|
||||||
<div class="settings-page">
|
<div class="settings-page">
|
||||||
<section class="panel">
|
<form class="settings-layout" @submit.prevent="saveSettings()">
|
||||||
<div class="panel-head">
|
<div class="settings-column">
|
||||||
<div class="panel-title">
|
<section class="panel">
|
||||||
<strong>External APIs</strong>
|
<div class="panel-head">
|
||||||
<span>Keys used by scheduled enrichment jobs</span>
|
<div class="panel-title">
|
||||||
</div>
|
<strong>OIDC</strong>
|
||||||
<span class="badge" :class="settings.lastfm_api_key_configured ? 'ok' : 'disabled'" x-text="settings.lastfm_api_key_configured ? 'configured' : 'not configured'"></span>
|
<span>Identity provider and group mapping</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-field settings-wide">
|
||||||
|
<label>Callback URL</label>
|
||||||
|
<input readonly :value="callbackUrl()" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>SSO button text</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('oidc_button_text')" x-text="settingSource('oidc_button_text')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.oidc_button_text" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Issuer URL</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('oidc_issuer')" x-text="settingSource('oidc_issuer')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.oidc_issuer" placeholder="https://accounts.google.com" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Client ID</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('oidc_client_id')" x-text="settingSource('oidc_client_id')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.oidc_client_id" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Client secret</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('oidc_client_secret')" x-text="settingSource('oidc_client_secret')"></span>
|
||||||
|
</label>
|
||||||
|
<input type="password" x-model="settingsDraft.oidc_client_secret" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Admin groups</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('oidc_admin_groups')" x-text="settingSource('oidc_admin_groups')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.oidc_admin_groups" placeholder="/admin,/furumusic-admins" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>User groups</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('oidc_user_groups')" x-text="settingSource('oidc_user_groups')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.oidc_user_groups" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<strong>Agent</strong>
|
||||||
|
<span>AI processing directories, LLM endpoint, and execution limits</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-toggle">
|
||||||
|
<label>
|
||||||
|
<span>Agent enabled</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_enabled')" x-text="settingSource('agent_enabled')"></span>
|
||||||
|
</label>
|
||||||
|
<div class="setting-toggle-row">
|
||||||
|
<span x-text="settingsDraft.agent_enabled ? 'Enabled' : 'Disabled'"></span>
|
||||||
|
<input type="checkbox" x-model="settingsDraft.agent_enabled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Concurrency</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_concurrency')" x-text="settingSource('agent_concurrency')"></span>
|
||||||
|
</label>
|
||||||
|
<input type="number" min="1" max="32" x-model="settingsDraft.agent_concurrency" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field settings-wide">
|
||||||
|
<label>
|
||||||
|
<span>Inbox directory</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_inbox_dir')" x-text="settingSource('agent_inbox_dir')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.agent_inbox_dir" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field settings-wide">
|
||||||
|
<label>
|
||||||
|
<span>Storage directory</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_storage_dir')" x-text="settingSource('agent_storage_dir')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.agent_storage_dir" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field settings-wide">
|
||||||
|
<label>
|
||||||
|
<span>LLM API URL</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_llm_url')" x-text="settingSource('agent_llm_url')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.agent_llm_url" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>LLM model</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_llm_model')" x-text="settingSource('agent_llm_model')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.agent_llm_model" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>LLM auth header</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_llm_auth')" x-text="settingSource('agent_llm_auth')"></span>
|
||||||
|
</label>
|
||||||
|
<input type="password" x-model="settingsDraft.agent_llm_auth" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Confidence threshold</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_confidence_threshold')" x-text="settingSource('agent_confidence_threshold')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.agent_confidence_threshold" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Context limit</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_context_limit')" x-text="settingSource('agent_context_limit')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.agent_context_limit" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<form class="settings-card" @submit.prevent="saveSettings()">
|
|
||||||
<div class="field">
|
<div class="settings-column settings-side">
|
||||||
<label>Last.fm API key</label>
|
<section class="panel">
|
||||||
<input type="password" x-model="settingsDraft.lastfm_api_key" autocomplete="off" placeholder="Paste Last.fm API key" />
|
<div class="panel-head">
|
||||||
</div>
|
<div class="panel-title">
|
||||||
|
<strong>Authentication</strong>
|
||||||
|
<span>Password and SSO access switches</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-toggle">
|
||||||
|
<label>
|
||||||
|
<span>Password login</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('auth_password_enabled')" x-text="settingSource('auth_password_enabled')"></span>
|
||||||
|
</label>
|
||||||
|
<div class="setting-toggle-row">
|
||||||
|
<span x-text="settingsDraft.auth_password_enabled ? 'Enabled' : 'Disabled'"></span>
|
||||||
|
<input type="checkbox" x-model="settingsDraft.auth_password_enabled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-toggle">
|
||||||
|
<label>
|
||||||
|
<span>SSO login</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('auth_sso_enabled')" x-text="settingSource('auth_sso_enabled')"></span>
|
||||||
|
</label>
|
||||||
|
<div class="setting-toggle-row">
|
||||||
|
<span x-text="settingsDraft.auth_sso_enabled ? 'Enabled' : 'Disabled'"></span>
|
||||||
|
<input type="checkbox" x-model="settingsDraft.auth_sso_enabled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<strong>API</strong>
|
||||||
|
<span>Developer and enrichment integrations</span>
|
||||||
|
</div>
|
||||||
|
<span class="badge" :class="settings.lastfm_api_key_configured ? 'ok' : 'disabled'" x-text="settings.lastfm_api_key_configured ? 'Last.fm configured' : 'Last.fm missing'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-toggle">
|
||||||
|
<label>
|
||||||
|
<span>Swagger UI</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('swagger_enabled')" x-text="settingSource('swagger_enabled')"></span>
|
||||||
|
</label>
|
||||||
|
<div class="setting-toggle-row">
|
||||||
|
<span x-text="settingsDraft.swagger_enabled ? 'Enabled' : 'Disabled'"></span>
|
||||||
|
<input type="checkbox" x-model="settingsDraft.swagger_enabled" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-help">Interactive API docs at /swagger/ after restart.</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Last.fm API key</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('lastfm_api_key')" x-text="settingSource('lastfm_api_key')"></span>
|
||||||
|
</label>
|
||||||
|
<input type="password" x-model="settingsDraft.lastfm_api_key" autocomplete="off" />
|
||||||
|
<div class="setting-help">Used by the weekly Last.fm popularity task.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<strong>Agent Status</strong>
|
||||||
|
<span x-text="settingsProbeSubtitle()"></span>
|
||||||
|
</div>
|
||||||
|
<span class="badge" :class="settingsProbeBadge()" x-text="settingsProbe.status || 'idle'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="probe-body">
|
||||||
|
<p class="probe-intro" x-show="settingsProbe.model_intro" x-text="settingsProbe.model_intro"></p>
|
||||||
|
<p class="probe-intro muted" x-show="!settingsProbe.model_intro" x-text="settingsProbeText()"></p>
|
||||||
|
<div class="probe-table" x-show="settingsProbe.ok">
|
||||||
|
<div class="probe-row"><span>Model</span><strong x-text="settingsProbe.model_name || 'unknown'"></strong></div>
|
||||||
|
<div class="probe-row"><span>Latency</span><strong x-text="settingsProbe.latency_ms + ' ms'"></strong></div>
|
||||||
|
<div class="probe-row"><span>Prompt tokens</span><strong x-text="settingsProbe.prompt_tokens ?? '-'"></strong></div>
|
||||||
|
<div class="probe-row"><span>Completion tokens</span><strong x-text="settingsProbe.completion_tokens ?? '-'"></strong></div>
|
||||||
|
<div class="probe-row"><span>Tokens/sec</span><strong x-text="settingsProbe.tokens_per_sec != null ? settingsProbe.tokens_per_sec.toFixed(1) : '-'"></strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar" style="margin-top:14px">
|
||||||
|
<button class="btn" type="button" @click="loadSettingsProbe()" :disabled="settingsProbeLoading">
|
||||||
|
<i data-lucide="activity"></i>
|
||||||
|
Test agent
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-strip settings-actions">
|
||||||
|
<span class="selection-summary">Settings are stored as database overrides unless an environment variable wins.</span>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="btn primary" type="submit">
|
|
||||||
<i data-lucide="save"></i>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button class="btn" type="button" @click="loadSettings()">
|
<button class="btn" type="button" @click="loadSettings()">
|
||||||
<i data-lucide="refresh-cw"></i>
|
<i data-lucide="refresh-cw"></i>
|
||||||
Reload
|
Reload
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button class="btn primary" type="submit" :disabled="settingsSaving">
|
||||||
</form>
|
<i :data-lucide="settingsSaving ? 'loader-circle' : 'save'"></i>
|
||||||
</section>
|
<span x-text="settingsSaving ? 'Saving...' : 'Save settings'"></span>
|
||||||
|
</button>
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-head">
|
|
||||||
<div class="panel-title">
|
|
||||||
<strong>Last.fm Popularity</strong>
|
|
||||||
<span>Weekly track rating refresh</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-note">
|
</form>
|
||||||
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.
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -1431,12 +1773,43 @@ function adminV2() {
|
|||||||
activeLibraryItem: null,
|
activeLibraryItem: null,
|
||||||
editorOpen: false,
|
editorOpen: false,
|
||||||
editorDraft: { title: '', hidden: 'false' },
|
editorDraft: { title: '', hidden: 'false' },
|
||||||
settings: { lastfm_api_key: '', lastfm_api_key_configured: false },
|
settings: { values: {}, sources: {}, lastfm_api_key_configured: false },
|
||||||
settingsDraft: { lastfm_api_key: '' },
|
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,
|
poller: null,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
this.applyRouteFromHash();
|
||||||
await this.refreshAll();
|
await this.refreshAll();
|
||||||
|
this.routeReady = true;
|
||||||
|
this.activateCurrentView(false);
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
this.applyRouteFromHash();
|
||||||
|
this.activateCurrentView(false);
|
||||||
|
});
|
||||||
this.poller = setInterval(() => this.poll(), 6000);
|
this.poller = setInterval(() => this.poll(), 6000);
|
||||||
this.icons();
|
this.icons();
|
||||||
},
|
},
|
||||||
@@ -1481,6 +1854,84 @@ function adminV2() {
|
|||||||
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
|
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) {
|
async loadReviews(resetOffset = true) {
|
||||||
if (resetOffset) this.reviews.offset = 0;
|
if (resetOffset) this.reviews.offset = 0;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -1533,7 +1984,7 @@ function adminV2() {
|
|||||||
async loadSettings(showErrors = true) {
|
async loadSettings(showErrors = true) {
|
||||||
try {
|
try {
|
||||||
this.settings = await this.request(`${this.apiBase}/settings`);
|
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) {
|
} catch (error) {
|
||||||
if (showErrors) this.showToast(error.message);
|
if (showErrors) this.showToast(error.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1542,23 +1993,81 @@ function adminV2() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async saveSettings() {
|
async saveSettings() {
|
||||||
|
if (this.settingsSaving) return;
|
||||||
|
this.settingsSaving = true;
|
||||||
try {
|
try {
|
||||||
await this.request(`${this.apiBase}/settings`, {
|
await this.request(`${this.apiBase}/settings`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(this.settingsDraft)
|
||||||
lastfm_api_key: this.settingsDraft.lastfm_api_key || ''
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
await this.loadSettings(false);
|
await this.loadSettings(false);
|
||||||
this.showToast('Settings saved');
|
this.showToast('Settings saved');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showToast(error.message);
|
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) {
|
setReviewStatus(status) {
|
||||||
this.reviewFilter.status = status;
|
this.openReviews(status);
|
||||||
this.loadReviews();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
openReview(row) {
|
openReview(row) {
|
||||||
@@ -1684,6 +2193,7 @@ function adminV2() {
|
|||||||
|
|
||||||
async selectJob(name) {
|
async selectJob(name) {
|
||||||
this.activeJobName = name;
|
this.activeJobName = name;
|
||||||
|
this.setRoute(`#jobs/${encodeURIComponent(name)}`);
|
||||||
this.activeReview = null;
|
this.activeReview = null;
|
||||||
this.activeRunDetail = null;
|
this.activeRunDetail = null;
|
||||||
await this.loadRunsForJob(name);
|
await this.loadRunsForJob(name);
|
||||||
|
|||||||
Reference in New Issue
Block a user