//! Strategies page — list / create / edit-config / delete. Assignment to //! peers/groups/users is intentionally still SQL-driven for v1; building a //! full assignment matrix UI is a follow-up. use super::shared::{html_escape, notice_html, require_admin}; 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, ) -> Result, ApiError> { require_admin(&admin)?; Ok(Html(render_full(&state).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, Form(form): Form, ) -> Result, ApiError> { require_admin(&admin)?; if form.name.trim().is_empty() { return notice_then(&state, "error", "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, "error", "config_options must be a JSON object").await } Err(e) => return notice_then(&state, "error", &format!("invalid JSON: {}", e)).await, } }; state .db .strategy_create(form.name.trim(), &cfg, "{}") .await .map_err(|e| ApiError::Internal(e.to_string()))?; notice_then( &state, "ok", &format!("Strategy '{}' created.", form.name), ) .await } #[derive(Debug, Deserialize)] pub struct UpdateForm { pub config_options_json: String, } pub async fn update( Extension(state): Extension>, admin: AuthedUser, 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, "error", "config_options must be a JSON object").await } }; state .db .strategy_update_config(id, &cfg) .await .map_err(|e| ApiError::Internal(e.to_string()))?; notice_then(&state, "ok", "Strategy updated.").await } pub async fn delete( Extension(state): Extension>, admin: AuthedUser, 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, if ok { "ok" } else { "error" }, if ok { "Strategy deleted." } else { "Already gone." }, ) .await } // ---------- rendering ---------- async fn notice_then( state: &Arc, kind: &str, msg: &str, ) -> Result, ApiError> { let mut html = notice_html(kind, msg); html.push_str(&render_full(state).await?); Ok(Html(html)) } async fn render_full(state: &Arc) -> Result { let strategies = state .db .strategies_list_all() .await .map_err(|e| ApiError::Internal(e.to_string()))?; let mut s = String::new(); s.push_str( r##"

Strategies

Pushed to clients via heartbeat. Use SQL to assign — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).

Create strategy

"##, ); if strategies.is_empty() { s.push_str(r##"

No strategies yet.

"##); } for str_ in &strategies { let _ = write!( s, r##"

{name}

id={id}, modified_at={mod_at}

"##, id = str_.id, name = html_escape(&str_.name), mod_at = str_.modified_at, cfg = html_escape(&str_.config_options_json), ); } s.push_str("
"); Ok(s) }