agent api: adopt hello-agent top_processes, device_class, network-events
build / build-linux-amd64 (push) Successful in 2m32s
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:
@@ -1352,6 +1352,20 @@ pub fn t(lang: Lang, key: &str) -> &'static str {
|
|||||||
"Top memorie",
|
"Top memorie",
|
||||||
"Mayor memoria",
|
"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" => (
|
"devices.perf_uptime" => (
|
||||||
"Uptime",
|
"Uptime",
|
||||||
"Laufzeit",
|
"Laufzeit",
|
||||||
@@ -1481,6 +1495,76 @@ pub fn t(lang: Lang, key: &str) -> &'static str {
|
|||||||
"Deconectare",
|
"Deconectare",
|
||||||
"Cierre de sesión",
|
"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" => (
|
"devices.serial_number" => (
|
||||||
"Serial number",
|
"Serial number",
|
||||||
"Seriennummer",
|
"Seriennummer",
|
||||||
@@ -1496,6 +1580,13 @@ pub fn t(lang: Lang, key: &str) -> &'static str {
|
|||||||
"Fabricante",
|
"Fabricante",
|
||||||
),
|
),
|
||||||
"devices.model" => ("Model", "Modell", "Modèle", "Model", "Modelo"),
|
"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" => (
|
"devices.windows_domain" => (
|
||||||
"Windows domain",
|
"Windows domain",
|
||||||
"Windows-Domäne",
|
"Windows-Domäne",
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ use crate::api::admin::i18n::{t, tf1, tf2, tf3, Lang};
|
|||||||
use crate::api::error::ApiError;
|
use crate::api::error::ApiError;
|
||||||
use crate::api::middleware::AuthedUser;
|
use crate::api::middleware::AuthedUser;
|
||||||
use crate::api::state::AppState;
|
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::extract::{Extension, Form, Path, Query};
|
||||||
use axum::response::Html;
|
use axum::response::Html;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -639,6 +641,13 @@ pub async fn detail(
|
|||||||
.perf_events_for_peer(&d.id, 20)
|
.perf_events_for_peer(&d.id, 20)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.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(
|
render_detail(
|
||||||
lang,
|
lang,
|
||||||
&d,
|
&d,
|
||||||
@@ -646,6 +655,7 @@ pub async fn detail(
|
|||||||
metrics_latest.as_ref(),
|
metrics_latest.as_ref(),
|
||||||
&metrics_24h,
|
&metrics_24h,
|
||||||
&perf_events,
|
&perf_events,
|
||||||
|
&network_events,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
None => format!(
|
None => format!(
|
||||||
@@ -1017,6 +1027,15 @@ fn render_device_row(
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string();
|
.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
|
// `username` is the active console user reported by the agent's
|
||||||
// sysinfo. The agent suppresses the field when nobody is logged in
|
// sysinfo. The agent suppresses the field when nobody is logged in
|
||||||
// (or when it's literally "SYSTEM" on Windows), so an empty value
|
// (or when it's literally "SYSTEM" on Windows), so an empty value
|
||||||
@@ -1196,11 +1215,28 @@ fn render_device_row(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if cols.host {
|
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!(
|
let _ = write!(
|
||||||
s,
|
s,
|
||||||
r##"
|
r##"
|
||||||
<td class="px-3 py-2 text-slate-400">{}</td>"##,
|
<td class="px-3 py-2 text-slate-400"><div class="flex flex-col gap-0.5">{host}{badge}</div></td>"##,
|
||||||
html_escape(&hostname),
|
host = host_disp,
|
||||||
|
badge = class_badge,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if cols.serial {
|
if cols.serial {
|
||||||
@@ -1483,6 +1519,7 @@ fn render_detail(
|
|||||||
metrics_latest: Option<&MetricsSampleRow>,
|
metrics_latest: Option<&MetricsSampleRow>,
|
||||||
metrics_24h: &[MetricsSampleRow],
|
metrics_24h: &[MetricsSampleRow],
|
||||||
perf_events: &[PerfEventRow],
|
perf_events: &[PerfEventRow],
|
||||||
|
network_events: &[NetworkEventRow],
|
||||||
) -> String {
|
) -> String {
|
||||||
let parsed: serde_json::Value =
|
let parsed: serde_json::Value =
|
||||||
serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null);
|
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 login_section = render_login_events(lang, login_events);
|
||||||
let perf_section = render_performance(lang, metrics_latest, metrics_24h, perf_events);
|
let perf_section = render_performance(lang, metrics_latest, metrics_24h, perf_events);
|
||||||
|
let network_section = render_network_events(lang, network_events);
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
r##"<div class="space-y-4">
|
r##"<div class="space-y-4">
|
||||||
@@ -1593,6 +1631,8 @@ fn render_detail(
|
|||||||
{inv}
|
{inv}
|
||||||
<h3 class="text-sm font-semibold text-slate-300 mt-4">{login_history}</h3>
|
<h3 class="text-sm font-semibold text-slate-300 mt-4">{login_history}</h3>
|
||||||
{login}
|
{login}
|
||||||
|
<h3 class="text-sm font-semibold text-slate-300 mt-4">{network_history}</h3>
|
||||||
|
{network}
|
||||||
</div>"##,
|
</div>"##,
|
||||||
back = back_button(lang),
|
back = back_button(lang),
|
||||||
detail_view = t(lang, "devices.detail_view"),
|
detail_view = t(lang, "devices.detail_view"),
|
||||||
@@ -1603,6 +1643,8 @@ fn render_detail(
|
|||||||
inv = inventory_section,
|
inv = inventory_section,
|
||||||
login_history = t(lang, "devices.login_history"),
|
login_history = t(lang, "devices.login_history"),
|
||||||
login = login_section,
|
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()
|
"—".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!(
|
format!(
|
||||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||||
<div class="flex items-baseline justify-between mb-3">
|
<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>
|
<dd class="text-slate-300 tabular-nums">{procs}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
{top_lists}
|
||||||
</div>"##,
|
</div>"##,
|
||||||
|
top_lists = top_lists,
|
||||||
l_now = t(lang, "devices.perf_now"),
|
l_now = t(lang, "devices.perf_now"),
|
||||||
l_age = tf1(lang, "devices.perf_sampled_ago", &age_str),
|
l_age = tf1(lang, "devices.perf_sampled_ago", &age_str),
|
||||||
at_full = html_escape(&fmt_unix_utc(s.at)),
|
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 (0–100) — green up to 60, amber up to
|
/// 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
|
/// 85, red above. Used for the snapshot stat tiles so the supporter
|
||||||
/// can spot a wedged-laptop at a glance.
|
/// can spot a wedged-laptop at a glance.
|
||||||
@@ -2096,6 +2214,118 @@ fn render_login_events(lang: Lang, events: &[LoginEventRow]) -> String {
|
|||||||
s
|
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
|
/// 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
|
/// SQLite's `current_timestamp` produces, so all the other timestamps on
|
||||||
/// the device detail page line up visually with login-event rows.
|
/// 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}
|
{sn}
|
||||||
{mfr}
|
{mfr}
|
||||||
{model}
|
{model}
|
||||||
|
{dclass}
|
||||||
{dom}
|
{dom}
|
||||||
{os_d}
|
{os_d}
|
||||||
{os_r}
|
{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"),
|
sn = row(t(lang, "devices.serial_number"), "serial_number"),
|
||||||
mfr = row(t(lang, "devices.manufacturer"), "manufacturer"),
|
mfr = row(t(lang, "devices.manufacturer"), "manufacturer"),
|
||||||
model = row(t(lang, "devices.model"), "model"),
|
model = row(t(lang, "devices.model"), "model"),
|
||||||
|
dclass = row(t(lang, "devices.device_class"), "device_class"),
|
||||||
dom = row(t(lang, "devices.windows_domain"), "domain"),
|
dom = row(t(lang, "devices.windows_domain"), "domain"),
|
||||||
os_d = row(t(lang, "devices.os_distro"), "os_distro"),
|
os_d = row(t(lang, "devices.os_distro"), "os_distro"),
|
||||||
os_r = row(t(lang, "devices.os_release"), "os_release"),
|
os_r = row(t(lang, "devices.os_release"), "os_release"),
|
||||||
|
|||||||
@@ -59,6 +59,33 @@ pub struct MetricsSampleIn {
|
|||||||
pub top_mem_name: String,
|
pub top_mem_name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub top_mem_mb: i64,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -128,6 +155,11 @@ pub async fn metrics(
|
|||||||
// garbage-in shouldn't propagate to garbage-on-screen.
|
// garbage-in shouldn't propagate to garbage-on-screen.
|
||||||
let cpu_pct = clamp_pct(s.cpu_pct);
|
let cpu_pct = clamp_pct(s.cpu_pct);
|
||||||
let top_cpu_pct = clamp_pct(s.top_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 {
|
let row = MetricsSampleRow {
|
||||||
at: s.at,
|
at: s.at,
|
||||||
cpu_pct,
|
cpu_pct,
|
||||||
@@ -139,6 +171,8 @@ pub async fn metrics(
|
|||||||
top_cpu_pct,
|
top_cpu_pct,
|
||||||
top_mem_name: truncate(&s.top_mem_name, MAX_PROC_NAME_LEN),
|
top_mem_name: truncate(&s.top_mem_name, MAX_PROC_NAME_LEN),
|
||||||
top_mem_mb: s.top_mem_mb.max(0),
|
top_mem_mb: s.top_mem_mb.max(0),
|
||||||
|
top_cpu_procs,
|
||||||
|
top_mem_procs,
|
||||||
};
|
};
|
||||||
if let Err(e) = state
|
if let Err(e) = state
|
||||||
.db
|
.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<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
|
/// Char-aware truncate (so we don't slice mid-multibyte). The cap is
|
||||||
/// generous so process names that include arguments or Unicode survive.
|
/// generous so process names that include arguments or Unicode survive.
|
||||||
fn truncate(s: &str, max_chars: usize) -> String {
|
fn truncate(s: &str, max_chars: usize) -> String {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub mod heartbeat;
|
|||||||
pub mod http_proxy;
|
pub mod http_proxy;
|
||||||
pub mod login_event;
|
pub mod login_event;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
|
pub mod network_event;
|
||||||
pub mod perf_events;
|
pub mod perf_events;
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub mod oidc;
|
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/exec-result", post(agent_exec::exec_result))
|
||||||
.route("/api/agent/login-event", post(login_event::login_event))
|
.route("/api/agent/login-event", post(login_event::login_event))
|
||||||
.route("/api/agent/metrics", post(metrics::metrics))
|
.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/agent/perf-events", post(perf_events::perf_events))
|
||||||
.route(
|
.route(
|
||||||
"/api/unattended-password",
|
"/api/unattended-password",
|
||||||
|
|||||||
@@ -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
@@ -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_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_name: r.try_get("top_mem_name").unwrap_or_default(),
|
||||||
top_mem_mb: r.try_get("top_mem_mb").unwrap_or(0),
|
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_cpu_pct: f64,
|
||||||
pub top_mem_name: String,
|
pub top_mem_name: String,
|
||||||
pub top_mem_mb: i64,
|
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
|
/// One row from `device_perf_events`. `provider` is the event-log
|
||||||
@@ -348,6 +359,22 @@ pub struct LoginEventRow {
|
|||||||
pub received_at: i64,
|
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)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct PeerListRow {
|
pub struct PeerListRow {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -531,6 +558,12 @@ impl Database {
|
|||||||
.execute(self.pool.get().await?.deref_mut())
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
.await?;
|
.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
|
// Soft-ALTERs run after schema creation. SQLite < 3.35 lacks
|
||||||
// `ADD COLUMN IF NOT EXISTS`; swallow the duplicate-column error
|
// `ADD COLUMN IF NOT EXISTS`; swallow the duplicate-column error
|
||||||
// so re-runs are idempotent. Newly-added soft alters get appended
|
// so re-runs are idempotent. Newly-added soft alters get appended
|
||||||
@@ -3606,8 +3639,8 @@ impl Database {
|
|||||||
"insert or ignore into device_metrics_samples \
|
"insert or ignore into device_metrics_samples \
|
||||||
(peer_id, peer_uuid, at, cpu_pct, mem_used_mb, mem_total_mb, \
|
(peer_id, peer_uuid, at, cpu_pct, mem_used_mb, mem_total_mb, \
|
||||||
proc_count, uptime_secs, top_cpu_name, top_cpu_pct, \
|
proc_count, uptime_secs, top_cpu_name, top_cpu_pct, \
|
||||||
top_mem_name, top_mem_mb) \
|
top_mem_name, top_mem_mb, top_cpu_procs, top_mem_procs) \
|
||||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(peer_id)
|
.bind(peer_id)
|
||||||
.bind(peer_uuid)
|
.bind(peer_uuid)
|
||||||
@@ -3621,6 +3654,8 @@ impl Database {
|
|||||||
.bind(s.top_cpu_pct)
|
.bind(s.top_cpu_pct)
|
||||||
.bind(&s.top_mem_name)
|
.bind(&s.top_mem_name)
|
||||||
.bind(s.top_mem_mb)
|
.bind(s.top_mem_mb)
|
||||||
|
.bind(&s.top_cpu_procs)
|
||||||
|
.bind(&s.top_mem_procs)
|
||||||
.execute(self.pool.get().await?.deref_mut())
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -3657,7 +3692,8 @@ impl Database {
|
|||||||
) -> ResultType<Option<MetricsSampleRow>> {
|
) -> ResultType<Option<MetricsSampleRow>> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"select at, cpu_pct, mem_used_mb, mem_total_mb, proc_count, \
|
"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 \
|
from device_metrics_samples \
|
||||||
where peer_id = ? \
|
where peer_id = ? \
|
||||||
order by at desc limit 1",
|
order by at desc limit 1",
|
||||||
@@ -3753,6 +3789,65 @@ impl Database {
|
|||||||
})
|
})
|
||||||
.collect())
|
.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
|
/// 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
|
// endpoint PUT /api/peers/:id/managed. Never written from request body
|
||||||
// — only the server flips it.
|
// — only the server flips it.
|
||||||
"ALTER TABLE peer ADD COLUMN managed INTEGER NOT NULL DEFAULT 0",
|
"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] = &[
|
const M3_SCHEMA: &[&str] = &[
|
||||||
@@ -4257,6 +4359,34 @@ const M8_SCHEMA: &[&str] = &[
|
|||||||
ON device_perf_events(peer_id, provider, record_id)",
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use hbb_common::tokio;
|
use hbb_common::tokio;
|
||||||
|
|||||||
Reference in New Issue
Block a user