Implement user login logging
build / build-linux-amd64 (push) Successful in 1m54s

This commit is contained in:
2026-05-22 20:10:11 +02:00
parent ac058d31c2
commit 62a8870ea2
6 changed files with 481 additions and 4 deletions
+56
View File
@@ -1217,6 +1217,62 @@ pub fn t(lang: Lang, key: &str) -> &'static str {
"Datele de inventar nu au fost încă raportate. Agentul le colectează la pornire și le încarcă la următorul ciclu sysinfo (≤120 s).",
"Aún no se han reportado datos de inventario. El agente los recopila al iniciar y los envía en el próximo ciclo sysinfo (≤120 s).",
),
"devices.login_history" => (
"Login history",
"Anmeldeverlauf",
"Historique de connexion",
"Istoric autentificări",
"Historial de inicio de sesión",
),
"devices.login_none" => (
"No login events recorded yet. The agent reports logons and logoffs as it observes them.",
"Noch keine Anmeldeereignisse aufgezeichnet. Der Agent meldet An- und Abmeldungen, sobald er sie beobachtet.",
"Aucun événement de connexion enregistré pour l'instant. L'agent signale les connexions et déconnexions au fur et à mesure qu'il les observe.",
"Niciun eveniment de autentificare înregistrat încă. Agentul raportează autentificările și deconectările pe măsură ce le observă.",
"Aún no hay eventos de inicio de sesión registrados. El agente informa los inicios y cierres de sesión a medida que los observa.",
),
"devices.login_col_when" => (
"When (UTC)",
"Wann (UTC)",
"Quand (UTC)",
"Când (UTC)",
"Cuándo (UTC)",
),
"devices.login_col_event" => (
"Event",
"Ereignis",
"Événement",
"Eveniment",
"Evento",
),
"devices.login_col_user" => (
"User",
"Benutzer",
"Utilisateur",
"Utilizator",
"Usuario",
),
"devices.login_col_session" => (
"Session",
"Sitzung",
"Session",
"Sesiune",
"Sesión",
),
"devices.login_kind_logon" => (
"Logon",
"Anmeldung",
"Connexion",
"Autentificare",
"Inicio de sesión",
),
"devices.login_kind_logoff" => (
"Logoff",
"Abmeldung",
"Déconnexion",
"Deconectare",
"Cierre de sesión",
),
"devices.serial_number" => (
"Serial number",
"Seriennummer",
+135 -3
View File
@@ -6,7 +6,7 @@ use crate::api::admin::i18n::{t, tf1, tf2, tf3, Lang};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use crate::database::DashboardDeviceRow;
use crate::database::{DashboardDeviceRow, LoginEventRow};
use axum::extract::{Extension, Form, Path, Query};
use axum::response::Html;
use serde::Deserialize;
@@ -459,7 +459,19 @@ pub async fn detail(
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let html = match row {
Some(d) => render_detail(lang, &d),
Some(d) => {
// Login events are surfaced on the detail page below the
// inventory. We cap at 50 — that's a few weeks of typical
// logon/logoff activity on a single-user box and stays
// skimmable. Deeper history can be added later behind a
// pagination control if anyone asks for it.
let events = state
.db
.login_events_for_peer(&d.id, 50)
.await
.unwrap_or_default();
render_detail(lang, &d, &events)
}
None => format!(
r##"<div class="space-y-4">
{back}
@@ -1130,7 +1142,7 @@ fn fmt_inv_value(v: Option<&serde_json::Value>) -> String {
}
}
fn render_detail(lang: Lang, d: &DashboardDeviceRow) -> String {
fn render_detail(lang: Lang, d: &DashboardDeviceRow, login_events: &[LoginEventRow]) -> String {
let parsed: serde_json::Value =
serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null);
let pick = |k: &str| -> String {
@@ -1224,6 +1236,8 @@ fn render_detail(lang: Lang, d: &DashboardDeviceRow) -> String {
),
};
let login_section = render_login_events(lang, login_events);
format!(
r##"<div class="space-y-4">
<div class="flex items-center justify-between">
@@ -1233,15 +1247,133 @@ fn render_detail(lang: Lang, d: &DashboardDeviceRow) -> String {
{header}
<h3 class="text-sm font-semibold text-slate-300 mt-4">{inventory}</h3>
{inv}
<h3 class="text-sm font-semibold text-slate-300 mt-4">{login_history}</h3>
{login}
</div>"##,
back = back_button(lang),
detail_view = t(lang, "devices.detail_view"),
inventory = t(lang, "devices.inventory"),
header = header,
inv = inventory_section,
login_history = t(lang, "devices.login_history"),
login = login_section,
)
}
/// Render the per-device login history table. Empty input → a neutral
/// "no events yet" panel so the heading still has something under it.
/// We render the agent-reported `at` in the standard SQLite UTC format
/// (no per-locale fiddling) and only flag a "received_at differs from at"
/// case as a tooltip — clock skew on the agent side is the only place that
/// matters operationally.
fn render_login_events(lang: Lang, events: &[LoginEventRow]) -> String {
if events.is_empty() {
return format!(
r##"<div class="rounded-md border border-slate-700 bg-slate-900 p-3 text-sm text-slate-400">
{msg}
</div>"##,
msg = t(lang, "devices.login_none"),
);
}
let mut s = format!(
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_event}</th>
<th class="text-left font-medium px-3 py-2">{c_user}</th>
<th class="text-left font-medium px-3 py-2">{c_session}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">"##,
c_when = t(lang, "devices.login_col_when"),
c_event = t(lang, "devices.login_col_event"),
c_user = t(lang, "devices.login_col_user"),
c_session = t(lang, "devices.login_col_session"),
);
for ev in events {
let when = fmt_unix_utc(ev.at);
// Surface clock-skew >5 min as a tooltip — that's the threshold the
// signed-API gate also uses, so anything above that is already on
// the operator's radar via 401s.
let skew = ev.received_at - ev.at;
let when_attr = if skew.abs() > 300 {
format!(
r##" title="received {recv} UTC (clock skew {skew:+}s)""##,
recv = html_escape(&fmt_unix_utc(ev.received_at)),
skew = skew,
)
} else {
String::new()
};
let (badge_class, badge_label) = match ev.kind.as_str() {
"logon" => (
"bg-emerald-900/40 text-emerald-300 border-emerald-800",
t(lang, "devices.login_kind_logon"),
),
"logoff" => (
"bg-slate-800 text-slate-300 border-slate-700",
t(lang, "devices.login_kind_logoff"),
),
_ => (
"bg-amber-900/40 text-amber-300 border-amber-800",
// Unknown kind — display the raw string so an operator
// running a newer agent against an older server can still
// see what was reported.
"",
),
};
let badge_text = if badge_label.is_empty() {
html_escape(&ev.kind)
} else {
badge_label.to_string()
};
let user_display = if ev.domain.is_empty() {
html_escape(if ev.username.is_empty() { "" } else { &ev.username })
} else if ev.username.is_empty() {
html_escape(&ev.domain)
} else {
html_escape(&format!("{}\\{}", ev.domain, ev.username))
};
let session_kind = if ev.session_kind.is_empty() {
"".to_string()
} else {
html_escape(&ev.session_kind)
};
let _ = write!(
s,
r##"<tr class="hover:bg-slate-800/40">
<td class="px-3 py-2 font-mono text-xs text-slate-300 whitespace-nowrap"{when_attr}>{when}</td>
<td class="px-3 py-2"><span class="inline-block text-[11px] px-1.5 py-0.5 rounded border {bc}">{bt}</span></td>
<td class="px-3 py-2 text-slate-200">{user}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{sk} #{sid}</td>
</tr>"##,
when_attr = when_attr,
when = html_escape(&when),
bc = badge_class,
bt = badge_text,
user = user_display,
sk = session_kind,
sid = ev.session_id,
);
}
s.push_str("</tbody></table></div>");
s
}
/// Format a unix epoch as `YYYY-MM-DD HH:MM:SS` UTC. Matches the format
/// SQLite's `current_timestamp` produces, so all the other timestamps on
/// the device detail page line up visually with login-event rows.
fn fmt_unix_utc(ts: i64) -> String {
use chrono::TimeZone;
chrono::Utc
.timestamp_opt(ts, 0)
.single()
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| ts.to_string())
}
fn render_inventory_table(lang: Lang, inv: &serde_json::Value) -> String {
let row = |label: &str, key: &str| {
format!(