Implement "strategy selector" so it is possible to push certain strategies only to selected devices
build / build-linux-amd64 (push) Successful in 1m49s
build / build-linux-amd64 (push) Successful in 1m49s
This commit is contained in:
+96
-5
@@ -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" => (
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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>> {
|
||||||
|
|||||||
Reference in New Issue
Block a user