469 lines
15 KiB
Rust
469 lines
15 KiB
Rust
//! 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};
|
|
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<Arc<AppState>>,
|
|
admin: AuthedUser,
|
|
lang: Lang,
|
|
) -> Result<Html<String>, ApiError> {
|
|
require_admin(&admin)?;
|
|
Ok(Html(render_full(&state, lang).await?))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateForm {
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub config_options_json: String,
|
|
}
|
|
|
|
pub async fn create(
|
|
Extension(state): Extension<Arc<AppState>>,
|
|
admin: AuthedUser,
|
|
lang: Lang,
|
|
Form(form): Form<CreateForm>,
|
|
) -> Result<Html<String>, ApiError> {
|
|
require_admin(&admin)?;
|
|
if form.name.trim().is_empty() {
|
|
return notice_then(&state, lang, "error", t(lang, "groups.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::<serde_json::Value>(&form.config_options_json) {
|
|
Ok(v) if v.is_object() => form.config_options_json.clone(),
|
|
Ok(_) => {
|
|
return notice_then(&state, lang, "error", t(lang, "strategies.json_obj_required")).await
|
|
}
|
|
Err(e) => {
|
|
return notice_then(
|
|
&state,
|
|
lang,
|
|
"error",
|
|
&tf1(lang, "strategies.invalid_json", &e.to_string()),
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
};
|
|
state
|
|
.db
|
|
.strategy_create(form.name.trim(), &cfg, "{}")
|
|
.await
|
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
notice_then(
|
|
&state,
|
|
lang,
|
|
"ok",
|
|
&tf1(lang, "strategies.created", &form.name),
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateForm {
|
|
pub config_options_json: String,
|
|
}
|
|
|
|
pub async fn update(
|
|
Extension(state): Extension<Arc<AppState>>,
|
|
admin: AuthedUser,
|
|
lang: Lang,
|
|
Path(id): Path<i64>,
|
|
Form(form): Form<UpdateForm>,
|
|
) -> Result<Html<String>, ApiError> {
|
|
require_admin(&admin)?;
|
|
let cfg = match serde_json::from_str::<serde_json::Value>(&form.config_options_json) {
|
|
Ok(v) if v.is_object() => form.config_options_json.clone(),
|
|
_ => {
|
|
return notice_then(&state, lang, "error", t(lang, "strategies.json_obj_required")).await
|
|
}
|
|
};
|
|
state
|
|
.db
|
|
.strategy_update_config(id, &cfg)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
notice_then(&state, lang, "ok", t(lang, "strategies.updated")).await
|
|
}
|
|
|
|
pub async fn delete(
|
|
Extension(state): Extension<Arc<AppState>>,
|
|
admin: AuthedUser,
|
|
lang: Lang,
|
|
Path(id): Path<i64>,
|
|
) -> Result<Html<String>, ApiError> {
|
|
require_admin(&admin)?;
|
|
let ok = state
|
|
.db
|
|
.strategy_delete(id)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
notice_then(
|
|
&state,
|
|
lang,
|
|
if ok { "ok" } else { "error" },
|
|
if ok { t(lang, "strategies.deleted") } else { t(lang, "common.already_gone") },
|
|
)
|
|
.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(
|
|
state: &Arc<AppState>,
|
|
lang: Lang,
|
|
kind: &str,
|
|
msg: &str,
|
|
) -> Result<Html<String>, ApiError> {
|
|
let mut html = notice_html(kind, msg);
|
|
html.push_str(&render_full(state, lang).await?);
|
|
Ok(Html(html))
|
|
}
|
|
|
|
async fn render_full(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
|
|
let strategies = state
|
|
.db
|
|
.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,
|
|
r##"<div id="strategies-region" class="space-y-6">
|
|
<header>
|
|
<h2 class="text-lg font-semibold">{heading}</h2>
|
|
<p class="text-xs text-slate-500 mt-1">{tagline}</p>
|
|
</header>
|
|
<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
|
<h3 class="text-sm font-semibold text-slate-300 mb-3">{create_heading}</h3>
|
|
<form class="space-y-2 text-sm" hx-post="/admin/pages/strategies/create" hx-target="#strategies-region" hx-swap="outerHTML">
|
|
<input name="name" placeholder="{ph}" required class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
|
|
<textarea name="config_options_json" rows="3" placeholder='{{"enable-udp": "N", "whitelist": ""}}'
|
|
class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono text-xs"></textarea>
|
|
<button class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 font-medium text-white">{create}</button>
|
|
</form>
|
|
</section>
|
|
"##,
|
|
heading = t(lang, "strategies.heading"),
|
|
tagline = t(lang, "strategies.tagline"),
|
|
create_heading = t(lang, "strategies.create_heading"),
|
|
ph = t(lang, "strategies.name_unique"),
|
|
create = t(lang, "common.create"),
|
|
);
|
|
if strategies.is_empty() {
|
|
let _ = write!(
|
|
s,
|
|
r##"<p class="text-slate-500 text-sm">{}</p>"##,
|
|
t(lang, "strategies.no_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!(
|
|
s,
|
|
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3">
|
|
<header class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="font-semibold">{name}</h3>
|
|
<p class="text-xs text-slate-500">{meta}</p>
|
|
</div>
|
|
<button class="text-xs text-rose-400 hover:text-rose-300"
|
|
hx-post="/admin/pages/strategies/{id}/delete"
|
|
hx-confirm="{confirm}"
|
|
hx-target="#strategies-region" hx-swap="outerHTML">{delete}</button>
|
|
</header>
|
|
<form class="space-y-2 text-sm"
|
|
hx-post="/admin/pages/strategies/{id}/update"
|
|
hx-target="#strategies-region" hx-swap="outerHTML">
|
|
<label class="block text-xs text-slate-400">{cfg_label}</label>
|
|
<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>"##,
|
|
id = str_.id,
|
|
name = html_escape(&str_.name),
|
|
meta = tf2(lang, "strategies.id_modified", &str_.id.to_string(), &str_.modified_at.to_string()),
|
|
cfg = html_escape(&str_.config_options_json),
|
|
confirm = html_escape(&tf1(lang, "strategies.confirm_delete", &str_.name)),
|
|
delete = t(lang, "common.delete"),
|
|
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>");
|
|
}
|