This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
//! full assignment matrix UI is a follow-up.
|
||||
|
||||
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;
|
||||
@@ -15,9 +16,10 @@ 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).await?))
|
||||
Ok(Html(render_full(&state, lang).await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -30,11 +32,12 @@ pub struct CreateForm {
|
||||
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, "error", "Name required").await;
|
||||
return notice_then(&state, lang, "error", t(lang, "groups.name_required")).await;
|
||||
}
|
||||
let cfg = if form.config_options_json.trim().is_empty() {
|
||||
"{}".to_string()
|
||||
@@ -44,9 +47,17 @@ pub async fn create(
|
||||
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, "error", "config_options must be a JSON object").await
|
||||
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
|
||||
}
|
||||
Err(e) => return notice_then(&state, "error", &format!("invalid JSON: {}", e)).await,
|
||||
}
|
||||
};
|
||||
state
|
||||
@@ -56,8 +67,9 @@ pub async fn create(
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then(
|
||||
&state,
|
||||
lang,
|
||||
"ok",
|
||||
&format!("Strategy '{}' created.", form.name),
|
||||
&tf1(lang, "strategies.created", &form.name),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -70,6 +82,7 @@ pub struct UpdateForm {
|
||||
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> {
|
||||
@@ -77,7 +90,7 @@ pub async fn update(
|
||||
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, "error", "config_options must be a JSON object").await
|
||||
return notice_then(&state, lang, "error", t(lang, "strategies.json_obj_required")).await
|
||||
}
|
||||
};
|
||||
state
|
||||
@@ -85,12 +98,13 @@ pub async fn update(
|
||||
.strategy_update_config(id, &cfg)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then(&state, "ok", "Strategy updated.").await
|
||||
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)?;
|
||||
@@ -101,8 +115,9 @@ pub async fn delete(
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then(
|
||||
&state,
|
||||
lang,
|
||||
if ok { "ok" } else { "error" },
|
||||
if ok { "Strategy deleted." } else { "Already gone." },
|
||||
if ok { t(lang, "strategies.deleted") } else { t(lang, "common.already_gone") },
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -111,40 +126,51 @@ pub async fn delete(
|
||||
|
||||
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).await?);
|
||||
html.push_str(&render_full(state, lang).await?);
|
||||
Ok(Html(html))
|
||||
}
|
||||
|
||||
async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
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 mut s = String::new();
|
||||
s.push_str(
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div id="strategies-region" class="space-y-6">
|
||||
<header>
|
||||
<h2 class="text-lg font-semibold">Strategies</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">Pushed to clients via heartbeat. Use SQL to assign — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).</p>
|
||||
<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 strategy</h3>
|
||||
<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="name (unique)" 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": ""}'
|
||||
<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>
|
||||
<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() {
|
||||
s.push_str(r##"<p class="text-slate-500 text-sm">No strategies yet.</p>"##);
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<p class="text-slate-500 text-sm">{}</p>"##,
|
||||
t(lang, "strategies.no_strategies"),
|
||||
);
|
||||
}
|
||||
for str_ in &strategies {
|
||||
let _ = write!(
|
||||
@@ -153,26 +179,30 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
<header class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold">{name}</h3>
|
||||
<p class="text-xs text-slate-500">id={id}, modified_at={mod_at}</p>
|
||||
<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="Delete strategy {name}? Assignments will be cleaned up too."
|
||||
hx-target="#strategies-region" hx-swap="outerHTML">Delete</button>
|
||||
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">config_options (JSON object)</label>
|
||||
<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>
|
||||
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{save}</button>
|
||||
</form>
|
||||
</section>"##,
|
||||
id = str_.id,
|
||||
name = html_escape(&str_.name),
|
||||
mod_at = str_.modified_at,
|
||||
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"),
|
||||
);
|
||||
}
|
||||
s.push_str("</div>");
|
||||
|
||||
Reference in New Issue
Block a user