From c2d320b7823efc826c9e10acb8d57a2fcac37ba0 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Thu, 21 May 2026 12:24:06 +0200 Subject: [PATCH] Implement "strategy selector" so it is possible to push certain strategies only to selected devices --- src/api/admin/i18n.rs | 101 ++++++++++- src/api/admin/mod.rs | 12 ++ src/api/admin/pages/strategies.rs | 268 +++++++++++++++++++++++++++++- src/database.rs | 107 ++++++++++++ 4 files changed, 478 insertions(+), 10 deletions(-) diff --git a/src/api/admin/i18n.rs b/src/api/admin/i18n.rs index df2c3cf..0f458e2 100644 --- a/src/api/admin/i18n.rs +++ b/src/api/admin/i18n.rs @@ -1207,11 +1207,11 @@ pub fn t(lang: Lang, key: &str) -> &'static str { "Estrategias", ), "strategies.tagline" => ( - "Pushed to clients via heartbeat. Use SQL to assign — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).", - "Wird Clients per Heartbeat zugestellt. Zur Zuweisung SQL verwenden — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).", - "Distribué aux clients via heartbeat. Utilisez SQL pour assigner — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).", - "Trimis clienților prin heartbeat. Folosiți SQL pentru atribuire — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).", - "Se envía a los clientes por heartbeat. Usa SQL para asignar — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).", + "Pushed to clients via heartbeat. Assign each strategy to device groups or individual peers below — peer assignments override group assignments.", + "Wird Clients per Heartbeat zugestellt. Strategien unten Gerätegruppen oder einzelnen Geräten zuweisen — Gerätezuweisungen haben Vorrang vor Gruppenzuweisungen.", + "Distribué aux clients via heartbeat. Affectez chaque stratégie ci-dessous à des groupes d'appareils ou à des appareils individuels — les affectations par appareil prévalent sur celles par groupe.", + "Trimis clienților prin heartbeat. Atribuiți fiecare strategie mai jos unor grupuri de dispozitive sau dispozitive individuale — atribuirile pe dispozitiv au prioritate față de cele pe grup.", + "Se envía a los clientes por heartbeat. Asigna cada estrategia a grupos de dispositivos o dispositivos individuales abajo — las asignaciones por dispositivo tienen prioridad sobre las de grupo.", ), "strategies.create_heading" => ( "Create strategy", @@ -1290,6 +1290,97 @@ pub fn t(lang: Lang, key: &str) -> &'static str { "id={0}, modified_at={1}", "id={0}, modified_at={1}", ), + "strategies.filter_heading" => ( + "Targets", + "Ziele", + "Cibles", + "Ținte", + "Destinos", + ), + "strategies.filter_hint" => ( + "Resolution order per client: direct peer > device group > user. The highest-priority match wins.", + "Auflösungsreihenfolge je Client: Gerät direkt > Gerätegruppe > Benutzer. Die höchste Priorität gewinnt.", + "Ordre de résolution par client : appareil direct > groupe d'appareils > utilisateur. La priorité la plus élevée l'emporte.", + "Ordinea de rezolvare per client: dispozitiv direct > grup de dispozitive > utilizator. Prioritatea cea mai mare câștigă.", + "Orden de resolución por cliente: dispositivo directo > grupo de dispositivos > usuario. Gana la prioridad más alta.", + ), + "strategies.groups_label" => ( + "Device groups", + "Gerätegruppen", + "Groupes d'appareils", + "Grupuri de dispozitive", + "Grupos de dispositivos", + ), + "strategies.peers_label" => ( + "Individual devices", + "Einzelne Geräte", + "Appareils individuels", + "Dispozitive individuale", + "Dispositivos individuales", + ), + "strategies.no_group_assignments" => ( + "No groups assigned.", + "Keine Gruppen zugewiesen.", + "Aucun groupe affecté.", + "Niciun grup atribuit.", + "Sin grupos asignados.", + ), + "strategies.no_peer_assignments" => ( + "No devices assigned.", + "Keine Geräte zugewiesen.", + "Aucun appareil affecté.", + "Niciun dispozitiv atribuit.", + "Sin dispositivos asignados.", + ), + "strategies.assign_group" => ( + "Assign group", + "Gruppe zuweisen", + "Affecter le groupe", + "Atribuie grupul", + "Asignar grupo", + ), + "strategies.assign_peer" => ( + "Assign device", + "Gerät zuweisen", + "Affecter l'appareil", + "Atribuie dispozitivul", + "Asignar dispositivo", + ), + "strategies.all_groups_assigned" => ( + "All device groups are already assigned.", + "Alle Gerätegruppen sind bereits zugewiesen.", + "Tous les groupes d'appareils sont déjà affectés.", + "Toate grupurile de dispozitive sunt deja atribuite.", + "Todos los grupos de dispositivos ya están asignados.", + ), + "strategies.no_groups_exist" => ( + "No device groups exist yet — create one on the Groups page.", + "Es gibt noch keine Gerätegruppen — auf der Seite „Gruppen\" anlegen.", + "Aucun groupe d'appareils n'existe — créez-en un sur la page Groupes.", + "Nu există încă grupuri de dispozitive — creați unul în pagina Grupuri.", + "Aún no hay grupos de dispositivos — crea uno en la página Grupos.", + ), + "strategies.group_assigned" => ( + "Device group assigned.", + "Gerätegruppe zugewiesen.", + "Groupe d'appareils affecté.", + "Grup de dispozitive atribuit.", + "Grupo de dispositivos asignado.", + ), + "strategies.peer_assigned" => ( + "Device '{0}' assigned to this strategy (any previous peer-level strategy was replaced).", + "Gerät „{0}\" dieser Strategie zugewiesen (eine vorhandene gerätebezogene Strategie wurde ersetzt).", + "Appareil « {0} » affecté à cette stratégie (toute stratégie précédente au niveau appareil a été remplacée).", + "Dispozitivul „{0}\" a fost atribuit acestei strategii (orice strategie anterioară la nivel de dispozitiv a fost înlocuită).", + "Dispositivo «{0}» asignado a esta estrategia (cualquier estrategia previa a nivel de dispositivo fue reemplazada).", + ), + "strategies.unassigned" => ( + "Assignment removed.", + "Zuweisung entfernt.", + "Affectation supprimée.", + "Atribuirea a fost eliminată.", + "Asignación eliminada.", + ), // ---- address books ---- "ab.heading" => ( diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index 0468262..a2a8ac4 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -145,6 +145,18 @@ pub fn build(state: Arc) -> Option { "/admin/pages/strategies/:id/delete", post(pages::strategies::delete), ) + .route( + "/admin/pages/strategies/:id/assignments/group", + post(pages::strategies::assign_group), + ) + .route( + "/admin/pages/strategies/:id/assignments/peer", + post(pages::strategies::assign_peer), + ) + .route( + "/admin/pages/strategies/:id/assignments/:assignment_id/delete", + post(pages::strategies::unassign), + ) .route("/admin/pages/deploy", get(pages::deploy::index)) .route( "/admin/pages/deploy/generate", diff --git a/src/api/admin/pages/strategies.rs b/src/api/admin/pages/strategies.rs index 0cf6e34..2fbc04a 100644 --- a/src/api/admin/pages/strategies.rs +++ b/src/api/admin/pages/strategies.rs @@ -1,6 +1,7 @@ -//! 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. +//! 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}; @@ -122,6 +123,93 @@ pub async fn delete( .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( @@ -141,6 +229,11 @@ async fn render_full(state: &Arc, lang: Lang) -> Result, lang: Lang) -> Result @@ -193,8 +291,7 @@ async fn render_full(state: &Arc, lang: Lang) -> Result{cfg} - -"##, + "##, id = str_.id, name = html_escape(&str_.name), meta = tf2(lang, "strategies.id_modified", &str_.id.to_string(), &str_.modified_at.to_string()), @@ -204,7 +301,168 @@ async fn render_full(state: &Arc, lang: Lang) -> Result"); } 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("
"); +} diff --git a/src/database.rs b/src/database.rs index cba74bc..3be2d08 100644 --- a/src/database.rs +++ b/src/database.rs @@ -145,6 +145,18 @@ pub struct AbShareDetailRow { pub rule: i64, } +#[derive(Debug, Clone)] +pub struct StrategyAssignmentRow { + pub id: i64, + /// Exactly one of `device_group_id` or `peer_id` is set for assignments + /// surfaced in the admin UI. (`user_id` assignments are still resolved by + /// the heartbeat path but are not editable from the strategies page yet.) + pub device_group_id: Option, + pub device_group_name: Option, + pub peer_id: Option, + pub priority: i64, +} + #[derive(Debug, Clone)] pub struct StrategyRow { pub id: i64, @@ -956,6 +968,101 @@ impl Database { Ok(res.rows_affected() > 0) } + /// List the device-group and peer assignments for a strategy, in the same + /// order the admin UI displays them (groups first, then peers). User-scoped + /// assignments are intentionally omitted — the strategies page only edits + /// group/peer scopes. + pub async fn strategy_assignments_for( + &self, + strategy_id: i64, + ) -> ResultType> { + let rows = sqlx::query( + "SELECT sa.id, sa.device_group_id, sa.peer_id, sa.priority, dg.name AS group_name \ + FROM strategy_assignments sa \ + LEFT JOIN device_groups dg ON dg.id = sa.device_group_id \ + WHERE sa.strategy_id = ? \ + AND (sa.device_group_id IS NOT NULL OR sa.peer_id IS NOT NULL) \ + ORDER BY (sa.device_group_id IS NULL), dg.name, sa.peer_id", + ) + .bind(strategy_id) + .fetch_all(self.pool.get().await?.deref_mut()) + .await?; + Ok(rows + .into_iter() + .map(|r| StrategyAssignmentRow { + id: r.try_get("id").unwrap_or(0), + device_group_id: r.try_get("device_group_id").ok().flatten(), + device_group_name: r.try_get("group_name").ok().flatten(), + peer_id: r.try_get("peer_id").ok().flatten(), + priority: r.try_get("priority").unwrap_or(0), + }) + .collect()) + } + + /// Idempotent — inserts an assignment row only if this (strategy, group) + /// pair doesn't already exist. Priority 50 matches the resolver's + /// "group sits between peer (100) and user (10)" tiers. + pub async fn strategy_assign_group( + &self, + strategy_id: i64, + device_group_id: i64, + ) -> ResultType<()> { + sqlx::query( + "INSERT INTO strategy_assignments(strategy_id, device_group_id, priority) \ + SELECT ?, ?, 50 \ + WHERE NOT EXISTS ( \ + SELECT 1 FROM strategy_assignments \ + WHERE strategy_id = ? AND device_group_id = ? \ + )", + ) + .bind(strategy_id) + .bind(device_group_id) + .bind(strategy_id) + .bind(device_group_id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(()) + } + + /// Replace any existing peer-scoped assignment for this peer with the + /// given strategy. The resolver picks at most one peer-scoped row per + /// peer, so storing more than one would just be dead data. + pub async fn strategy_assign_peer_replace( + &self, + strategy_id: i64, + peer_id: &str, + ) -> ResultType<()> { + sqlx::query("DELETE FROM strategy_assignments WHERE peer_id = ?") + .bind(peer_id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + sqlx::query( + "INSERT INTO strategy_assignments(strategy_id, peer_id, priority) \ + VALUES(?, ?, 100)", + ) + .bind(strategy_id) + .bind(peer_id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(()) + } + + /// Delete one assignment row by id. Returns true if a row was deleted. + pub async fn strategy_assignment_delete( + &self, + strategy_id: i64, + assignment_id: i64, + ) -> ResultType { + let res = sqlx::query( + "DELETE FROM strategy_assignments WHERE id = ? AND strategy_id = ?", + ) + .bind(assignment_id) + .bind(strategy_id) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(res.rows_affected() > 0) + } + /// Audit listings (newest first) — used by the dashboard browser. Each /// returns at most `limit` rows; the dashboard caps at a few hundred. pub async fn audit_conn_list(&self, limit: i64) -> ResultType> {