//! Devices page — list devices currently / recently registered, with //! force-disconnect (queues a `heartbeat_commands` row consumed on the //! peer's next /api/heartbeat tick) and force-sysinfo refresh. 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 axum::extract::{Extension, Form, Path, Query}; use axum::response::Html; use serde::Deserialize; use std::collections::HashSet; use std::fmt::Write as _; use std::sync::Arc; // ---------- pagination / search prefs ---------- const PAGE_SIZE_DEFAULT: i64 = 100; const PAGE_SIZE_OPTIONS: &[i64] = &[25, 50, 100, 500]; const DEVICE_PAGE_SIZE_KEY: &str = "devices.page_size"; const DEVICE_COLS_HIDE_KEY: &str = "devices.cols_hide"; /// Devices that have heartbeated within this many seconds are considered /// online. Clients heartbeat every 15s (hbb_common::config::REG_INTERVAL), /// so 45s allows up to two missed beats before we flip the dot to red. const ONLINE_THRESHOLD_SECS: i64 = 45; #[derive(Debug, Deserialize, Default)] pub struct DevicesListParams { #[serde(default)] pub page: Option, #[serde(default)] pub q: Option, } impl DevicesListParams { fn page_or_first(&self) -> i64 { self.page.unwrap_or(1).max(1) } fn query(&self) -> &str { match &self.q { Some(s) => s.trim(), None => "", } } } /// Per-user column visibility for the devices table. The peer-id cell /// and actions menu aren't user-toggleable, so they're absent here. #[derive(Clone, Copy, Debug)] pub struct DeviceColumns { pub owner: bool, pub host: bool, pub serial: bool, pub user: bool, pub unattended_pwd: bool, pub os: bool, pub version: bool, pub last_heartbeat: bool, pub conns: bool, pub auth: bool, } impl Default for DeviceColumns { fn default() -> Self { Self { owner: true, host: true, serial: true, user: true, unattended_pwd: true, os: true, version: true, last_heartbeat: true, conns: true, auth: true, } } } impl DeviceColumns { fn from_hidden_csv(raw: &str) -> Self { let hidden: HashSet<&str> = raw .split(',') .map(str::trim) .filter(|s| !s.is_empty()) .collect(); Self { owner: !hidden.contains("owner"), host: !hidden.contains("host"), serial: !hidden.contains("serial"), user: !hidden.contains("user"), unattended_pwd: !hidden.contains("unattended_pwd"), os: !hidden.contains("os"), version: !hidden.contains("version"), last_heartbeat: !hidden.contains("last_heartbeat"), conns: !hidden.contains("conns"), auth: !hidden.contains("auth"), } } fn to_hidden_csv(self) -> String { let mut hidden: Vec<&str> = Vec::new(); if !self.owner { hidden.push("owner"); } if !self.host { hidden.push("host"); } if !self.serial { hidden.push("serial"); } if !self.user { hidden.push("user"); } if !self.unattended_pwd { hidden.push("unattended_pwd"); } if !self.os { hidden.push("os"); } if !self.version { hidden.push("version"); } if !self.last_heartbeat { hidden.push("last_heartbeat"); } if !self.conns { hidden.push("conns"); } if !self.auth { hidden.push("auth"); } hidden.join(",") } fn visible_count(self) -> i64 { self.owner as i64 + self.host as i64 + self.serial as i64 + self.user as i64 + self.unattended_pwd as i64 + self.os as i64 + self.version as i64 + self.last_heartbeat as i64 + self.conns as i64 + self.auth as i64 } } async fn load_device_columns(state: &Arc, user_id: i64) -> DeviceColumns { match state.db.user_pref_get(user_id, DEVICE_COLS_HIDE_KEY).await { Ok(Some(raw)) => DeviceColumns::from_hidden_csv(&raw), _ => DeviceColumns::default(), } } async fn load_device_page_size(state: &Arc, user_id: i64) -> i64 { let raw = state .db .user_pref_get(user_id, DEVICE_PAGE_SIZE_KEY) .await .ok() .flatten() .and_then(|s| s.parse::().ok()); match raw { Some(v) if PAGE_SIZE_OPTIONS.contains(&v) => v, _ => PAGE_SIZE_DEFAULT, } } pub async fn index( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; let cols = load_device_columns(&state, admin.user_id).await; let page_size = load_device_page_size(&state, admin.user_id).await; let page = pg.page_or_first(); let q = pg.query(); let table = render_table(&state, lang, cols, q, page, page_size).await?; let columns_popover = render_columns_popover(lang, cols); Ok(Html(format!( r##"

{heading}

{tagline}

