From dc7a5dd31a895f5b6dd4d8df91f7859a5e7d36fa Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Sat, 20 Jun 2026 11:43:18 +0000 Subject: [PATCH] agent api: adopt hello-agent top_processes, device_class, network-events Catch the server up to three hello-agent reporting changes that landed after the "performance monitor" work: - metrics: ingest the `top_processes` object on /api/agent/metrics and store the top-5-by-CPU / top-5-by-memory lists (bounded JSON, soft-ALTER columns on device_metrics_samples). The device detail "Live performance" card renders them as two 5-row lists; older agents that only send the single top_cpu_*/top_mem_* scalars collapse the block cleanly. - inventory: surface the agent's `inventory.device_class` form factor (Laptop/Desktop/Server/VM/...) in the Hardware tab and as a badge under the hostname in the device list. Payload was already stored whole; this only adds rendering. - network-events: add the missing POST /api/agent/network-events route + handler + device_network_events table (M9) so connectivity-change reports (public IP / LAN IPv4 / Wi-Fi SSID/BSSID) are captured instead of 404'd. Device detail page gets a Network-history table. Same signed-API gate, batching and INSERT-OR-IGNORE idempotency as the other agent endpoints. i18n keys added across all five languages. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api/admin/i18n.rs | 91 +++++++++++++ src/api/admin/pages/devices.rs | 238 ++++++++++++++++++++++++++++++++- src/api/metrics.rs | 77 +++++++++++ src/api/mod.rs | 5 + src/api/network_event.rs | 168 +++++++++++++++++++++++ src/database.rs | 136 ++++++++++++++++++- 6 files changed, 709 insertions(+), 6 deletions(-) create mode 100644 src/api/network_event.rs diff --git a/src/api/admin/i18n.rs b/src/api/admin/i18n.rs index 7b5b773..5307414 100644 --- a/src/api/admin/i18n.rs +++ b/src/api/admin/i18n.rs @@ -1352,6 +1352,20 @@ pub fn t(lang: Lang, key: &str) -> &'static str { "Top memorie", "Mayor memoria", ), + "devices.perf_top5_cpu" => ( + "Top 5 by CPU", + "Top 5 nach CPU", + "Top 5 par CPU", + "Top 5 după CPU", + "Top 5 por CPU", + ), + "devices.perf_top5_mem" => ( + "Top 5 by memory", + "Top 5 nach Speicher", + "Top 5 par mémoire", + "Top 5 după memorie", + "Top 5 por memoria", + ), "devices.perf_uptime" => ( "Uptime", "Laufzeit", @@ -1481,6 +1495,76 @@ pub fn t(lang: Lang, key: &str) -> &'static str { "Deconectare", "Cierre de sesión", ), + "devices.network_history" => ( + "Network history", + "Netzwerkverlauf", + "Historique réseau", + "Istoric rețea", + "Historial de red", + ), + "devices.network_none" => ( + "No network changes reported yet.", + "Noch keine Netzwerkänderungen gemeldet.", + "Aucun changement réseau signalé pour l'instant.", + "Nicio modificare de rețea raportată încă.", + "Aún no se han notificado cambios de red.", + ), + "devices.network_col_when" => ( + "When", + "Wann", + "Quand", + "Când", + "Cuándo", + ), + "devices.network_col_kind" => ( + "Change", + "Änderung", + "Changement", + "Modificare", + "Cambio", + ), + "devices.network_col_iface" => ( + "Interface", + "Schnittstelle", + "Interface", + "Interfață", + "Interfaz", + ), + "devices.network_col_change" => ( + "Old → New", + "Alt → Neu", + "Ancien → Nouveau", + "Vechi → Nou", + "Anterior → Nuevo", + ), + "devices.network_kind_public_ip" => ( + "Public IP", + "Öffentliche IP", + "IP publique", + "IP public", + "IP pública", + ), + "devices.network_kind_lan_ipv4" => ( + "LAN IPv4", + "LAN-IPv4", + "IPv4 LAN", + "LAN IPv4", + "IPv4 LAN", + ), + "devices.network_kind_wifi_ssid" => ( + "Wi-Fi SSID", + "WLAN-SSID", + "SSID Wi-Fi", + "SSID Wi-Fi", + "SSID Wi-Fi", + ), + "devices.network_kind_wifi_bssid" => ( + "Wi-Fi BSSID", + "WLAN-BSSID", + "BSSID Wi-Fi", + "BSSID Wi-Fi", + "BSSID Wi-Fi", + ), "devices.serial_number" => ( "Serial number", "Seriennummer", @@ -1496,6 +1580,13 @@ pub fn t(lang: Lang, key: &str) -> &'static str { "Fabricante", ), "devices.model" => ("Model", "Modell", "Modèle", "Model", "Modelo"), + "devices.device_class" => ( + "Device class", + "Geräteklasse", + "Classe d'appareil", + "Clasă dispozitiv", + "Clase de dispositivo", + ), "devices.windows_domain" => ( "Windows domain", "Windows-Domäne", diff --git a/src/api/admin/pages/devices.rs b/src/api/admin/pages/devices.rs index a1a0842..4e3d163 100644 --- a/src/api/admin/pages/devices.rs +++ b/src/api/admin/pages/devices.rs @@ -6,7 +6,9 @@ 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, MetricsSampleRow, PerfEventRow}; +use crate::database::{ + DashboardDeviceRow, LoginEventRow, MetricsSampleRow, NetworkEventRow, PerfEventRow, +}; use axum::extract::{Extension, Form, Path, Query}; use axum::response::Html; use serde::Deserialize; @@ -639,6 +641,13 @@ pub async fn detail( .perf_events_for_peer(&d.id, 20) .await .unwrap_or_default(); + // Network history — connectivity changes (public IP, LAN, Wi-Fi). + // Capped like login events; best-effort. + let network_events = state + .db + .network_events_for_peer(&d.id, 50) + .await + .unwrap_or_default(); render_detail( lang, &d, @@ -646,6 +655,7 @@ pub async fn detail( metrics_latest.as_ref(), &metrics_24h, &perf_events, + &network_events, ) } None => format!( @@ -1017,6 +1027,15 @@ fn render_device_row( .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); + // Coarse form factor (Laptop/Desktop/Server/Virtual Machine/…) reported + // in the agent's inventory blob. Surfaced as a small badge under the + // hostname; absent for vanilla rustdesk and agents that predate it. + let device_class = parsed + .get("inventory") + .and_then(|inv| inv.get("device_class")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); // `username` is the active console user reported by the agent's // sysinfo. The agent suppresses the field when nobody is logged in // (or when it's literally "SYSTEM" on Windows), so an empty value @@ -1196,11 +1215,28 @@ fn render_device_row( ); } if cols.host { + // Hostname with a muted device-class pill beneath it (when the agent + // reported one). Keeps the class visible in the list without claiming + // its own manageable column. + let class_badge = if device_class.is_empty() { + String::new() + } else { + format!( + r##"{}"##, + html_escape(&device_class), + ) + }; + let host_disp = if hostname.is_empty() { + r##""##.to_string() + } else { + html_escape(&hostname) + }; let _ = write!( s, r##" - {}"##, - html_escape(&hostname), +
{host}{badge}
"##, + host = host_disp, + badge = class_badge, ); } if cols.serial { @@ -1483,6 +1519,7 @@ fn render_detail( metrics_latest: Option<&MetricsSampleRow>, metrics_24h: &[MetricsSampleRow], perf_events: &[PerfEventRow], + network_events: &[NetworkEventRow], ) -> String { let parsed: serde_json::Value = serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null); @@ -1579,6 +1616,7 @@ fn render_detail( let login_section = render_login_events(lang, login_events); let perf_section = render_performance(lang, metrics_latest, metrics_24h, perf_events); + let network_section = render_network_events(lang, network_events); format!( r##"
@@ -1593,6 +1631,8 @@ fn render_detail( {inv}

{login_history}

{login} +

{network_history}

+ {network}
"##, back = back_button(lang), detail_view = t(lang, "devices.detail_view"), @@ -1603,6 +1643,8 @@ fn render_detail( inv = inventory_section, login_history = t(lang, "devices.login_history"), login = login_section, + network_history = t(lang, "devices.network_history"), + network = network_section, ) } @@ -1718,6 +1760,23 @@ fn render_perf_snapshot(lang: Lang, latest: Option<&MetricsSampleRow>) -> String "—".to_string() }; + // Top-5 process lists (agent's `top_processes`). Empty for older agents + // that only send the single top_cpu_*/top_mem_* scalars — in that case + // the whole two-column block collapses to nothing. + let top_cpu_list = + render_top_proc_list(t(lang, "devices.perf_top5_cpu"), &s.top_cpu_procs, "pct"); + let top_mem_list = + render_top_proc_list(t(lang, "devices.perf_top5_mem"), &s.top_mem_procs, "mb"); + let top_lists = if top_cpu_list.is_empty() && top_mem_list.is_empty() { + String::new() + } else { + format!( + r##"
{cpu}{mem}
"##, + cpu = top_cpu_list, + mem = top_mem_list, + ) + }; + format!( r##"
@@ -1751,7 +1810,9 @@ fn render_perf_snapshot(lang: Lang, latest: Option<&MetricsSampleRow>) -> String
{procs}
+ {top_lists}
"##, + top_lists = top_lists, 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)), @@ -1776,6 +1837,63 @@ fn render_perf_snapshot(lang: Lang, latest: Option<&MetricsSampleRow>) -> String ) } +/// Render one "top-5 processes" list from the compact JSON the metrics +/// endpoint stored (`[{"name":..,"pct":..}]` or `[{"name":..,"mb":..}]`). +/// `value_key` selects which numeric field to show and how to format it +/// (`"pct"` → `NN%`, `"mb"` → `NN MB` / `N.N GB`). Returns "" when the +/// JSON is empty or unparseable so the caller can collapse the block. +fn render_top_proc_list(title: &str, json: &str, value_key: &str) -> String { + if json.is_empty() { + return String::new(); + } + let rows: Vec = match serde_json::from_str(json) { + Ok(serde_json::Value::Array(a)) => a, + _ => return String::new(), + }; + if rows.is_empty() { + return String::new(); + } + let mut body = String::new(); + for r in &rows { + let name = r.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if name.is_empty() { + continue; + } + let value = if value_key == "pct" { + let pct = r.get("pct").and_then(|v| v.as_f64()).unwrap_or(0.0); + format!("{pct:.0}%") + } else { + let mb = r.get("mb").and_then(|v| v.as_i64()).unwrap_or(0); + if mb >= 1024 { + format!("{:.1} GB", (mb as f64) / 1024.0) + } else { + format!("{mb} MB") + } + }; + let _ = write!( + body, + r##"
  • + {name} + {value} +
  • "##, + name_raw = html_escape(name), + name = html_escape(name), + value = html_escape(&value), + ); + } + if body.is_empty() { + return String::new(); + } + format!( + r##"
    +
    {title}
    +
      {body}
    +
    "##, + title = html_escape(title), + body = body, + ) +} + /// 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. @@ -2096,6 +2214,118 @@ fn render_login_events(lang: Lang, events: &[LoginEventRow]) -> String { s } +/// Per-device network history: connectivity changes the agent reported +/// (public IP, per-interface LAN IPv4, Wi-Fi SSID/BSSID). Each row shows +/// the change as `old → new`. Same skew tooltip + empty-state treatment +/// as the login-events table. +fn render_network_events(lang: Lang, events: &[NetworkEventRow]) -> String { + if events.is_empty() { + return format!( + r##"
    + {msg} +
    "##, + msg = t(lang, "devices.network_none"), + ); + } + let mut s = format!( + r##"
    + + + + + + + + + + "##, + c_when = t(lang, "devices.network_col_when"), + c_kind = t(lang, "devices.network_col_kind"), + c_iface = t(lang, "devices.network_col_iface"), + c_change = t(lang, "devices.network_col_change"), + ); + for ev in events { + let when = fmt_unix_utc(ev.at); + 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() + }; + // Friendly label + badge tint per kind. Unknown kinds (a newer agent + // against this server) render the raw string in a neutral badge. + let (badge_class, kind_label) = match ev.kind.as_str() { + "public_ip" => ( + "bg-sky-900/40 text-sky-300 border-sky-800", + t(lang, "devices.network_kind_public_ip"), + ), + "lan_ipv4" => ( + "bg-indigo-900/40 text-indigo-300 border-indigo-800", + t(lang, "devices.network_kind_lan_ipv4"), + ), + "wifi_ssid" => ( + "bg-violet-900/40 text-violet-300 border-violet-800", + t(lang, "devices.network_kind_wifi_ssid"), + ), + "wifi_bssid" => ( + "bg-fuchsia-900/40 text-fuchsia-300 border-fuchsia-800", + t(lang, "devices.network_kind_wifi_bssid"), + ), + _ => ("bg-slate-800 text-slate-300 border-slate-700", ""), + }; + let kind_text = if kind_label.is_empty() { + html_escape(&ev.kind) + } else { + kind_label.to_string() + }; + let iface = if ev.iface.is_empty() { + r##""##.to_string() + } else { + html_escape(&ev.iface) + }; + // old → new. An empty `old` is the first-ever observation; show a + // muted placeholder rather than an empty cell. + let old_disp = if ev.old.is_empty() { + r##""##.to_string() + } else { + format!( + r##"{}"##, + html_escape(&ev.old), + ) + }; + let new_disp = if ev.new.is_empty() { + r##""##.to_string() + } else { + format!( + r##"{}"##, + html_escape(&ev.new), + ) + }; + let _ = write!( + s, + r##" + + + + +"##, + when_attr = when_attr, + when = html_escape(&when), + bc = badge_class, + kind = kind_text, + iface = iface, + old = old_disp, + new = new_disp, + ); + } + s.push_str("
    {c_when}{c_kind}{c_iface}{c_change}
    {when}{kind}{iface}{old} {new}
    "); + 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. @@ -2192,6 +2422,7 @@ fn render_inventory_table(lang: Lang, inv: &serde_json::Value) -> String { {sn} {mfr} {model} + {dclass} {dom} {os_d} {os_r} @@ -2227,6 +2458,7 @@ fn render_inventory_table(lang: Lang, inv: &serde_json::Value) -> String { sn = row(t(lang, "devices.serial_number"), "serial_number"), mfr = row(t(lang, "devices.manufacturer"), "manufacturer"), model = row(t(lang, "devices.model"), "model"), + dclass = row(t(lang, "devices.device_class"), "device_class"), dom = row(t(lang, "devices.windows_domain"), "domain"), os_d = row(t(lang, "devices.os_distro"), "os_distro"), os_r = row(t(lang, "devices.os_release"), "os_release"), diff --git a/src/api/metrics.rs b/src/api/metrics.rs index 594a09a..b3d2364 100644 --- a/src/api/metrics.rs +++ b/src/api/metrics.rs @@ -59,6 +59,33 @@ pub struct MetricsSampleIn { pub top_mem_name: String, #[serde(default)] pub top_mem_mb: i64, + /// Top-5 processes by CPU and by memory. Optional — agents that predate + /// the field omit it and we fall back to the single top_*_name/top_* + /// scalars above. Stored verbatim (re-serialised, bounded) so the admin + /// UI can render the 5-row lists without a separate table. + #[serde(default)] + pub top_processes: TopProcs, +} + +/// `top_processes` object: `{ "cpu": [{name,pct}], "mem": [{name,mb}] }`. +#[derive(Debug, Default, Deserialize)] +pub struct TopProcs { + #[serde(default)] + pub cpu: Vec, + #[serde(default)] + pub mem: Vec, +} + +/// One row in a `top_processes` list. `pct` is set for the CPU list, `mb` +/// for the memory list; the unused one stays at its default. +#[derive(Debug, Default, Deserialize)] +pub struct TopProc { + #[serde(default)] + pub name: String, + #[serde(default)] + pub pct: f64, + #[serde(default)] + pub mb: i64, } #[derive(Debug, Deserialize)] @@ -128,6 +155,11 @@ pub async fn metrics( // garbage-in shouldn't propagate to garbage-on-screen. let cpu_pct = clamp_pct(s.cpu_pct); let top_cpu_pct = clamp_pct(s.top_cpu_pct); + // Re-serialise the top-5 lists into the compact JSON we store. Cap at + // 5 rows, truncate names, and clamp the numbers — same defensive + // posture as the scalar columns, since this is public-API input. + let top_cpu_procs = encode_top_cpu(&s.top_processes.cpu); + let top_mem_procs = encode_top_mem(&s.top_processes.mem); let row = MetricsSampleRow { at: s.at, cpu_pct, @@ -139,6 +171,8 @@ pub async fn metrics( top_cpu_pct, top_mem_name: truncate(&s.top_mem_name, MAX_PROC_NAME_LEN), top_mem_mb: s.top_mem_mb.max(0), + top_cpu_procs, + top_mem_procs, }; if let Err(e) = state .db @@ -172,6 +206,49 @@ fn clamp_pct(v: f64) -> f64 { } } +/// How many process rows we keep per list. The agent already sends 5; +/// we re-clamp here so a future/edited agent can't balloon the stored row. +const TOP_PROC_KEEP: usize = 5; + +/// Compact JSON for the top-CPU list: `[{"name":..,"pct":..}]`. Names are +/// truncated, percentages clamped to 0–100, list capped at `TOP_PROC_KEEP`. +/// Returns "" for an empty list so old agents store nothing. +fn encode_top_cpu(procs: &[TopProc]) -> String { + if procs.is_empty() { + return String::new(); + } + let rows: Vec = procs + .iter() + .take(TOP_PROC_KEEP) + .map(|p| { + serde_json::json!({ + "name": truncate(&p.name, MAX_PROC_NAME_LEN), + "pct": clamp_pct(p.pct), + }) + }) + .collect(); + serde_json::to_string(&rows).unwrap_or_default() +} + +/// Compact JSON for the top-memory list: `[{"name":..,"mb":..}]`. Same +/// bounds as [`encode_top_cpu`]; memory is clamped non-negative. +fn encode_top_mem(procs: &[TopProc]) -> String { + if procs.is_empty() { + return String::new(); + } + let rows: Vec = procs + .iter() + .take(TOP_PROC_KEEP) + .map(|p| { + serde_json::json!({ + "name": truncate(&p.name, MAX_PROC_NAME_LEN), + "mb": p.mb.max(0), + }) + }) + .collect(); + serde_json::to_string(&rows).unwrap_or_default() +} + /// Char-aware truncate (so we don't slice mid-multibyte). The cap is /// generous so process names that include arguments or Unicode survive. fn truncate(s: &str, max_chars: usize) -> String { diff --git a/src/api/mod.rs b/src/api/mod.rs index ce8763c..0910990 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -17,6 +17,7 @@ pub mod heartbeat; pub mod http_proxy; pub mod login_event; pub mod metrics; +pub mod network_event; pub mod perf_events; pub mod middleware; pub mod oidc; @@ -56,6 +57,10 @@ pub fn router(state: Arc) -> Router { .route("/api/agent/exec-result", post(agent_exec::exec_result)) .route("/api/agent/login-event", post(login_event::login_event)) .route("/api/agent/metrics", post(metrics::metrics)) + .route( + "/api/agent/network-events", + post(network_event::network_event), + ) .route("/api/agent/perf-events", post(perf_events::perf_events)) .route( "/api/unattended-password", diff --git a/src/api/network_event.rs b/src/api/network_event.rs new file mode 100644 index 0000000..5f6a1fe --- /dev/null +++ b/src/api/network_event.rs @@ -0,0 +1,168 @@ +//! `POST /api/agent/network-events` — agent-side reporting of connectivity +//! changes observed on the controlled machine: public-IP changes, +//! per-interface LAN IPv4 changes, and Wi-Fi SSID/BSSID roams. The agent +//! diffs its own snapshot and posts only the deltas (`old` → `new`), so a +//! quiet machine sends nothing. Surfaces a per-device network history on +//! the admin Devices detail page. +//! +//! Auth: same per-peer signed-API gate as `/api/agent/login-event` / +//! `/api/sysinfo` — see [`crate::api::device_auth`]. Stock RustDesk never +//! posts here; in practice every caller is a managed agent. The +//! `LegacyUnsigned → enforce_managed_for_id` branch is kept for symmetry. +//! +//! Body shape (events batched so an agent that was offline can catch up +//! on reconnect): +//! +//! ```json +//! { +//! "id": "", +//! "uuid": "", +//! "events": [ +//! { +//! "at": 1717920000, +//! "kind": "public_ip", // or wifi_ssid / wifi_bssid / lan_ipv4 +//! "iface": "Wi-Fi", // set for lan_ipv4, "" otherwise +//! "old": "1.2.3.4", // "" on the first-ever observation +//! "new": "5.6.7.8" +//! } +//! ] +//! } +//! ``` +//! +//! Response: `"OK"` on success, `"ID_NOT_FOUND"` for an unregistered peer +//! (same shape as the other agent endpoints so the agent reuses one retry +//! helper). + +use crate::api::device_auth::{self, AuthOutcome}; +use crate::api::error::ApiError; +use crate::api::state::AppState; +use axum::body::Bytes; +use axum::extract::Extension; +use axum::http::HeaderMap; +use serde::Deserialize; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct NetworkEventIn { + pub at: i64, + pub kind: String, + #[serde(default)] + pub iface: String, + #[serde(default)] + pub old: String, + #[serde(default)] + pub new: String, +} + +#[derive(Debug, Deserialize)] +pub struct NetworkEventBody { + pub id: String, + pub uuid: String, + pub events: Vec, +} + +/// Cap per-request to bound DB cost from a misbehaving / catching-up agent. +const MAX_EVENTS_PER_POST: usize = 256; +/// Interface label is short ("Wi-Fi", "Ethernet 2"); the value strings hold +/// a comma-joined IP list or an IP/SSID/BSSID. Generous but bounded. +const MAX_IFACE_LEN: usize = 128; +const MAX_VALUE_LEN: usize = 512; +const MAX_KIND_LEN: usize = 32; + +pub async fn network_event( + Extension(state): Extension>, + headers: HeaderMap, + body: Bytes, +) -> Result { + let outcome = + device_auth::verify(&state, "POST", "/api/agent/network-events", &headers, &body).await?; + + let payload: NetworkEventBody = serde_json::from_slice(&body) + .map_err(|_| ApiError::BadRequest("invalid json".into()))?; + + if payload.id.is_empty() || payload.uuid.is_empty() { + return Err(ApiError::BadRequest("id and uuid are required".into())); + } + if payload.events.is_empty() { + return Ok("OK".to_string()); + } + if payload.events.len() > MAX_EVENTS_PER_POST { + return Err(ApiError::BadRequest(format!( + "too many events in one POST (max {MAX_EVENTS_PER_POST})" + ))); + } + + // Bind the trusted identity to the body. Same rule as the other agent + // endpoints: signed → header id must equal body id; unsigned → peer + // must not be `managed=1`. + let id = match outcome { + AuthOutcome::Verified { id: signed_id } => { + if payload.id != signed_id { + return Err(ApiError::Unauthorized); + } + signed_id + } + AuthOutcome::LegacyUnsigned => { + device_auth::enforce_managed_for_id(&state, &payload.id).await?; + payload.id.clone() + } + }; + + let peer = state + .db + .get_peer(&id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + if peer.is_none() { + // Same shape as the other agent endpoints — the agent treats this as + // "retry later, rendezvous hasn't registered me yet". + return Ok("ID_NOT_FOUND".to_string()); + } + + let mut accepted = 0usize; + for ev in &payload.events { + let kind = ev.kind.trim(); + if kind.is_empty() { + continue; + } + // Unknown kinds are accepted (not 400'd) so a newer agent can post a + // mixed batch against an older server without losing rows; the + // renderer shows the kind verbatim. + if let Err(e) = state + .db + .network_event_insert( + &id, + &payload.uuid, + ev.at, + &truncate(kind, MAX_KIND_LEN), + &truncate(ev.iface.trim(), MAX_IFACE_LEN), + &truncate(ev.old.trim(), MAX_VALUE_LEN), + &truncate(ev.new.trim(), MAX_VALUE_LEN), + ) + .await + { + // Don't fail the whole batch on a single insert error — the + // agent's retry loop resends what didn't land. + hbb_common::log::warn!("network_event_insert for peer {} failed: {}", id, e); + continue; + } + accepted += 1; + } + + hbb_common::log::debug!( + "network-event: peer={} accepted={}/{}", + id, + accepted, + payload.events.len() + ); + Ok("OK".to_string()) +} + +/// Char-aware truncate (so we don't slice mid-multibyte). +fn truncate(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + s.to_string() + } else { + s.chars().take(max_chars).collect() + } +} diff --git a/src/database.rs b/src/database.rs index 61a2d86..017d3e3 100644 --- a/src/database.rs +++ b/src/database.rs @@ -273,6 +273,10 @@ fn metrics_sample_row_from(r: sqlx::sqlite::SqliteRow) -> MetricsSampleRow { top_cpu_pct: r.try_get("top_cpu_pct").unwrap_or(0.0), top_mem_name: r.try_get("top_mem_name").unwrap_or_default(), top_mem_mb: r.try_get("top_mem_mb").unwrap_or(0), + // Absent from the sparkline SELECT (and from pre-`top_processes` + // rows); `try_get` on a missing column falls back to "". + top_cpu_procs: r.try_get("top_cpu_procs").unwrap_or_default(), + top_mem_procs: r.try_get("top_mem_procs").unwrap_or_default(), } } @@ -317,6 +321,13 @@ pub struct MetricsSampleRow { pub top_cpu_pct: f64, pub top_mem_name: String, pub top_mem_mb: i64, + /// Top-5 processes by CPU / by memory, stored verbatim as the compact + /// JSON the agent sent (`[{"name":..,"pct":..}]` / `[{"name":..,"mb":..}]`). + /// Only populated on the *latest* sample read (the snapshot card renders + /// them); the sparkline range query leaves these empty to keep rows small. + /// Empty string when the agent predates the `top_processes` field. + pub top_cpu_procs: String, + pub top_mem_procs: String, } /// One row from `device_perf_events`. `provider` is the event-log @@ -348,6 +359,22 @@ pub struct LoginEventRow { pub received_at: i64, } +/// One agent-reported connectivity-change event, surfaced on the device +/// detail page. `at` is when the agent observed the change (unix seconds); +/// `received_at` is when the row landed. `kind` is `public_ip`, +/// `wifi_ssid`, `wifi_bssid`, or `lan_ipv4` (unknown kinds rendered +/// verbatim). `iface` is the adapter name for `lan_ipv4`, "" otherwise. +/// `old`/`new` are the before/after values (`old` empty on first sight). +#[derive(Debug, Clone, Default)] +pub struct NetworkEventRow { + pub at: i64, + pub kind: String, + pub iface: String, + pub old: String, + pub new: String, + pub received_at: i64, +} + #[derive(Debug, Clone, Default)] pub struct PeerListRow { pub id: String, @@ -531,6 +558,12 @@ impl Database { .execute(self.pool.get().await?.deref_mut()) .await?; } + // M9 schema: agent-reported connectivity-change events. + for stmt in M9_SCHEMA { + sqlx::query(stmt) + .execute(self.pool.get().await?.deref_mut()) + .await?; + } // Soft-ALTERs run after schema creation. SQLite < 3.35 lacks // `ADD COLUMN IF NOT EXISTS`; swallow the duplicate-column error // so re-runs are idempotent. Newly-added soft alters get appended @@ -3606,8 +3639,8 @@ impl Database { "insert or ignore into device_metrics_samples \ (peer_id, peer_uuid, at, cpu_pct, mem_used_mb, mem_total_mb, \ proc_count, uptime_secs, top_cpu_name, top_cpu_pct, \ - top_mem_name, top_mem_mb) \ - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + top_mem_name, top_mem_mb, top_cpu_procs, top_mem_procs) \ + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(peer_id) .bind(peer_uuid) @@ -3621,6 +3654,8 @@ impl Database { .bind(s.top_cpu_pct) .bind(&s.top_mem_name) .bind(s.top_mem_mb) + .bind(&s.top_cpu_procs) + .bind(&s.top_mem_procs) .execute(self.pool.get().await?.deref_mut()) .await?; Ok(()) @@ -3657,7 +3692,8 @@ impl Database { ) -> ResultType> { let row = sqlx::query( "select at, cpu_pct, mem_used_mb, mem_total_mb, proc_count, \ - uptime_secs, top_cpu_name, top_cpu_pct, top_mem_name, top_mem_mb \ + uptime_secs, top_cpu_name, top_cpu_pct, top_mem_name, top_mem_mb, \ + top_cpu_procs, top_mem_procs \ from device_metrics_samples \ where peer_id = ? \ order by at desc limit 1", @@ -3753,6 +3789,65 @@ impl Database { }) .collect()) } + + // ───────────────────── device_network_events (M9) ────────────────────── + // + // Connectivity-change events reported by the agent. INSERT OR IGNORE + // keeps the on-the-wire retry path idempotent. + + #[allow(clippy::too_many_arguments)] + pub async fn network_event_insert( + &self, + peer_id: &str, + peer_uuid: &str, + at: i64, + kind: &str, + iface: &str, + old_value: &str, + new_value: &str, + ) -> ResultType<()> { + sqlx::query( + "insert or ignore into device_network_events \ + (peer_id, peer_uuid, at, kind, iface, old_value, new_value) \ + values (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(peer_id) + .bind(peer_uuid) + .bind(at) + .bind(kind) + .bind(iface) + .bind(old_value) + .bind(new_value) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(()) + } + + pub async fn network_events_for_peer( + &self, + peer_id: &str, + limit: i64, + ) -> ResultType> { + let rows = sqlx::query( + "select at, kind, iface, old_value, new_value, received_at \ + from device_network_events where peer_id = ? order by at desc, id desc limit ?", + ) + .bind(peer_id) + .bind(limit) + .fetch_all(self.pool.get().await?.deref_mut()) + .await?; + Ok(rows + .into_iter() + .map(|r| NetworkEventRow { + at: r.try_get("at").unwrap_or(0), + kind: r.try_get("kind").unwrap_or_default(), + iface: r.try_get("iface").unwrap_or_default(), + old: r.try_get("old_value").unwrap_or_default(), + new: r.try_get("new_value").unwrap_or_default(), + received_at: r.try_get("received_at").unwrap_or(0), + }) + .collect()) + } } /// Timing-safe equality for hash comparisons. Slightly paranoid given the @@ -3981,6 +4076,13 @@ const M2_SOFT_ALTERS: &[&str] = &[ // endpoint PUT /api/peers/:id/managed. Never written from request body // — only the server flips it. "ALTER TABLE peer ADD COLUMN managed INTEGER NOT NULL DEFAULT 0", + // Top-5 processes by CPU / by memory on a metrics sample, stored as the + // compact JSON the agent's `top_processes` field carries. Added after + // M8 shipped `device_metrics_samples`; agents that predate the field + // simply leave these empty and the snapshot card falls back to the + // single top_cpu_*/top_mem_* scalars. + "ALTER TABLE device_metrics_samples ADD COLUMN top_cpu_procs TEXT NOT NULL DEFAULT ''", + "ALTER TABLE device_metrics_samples ADD COLUMN top_mem_procs TEXT NOT NULL DEFAULT ''", ]; const M3_SCHEMA: &[&str] = &[ @@ -4257,6 +4359,34 @@ const M8_SCHEMA: &[&str] = &[ ON device_perf_events(peer_id, provider, record_id)", ]; +/// M9: agent-reported connectivity-change events. The agent diffs its own +/// network snapshot (public IP, per-interface LAN IPv4, Wi-Fi SSID/BSSID) +/// and posts only the deltas, so rows are sparse — a roaming laptop might +/// produce a handful a day, a deskbound machine almost none. Same +/// UNIQUE-INDEX + `INSERT OR IGNORE` idempotency as the M8 tables so a +/// transport retry or a restart that loses the in-memory queue doesn't +/// pile up duplicates. Retention isn't enforced server-side yet. +const M9_SCHEMA: &[&str] = &[ + "CREATE TABLE IF NOT EXISTS device_network_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + peer_id TEXT NOT NULL, + peer_uuid TEXT NOT NULL, + at INTEGER NOT NULL, + kind TEXT NOT NULL, + iface TEXT NOT NULL DEFAULT '', + old_value TEXT NOT NULL DEFAULT '', + new_value TEXT NOT NULL DEFAULT '', + received_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) + )", + "CREATE INDEX IF NOT EXISTS idx_dne_peer_at \ + ON device_network_events(peer_id, at DESC)", + // A genuine change at the same second on the same (kind, iface) is the + // same event; the agent re-emits it after a restart that re-reads the + // baseline, and this + INSERT OR IGNORE makes that a no-op. + "CREATE UNIQUE INDEX IF NOT EXISTS uq_dne \ + ON device_network_events(peer_id, at, kind, iface, new_value)", +]; + #[cfg(test)] mod tests { use hbb_common::tokio;