//! Strategies page — list / create / edit-config / delete, plus the //! assignment matrix that decides which clients receive each strategy. //! Assignments are scoped to device groups or individual peers; user-level //! assignments are still SQL-driven (rare in practice). use super::shared::{html_escape, notice_html, require_admin}; use crate::api::admin::i18n::{t, tf1, tf2, Lang}; use crate::api::error::ApiError; use crate::api::middleware::AuthedUser; use crate::api::state::AppState; use axum::extract::{Extension, Form, Path}; use axum::response::Html; use serde::Deserialize; use std::fmt::Write as _; use std::sync::Arc; pub async fn index( Extension(state): Extension>, admin: AuthedUser, lang: Lang, ) -> Result, ApiError> { require_admin(&admin)?; Ok(Html(render_full(&state, lang).await?)) } #[derive(Debug, Deserialize)] pub struct CreateForm { pub name: String, #[serde(default)] pub config_options_json: String, } pub async fn create( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Form(form): Form, ) -> Result, ApiError> { require_admin(&admin)?; if form.name.trim().is_empty() { return notice_then(&state, lang, "error", t(lang, "groups.name_required")).await; } let cfg = if form.config_options_json.trim().is_empty() { "{}".to_string() } else { // Validate it's a JSON object — empty object is fine, anything else // gets rejected with a friendly message. match serde_json::from_str::(&form.config_options_json) { Ok(v) if v.is_object() => form.config_options_json.clone(), Ok(_) => { return notice_then(&state, lang, "error", t(lang, "strategies.json_obj_required")).await } Err(e) => { return notice_then( &state, lang, "error", &tf1(lang, "strategies.invalid_json", &e.to_string()), ) .await } } }; state .db .strategy_create(form.name.trim(), &cfg, "{}") .await .map_err(|e| ApiError::Internal(e.to_string()))?; notice_then( &state, lang, "ok", &tf1(lang, "strategies.created", &form.name), ) .await } #[derive(Debug, Deserialize)] pub struct UpdateForm { pub config_options_json: String, } pub async fn update( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Path(id): Path, Form(form): Form, ) -> Result, ApiError> { require_admin(&admin)?; let cfg = match serde_json::from_str::(&form.config_options_json) { Ok(v) if v.is_object() => form.config_options_json.clone(), _ => { return notice_then(&state, lang, "error", t(lang, "strategies.json_obj_required")).await } }; state .db .strategy_update_config(id, &cfg) .await .map_err(|e| ApiError::Internal(e.to_string()))?; notice_then(&state, lang, "ok", t(lang, "strategies.updated")).await } pub async fn delete( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Path(id): Path, ) -> Result, ApiError> { require_admin(&admin)?; let ok = state .db .strategy_delete(id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; notice_then( &state, lang, if ok { "ok" } else { "error" }, if ok { t(lang, "strategies.deleted") } else { t(lang, "common.already_gone") }, ) .await } #[derive(Debug, Deserialize)] pub struct AssignGroupForm { pub device_group_id: i64, } pub async fn assign_group( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Path(id): Path, Form(form): Form, ) -> Result, ApiError> { require_admin(&admin)?; state .db .strategy_assign_group(id, form.device_group_id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; notice_then(&state, lang, "ok", t(lang, "strategies.group_assigned")).await } #[derive(Debug, Deserialize)] pub struct AssignPeerForm { pub peer_id: String, } pub async fn assign_peer( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Path(id): Path, Form(form): Form, ) -> Result, ApiError> { require_admin(&admin)?; let peer_id = form.peer_id.trim(); if peer_id.is_empty() { return notice_then(&state, lang, "error", t(lang, "groups.peer_id_required")).await; } let exists = state .db .peer_exists(peer_id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; if !exists { return notice_then( &state, lang, "error", &tf1(lang, "groups.no_device_yet", peer_id), ) .await; } state .db .strategy_assign_peer_replace(id, peer_id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; notice_then( &state, lang, "ok", &tf1(lang, "strategies.peer_assigned", peer_id), ) .await } pub async fn unassign( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Path((id, assignment_id)): Path<(i64, i64)>, ) -> Result, ApiError> { require_admin(&admin)?; let ok = state .db .strategy_assignment_delete(id, assignment_id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; notice_then( &state, lang, if ok { "ok" } else { "error" }, if ok { t(lang, "strategies.unassigned") } else { t(lang, "common.already_gone") }, ) .await } // ---------- rendering ---------- async fn notice_then( state: &Arc, lang: Lang, kind: &str, msg: &str, ) -> Result, ApiError> { let mut html = notice_html(kind, msg); html.push_str(&render_full(state, lang).await?); Ok(Html(html)) } async fn render_full(state: &Arc, lang: Lang) -> Result { let strategies = state .db .strategies_list_all() .await .map_err(|e| ApiError::Internal(e.to_string()))?; let all_groups = state .db .device_groups_list_all() .await .map_err(|e| ApiError::Internal(e.to_string()))?; let mut s = String::new(); let _ = write!( s, r##"