{columns_popover}
{table}
"##, heading = t(lang, "devices.heading"), tagline = t(lang, "devices.tagline"), search_placeholder = t(lang, "devices.search_placeholder"), q_value = html_escape(q), columns_popover = columns_popover, table = table, ))) } pub async fn force_disconnect( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Path(peer_id): Path, Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; let cols = load_device_columns(&state, admin.user_id).await; let page_size = load_device_page_size(&state, admin.user_id).await; let conns = state .db .device_sysinfo_get_conns(&peer_id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; state .db .heartbeat_command_queue(&peer_id, "disconnect", Some(&conns)) .await .map_err(|e| ApiError::Internal(e.to_string()))?; notice_then_table( &state, lang, cols, pg.query(), pg.page_or_first(), page_size, "ok", &tf2(lang, "devices.queued_disconnect", &peer_id, &conns), ) .await } pub async fn force_sysinfo( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Path(peer_id): Path, Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; let cols = load_device_columns(&state, admin.user_id).await; let page_size = load_device_page_size(&state, admin.user_id).await; state .db .heartbeat_command_queue(&peer_id, "sysinfo", None) .await .map_err(|e| ApiError::Internal(e.to_string()))?; notice_then_table( &state, lang, cols, pg.query(), pg.page_or_first(), page_size, "ok", &tf1(lang, "devices.queued_sysinfo", &peer_id), ) .await } pub async fn delete( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Path(peer_id): Path, Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; let cols = load_device_columns(&state, admin.user_id).await; let page_size = load_device_page_size(&state, admin.user_id).await; let ok = state .db .device_delete(&peer_id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let msg = if ok { tf1(lang, "devices.deleted", &peer_id) } else { t(lang, "devices.already_gone").to_string() }; notice_then_table( &state, lang, cols, pg.query(), pg.page_or_first(), page_size, if ok { "ok" } else { "error" }, &msg, ) .await } /// Flip `peer.managed` between 0 and 1. Same effect as calling the JSON /// API `PUT /api/peers/:id/managed`, but rendered as an HTMX action so the /// table refreshes in place. The handler reads the current value, flips /// it, and writes back — this avoids a stale-toggle race where the row /// the admin clicked on showed a stale state (e.g. TOFU just promoted it /// in the background) and a "set to N" command would no-op silently. pub async fn toggle_managed( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Path(peer_id): Path, Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; let cols = load_device_columns(&state, admin.user_id).await; let page_size = load_device_page_size(&state, admin.user_id).await; let q = pg.query(); let page = pg.page_or_first(); let row = state .db .peer_get_auth(&peer_id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let (_pk, was_managed) = match row { Some(r) => r, None => { return notice_then_table( &state, lang, cols, q, page, page_size, "error", &tf1(lang, "devices.managed_no_peer", &peer_id), ) .await; } }; let new_value = !was_managed; state .db .peer_set_managed(&peer_id, new_value) .await .map_err(|e| ApiError::Internal(e.to_string()))?; hbb_common::log::info!( "admin {} set peer {} managed={} via dashboard", admin.name, peer_id, new_value ); let key = if new_value { "devices.managed_now_on" } else { "devices.managed_now_off" }; notice_then_table( &state, lang, cols, q, page, page_size, "ok", &tf1(lang, key, &peer_id), ) .await } // ---------- column visibility / page size POST handlers ---------- #[derive(Debug, Deserialize)] pub struct ColumnsForm { pub col: String, pub visible: String, #[serde(default)] pub q: Option, } pub async fn set_columns( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Form(form): Form, ) -> Result, ApiError> { require_admin(&admin)?; let mut cols = load_device_columns(&state, admin.user_id).await; let visible = form.visible == "1" || form.visible.eq_ignore_ascii_case("true"); match form.col.as_str() { "owner" => cols.owner = visible, "host" => cols.host = visible, "serial" => cols.serial = visible, "user" => cols.user = visible, "unattended_pwd" => cols.unattended_pwd = visible, "os" => cols.os = visible, "version" => cols.version = visible, "last_heartbeat" => cols.last_heartbeat = visible, "conns" => cols.conns = visible, "auth" => cols.auth = visible, _ => {} } state .db .user_pref_set(admin.user_id, DEVICE_COLS_HIDE_KEY, &cols.to_hidden_csv()) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let page_size = load_device_page_size(&state, admin.user_id).await; let q = form.q.as_deref().unwrap_or("").trim(); Ok(Html( render_table(&state, lang, cols, q, 1, page_size).await?, )) } #[derive(Debug, Deserialize)] pub struct PageSizeForm { pub size: i64, #[serde(default)] pub q: Option, } pub async fn set_page_size( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Form(form): Form, ) -> Result, ApiError> { require_admin(&admin)?; if !PAGE_SIZE_OPTIONS.contains(&form.size) { return Err(ApiError::BadRequest("invalid page size".into())); } state .db .user_pref_set( admin.user_id, DEVICE_PAGE_SIZE_KEY, &form.size.to_string(), ) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let cols = load_device_columns(&state, admin.user_id).await; let q = form.q.as_deref().unwrap_or("").trim(); Ok(Html( render_table(&state, lang, cols, q, 1, form.size).await?, )) } /// Per-device detail page: hardware / OS inventory reported by hello-agent /// alongside the standard sysinfo (CPU/RAM/OS/hostname). Replaces the /// devices list in `#devices-region` via HTMX; a "Back to devices" button /// re-fetches the table. Vanilla rustdesk clients don't report inventory, /// so for those we surface a hint pointing the operator at hello-agent. pub async fn detail( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Path(peer_id): Path, ) -> Result, ApiError> { require_admin(&admin)?; let row = state .db .device_get_by_id(&peer_id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let html = match row { Some(d) => { // Login events are surfaced on the detail page below the // inventory. We cap at 50 — that's a few weeks of typical // logon/logoff activity on a single-user box and stays // skimmable. Deeper history can be added later behind a // pagination control if anyone asks for it. let events = state .db .login_events_for_peer(&d.id, 50) .await .unwrap_or_default(); // Performance: pull the most recent metrics sample for the // "right now" card, plus 24 h of samples for the sparkline, // plus the most recent perf events (boot/shutdown/memory- // exhaustion etc.) for the "recent slow events" table. // All three are best-effort — none of them is required for // the detail page to render meaningfully. let metrics_latest = state .db .metrics_latest(&d.id) .await .unwrap_or_default(); let since_24h = chrono::Utc::now().timestamp() - 24 * 3600; let metrics_24h = state .db .metrics_samples_since(&d.id, since_24h) .await .unwrap_or_default(); let perf_events = state .db .perf_events_for_peer(&d.id, 20) .await .unwrap_or_default(); render_detail( lang, &d, &events, metrics_latest.as_ref(), &metrics_24h, &perf_events, ) } None => format!( r##"
{back}
{no_device} {id} {in_dashboard}
"##, back = back_button(lang), no_device = t(lang, "devices.no_device_with_id"), in_dashboard = t(lang, "devices.in_dashboard"), id = html_escape(&peer_id), ), }; Ok(Html(html)) } // ---------- helpers ---------- /// Compute online/offline state from a SQLite `current_timestamp` string /// ("YYYY-MM-DD HH:MM:SS" in UTC). Returns `(is_online, age_seconds)` — /// the age is also useful for the tooltip text. On parse failure we fall /// back to "offline" with `i64::MAX`, which is the safe direction (better /// to show a stale row as offline than fake online). fn online_state(last_heartbeat_at: &str, now: chrono::DateTime) -> (bool, i64) { let parsed = chrono::NaiveDateTime::parse_from_str(last_heartbeat_at, "%Y-%m-%d %H:%M:%S"); match parsed { Ok(naive) => { let last = chrono::DateTime::::from_naive_utc_and_offset(naive, chrono::Utc); let age = (now - last).num_seconds().max(0); (age <= ONLINE_THRESHOLD_SECS, age) } Err(_) => (false, i64::MAX), } } async fn render_table( state: &Arc, lang: Lang, cols: DeviceColumns, q: &str, page: i64, page_size: i64, ) -> Result { // Probe the filtered total so we can clamp the requested page before // doing the offset query (e.g. asking for page 5 after a delete that // left only 1 page should land on page 1, not show an empty list). let (total, _) = state .db .devices_list_filtered(q, 0, 0) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let total_pages = if total == 0 { 1 } else { (total + page_size - 1) / page_size }; let page = page.clamp(1, total_pages); let offset = (page - 1) * page_size; let (_, devices) = state .db .devices_list_filtered(q, offset, page_size) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let now = chrono::Utc::now(); let mut s = String::new(); // `overflow-x-auto` on the inner wrapper gives the wide table a // horizontal scrollbar instead of pushing past the viewport. The // action-menu popover compensates for the implicit `overflow-y: auto` // (CSS spec) by switching to `position: fixed` on toggle — see // `actionMenuToggle` in admin_ui/index.html. let _ = write!( s, r##"
"##, c_peer = t(lang, "devices.col_peer_id"), ); if cols.owner { let _ = write!(s, r##" "##, t(lang, "devices.col_owner")); } if cols.host { let _ = write!(s, r##" "##, t(lang, "devices.col_hostname")); } if cols.serial { let _ = write!(s, r##" "##, t(lang, "devices.col_serial")); } if cols.user { let _ = write!(s, r##" "##, t(lang, "devices.col_user")); } if cols.unattended_pwd { let _ = write!(s, r##" "##, t(lang, "devices.col_unattended_pwd")); } if cols.os { let _ = write!(s, r##" "##, t(lang, "devices.col_os")); } if cols.version { let _ = write!(s, r##" "##, t(lang, "devices.col_version")); } if cols.last_heartbeat { let _ = write!(s, r##" "##, t(lang, "devices.col_last_heartbeat")); } if cols.conns { let _ = write!(s, r##" "##, t(lang, "devices.col_conns")); } if cols.auth { let _ = write!(s, r##" "##, t(lang, "devices.col_auth")); } let _ = write!( s, r##" "##, c_actions = t(lang, "common.actions"), ); if devices.is_empty() { // colspan = peer column + visible toggle columns + actions column. let colspan = 1 + cols.visible_count() + 1; let empty_msg = if q.is_empty() { t(lang, "devices.no_devices") } else { t(lang, "devices.no_match") }; let _ = write!( s, r##""##, colspan = colspan, empty = empty_msg, ); } else { let always_show_pwd = unattended_pwd_always_visible(); for d in &devices { render_device_row(&mut s, lang, cols, q, page, d, now, always_show_pwd); } } s.push_str("\n
{c_peer}{}{}{}{}{}{}{}{}{}{}{c_actions}
{empty}
"); s.push_str(&render_pagination_footer(lang, q, page, page_size, total, total_pages)); Ok(s) } fn render_columns_popover(lang: Lang, cols: DeviceColumns) -> String { let items: [(&str, bool, &str); 10] = [ ("owner", cols.owner, t(lang, "devices.col_owner")), ("host", cols.host, t(lang, "devices.col_hostname")), ("serial", cols.serial, t(lang, "devices.col_serial")), ("user", cols.user, t(lang, "devices.col_user")), ("unattended_pwd", cols.unattended_pwd, t(lang, "devices.col_unattended_pwd")), ("os", cols.os, t(lang, "devices.col_os")), ("version", cols.version, t(lang, "devices.col_version")), ("last_heartbeat", cols.last_heartbeat, t(lang, "devices.col_last_heartbeat")), ("conns", cols.conns, t(lang, "devices.col_conns")), ("auth", cols.auth, t(lang, "devices.col_auth")), ]; let mut checkboxes = String::new(); for (id, visible, label) in items { let checked = if visible { " checked" } else { "" }; let _ = write!( checkboxes, r##""##, id = id, checked = checked, label = html_escape(label), ); } format!( r##"
{columns_label} ▾
{checkboxes}
"##, columns_label = t(lang, "devices.columns"), checkboxes = checkboxes, ) } fn render_pagination_footer( lang: Lang, q: &str, page: i64, page_size: i64, total: i64, total_pages: i64, ) -> String { let from = if total == 0 { 0 } else { (page - 1) * page_size + 1 }; let to = ((page * page_size).min(total)).max(0); let showing = tf3( lang, "devices.showing_range", &from.to_string(), &to.to_string(), &total.to_string(), ); let mut size_options = String::new(); for &n in PAGE_SIZE_OPTIONS { let selected = if n == page_size { " selected" } else { "" }; let _ = write!( size_options, r##""##, n = n, selected = selected, ); } let q_param = if q.is_empty() { String::new() } else { format!("&q={}", url_encode(q)) }; let prev_disabled = page <= 1; let next_disabled = page >= total_pages; let prev_attrs = if prev_disabled { " disabled".to_string() } else { format!( r##" hx-get="/admin/pages/devices/list-fragment?page={p}{q_param}" hx-target="#devices-region" hx-swap="innerHTML""##, p = page - 1, q_param = q_param, ) }; let next_attrs = if next_disabled { " disabled".to_string() } else { format!( r##" hx-get="/admin/pages/devices/list-fragment?page={p}{q_param}" hx-target="#devices-region" hx-swap="innerHTML""##, p = page + 1, q_param = q_param, ) }; let btn_base = "px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 border border-slate-700 \ disabled:opacity-40 disabled:hover:bg-slate-800 disabled:cursor-not-allowed"; format!( r##"
{showing}
{page} / {total_pages}
"##, showing = html_escape(&showing), per_page_label = t(lang, "devices.per_page"), size_options = size_options, prev = t(lang, "common.prev"), next = t(lang, "common.next"), btn = btn_base, prev_attrs = prev_attrs, next_attrs = next_attrs, page = page, total_pages = total_pages, ) } /// Percent-encode characters that aren't safe in a URL query value. fn url_encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.as_bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(*b as char); } _ => { let _ = write!(out, "%{:02X}", b); } } } out } /// Resolves the `--unattended-pwd-visibility` setting (env key /// `UNATTENDED-PWD-VISIBILITY`, also settable via `.env`). Returns `true` /// when the admin UI should surface the unattended password even while an /// interactive user is logged in. Default (`logged-out`, or any /// unrecognised value) keeps the original behaviour: shown only when nobody /// is logged in. fn unattended_pwd_always_visible() -> bool { crate::common::get_arg_or("unattended-pwd-visibility", "logged-out".to_owned()) .trim() .eq_ignore_ascii_case("always") } fn render_device_row( s: &mut String, lang: Lang, cols: DeviceColumns, q: &str, page: i64, d: &DashboardDeviceRow, now: chrono::DateTime, always_show_pwd: bool, ) { let parsed: serde_json::Value = serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null); let pick = |k: &str| -> String { parsed .get(k) .and_then(|v| v.as_str()) .unwrap_or_default() .to_string() }; let hostname = pick("hostname"); // Hardware serial number — only present when the agent (e.g. // hello-agent) ships an inventory blob alongside the sysinfo // payload. Vanilla rustdesk doesn't, so most rows render a dash. let serial_number = parsed .get("inventory") .and_then(|inv| inv.get("serial_number")) .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 // here means "no interactive user" — render that as a dash. let active_user = pick("username"); let os = pick("os"); // Version label. The sysinfo upload always carries `version` (the // embedded rustdesk core version, e.g. "1.4.6"). Rebrands like // hello-agent additionally stamp `agent_name` + `agent_version` — // when present we surface those instead so the admin sees // "Hello Agent 0.1.0" rather than the embedded core version. // Fallback "RustDesk " is the right default for vanilla // installs (they don't override the agent fields). let version_label = { let core_ver = pick("version"); let agent_name = pick("agent_name"); let agent_ver = pick("agent_version"); if !agent_name.is_empty() { if !agent_ver.is_empty() { format!("{agent_name} {agent_ver}") } else { agent_name } } else if !core_ver.is_empty() { format!("RustDesk {core_ver}") } else { "—".to_string() } }; let conn_count = serde_json::from_str::>(&d.conns_json) .map(|v| v.len()) .unwrap_or(0); let (is_online, age_secs) = online_state(&d.last_heartbeat_at, now); let (dot_class, tooltip) = if is_online { ( "bg-emerald-400", tf1(lang, "devices.online", &age_secs.to_string()), ) } else if age_secs == i64::MAX { ("bg-slate-500", t(lang, "devices.no_heartbeat").to_string()) } else { ( "bg-rose-500", tf1(lang, "devices.offline", &fmt_age(age_secs)), ) }; // Per-boot unattended-access password reported by hello-agent. Visible // only when (a) the device is online (offline rows show stale data), // (b) no interactive user is logged in (otherwise the supporter // should be using the per-session approval popup, not the password) // — unless `--unattended-pwd-visibility=always` overrides (b), and // (c) the agent has actually reported one (vanilla rustdesk // never will). Otherwise show a neutral dash so the column lines up. let unattended_pwd_cell = if is_online && (always_show_pwd || active_user.is_empty()) && !d.unattended_password.is_empty() { format!( r##"{pw}"##, pw = html_escape(&d.unattended_password), set_at = html_escape(&d.unattended_password_set_at), ) } else { r##""##.to_string() }; let id_cell = format!( r##" {id} "##, tt = html_escape(&tooltip), dot = dot_class, id = html_escape(&d.id), ); // `?page={page}&q={q}` appended to every per-row action so the // re-rendered table comes back showing the same filtered page. let q_param = if q.is_empty() { String::new() } else { format!("&q={}", url_encode(q)) }; // Auth toggle: the menu entry's label flips based on current state, // and only the off→on transition needs no confirm (it strengthens // security). on→off removes the signature requirement and reintroduces // the spoofing surface, so we require a confirm on that direction. let toggle_managed_item = if d.managed { format!( r##""##, id = html_escape(&d.id), page = page, q_param = q_param, confirm = html_escape(&tf1(lang, "devices.confirm_managed_off", &d.id)), label = t(lang, "devices.mark_unsigned"), ) } else { format!( r##""##, id = html_escape(&d.id), page = page, q_param = q_param, label = t(lang, "devices.mark_managed"), ) }; // Open + the always-on peer id cell. let _ = write!(s, r##" {id_cell}"##, id_cell = id_cell); if cols.owner { let _ = write!( s, r##" {}"##, html_escape(&d.owner_username), ); } if cols.host { let _ = write!( s, r##" {}"##, html_escape(&hostname), ); } if cols.serial { let cell = if serial_number.is_empty() { r##""##.to_string() } else { format!( r##"{}"##, html_escape(&serial_number), ) }; let _ = write!( s, r##" {}"##, cell, ); } if cols.user { let user_cell = if active_user.is_empty() { "—".to_string() } else { html_escape(&active_user) }; let _ = write!( s, r##" {}"##, user_cell, ); } if cols.unattended_pwd { let _ = write!( s, r##" {}"##, unattended_pwd_cell, ); } if cols.os { let _ = write!( s, r##" {}"##, html_escape(&os), ); } if cols.version { let _ = write!( s, r##" {}"##, html_escape(&version_label), ); } if cols.last_heartbeat { let _ = write!( s, r##" {}"##, html_escape(&d.last_heartbeat_at), ); } if cols.conns { let _ = write!( s, r##" {}"##, conn_count, ); } if cols.auth { // Auth badge: `Signed` (emerald) when peer.managed=1 — heartbeat / // sysinfo posts must carry a valid Ed25519 signature; `—` (slate) when // managed=0 and the device still posts unsigned bodies. The tooltip // gives the operator the one-line explanation so they know what // flipping the flag will do. let auth_cell = if d.managed { format!( r##" {label} "##, tt = html_escape(t(lang, "devices.auth_signed_tooltip")), label = t(lang, "devices.auth_signed"), ) } else { format!( r##" {label} "##, tt = html_escape(t(lang, "devices.auth_unsigned_tooltip")), label = t(lang, "devices.auth_unsigned"), ) }; let _ = write!(s, "\n {}", auth_cell); } let _ = write!( s, r##"
···
{connect_web}
{toggle_managed_item}

