//! 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, } pub async fn index( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Query(q): Query, ) -> Result, 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##"{label}"##, id = id, cls = cls, label = label, ) }; Ok(Html(format!( r##"

{heading}

{latest}

{pill_conn}{pill_file}{pill_alarm}
{body}
"##, 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, lang: Lang) -> Result { 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##"
"##, 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##""##, 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("
{c_when} {c_peer} {c_conn} {c_ip} {c_action} {c_note}
{when} {peer} {conn} / {sess} {ip} {action} {note}
"); Ok(s) } async fn render_file(state: &Arc, lang: Lang) -> Result { 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##"
"##, 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##""##, 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("
{c_when} {c_peer} {c_dir} {c_path} {c_remote}
{when} {peer} {dir} {path} {remote}
"); Ok(s) } async fn render_alarm(state: &Arc, lang: Lang) -> Result { 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##"
"##, 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##""##, when = html_escape(&fmt_unix(r.at)), peer = html_escape(&r.peer_id), typ = typ, info = html_escape(&r.info_json) ); } s.push_str("
{c_when} {c_peer} {c_type} {c_info}
{when} {peer} {typ} {info}
"); Ok(s) } fn empty_table(msg: &str) -> String { format!( r##"
{}
"##, html_escape(msg) ) }