{heading}

{tagline}

{create_heading}

"##, heading = t(lang, "strategies.heading"), tagline = t(lang, "strategies.tagline"), create_heading = t(lang, "strategies.create_heading"), ph = t(lang, "strategies.name_unique"), create = t(lang, "common.create"), ); if strategies.is_empty() { let _ = write!( s, r##"

{}

"##, t(lang, "strategies.no_strategies"), ); } for str_ in &strategies { let assignments = state .db .strategy_assignments_for(str_.id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let _ = write!( s, r##"

{name}

{meta}

"##, id = str_.id, name = html_escape(&str_.name), meta = tf2(lang, "strategies.id_modified", &str_.id.to_string(), &str_.modified_at.to_string()), cfg = html_escape(&str_.config_options_json), confirm = html_escape(&tf1(lang, "strategies.confirm_delete", &str_.name)), delete = t(lang, "common.delete"), cfg_label = t(lang, "strategies.config_label"), save = t(lang, "common.save"), ); // ---- Assignments section ---- render_assignments(&mut s, lang, str_.id, &assignments, &all_groups); s.push_str("
"); } s.push_str("
"); Ok(s) } fn render_assignments( s: &mut String, lang: Lang, strategy_id: i64, assignments: &[crate::database::StrategyAssignmentRow], all_groups: &[crate::database::DeviceGroupRow], ) { let group_assigned: std::collections::HashSet = assignments .iter() .filter_map(|a| a.device_group_id) .collect(); let group_rows: Vec<&crate::database::StrategyAssignmentRow> = assignments .iter() .filter(|a| a.device_group_id.is_some()) .collect(); let peer_rows: Vec<&crate::database::StrategyAssignmentRow> = assignments .iter() .filter(|a| a.peer_id.is_some()) .collect(); let _ = write!( s, r##"

{filter_heading}

{filter_hint}

"##, filter_heading = t(lang, "strategies.filter_heading"), filter_hint = t(lang, "strategies.filter_hint"), ); // ---- Device groups ---- let _ = write!( s, r##"
{groups_label}
    "##, groups_label = t(lang, "strategies.groups_label"), ); if group_rows.is_empty() { let _ = write!( s, r##"
  • {}
  • "##, t(lang, "strategies.no_group_assignments"), ); } for a in &group_rows { let name = a .device_group_name .as_deref() .unwrap_or("(deleted group)"); let _ = write!( s, r##"
  • {name}
  • "##, name = html_escape(name), sid = strategy_id, aid = a.id, remove = t(lang, "common.remove"), ); } s.push_str("
"); let candidates: Vec<&crate::database::DeviceGroupRow> = all_groups .iter() .filter(|g| !group_assigned.contains(&g.id)) .collect(); if !candidates.is_empty() { let _ = write!( s, r##"
"##, assign = t(lang, "strategies.assign_group"), ); } else if !all_groups.is_empty() { let _ = write!( s, r##"

{}

"##, t(lang, "strategies.all_groups_assigned"), ); } else { let _ = write!( s, r##"

{}

"##, t(lang, "strategies.no_groups_exist"), ); } s.push_str("
"); // ---- Peers ---- let _ = write!( s, r##"
{peers_label}
    "##, peers_label = t(lang, "strategies.peers_label"), ); if peer_rows.is_empty() { let _ = write!( s, r##"
  • {}
  • "##, t(lang, "strategies.no_peer_assignments"), ); } for a in &peer_rows { let pid = a.peer_id.as_deref().unwrap_or(""); let _ = write!( s, r##"
  • {pid}
  • "##, pid = html_escape(pid), sid = strategy_id, aid = a.id, remove = t(lang, "common.remove"), ); } s.push_str("
"); let _ = write!( s, r##"
"##, sid = strategy_id, ph = t(lang, "groups.peer_id_placeholder"), assign = t(lang, "strategies.assign_peer"), ); s.push_str("
"); }