"##, id = html_escape(&d.id), page = page, q_param = q_param, toggle_managed_item = toggle_managed_item, connect_web = t(lang, "devices.connect_web"), confirm_connect = html_escape(&tf1(lang, "devices.confirm_connect", &d.id)), details = t(lang, "devices.details"), run_command = t(lang, "devices.run_command"), confirm_disc = html_escape(&tf1(lang, "devices.confirm_disconnect", &d.id)), force_disc = t(lang, "devices.force_disconnect"), force_sysinfo = t(lang, "devices.force_sysinfo"), confirm_delete = html_escape(&tf1(lang, "devices.confirm_delete", &d.id)), delete_device = t(lang, "devices.delete_device"), ); } // ---------- detail page ---------- /// HTMX-only endpoint returning just the devices table fragment (no /// outer header), so the per-device detail view's "Back to devices" /// button, the search input, and the pagination buttons can all swap /// the table back into `#devices-region` without re-rendering the /// whole page wrapper. Accepts `?page=N&q=...` so the same handler /// drives both navigation and filtering. pub async fn list_fragment( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Query(pg): Query, ) -> Result, ApiError> { require_admin(&admin)?; let cols = load_device_columns(&state, admin.user_id).await; let page_size = load_device_page_size(&state, admin.user_id).await; Ok(Html( render_table(&state, lang, cols, pg.query(), pg.page_or_first(), page_size).await?, )) } /// "Back to devices" — refetches the devices table fragment via HTMX /// and swaps it back into `#devices-region`. Used by the detail page. /// `hx-push-url="#devices"` resets the address bar to the list-level /// hash so a subsequent refresh lands on the list, not the detail. fn back_button(lang: Lang) -> String { format!( r##""##, label = t(lang, "devices.back"), ) } /// Pretty-print a JSON value for the inventory table cells. Strings are /// returned as-is, numbers / bools rendered via Display, null / missing /// becomes a dash. Anything more complex (objects, arrays of non-disks) /// falls back to compact JSON so the page never panics on unexpected data. fn fmt_inv_value(v: Option<&serde_json::Value>) -> String { match v { None | Some(serde_json::Value::Null) => "—".to_string(), Some(serde_json::Value::String(s)) if s.is_empty() => "—".to_string(), Some(serde_json::Value::String(s)) => html_escape(s), Some(serde_json::Value::Number(n)) => n.to_string(), Some(serde_json::Value::Bool(b)) => b.to_string(), Some(other) => html_escape(&other.to_string()), } } fn render_detail( lang: Lang, d: &DashboardDeviceRow, login_events: &[LoginEventRow], metrics_latest: Option<&MetricsSampleRow>, metrics_24h: &[MetricsSampleRow], perf_events: &[PerfEventRow], ) -> String { let parsed: serde_json::Value = serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null); let pick = |k: &str| -> String { parsed .get(k) .and_then(|v| v.as_str()) .unwrap_or_default() .to_string() }; let agent_name = pick("agent_name"); let agent_version = pick("agent_version"); let core_version = pick("version"); let hostname = pick("hostname"); let active_user = pick("username"); let os_runtime = pick("os"); let cpu_runtime = pick("cpu"); let mem_runtime = pick("memory"); let identity_label = if !agent_name.is_empty() { if !agent_version.is_empty() { format!("{agent_name} {agent_version}") } else { agent_name.clone() } } else if !core_version.is_empty() { format!("RustDesk {core_version}") } else { "—".to_string() }; // Header summary — same data the list shows, rendered as a description list. let header = format!( r##"

