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
+263 -5
View File
@@ -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<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 ----------
async fn notice_then(
@@ -141,6 +229,11 @@ async fn render_full(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiErr
.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,
@@ -173,6 +266,11 @@ async fn render_full(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiErr
);
}
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##"<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"
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>
</form>
</section>"##,
</form>"##,
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<AppState>, lang: Lang) -> Result<String, ApiErr
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("</section>");
}
s.push_str("</div>");
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>");
}