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
+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!(