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
+56 -35
View File
@@ -3,6 +3,7 @@
//! a follow-up if the operator outgrows this view.
use super::shared::{fmt_unix, html_escape, require_admin};
use crate::api::admin::i18n::{t, tf1, Lang};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
@@ -23,14 +24,15 @@ pub struct TabQuery {
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Query(q): Query<TabQuery>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let tab = q.tab.as_deref().unwrap_or("conn");
let body = match tab {
"file" => render_file(&state).await?,
"alarm" => render_alarm(&state).await?,
_ => render_conn(&state).await?,
"file" => render_file(&state, lang).await?,
"alarm" => render_alarm(&state, lang).await?,
_ => render_conn(&state, lang).await?,
};
let pill = |id: &str, label: &str| {
let active = id == tab;
@@ -49,42 +51,50 @@ pub async fn index(
Ok(Html(format!(
r##"<div class="space-y-4">
<header class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Audit log</h2>
<p class="text-xs text-slate-500">Latest {n} rows.</p>
<h2 class="text-lg font-semibold">{heading}</h2>
<p class="text-xs text-slate-500">{latest}</p>
</header>
<div class="flex gap-2 text-xs">{pill_conn}{pill_file}{pill_alarm}</div>
{body}
</div>"##,
n = PAGE_SIZE,
pill_conn = pill("conn", "Connections"),
pill_file = pill("file", "File transfers"),
pill_alarm = pill("alarm", "Alarms"),
heading = t(lang, "audit.heading"),
latest = tf1(lang, "audit.latest", &PAGE_SIZE.to_string()),
pill_conn = pill("conn", t(lang, "audit.tab_conn")),
pill_file = pill("file", t(lang, "audit.tab_file")),
pill_alarm = pill("alarm", t(lang, "audit.tab_alarm")),
body = body,
)))
}
async fn render_conn(state: &Arc<AppState>) -> Result<String, ApiError> {
async fn render_conn(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
let rows = state
.db
.audit_conn_list(PAGE_SIZE)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if rows.is_empty() {
return Ok(empty_table("No connection audit rows yet."));
return Ok(empty_table(t(lang, "audit.no_conn")));
}
let mut s = String::new();
s.push_str(
let _ = write!(
s,
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
<th class="text-left font-medium px-3 py-2">When</th>
<th class="text-left font-medium px-3 py-2">Peer</th>
<th class="text-left font-medium px-3 py-2">Conn / Session</th>
<th class="text-left font-medium px-3 py-2">IP</th>
<th class="text-left font-medium px-3 py-2">Action</th>
<th class="text-left font-medium px-3 py-2">Note</th>
<th class="text-left font-medium px-3 py-2">{c_when}</th>
<th class="text-left font-medium px-3 py-2">{c_peer}</th>
<th class="text-left font-medium px-3 py-2">{c_conn}</th>
<th class="text-left font-medium px-3 py-2">{c_ip}</th>
<th class="text-left font-medium px-3 py-2">{c_action}</th>
<th class="text-left font-medium px-3 py-2">{c_note}</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
c_when = t(lang, "audit.col_when"),
c_peer = t(lang, "audit.col_peer"),
c_conn = t(lang, "audit.col_conn_session"),
c_ip = t(lang, "audit.col_ip"),
c_action = t(lang, "audit.col_action"),
c_note = t(lang, "audit.col_note"),
);
for r in &rows {
let _ = write!(
@@ -110,32 +120,38 @@ async fn render_conn(state: &Arc<AppState>) -> Result<String, ApiError> {
Ok(s)
}
async fn render_file(state: &Arc<AppState>) -> Result<String, ApiError> {
async fn render_file(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
let rows = state
.db
.audit_file_list(PAGE_SIZE)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if rows.is_empty() {
return Ok(empty_table("No file-transfer audit rows yet."));
return Ok(empty_table(t(lang, "audit.no_file")));
}
let mut s = String::new();
s.push_str(
let _ = write!(
s,
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
<th class="text-left font-medium px-3 py-2">When</th>
<th class="text-left font-medium px-3 py-2">Peer</th>
<th class="text-left font-medium px-3 py-2">Direction</th>
<th class="text-left font-medium px-3 py-2">Path</th>
<th class="text-left font-medium px-3 py-2">Remote</th>
<th class="text-left font-medium px-3 py-2">{c_when}</th>
<th class="text-left font-medium px-3 py-2">{c_peer}</th>
<th class="text-left font-medium px-3 py-2">{c_dir}</th>
<th class="text-left font-medium px-3 py-2">{c_path}</th>
<th class="text-left font-medium px-3 py-2">{c_remote}</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
c_when = t(lang, "audit.col_when"),
c_peer = t(lang, "audit.col_peer"),
c_dir = t(lang, "audit.col_direction"),
c_path = t(lang, "audit.col_path"),
c_remote = t(lang, "audit.col_remote"),
);
for r in &rows {
let dir = match r.direction {
0 => "→ remote",
1 => "← remote",
0 => t(lang, "audit.dir_to_remote"),
1 => t(lang, "audit.dir_from_remote"),
_ => "?",
};
let _ = write!(
@@ -158,26 +174,31 @@ async fn render_file(state: &Arc<AppState>) -> Result<String, ApiError> {
Ok(s)
}
async fn render_alarm(state: &Arc<AppState>) -> Result<String, ApiError> {
async fn render_alarm(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
let rows = state
.db
.audit_alarm_list(PAGE_SIZE)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if rows.is_empty() {
return Ok(empty_table("No alarm audit rows yet."));
return Ok(empty_table(t(lang, "audit.no_alarm")));
}
let mut s = String::new();
s.push_str(
let _ = write!(
s,
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
<th class="text-left font-medium px-3 py-2">When</th>
<th class="text-left font-medium px-3 py-2">Peer</th>
<th class="text-left font-medium px-3 py-2">Type</th>
<th class="text-left font-medium px-3 py-2">Info</th>
<th class="text-left font-medium px-3 py-2">{c_when}</th>
<th class="text-left font-medium px-3 py-2">{c_peer}</th>
<th class="text-left font-medium px-3 py-2">{c_type}</th>
<th class="text-left font-medium px-3 py-2">{c_info}</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
c_when = t(lang, "audit.col_when"),
c_peer = t(lang, "audit.col_peer"),
c_type = t(lang, "audit.col_type"),
c_info = t(lang, "audit.col_info"),
);
for r in &rows {
let typ = match r.typ {