{device_label} {id}

UUID {uuid}
{l_host}
{host}
{l_owner}
{owner}
{l_user}
{user}
{l_agent}
{ident}
{l_os_rt}
{os_rt}
{l_last}
{last}
{l_cpu_rt}
{cpu_rt}
{l_mem_rt}
{mem_rt}
"##, device_label = t(lang, "devices.device_label"), l_host = t(lang, "devices.col_hostname"), l_owner = t(lang, "devices.col_owner"), l_user = t(lang, "devices.detail_active_user"), l_agent = t(lang, "devices.detail_agent"), l_os_rt = t(lang, "devices.detail_os_runtime"), l_last = t(lang, "devices.col_last_heartbeat"), l_cpu_rt = t(lang, "devices.detail_cpu_runtime"), l_mem_rt = t(lang, "devices.detail_memory_runtime"), id = html_escape(&d.id), uuid = html_escape(&d.uuid), host = html_escape(if hostname.is_empty() { "—" } else { &hostname }), owner = html_escape(if d.owner_username.is_empty() { "—" } else { &d.owner_username }), user = html_escape(if active_user.is_empty() { "—" } else { &active_user }), ident = html_escape(&identity_label), os_rt = html_escape(if os_runtime.is_empty() { "—" } else { &os_runtime }), last = html_escape(&d.last_heartbeat_at), cpu_rt = html_escape(if cpu_runtime.is_empty() { "—" } else { &cpu_runtime }), mem_rt = html_escape(if mem_runtime.is_empty() { "—" } else { &mem_runtime }), ); // Inventory section — rendered whenever the device's sysinfo payload // contains a populated `inventory` object, regardless of which client // sent it. We used to gate this on `agent_name == "HelloAgent"` (the // explicit rebrand identity stamped by hello-agent), but that filter // (a) broke silently when hello-agent's APP_NAME changed casing, and // (b) hid any inventory data shipped by a future client variant that // didn't carry the exact same agent_name string. Rendering on presence // (`inv.is_object()`) instead of identity makes the page robust to both. // The "pending" message covers the only remaining absence case: an // agent that doesn't (or doesn't yet) report inventory. let inventory_section = match parsed.get("inventory") { Some(inv) if inv.is_object() => render_inventory_table(lang, inv), _ => format!( r##"
{msg}
"##, msg = t(lang, "devices.inventory_pending"), ), }; let login_section = render_login_events(lang, login_events); let perf_section = render_performance(lang, metrics_latest, metrics_24h, perf_events); format!( r##"
{back}
{detail_view}
{header}

