Implementing multi-language Admin UI
build / build-linux-amd64 (push) Successful in 2m2s

This commit is contained in:
2026-05-09 16:58:20 +02:00
parent a7b3e83f02
commit 1e961cdd92
14 changed files with 2989 additions and 487 deletions
+54 -24
View File
@@ -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>");