This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user