{performance}

{perf}

{inventory}

{inv}

{login_history}

{login}
"##, back = back_button(lang), detail_view = t(lang, "devices.detail_view"), performance = t(lang, "devices.performance"), perf = perf_section, inventory = t(lang, "devices.inventory"), header = header, inv = inventory_section, login_history = t(lang, "devices.login_history"), login = login_section, ) } /// Top-level Performance section: snapshot card, two sparklines (CPU / /// memory), and a recent-events table. The whole thing is omitted in /// favour of a "no data yet" panel when the agent hasn't reported. fn render_performance( lang: Lang, latest: Option<&MetricsSampleRow>, series: &[MetricsSampleRow], events: &[PerfEventRow], ) -> String { if latest.is_none() && series.is_empty() && events.is_empty() { return format!( r##"
{msg}
"##, msg = t(lang, "devices.perf_none"), ); } let snapshot = render_perf_snapshot(lang, latest); let cpu_chart = render_sparkline( lang, series.iter().map(|s| (s.at, s.cpu_pct)).collect(), 100.0, true, t(lang, "devices.perf_cpu"), ); let mem_chart = { // Mem is reported as MB used / MB total; chart uses % so the // y-axis stays comparable to the CPU panel. let series_pct: Vec<(i64, f64)> = series .iter() .filter(|s| s.mem_total_mb > 0) .map(|s| { let pct = 100.0 * (s.mem_used_mb as f64) / (s.mem_total_mb as f64); (s.at, pct) }) .collect(); render_sparkline(lang, series_pct, 100.0, true, t(lang, "devices.perf_mem")) }; let events_section = render_perf_events_table(lang, events); format!( r##"
{snapshot}
{cpu} {mem}
{events}
"##, snapshot = snapshot, cpu = cpu_chart, mem = mem_chart, events = events_section, ) } /// "Right now" card — the most recent metrics sample. Drawn as a 4-up /// stat tile so the supporter can glance at CPU / memory / top /// processes without reading a chart. Falls back to a thin "no live /// data" pill when the agent has never reported. fn render_perf_snapshot(lang: Lang, latest: Option<&MetricsSampleRow>) -> String { let Some(s) = latest else { return format!( r##"
{msg}
"##, msg = t(lang, "devices.perf_no_live"), ); }; let now = chrono::Utc::now().timestamp(); let age = (now - s.at).max(0); let age_str = fmt_age(age); let cpu_color = pct_color(s.cpu_pct); let mem_pct = if s.mem_total_mb > 0 { 100.0 * (s.mem_used_mb as f64) / (s.mem_total_mb as f64) } else { 0.0 }; let mem_color = pct_color(mem_pct); let mem_used_gb = (s.mem_used_mb as f64) / 1024.0; let mem_total_gb = (s.mem_total_mb as f64) / 1024.0; let top_cpu = if s.top_cpu_name.is_empty() { "—".to_string() } else { format!( "{name} {pct:.0}%", name = html_escape(&s.top_cpu_name), pct = s.top_cpu_pct, ) }; let top_mem = if s.top_mem_name.is_empty() { "—".to_string() } else { let mb = s.top_mem_mb; let mem_disp = if mb >= 1024 { format!("{:.1} GB", (mb as f64) / 1024.0) } else { format!("{} MB", mb) }; format!( "{name} {disp}", name = html_escape(&s.top_mem_name), disp = html_escape(&mem_disp), ) }; let uptime_str = if s.uptime_secs > 0 { fmt_age(s.uptime_secs) } else { "—".to_string() }; format!( r##"

{l_now}

