Implement "strategy selector" so it is possible to push certain strategies only to selected devices
build / build-linux-amd64 (push) Successful in 1m49s

This commit is contained in:
2026-05-21 12:24:06 +02:00
parent e22e4f6fb6
commit c2d320b782
4 changed files with 478 additions and 10 deletions
+96 -5
View File
@@ -1207,11 +1207,11 @@ pub fn t(lang: Lang, key: &str) -> &'static str {
"Estrategias", "Estrategias",
), ),
"strategies.tagline" => ( "strategies.tagline" => (
"Pushed to clients via heartbeat. Use SQL to assign 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. Zur Zuweisung SQL verwenden — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).", "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. Utilisez SQL pour assigner — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).", "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. Folosiți SQL pentru atribuire — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).", "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. Usa SQL para asignar — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).", "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" => ( "strategies.create_heading" => (
"Create strategy", "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}",
"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 ---- // ---- address books ----
"ab.heading" => ( "ab.heading" => (
+12
View File
@@ -145,6 +145,18 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
"/admin/pages/strategies/:id/delete", "/admin/pages/strategies/:id/delete",
post(pages::strategies::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", get(pages::deploy::index))
.route( .route(
"/admin/pages/deploy/generate", "/admin/pages/deploy/generate",
+263 -5
View File
@@ -1,6 +1,7 @@
//! Strategies page — list / create / edit-config / delete. Assignment to //! Strategies page — list / create / edit-config / delete, plus the
//! peers/groups/users is intentionally still SQL-driven for v1; building a //! assignment matrix that decides which clients receive each strategy.
//! full assignment matrix UI is a follow-up. //! 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 super::shared::{html_escape, notice_html, require_admin};
use crate::api::admin::i18n::{t, tf1, tf2, Lang}; use crate::api::admin::i18n::{t, tf1, tf2, Lang};
@@ -122,6 +123,93 @@ pub async fn delete(
.await .await
} }
#[derive(Debug, Deserialize)]
pub struct AssignGroupForm {
pub device_group_id: i64,
}
pub async fn assign_group(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>,
Form(form): Form<AssignGroupForm>,
) -> Result<Html<String>, 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<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>,
Form(form): Form<AssignPeerForm>,
) -> Result<Html<String>, 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<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path((id, assignment_id)): Path<(i64, i64)>,
) -> Result<Html<String>, 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 ---------- // ---------- rendering ----------
async fn notice_then( async fn notice_then(
@@ -141,6 +229,11 @@ async fn render_full(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiErr
.strategies_list_all() .strategies_list_all()
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .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 mut s = String::new();
let _ = write!( let _ = write!(
s, s,
@@ -173,6 +266,11 @@ async fn render_full(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiErr
); );
} }
for str_ in &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!( let _ = write!(
s, s,
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3"> r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3">
@@ -193,8 +291,7 @@ async fn render_full(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiErr
<textarea name="config_options_json" rows="4" <textarea name="config_options_json" rows="4"
class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono text-xs">{cfg}</textarea> class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono text-xs">{cfg}</textarea>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{save}</button> <button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{save}</button>
</form> </form>"##,
</section>"##,
id = str_.id, id = str_.id,
name = html_escape(&str_.name), name = html_escape(&str_.name),
meta = tf2(lang, "strategies.id_modified", &str_.id.to_string(), &str_.modified_at.to_string()), 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<AppState>, lang: Lang) -> Result<String, ApiErr
cfg_label = t(lang, "strategies.config_label"), cfg_label = t(lang, "strategies.config_label"),
save = t(lang, "common.save"), save = t(lang, "common.save"),
); );
// ---- Assignments section ----
render_assignments(&mut s, lang, str_.id, &assignments, &all_groups);
s.push_str("</section>");
} }
s.push_str("</div>"); s.push_str("</div>");
Ok(s) 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<i64> = 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##"<div class="pt-3 border-t border-slate-800 space-y-3">
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide">{filter_heading}</h4>
<p class="text-[11px] text-slate-500">{filter_hint}</p>"##,
filter_heading = t(lang, "strategies.filter_heading"),
filter_hint = t(lang, "strategies.filter_hint"),
);
// ---- Device groups ----
let _ = write!(
s,
r##"<div>
<div class="text-[11px] font-semibold text-slate-400 mb-1">{groups_label}</div>
<ul class="text-sm divide-y divide-slate-800">"##,
groups_label = t(lang, "strategies.groups_label"),
);
if group_rows.is_empty() {
let _ = write!(
s,
r##"<li class="py-2 text-slate-500 text-xs">{}</li>"##,
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##"<li class="py-2 flex items-center justify-between">
<span class="text-slate-200">{name}</span>
<button class="text-xs text-slate-400 hover:text-rose-300"
hx-post="/admin/pages/strategies/{sid}/assignments/{aid}/delete"
hx-target="#strategies-region" hx-swap="outerHTML">{remove}</button>
</li>"##,
name = html_escape(name),
sid = strategy_id,
aid = a.id,
remove = t(lang, "common.remove"),
);
}
s.push_str("</ul>");
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##"<form class="flex gap-2 text-sm pt-2"
hx-post="/admin/pages/strategies/{sid}/assignments/group"
hx-target="#strategies-region" hx-swap="outerHTML">
<select name="device_group_id" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5">
"##,
sid = strategy_id,
);
for g in &candidates {
let _ = write!(
s,
r##"<option value="{gid}">{name}</option>"##,
gid = g.id,
name = html_escape(&g.name),
);
}
let _ = write!(
s,
r##"</select>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{assign}</button>
</form>"##,
assign = t(lang, "strategies.assign_group"),
);
} else if !all_groups.is_empty() {
let _ = write!(
s,
r##"<p class="text-[11px] text-slate-500 pt-1">{}</p>"##,
t(lang, "strategies.all_groups_assigned"),
);
} else {
let _ = write!(
s,
r##"<p class="text-[11px] text-slate-500 pt-1">{}</p>"##,
t(lang, "strategies.no_groups_exist"),
);
}
s.push_str("</div>");
// ---- Peers ----
let _ = write!(
s,
r##"<div>
<div class="text-[11px] font-semibold text-slate-400 mb-1">{peers_label}</div>
<ul class="text-sm divide-y divide-slate-800">"##,
peers_label = t(lang, "strategies.peers_label"),
);
if peer_rows.is_empty() {
let _ = write!(
s,
r##"<li class="py-2 text-slate-500 text-xs">{}</li>"##,
t(lang, "strategies.no_peer_assignments"),
);
}
for a in &peer_rows {
let pid = a.peer_id.as_deref().unwrap_or("");
let _ = write!(
s,
r##"<li class="py-2 flex items-center justify-between">
<span class="font-mono text-slate-200">{pid}</span>
<button class="text-xs text-slate-400 hover:text-rose-300"
hx-post="/admin/pages/strategies/{sid}/assignments/{aid}/delete"
hx-target="#strategies-region" hx-swap="outerHTML">{remove}</button>
</li>"##,
pid = html_escape(pid),
sid = strategy_id,
aid = a.id,
remove = t(lang, "common.remove"),
);
}
s.push_str("</ul>");
let _ = write!(
s,
r##"<form class="flex gap-2 text-sm pt-2"
hx-post="/admin/pages/strategies/{sid}/assignments/peer"
hx-target="#strategies-region" hx-swap="outerHTML">
<input name="peer_id" placeholder="{ph}" required
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono"/>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{assign}</button>
</form>"##,
sid = strategy_id,
ph = t(lang, "groups.peer_id_placeholder"),
assign = t(lang, "strategies.assign_peer"),
);
s.push_str("</div></div>");
}
+107
View File
@@ -145,6 +145,18 @@ pub struct AbShareDetailRow {
pub rule: i64, 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<i64>,
pub device_group_name: Option<String>,
pub peer_id: Option<String>,
pub priority: i64,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct StrategyRow { pub struct StrategyRow {
pub id: i64, pub id: i64,
@@ -956,6 +968,101 @@ impl Database {
Ok(res.rows_affected() > 0) 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<Vec<StrategyAssignmentRow>> {
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<bool> {
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 /// Audit listings (newest first) — used by the dashboard browser. Each
/// returns at most `limit` rows; the dashboard caps at a few hundred. /// returns at most `limit` rows; the dashboard caps at a few hundred.
pub async fn audit_conn_list(&self, limit: i64) -> ResultType<Vec<AuditConnRow>> { pub async fn audit_conn_list(&self, limit: i64) -> ResultType<Vec<AuditConnRow>> {