"##,
+ 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}
+
{c_kind}
+
{c_iface}
+
{c_change}
+
+
+ "##,
+ 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}
+
{kind}
+
{iface}
+
{old} → {new}
+
"##,
+ 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("
");
+ 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