{l_age}
{l_cpu}
{cpu:.0}%
{l_mem}
{mem_pct:.0}%
{used:.1} / {total:.1} GB
{l_top_cpu}
{top_cpu}
{l_top_mem}
{top_mem}
{l_uptime}
{uptime}
{l_procs}
{procs}
"##, l_now = t(lang, "devices.perf_now"), l_age = tf1(lang, "devices.perf_sampled_ago", &age_str), at_full = html_escape(&fmt_unix_utc(s.at)), l_cpu = t(lang, "devices.perf_cpu"), cpu_cls = cpu_color, cpu = s.cpu_pct, l_mem = t(lang, "devices.perf_mem"), mem_cls = mem_color, mem_pct = mem_pct, used = mem_used_gb, total = mem_total_gb, l_top_cpu = t(lang, "devices.perf_top_cpu"), top_cpu_raw = html_escape(&s.top_cpu_name), top_cpu = top_cpu, l_top_mem = t(lang, "devices.perf_top_mem"), top_mem_raw = html_escape(&s.top_mem_name), top_mem = top_mem, l_uptime = t(lang, "devices.perf_uptime"), uptime = html_escape(&uptime_str), l_procs = t(lang, "devices.perf_proc_count"), procs = s.proc_count, ) } /// Color-code a percentage value (0–100) — green up to 60, amber up to /// 85, red above. Used for the snapshot stat tiles so the supporter /// can spot a wedged-laptop at a glance. fn pct_color(pct: f64) -> &'static str { if pct >= 85.0 { "text-rose-400" } else if pct >= 60.0 { "text-amber-300" } else { "text-emerald-300" } } /// Render an inline-SVG sparkline. `series` is a (unix-seconds, value) /// vector; `max_y` clamps the y-axis (so two side-by-side charts share /// a scale); `bucketed = true` downsamples by averaging into 96 buckets /// so the polyline string stays short for a wide time window. fn render_sparkline( lang: Lang, series: Vec<(i64, f64)>, max_y: f64, bucketed: bool, title: &str, ) -> String { const WIDTH: f64 = 600.0; const HEIGHT: f64 = 80.0; const PAD: f64 = 4.0; if series.is_empty() { return format!( r##"

{title}

{msg}
"##, title = html_escape(title), msg = t(lang, "devices.perf_no_chart"), ); } let points = if bucketed && series.len() > 96 { downsample_avg(&series, 96) } else { series.clone() }; let min_x = points.first().map(|p| p.0).unwrap_or(0); let max_x = points.last().map(|p| p.0).unwrap_or(0); let span_x = (max_x - min_x).max(1) as f64; let plot_w = WIDTH - 2.0 * PAD; let plot_h = HEIGHT - 2.0 * PAD; let mut path = String::new(); let mut area = String::new(); let mut peak: f64 = 0.0; let mut last: f64 = 0.0; for (i, (t, v)) in points.iter().enumerate() { let x = PAD + plot_w * ((t - min_x) as f64) / span_x; let y_norm = (v / max_y).clamp(0.0, 1.0); let y = PAD + plot_h * (1.0 - y_norm); if i == 0 { path.push_str(&format!("M{:.1},{:.1}", x, y)); area.push_str(&format!("M{:.1},{:.1}", x, PAD + plot_h)); area.push_str(&format!(" L{:.1},{:.1}", x, y)); } else { path.push_str(&format!(" L{:.1},{:.1}", x, y)); area.push_str(&format!(" L{:.1},{:.1}", x, y)); } peak = peak.max(*v); last = *v; } let last_x = PAD + plot_w; area.push_str(&format!(" L{:.1},{:.1} Z", last_x, PAD + plot_h)); // Hours-from-now labels: oldest point's age, "now" on the right. let span_secs = (max_x - min_x).max(0); let span_label = if span_secs >= 3600 { format!("-{}h", span_secs / 3600) } else if span_secs >= 60 { format!("-{}m", span_secs / 60) } else { format!("-{}s", span_secs) }; format!( r##"

{title}

