diff --git a/docs/AGENT-API-AUTH.md b/docs/AGENT-API-AUTH.md
index 57b4788..d912536 100644
--- a/docs/AGENT-API-AUTH.md
+++ b/docs/AGENT-API-AUTH.md
@@ -1,7 +1,7 @@
# Agent API authentication
Reference for the per-device signature gate on the agent-facing HTTP
-API. Five endpoints are gated:
+API. Seven endpoints are gated:
- `POST /api/heartbeat`
- `POST /api/sysinfo`
@@ -11,6 +11,15 @@ API. Five endpoints are gated:
by the agent. Same TOFU lifecycle as heartbeat / sysinfo: stock
RustDesk doesn't post here at all, so in practice every caller is a
managed agent; the legacy/unsigned path is kept for symmetry.
+- `POST /api/agent/metrics` — continuous CPU / memory / top-process
+ samples (≈1 / minute). Surfaced on the admin Devices detail page as
+ a 24 h sparkline + live snapshot card.
+- `POST /api/agent/perf-events` — sparse Windows-event-log entries
+ flagged by `Microsoft-Windows-Diagnostics-Performance/Operational`,
+ `Microsoft-Windows-Resource-Exhaustion-Detector/Operational`, and
+ hand-picked `System` IDs (41 / 6008 / 1001 — unexpected reboot /
+ dirty shutdown / BSOD). Server dedups via UNIQUE (peer_id, provider,
+ record_id).
For the operator workflow — turning it on, the dashboard toggle, what
happens when a managed agent is uninstalled — see the matching section
diff --git a/src/api/admin/i18n.rs b/src/api/admin/i18n.rs
index ee89296..f812131 100644
--- a/src/api/admin/i18n.rs
+++ b/src/api/admin/i18n.rs
@@ -1224,6 +1224,137 @@ pub fn t(lang: Lang, key: &str) -> &'static str {
"Istoric autentificări",
"Historial de inicio de sesión",
),
+ "devices.performance" => (
+ "Performance",
+ "Leistung",
+ "Performances",
+ "Performanță",
+ "Rendimiento",
+ ),
+ "devices.perf_none" => (
+ "No performance data reported yet. The agent collects CPU / memory samples once per minute and Windows-reported performance events as they happen.",
+ "Noch keine Leistungsdaten gemeldet. Der Agent sammelt CPU-/Speicher-Stichproben einmal pro Minute und die von Windows gemeldeten Leistungsereignisse, sobald sie auftreten.",
+ "Aucune donnée de performance signalée pour l'instant. L'agent collecte des échantillons CPU / mémoire une fois par minute et les événements de performance signalés par Windows au fur et à mesure.",
+ "Niciun fel de date de performanță raportate încă. Agentul colectează eșantioane CPU / memorie o dată pe minut și evenimentele de performanță raportate de Windows pe măsură ce se întâmplă.",
+ "Aún no se han reportado datos de rendimiento. El agente recopila muestras de CPU / memoria una vez por minuto y los eventos de rendimiento reportados por Windows a medida que ocurren.",
+ ),
+ "devices.perf_no_live" => (
+ "No live snapshot yet — waiting for the agent's first sample.",
+ "Noch keine Live-Stichprobe – warte auf die erste Probe des Agenten.",
+ "Pas encore d'instantané en direct — en attente du premier échantillon de l'agent.",
+ "Niciun instantaneu live încă — se așteaptă prima probă a agentului.",
+ "Aún no hay instantánea en vivo — esperando la primera muestra del agente.",
+ ),
+ "devices.perf_now" => ("Live", "Live", "En direct", "Live", "En vivo"),
+ "devices.perf_sampled_ago" => (
+ "Sampled {0} ago",
+ "Vor {0} aufgenommen",
+ "Échantillonné il y a {0}",
+ "Eșantionat acum {0}",
+ "Muestreado hace {0}",
+ ),
+ "devices.perf_cpu" => ("CPU", "CPU", "Processeur", "CPU", "CPU"),
+ "devices.perf_mem" => (
+ "Memory",
+ "Speicher",
+ "Mémoire",
+ "Memorie",
+ "Memoria",
+ ),
+ "devices.perf_top_cpu" => (
+ "Top CPU",
+ "Top-CPU",
+ "Plus gros CPU",
+ "Top CPU",
+ "Mayor CPU",
+ ),
+ "devices.perf_top_mem" => (
+ "Top memory",
+ "Top-Speicher",
+ "Plus grosse mémoire",
+ "Top memorie",
+ "Mayor memoria",
+ ),
+ "devices.perf_uptime" => (
+ "Uptime",
+ "Laufzeit",
+ "Disponibilité",
+ "Timp activ",
+ "Tiempo activo",
+ ),
+ "devices.perf_proc_count" => (
+ "Processes",
+ "Prozesse",
+ "Processus",
+ "Procese",
+ "Procesos",
+ ),
+ "devices.perf_no_chart" => (
+ "No samples in the last 24 h",
+ "Keine Daten in den letzten 24 Std",
+ "Aucun échantillon ces 24 dernières h",
+ "Niciun eșantion în ultimele 24 h",
+ "Sin muestras en las últimas 24 h",
+ ),
+ "devices.perf_peak" => ("peak", "Spitze", "max.", "vârf", "máx."),
+ "devices.perf_latest" => ("last", "letzter", "dernier", "ultim", "último"),
+ "devices.perf_now_short" => ("now", "jetzt", "maint.", "acum", "ahora"),
+ "devices.perf_events_heading" => (
+ "Recent performance events",
+ "Aktuelle Leistungsereignisse",
+ "Événements de performance récents",
+ "Evenimente recente de performanță",
+ "Eventos de rendimiento recientes",
+ ),
+ "devices.perf_events_none" => (
+ "No performance events reported. Windows flags boot / shutdown / sleep slow paths, memory exhaustion, unexpected reboots and BSODs here.",
+ "Keine Leistungsereignisse gemeldet. Windows markiert hier verlangsamte Start-/Herunterfahren-/Standby-Vorgänge, Speichermangel, unerwartete Neustarts und Bluescreens.",
+ "Aucun événement de performance signalé. Windows signale ici les démarrages / arrêts / veilles lents, l'épuisement de la mémoire, les redémarrages inattendus et les BSOD.",
+ "Niciun eveniment de performanță raportat. Windows marchează aici pornirile / opririle / repausurile lente, epuizarea memoriei, repornirile neașteptate și BSOD-urile.",
+ "No se han reportado eventos de rendimiento. Windows registra aquí arranques / apagados / suspensiones lentos, agotamiento de memoria, reinicios inesperados y BSOD.",
+ ),
+ "devices.perf_events_col_when" => (
+ "When (UTC)",
+ "Wann (UTC)",
+ "Quand (UTC)",
+ "Când (UTC)",
+ "Cuándo (UTC)",
+ ),
+ "devices.perf_events_col_source" => (
+ "Source",
+ "Quelle",
+ "Source",
+ "Sursă",
+ "Origen",
+ ),
+ "devices.perf_events_col_summary" => (
+ "Summary",
+ "Zusammenfassung",
+ "Résumé",
+ "Rezumat",
+ "Resumen",
+ ),
+ "devices.perf_src_diag_perf" => (
+ "Diag-Perf",
+ "Diag-Perf",
+ "Diag-Perf",
+ "Diag-Perf",
+ "Diag-Perf",
+ ),
+ "devices.perf_src_res_exh" => (
+ "Res-Exh",
+ "Res-Exh",
+ "Res-Exh",
+ "Res-Exh",
+ "Res-Exh",
+ ),
+ "devices.perf_src_system" => (
+ "System",
+ "System",
+ "Système",
+ "Sistem",
+ "Sistema",
+ ),
"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.",
diff --git a/src/api/admin/pages/devices.rs b/src/api/admin/pages/devices.rs
index 5440f35..c476fa9 100644
--- a/src/api/admin/pages/devices.rs
+++ b/src/api/admin/pages/devices.rs
@@ -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, LoginEventRow};
+use crate::database::{DashboardDeviceRow, LoginEventRow, MetricsSampleRow, PerfEventRow};
use axum::extract::{Extension, Form, Path, Query};
use axum::response::Html;
use serde::Deserialize;
@@ -470,7 +470,36 @@ pub async fn detail(
.login_events_for_peer(&d.id, 50)
.await
.unwrap_or_default();
- render_detail(lang, &d, &events)
+ // Performance: pull the most recent metrics sample for the
+ // "right now" card, plus 24 h of samples for the sparkline,
+ // plus the most recent perf events (boot/shutdown/memory-
+ // exhaustion etc.) for the "recent slow events" table.
+ // All three are best-effort — none of them is required for
+ // the detail page to render meaningfully.
+ let metrics_latest = state
+ .db
+ .metrics_latest(&d.id)
+ .await
+ .unwrap_or_default();
+ let since_24h = chrono::Utc::now().timestamp() - 24 * 3600;
+ let metrics_24h = state
+ .db
+ .metrics_samples_since(&d.id, since_24h)
+ .await
+ .unwrap_or_default();
+ let perf_events = state
+ .db
+ .perf_events_for_peer(&d.id, 20)
+ .await
+ .unwrap_or_default();
+ render_detail(
+ lang,
+ &d,
+ &events,
+ metrics_latest.as_ref(),
+ &metrics_24h,
+ &perf_events,
+ )
}
None => format!(
r##"
"##,
back = back_button(lang),
detail_view = t(lang, "devices.detail_view"),
+ performance = t(lang, "devices.performance"),
+ perf = perf_section,
inventory = t(lang, "devices.inventory"),
header = header,
inv = inventory_section,
@@ -1260,6 +1301,389 @@ fn render_detail(lang: Lang, d: &DashboardDeviceRow, login_events: &[LoginEventR
)
}
+/// Top-level Performance section: snapshot card, two sparklines (CPU /
+/// memory), and a recent-events table. The whole thing is omitted in
+/// favour of a "no data yet" panel when the agent hasn't reported.
+fn render_performance(
+ lang: Lang,
+ latest: Option<&MetricsSampleRow>,
+ series: &[MetricsSampleRow],
+ events: &[PerfEventRow],
+) -> String {
+ if latest.is_none() && series.is_empty() && events.is_empty() {
+ return format!(
+ r##"
+ {msg}
+
"##,
+ msg = t(lang, "devices.perf_none"),
+ );
+ }
+
+ let snapshot = render_perf_snapshot(lang, latest);
+ let cpu_chart = render_sparkline(
+ lang,
+ series.iter().map(|s| (s.at, s.cpu_pct)).collect(),
+ 100.0,
+ true,
+ t(lang, "devices.perf_cpu"),
+ );
+ let mem_chart = {
+ // Mem is reported as MB used / MB total; chart uses % so the
+ // y-axis stays comparable to the CPU panel.
+ let series_pct: Vec<(i64, f64)> = series
+ .iter()
+ .filter(|s| s.mem_total_mb > 0)
+ .map(|s| {
+ let pct = 100.0 * (s.mem_used_mb as f64) / (s.mem_total_mb as f64);
+ (s.at, pct)
+ })
+ .collect();
+ render_sparkline(lang, series_pct, 100.0, true, t(lang, "devices.perf_mem"))
+ };
+ let events_section = render_perf_events_table(lang, events);
+
+ format!(
+ r##"
+ {snapshot}
+
+ {cpu}
+ {mem}
+
+ {events}
+
"##,
+ snapshot = snapshot,
+ cpu = cpu_chart,
+ mem = mem_chart,
+ events = events_section,
+ )
+}
+
+/// "Right now" card — the most recent metrics sample. Drawn as a 4-up
+/// stat tile so the supporter can glance at CPU / memory / top
+/// processes without reading a chart. Falls back to a thin "no live
+/// data" pill when the agent has never reported.
+fn render_perf_snapshot(lang: Lang, latest: Option<&MetricsSampleRow>) -> String {
+ let Some(s) = latest else {
+ return format!(
+ r##"
+ {msg}
+
"##,
+ msg = t(lang, "devices.perf_no_live"),
+ );
+ };
+ let now = chrono::Utc::now().timestamp();
+ let age = (now - s.at).max(0);
+ let age_str = fmt_age(age);
+ let cpu_color = pct_color(s.cpu_pct);
+ let mem_pct = if s.mem_total_mb > 0 {
+ 100.0 * (s.mem_used_mb as f64) / (s.mem_total_mb as f64)
+ } else {
+ 0.0
+ };
+ let mem_color = pct_color(mem_pct);
+ let mem_used_gb = (s.mem_used_mb as f64) / 1024.0;
+ let mem_total_gb = (s.mem_total_mb as f64) / 1024.0;
+ let top_cpu = if s.top_cpu_name.is_empty() {
+ "—".to_string()
+ } else {
+ format!(
+ "{name} {pct:.0}%",
+ name = html_escape(&s.top_cpu_name),
+ pct = s.top_cpu_pct,
+ )
+ };
+ let top_mem = if s.top_mem_name.is_empty() {
+ "—".to_string()
+ } else {
+ let mb = s.top_mem_mb;
+ let mem_disp = if mb >= 1024 {
+ format!("{:.1} GB", (mb as f64) / 1024.0)
+ } else {
+ format!("{} MB", mb)
+ };
+ format!(
+ "{name} {disp}",
+ name = html_escape(&s.top_mem_name),
+ disp = html_escape(&mem_disp),
+ )
+ };
+ let uptime_str = if s.uptime_secs > 0 {
+ fmt_age(s.uptime_secs)
+ } else {
+ "—".to_string()
+ };
+
+ format!(
+ r##"
+
+
{l_now}
+ {l_age}
+
+
+
+
{l_cpu}
+
{cpu:.0}%
+
+
+
{l_mem}
+
{mem_pct:.0}%
+
{used:.1} / {total:.1} GB
+
+
+
{l_top_cpu}
+
{top_cpu}
+
+
+
{l_top_mem}
+
{top_mem}
+
+
+
{l_uptime}
+
{uptime}
+
+
+
{l_procs}
+
{procs}
+
+
+
"##,
+ l_now = t(lang, "devices.perf_now"),
+ l_age = tf1(lang, "devices.perf_sampled_ago", &age_str),
+ at_full = html_escape(&fmt_unix_utc(s.at)),
+ l_cpu = t(lang, "devices.perf_cpu"),
+ cpu_cls = cpu_color,
+ cpu = s.cpu_pct,
+ l_mem = t(lang, "devices.perf_mem"),
+ mem_cls = mem_color,
+ mem_pct = mem_pct,
+ used = mem_used_gb,
+ total = mem_total_gb,
+ l_top_cpu = t(lang, "devices.perf_top_cpu"),
+ top_cpu_raw = html_escape(&s.top_cpu_name),
+ top_cpu = top_cpu,
+ l_top_mem = t(lang, "devices.perf_top_mem"),
+ top_mem_raw = html_escape(&s.top_mem_name),
+ top_mem = top_mem,
+ l_uptime = t(lang, "devices.perf_uptime"),
+ uptime = html_escape(&uptime_str),
+ l_procs = t(lang, "devices.perf_proc_count"),
+ procs = s.proc_count,
+ )
+}
+
+/// Color-code a percentage value (0–100) — green up to 60, amber up to
+/// 85, red above. Used for the snapshot stat tiles so the supporter
+/// can spot a wedged-laptop at a glance.
+fn pct_color(pct: f64) -> &'static str {
+ if pct >= 85.0 {
+ "text-rose-400"
+ } else if pct >= 60.0 {
+ "text-amber-300"
+ } else {
+ "text-emerald-300"
+ }
+}
+
+/// Render an inline-SVG sparkline. `series` is a (unix-seconds, value)
+/// vector; `max_y` clamps the y-axis (so two side-by-side charts share
+/// a scale); `bucketed = true` downsamples by averaging into 96 buckets
+/// so the polyline string stays short for a wide time window.
+fn render_sparkline(
+ lang: Lang,
+ series: Vec<(i64, f64)>,
+ max_y: f64,
+ bucketed: bool,
+ title: &str,
+) -> String {
+ const WIDTH: f64 = 600.0;
+ const HEIGHT: f64 = 80.0;
+ const PAD: f64 = 4.0;
+
+ if series.is_empty() {
+ return format!(
+ r##"
+
{title}
+
{msg}
+
"##,
+ title = html_escape(title),
+ msg = t(lang, "devices.perf_no_chart"),
+ );
+ }
+
+ let points = if bucketed && series.len() > 96 {
+ downsample_avg(&series, 96)
+ } else {
+ series.clone()
+ };
+
+ let min_x = points.first().map(|p| p.0).unwrap_or(0);
+ let max_x = points.last().map(|p| p.0).unwrap_or(0);
+ let span_x = (max_x - min_x).max(1) as f64;
+
+ let plot_w = WIDTH - 2.0 * PAD;
+ let plot_h = HEIGHT - 2.0 * PAD;
+
+ let mut path = String::new();
+ let mut area = String::new();
+ let mut peak: f64 = 0.0;
+ let mut last: f64 = 0.0;
+ for (i, (t, v)) in points.iter().enumerate() {
+ let x = PAD + plot_w * ((t - min_x) as f64) / span_x;
+ let y_norm = (v / max_y).clamp(0.0, 1.0);
+ let y = PAD + plot_h * (1.0 - y_norm);
+ if i == 0 {
+ path.push_str(&format!("M{:.1},{:.1}", x, y));
+ area.push_str(&format!("M{:.1},{:.1}", x, PAD + plot_h));
+ area.push_str(&format!(" L{:.1},{:.1}", x, y));
+ } else {
+ path.push_str(&format!(" L{:.1},{:.1}", x, y));
+ area.push_str(&format!(" L{:.1},{:.1}", x, y));
+ }
+ peak = peak.max(*v);
+ last = *v;
+ }
+ let last_x = PAD + plot_w;
+ area.push_str(&format!(" L{:.1},{:.1} Z", last_x, PAD + plot_h));
+
+ // Hours-from-now labels: oldest point's age, "now" on the right.
+ let span_secs = (max_x - min_x).max(0);
+ let span_label = if span_secs >= 3600 {
+ format!("-{}h", span_secs / 3600)
+ } else if span_secs >= 60 {
+ format!("-{}m", span_secs / 60)
+ } else {
+ format!("-{}s", span_secs)
+ };
+
+ format!(
+ r##"
+
+
{title}
+ {l_peak} {peak:.0}% {l_now} {last:.0}%
+
+
+
+ {older}
+ {l_now_short}
+
+
"##,
+ title = html_escape(title),
+ l_peak = t(lang, "devices.perf_peak"),
+ peak = peak,
+ l_now = t(lang, "devices.perf_latest"),
+ last = last,
+ w = WIDTH,
+ h = HEIGHT,
+ pad = PAD,
+ ymid = PAD + plot_h * 0.5,
+ xend = WIDTH - PAD,
+ area = area,
+ path = path,
+ older = html_escape(&span_label),
+ l_now_short = t(lang, "devices.perf_now_short"),
+ )
+}
+
+/// Mean-pool a (timestamp, value) series down to `target` buckets,
+/// keeping the bucket-mean timestamp as the bucket's x. Empty buckets
+/// are dropped so the resulting polyline doesn't draw zero-lines for
+/// stretches where the agent was offline.
+fn downsample_avg(series: &[(i64, f64)], target: usize) -> Vec<(i64, f64)> {
+ if series.len() <= target {
+ return series.to_vec();
+ }
+ let min_x = series.first().map(|p| p.0).unwrap_or(0);
+ let max_x = series.last().map(|p| p.0).unwrap_or(0);
+ let span = (max_x - min_x).max(1);
+ let bucket_secs = (span as usize) / target.max(1);
+ let bucket_secs = bucket_secs.max(1) as i64;
+
+ let mut buckets: Vec<(i64, f64, usize)> = Vec::with_capacity(target);
+ let mut current_bucket: i64 = -1;
+ for (t, v) in series {
+ let b = (t - min_x) / bucket_secs;
+ if b != current_bucket {
+ buckets.push((*t, *v, 1));
+ current_bucket = b;
+ } else if let Some(last) = buckets.last_mut() {
+ last.1 += *v;
+ last.2 += 1;
+ }
+ }
+ buckets
+ .into_iter()
+ .map(|(t, sum, n)| (t, sum / (n as f64)))
+ .collect()
+}
+
+/// Recent perf-events table — boot/shutdown/sleep degradation, memory
+/// exhaustion, BSODs, unexpected reboots. Empty list → a neutral
+/// "nothing flagged yet" panel so the heading still has a body.
+fn render_perf_events_table(lang: Lang, events: &[PerfEventRow]) -> String {
+ if events.is_empty() {
+ return format!(
+ r##"
+ {msg}
+
"##,
+ msg = t(lang, "devices.perf_events_none"),
+ );
+ }
+ let mut s = format!(
+ r##"