Files
rustdesk-server/src/api/admin/pages/audit.rs
T
mike 1e961cdd92
build / build-linux-amd64 (push) Successful in 2m2s
Implementing multi-language Admin UI
2026-05-09 16:58:20 +02:00

235 lines
8.4 KiB
Rust

//! Audit log browser — three tabs (conn / file / alarm), each capped at the
//! latest 200 rows. M5c MVP. Pagination/filtering by date range can come in
//! 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;
use axum::extract::{Extension, Query};
use axum::response::Html;
use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::Arc;
const PAGE_SIZE: i64 = 200;
#[derive(Debug, Deserialize)]
pub struct TabQuery {
#[serde(default)]
pub tab: Option<String>,
}
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, lang).await?,
"alarm" => render_alarm(&state, lang).await?,
_ => render_conn(&state, lang).await?,
};
let pill = |id: &str, label: &str| {
let active = id == tab;
let cls = if active {
"bg-slate-800 text-sky-300 border-sky-800"
} else {
"bg-slate-900 text-slate-400 border-slate-800 hover:text-slate-200"
};
format!(
r##"<a href="#audit" hx-get="/admin/pages/audit?tab={id}" hx-target="#main" class="px-3 py-1 rounded border {cls}">{label}</a>"##,
id = id,
cls = cls,
label = label,
)
};
Ok(Html(format!(
r##"<div class="space-y-4">
<header class="flex items-center justify-between">
<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>"##,
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>, 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(t(lang, "audit.no_conn")));
}
let mut s = String::new();
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">{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!(
s,
r##"<tr>
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
<td class="px-3 py-2 text-slate-400">{conn} / {sess}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{ip}</td>
<td class="px-3 py-2 text-slate-300">{action}</td>
<td class="px-3 py-2 text-slate-400">{note}</td>
</tr>"##,
when = html_escape(&fmt_unix(r.started_at)),
peer = html_escape(&r.peer_id),
conn = r.conn_id,
sess = r.session_id,
ip = html_escape(&r.ip),
action = html_escape(&r.action),
note = html_escape(&r.note)
);
}
s.push_str("</tbody></table></div>");
Ok(s)
}
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(t(lang, "audit.no_file")));
}
let mut s = String::new();
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">{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 => t(lang, "audit.dir_to_remote"),
1 => t(lang, "audit.dir_from_remote"),
_ => "?",
};
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
<td class="px-3 py-2 text-slate-400">{dir}</td>
<td class="px-3 py-2 text-slate-300 font-mono text-xs">{path}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{remote}</td>
</tr>"##,
when = html_escape(&fmt_unix(r.at)),
peer = html_escape(&r.peer_id),
dir = dir,
path = html_escape(&r.path),
remote = html_escape(&r.remote_peer)
);
}
s.push_str("</tbody></table></div>");
Ok(s)
}
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(t(lang, "audit.no_alarm")));
}
let mut s = String::new();
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">{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 {
0 => "IpWhitelist",
1 => "ExceedThirtyAttempts",
2 => "SixAttemptsWithinOneMinute",
6 => "ExceedIPv6PrefixAttempts",
n => return Ok(format!("(unknown alarm type {})", n)),
};
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
<td class="px-3 py-2 text-amber-300">{typ}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs break-all">{info}</td>
</tr>"##,
when = html_escape(&fmt_unix(r.at)),
peer = html_escape(&r.peer_id),
typ = typ,
info = html_escape(&r.info_json)
);
}
s.push_str("</tbody></table></div>");
Ok(s)
}
fn empty_table(msg: &str) -> String {
format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-6 text-center text-sm text-slate-500">{}</div>"##,
html_escape(msg)
)
}