agent api: adopt hello-agent top_processes, device_class, network-events
build / build-linux-amd64 (push) Successful in 2m32s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 11:43:18 +00:00
parent 0df4ee4143
commit dc7a5dd31a
6 changed files with 709 additions and 6 deletions
+91
View File
@@ -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",
+235 -3
View File
@@ -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##"<span class="ml-0 mt-0.5 inline-block rounded border border-slate-700 bg-slate-800/40 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-slate-400">{}</span>"##,
html_escape(&device_class),
)
};
let host_disp = if hostname.is_empty() {
r##"<span class="text-slate-600">—</span>"##.to_string()
} else {
html_escape(&hostname)
};
let _ = write!(
s,
r##"
<td class="px-3 py-2 text-slate-400">{}</td>"##,
html_escape(&hostname),
<td class="px-3 py-2 text-slate-400"><div class="flex flex-col gap-0.5">{host}{badge}</div></td>"##,
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##"<div class="space-y-4">
@@ -1593,6 +1631,8 @@ fn render_detail(
{inv}
<h3 class="text-sm font-semibold text-slate-300 mt-4">{login_history}</h3>
{login}
<h3 class="text-sm font-semibold text-slate-300 mt-4">{network_history}</h3>
{network}
</div>"##,
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##"<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">{cpu}{mem}</div>"##,
cpu = top_cpu_list,
mem = top_mem_list,
)
};
format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-4">
<div class="flex items-baseline justify-between mb-3">
@@ -1751,7 +1810,9 @@ fn render_perf_snapshot(lang: Lang, latest: Option<&MetricsSampleRow>) -> String
<dd class="text-slate-300 tabular-nums">{procs}</dd>
</div>
</dl>
{top_lists}
</div>"##,
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<serde_json::Value> = 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##"<li class="flex items-baseline justify-between gap-2 py-0.5">
<span class="font-mono text-xs text-slate-300 truncate" title="{name_raw}">{name}</span>
<span class="text-xs text-slate-400 tabular-nums whitespace-nowrap">{value}</span>
</li>"##,
name_raw = html_escape(name),
name = html_escape(name),
value = html_escape(&value),
);
}
if body.is_empty() {
return String::new();
}
format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-950/40 p-3">
<h5 class="text-xs uppercase text-slate-500 mb-1">{title}</h5>
<ul class="divide-y divide-slate-800/60">{body}</ul>
</div>"##,
title = html_escape(title),
body = body,
)
}
/// Color-code a percentage value (0100) — 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##"<div class="rounded-md border border-slate-700 bg-slate-900 p-3 text-sm text-slate-400">
{msg}
</div>"##,
msg = t(lang, "devices.network_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_kind}</th>
<th class="text-left font-medium px-3 py-2">{c_iface}</th>
<th class="text-left font-medium px-3 py-2">{c_change}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">"##,
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##"<span class="text-slate-600">—</span>"##.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##"<span class="text-slate-600">—</span>"##.to_string()
} else {
format!(
r##"<span class="text-slate-400 line-through">{}</span>"##,
html_escape(&ev.old),
)
};
let new_disp = if ev.new.is_empty() {
r##"<span class="text-slate-600">—</span>"##.to_string()
} else {
format!(
r##"<span class="text-slate-200">{}</span>"##,
html_escape(&ev.new),
)
};
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}">{kind}</span></td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{iface}</td>
<td class="px-3 py-2 font-mono text-xs">{old} <span class="text-slate-600">→</span> {new}</td>
</tr>"##,
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("</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.
@@ -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"),
+77
View File
@@ -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<TopProc>,
#[serde(default)]
pub mem: Vec<TopProc>,
}
/// 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 0100, 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<serde_json::Value> = 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<serde_json::Value> = 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 {
+5
View File
@@ -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<AppState>) -> 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",
+168
View File
@@ -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": "<peer id>",
//! "uuid": "<peer 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<NetworkEventIn>,
}
/// 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<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Result<String, ApiError> {
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()
}
}
+133 -3
View File
@@ -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<Option<MetricsSampleRow>> {
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<Vec<NetworkEventRow>> {
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;