{l_peak} {peak:.0}%   {l_now} {last:.0}%
{older} {l_now_short}
"##, title = html_escape(title), l_peak = t(lang, "devices.perf_peak"), peak = peak, l_now = t(lang, "devices.perf_latest"), last = last, w = WIDTH, h = HEIGHT, pad = PAD, ymid = PAD + plot_h * 0.5, xend = WIDTH - PAD, area = area, path = path, older = html_escape(&span_label), l_now_short = t(lang, "devices.perf_now_short"), ) } /// Mean-pool a (timestamp, value) series down to `target` buckets, /// keeping the bucket-mean timestamp as the bucket's x. Empty buckets /// are dropped so the resulting polyline doesn't draw zero-lines for /// stretches where the agent was offline. fn downsample_avg(series: &[(i64, f64)], target: usize) -> Vec<(i64, f64)> { if series.len() <= target { return series.to_vec(); } let min_x = series.first().map(|p| p.0).unwrap_or(0); let max_x = series.last().map(|p| p.0).unwrap_or(0); let span = (max_x - min_x).max(1); let bucket_secs = (span as usize) / target.max(1); let bucket_secs = bucket_secs.max(1) as i64; let mut buckets: Vec<(i64, f64, usize)> = Vec::with_capacity(target); let mut current_bucket: i64 = -1; for (t, v) in series { let b = (t - min_x) / bucket_secs; if b != current_bucket { buckets.push((*t, *v, 1)); current_bucket = b; } else if let Some(last) = buckets.last_mut() { last.1 += *v; last.2 += 1; } } buckets .into_iter() .map(|(t, sum, n)| (t, sum / (n as f64))) .collect() } /// Recent perf-events table — boot/shutdown/sleep degradation, memory /// exhaustion, BSODs, unexpected reboots. Empty list → a neutral /// "nothing flagged yet" panel so the heading still has a body. fn render_perf_events_table(lang: Lang, events: &[PerfEventRow]) -> String { if events.is_empty() { return format!( r##"
{msg}
"##, msg = t(lang, "devices.perf_events_none"), ); } // Collapsed by default — the table is information-dense and most // operators only look at it when they're chasing a specific // complaint. The Wi-Fi-nearby section uses the same `
` // pattern earlier on this page. let mut s = format!( r##"
{l_events} ({n})
"##, l_events = t(lang, "devices.perf_events_heading"), n = events.len(), c_when = t(lang, "devices.perf_events_col_when"), c_source = t(lang, "devices.perf_events_col_source"), c_summary = t(lang, "devices.perf_events_col_summary"), ); for ev in events { let when = fmt_unix_utc(ev.at); let (level_cls, _level_label) = match ev.level { 1 => ("bg-rose-900/40 text-rose-300 border-rose-800", "critical"), 2 => ("bg-rose-900/30 text-rose-300 border-rose-900", "error"), 3 => ("bg-amber-900/40 text-amber-300 border-amber-800", "warning"), _ => ("bg-slate-800 text-slate-300 border-slate-700", "info"), }; let source_label = match ev.provider.as_str() { "diag-perf" => t(lang, "devices.perf_src_diag_perf"), "res-exh" => t(lang, "devices.perf_src_res_exh"), "system" => t(lang, "devices.perf_src_system"), other => other, }; let _ = write!( s, r##""##, when = html_escape(&when), lvl_cls = level_cls, src = html_escape(source_label), eid = ev.event_id, summary = html_escape(&ev.summary), ); } s.push_str("
{c_when} {c_source} {c_summary}
{when} {src} · {eid} {summary}
"); s } /// Render the per-device login history table. Empty input → a neutral /// "no events yet" panel so the heading still has something under it. /// We render the agent-reported `at` in the standard SQLite UTC format /// (no per-locale fiddling) and only flag a "received_at differs from at" /// case as a tooltip — clock skew on the agent side is the only place that /// matters operationally. fn render_login_events(lang: Lang, events: &[LoginEventRow]) -> String { if events.is_empty() { return format!( r##"
{msg}
"##, msg = t(lang, "devices.login_none"), ); } let mut s = format!( r##"
"##, c_when = t(lang, "devices.login_col_when"), c_event = t(lang, "devices.login_col_event"), c_user = t(lang, "devices.login_col_user"), c_session = t(lang, "devices.login_col_session"), ); for ev in events { let when = fmt_unix_utc(ev.at); // Surface clock-skew >5 min as a tooltip — that's the threshold the // signed-API gate also uses, so anything above that is already on // the operator's radar via 401s. 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() }; let (badge_class, badge_label) = match ev.kind.as_str() { "logon" => ( "bg-emerald-900/40 text-emerald-300 border-emerald-800", t(lang, "devices.login_kind_logon"), ), "logoff" => ( "bg-slate-800 text-slate-300 border-slate-700", t(lang, "devices.login_kind_logoff"), ), _ => ( "bg-amber-900/40 text-amber-300 border-amber-800", // Unknown kind — display the raw string so an operator // running a newer agent against an older server can still // see what was reported. "", ), }; let badge_text = if badge_label.is_empty() { html_escape(&ev.kind) } else { badge_label.to_string() }; let user_display = if ev.domain.is_empty() { html_escape(if ev.username.is_empty() { "—" } else { &ev.username }) } else if ev.username.is_empty() { html_escape(&ev.domain) } else { html_escape(&format!("{}\\{}", ev.domain, ev.username)) }; let session_kind = if ev.session_kind.is_empty() { "—".to_string() } else { html_escape(&ev.session_kind) }; let _ = write!( s, r##""##, when_attr = when_attr, when = html_escape(&when), bc = badge_class, bt = badge_text, user = user_display, sk = session_kind, sid = ev.session_id, ); } s.push_str("
{c_when} {c_event} {c_user} {c_session}
{when} {bt} {user} {sk} #{sid}
"); 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. fn fmt_unix_utc(ts: i64) -> String { use chrono::TimeZone; chrono::Utc .timestamp_opt(ts, 0) .single() .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_else(|| ts.to_string()) } fn render_inventory_table(lang: Lang, inv: &serde_json::Value) -> String { let row = |label: &str, key: &str| { format!( r##" {label} {val} "##, label = label, val = fmt_inv_value(inv.get(key)), ) }; // Disks need their own renderer — they're an array of objects. let disks_html = match inv.get("disks") { Some(serde_json::Value::Array(arr)) if !arr.is_empty() => { let mut s = format!( r##""##, c_name = t(lang, "devices.disk_name"), c_model = t(lang, "devices.disk_model"), c_size = t(lang, "devices.disk_size"), c_media = t(lang, "devices.disk_media"), ); for disk in arr { let name = fmt_inv_value(disk.get("name")); let model = fmt_inv_value(disk.get("model")); let size = fmt_inv_value(disk.get("size_gb")); let media = fmt_inv_value(disk.get("media")); let _ = write!( s, r##""##, ); } s.push_str("
{c_name}{c_model}{c_size}{c_media}
{name}{model}{size}{media}
"); s } _ => r##""##.to_string(), }; // BitLocker is sensitive — render in a copy-friendly monospace box and // a slightly louder color, but don't try to obscure it. The whole // detail page already requires admin auth. let bl_key_raw = inv .get("bitlocker_recovery_key") .and_then(|v| v.as_str()) .unwrap_or(""); let bl_html = if bl_key_raw.is_empty() { format!( r##"{}"##, t(lang, "devices.bitlocker_unavailable"), ) } else { format!( r##"{}"##, html_escape(bl_key_raw) ) }; let nics_html = render_nics(lang, inv.get("network_interfaces")); let wifi_html = render_wifi(lang, inv.get("wifi_current"), inv.get("wifi_nearby")); let software_html = render_installed_software(lang, inv.get("installed_software")); let public_ip_raw = inv .get("public_ip") .and_then(|v| v.as_str()) .unwrap_or(""); let public_ip_html = if public_ip_raw.is_empty() { format!( r##"{}"##, t(lang, "devices.public_ip_failed"), ) } else { format!( r##"{}"##, html_escape(public_ip_raw) ) }; format!( r##"
{sn} {mfr} {model} {dom} {os_d} {os_r} {cpu_m} {cpu_s} {cpu_pc} {cpu_lc} {ram}

{l_disks}

{disks}

{l_nics}

{nics}
{wifi}

{l_pip}

{public_ip}
{software}

{l_bl}

{bl}
"##, sn = row(t(lang, "devices.serial_number"), "serial_number"), mfr = row(t(lang, "devices.manufacturer"), "manufacturer"), model = row(t(lang, "devices.model"), "model"), 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"), cpu_m = row(t(lang, "devices.cpu_model"), "cpu_model"), cpu_s = row(t(lang, "devices.cpu_speed"), "cpu_speed_ghz"), cpu_pc = row(t(lang, "devices.cpu_phys_cores"), "cpu_cores_physical"), cpu_lc = row(t(lang, "devices.cpu_logical_cores"), "cpu_cores_logical"), ram = row(t(lang, "devices.ram_gb"), "ram_gb"), disks = disks_html, nics = nics_html, wifi = wifi_html, public_ip = public_ip_html, software = software_html, l_disks = t(lang, "devices.disks"), l_nics = t(lang, "devices.network_interfaces"), l_pip = t(lang, "devices.public_ip"), l_bl = t(lang, "devices.bitlocker"), bl = bl_html, ) } /// Render the network-interfaces array as a table (one row per NIC). /// Wi-Fi NICs get a small badge so the operator can spot them at a glance /// next to the Wi-Fi-current section. Empty input → dash. fn render_nics(lang: Lang, nics: Option<&serde_json::Value>) -> String { let arr = match nics { Some(serde_json::Value::Array(a)) if !a.is_empty() => a, _ => { return r##"
"##.to_string(); } }; let mut s = format!( r##"
"##, c_name = t(lang, "devices.disk_name"), c_desc = t(lang, "devices.nic_description"), c_status = t(lang, "devices.nic_status"), ); for nic in arr { let name = fmt_inv_value(nic.get("name")); let descr = fmt_inv_value(nic.get("description")); let mac = fmt_inv_value(nic.get("mac")); let status = fmt_inv_value(nic.get("status")); let speed = fmt_inv_value(nic.get("speed_mbps")); let is_wifi = nic .get("is_wifi") .and_then(|v| v.as_bool()) .unwrap_or(false); let wifi_badge = if is_wifi { r##" Wi-Fi"## } else { "" }; let ipv4 = render_ip_list(nic.get("ipv4")); let ipv6 = render_ip_list(nic.get("ipv6")); let _ = write!( &mut s, r##""##, name = name, badge = wifi_badge, descr = descr, mac = mac, status = status, ipv4 = ipv4, ipv6 = ipv6, speed = speed, ); } s.push_str("
{c_name}{c_desc}MAC{c_status}IPv4IPv6Mbps
{name}{badge}{descr}{mac}{status}{ipv4}{ipv6}{speed}
"); s } /// Render an array-of-strings IP list as a `
`-separated block, or /// dash when the array is empty / missing. Each address is HTML-escaped. fn render_ip_list(v: Option<&serde_json::Value>) -> String { match v { Some(serde_json::Value::Array(a)) if !a.is_empty() => a .iter() .filter_map(|x| x.as_str()) .map(html_escape) .collect::>() .join("
"), _ => "—".to_string(), } } /// Render the Wi-Fi section: current connection (if any) and the /// nearby-SSID scan (collapsed by default to keep the page short). /// Returns an empty string when neither is present, so the surrounding /// detail page can omit the heading entirely. fn render_wifi( lang: Lang, current: Option<&serde_json::Value>, nearby: Option<&serde_json::Value>, ) -> String { let has_current = matches!(current, Some(v) if v.is_object()); let nearby_arr = match nearby { Some(serde_json::Value::Array(a)) if !a.is_empty() => Some(a), _ => None, }; if !has_current && nearby_arr.is_none() { return String::new(); } let current_html = if let Some(c) = current.filter(|v| v.is_object()) { // Rate: native API returns rx/tx in Kbps; render the higher of // the two as Mbps. Most APs report identical rx/tx, so a single // figure is plenty for the operator's "is this slow" question. let rate_mbps = { let rx = c.get("rx_kbps").and_then(|v| v.as_u64()).unwrap_or(0); let tx = c.get("tx_kbps").and_then(|v| v.as_u64()).unwrap_or(0); let max = rx.max(tx); if max == 0 { "—".to_string() } else { format!("{} Mbps", max / 1000) } }; let signal_with_dbm = { let pct = c.get("signal_pct").and_then(|v| v.as_u64()).unwrap_or(0); let dbm = c.get("rssi_dbm").and_then(|v| v.as_i64()); match dbm { Some(d) => format!("{pct}% ({d} dBm)"), None => format!("{pct}%"), } }; format!( r##"
SSID
{ssid}
BSSID
{bssid}
{l_signal}
{sig}
{l_auth}
{auth}
{l_cipher}
{cipher}
{l_rate}
{rate}
"##, ssid = fmt_inv_value(c.get("ssid")), bssid = fmt_inv_value(c.get("bssid")), sig = signal_with_dbm, auth = fmt_inv_value(c.get("auth")), cipher = fmt_inv_value(c.get("cipher")), rate = rate_mbps, l_signal = t(lang, "devices.wifi_signal"), l_auth = t(lang, "devices.wifi_auth"), l_cipher = t(lang, "devices.wifi_cipher"), l_rate = t(lang, "devices.wifi_rate"), ) } else { format!( r##"
{}
"##, t(lang, "devices.wifi_not_connected"), ) }; let nearby_html = if let Some(arr) = nearby_arr { let mut s = format!( r##"
{nearby_label}
"##, nearby_label = tf1(lang, "devices.wifi_nearby", &arr.len().to_string()), l_auth = t(lang, "devices.wifi_auth"), l_cipher = t(lang, "devices.wifi_cipher"), l_signal = t(lang, "devices.wifi_signal"), ); for net in arr { let _ = write!( &mut s, r##""##, ssid = fmt_inv_value(net.get("ssid")), auth = fmt_inv_value(net.get("auth")), cipher = fmt_inv_value(net.get("cipher")), sig = fmt_inv_value(net.get("signal_pct")), ); } s.push_str("
SSID{l_auth}{l_cipher}{l_signal}
{ssid}{auth}{cipher}{sig}%
"); s } else { String::new() }; format!( r##"

{wifi_current}

{current} {nearby}
"##, wifi_current = t(lang, "devices.wifi_current"), current = current_html, nearby = if nearby_html.is_empty() { String::new() } else { format!("
{}
", nearby_html) }, ) } /// Render the `installed_software` array as a collapsed `
` table. /// /// hello-agent enumerates the Windows Add/Remove Programs registry hives /// and uploads the result under `inventory.installed_software` as a sorted /// array of `{name, version, publisher, install_date, bitness}` objects. /// We render it collapsed by default because the list is routinely /// 100-300 entries on a real machine — expanding it inline would push /// the rest of the inventory off the screen. The summary line carries /// the entry count so the operator can see "is there anything here" /// without opening it. /// /// Returns an empty string when the field is absent or empty so the /// surrounding template can drop the entire block — agents that don't /// (or can't) report installed software don't get a stray empty header. fn render_installed_software(lang: Lang, sw: Option<&serde_json::Value>) -> String { let arr = match sw { Some(serde_json::Value::Array(a)) if !a.is_empty() => a, _ => return String::new(), }; let mut rows = String::new(); for entry in arr { let name = fmt_inv_value(entry.get("name")); let version = fmt_inv_value(entry.get("version")); let publisher = fmt_inv_value(entry.get("publisher")); // Bitness is a small badge next to the name when present, matching // the Wi-Fi badge on the NICs table. We treat the absence of the // field as "unknown" and render nothing rather than a "?" — the // page doesn't owe the operator a verdict it can't actually make. let bitness_raw = entry .get("bitness") .and_then(|v| v.as_str()) .unwrap_or(""); let bitness_badge = match bitness_raw { "64" => { r##" x64"## } "32" => { r##" x86"## } _ => "", }; let _ = write!( &mut rows, r##"{name}{badge}{version}{publisher}"##, name = name, badge = bitness_badge, version = version, publisher = publisher, ); } format!( r##"

{label}

{count_label}
{rows}
{c_name}{c_version}{c_publisher}
"##, label = t(lang, "devices.installed_software"), count_label = tf1(lang, "devices.installed_software_count", &arr.len().to_string()), c_name = t(lang, "devices.col_software_name"), c_version = t(lang, "devices.col_software_version"), c_publisher = t(lang, "devices.col_software_publisher"), rows = rows, ) } /// Render an elapsed-seconds count as a short "Xs / Xm / Xh / Xd" string /// for the offline tooltip. The exact heartbeat timestamp is already /// shown in the table cell — this is just for the friendly tooltip. fn fmt_age(secs: i64) -> String { if secs < 60 { format!("{}s", secs) } else if secs < 3600 { format!("{}m", secs / 60) } else if secs < 86_400 { format!("{}h", secs / 3600) } else { format!("{}d", secs / 86_400) } } async fn notice_then_table( state: &Arc, lang: Lang, cols: DeviceColumns, q: &str, page: i64, page_size: i64, kind: &str, msg: &str, ) -> Result, ApiError> { let mut html = notice_html(kind, msg); html.push_str(&render_table(state, lang, cols, q, page, page_size).await?); Ok(Html(html)) } fn notice_html(kind: &str, msg: &str) -> String { let (border, bg, text) = match kind { "ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"), _ => ("rose-700/50", "rose-900/30", "rose-300"), }; format!( r##"
{msg}
"##, border = border, bg = bg, text = text, msg = html_escape(msg), ) } fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } fn require_admin(u: &AuthedUser) -> Result<(), ApiError> { if u.is_admin { Ok(()) } else { Err(ApiError::Forbidden("admin required".into())) } }