//! 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, Lang}; use crate::api::error::ApiError; use crate::api::middleware::AuthedUser; use crate::api::state::AppState; use crate::database::DashboardDeviceRow; use axum::extract::{Extension, Path}; use axum::response::Html; use std::fmt::Write as _; use std::sync::Arc; const PAGE_SIZE: i64 = 100; /// 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; pub async fn index( Extension(state): Extension>, admin: AuthedUser, lang: Lang, ) -> Result, ApiError> { require_admin(&admin)?; let table = render_table(&state, lang).await?; Ok(Html(format!( r##"

{heading}

{tagline}

{table}
"##, heading = t(lang, "devices.heading"), tagline = t(lang, "devices.tagline"), table = table, ))) } pub async fn force_disconnect( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Path(peer_id): Path, ) -> Result, ApiError> { require_admin(&admin)?; 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, "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, ) -> Result, ApiError> { require_admin(&admin)?; state .db .heartbeat_command_queue(&peer_id, "sysinfo", None) .await .map_err(|e| ApiError::Internal(e.to_string()))?; notice_then_table( &state, lang, "ok", &tf1(lang, "devices.queued_sysinfo", &peer_id), ) .await } pub async fn delete( Extension(state): Extension>, admin: AuthedUser, lang: Lang, Path(peer_id): Path, ) -> Result, ApiError> { require_admin(&admin)?; 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, 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, ) -> Result, ApiError> { require_admin(&admin)?; 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, "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, "ok", &tf1(lang, key, &peer_id)).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) => render_detail(lang, &d), 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) -> Result { let (total, devices) = state .db .devices_list_all(0, PAGE_SIZE) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let now = chrono::Utc::now(); let mut s = String::new(); // No `overflow-hidden` on the table wrapper: the per-row action menu is // an absolutely-positioned `
` popover inside a , and the // wrapper's clipping was hiding the bottom half of the menu. let _ = write!( s, r##"
"##, c_peer = t(lang, "devices.col_peer_id"), c_owner = t(lang, "devices.col_owner"), c_host = t(lang, "devices.col_hostname"), c_user = t(lang, "devices.col_user"), c_pwd = t(lang, "devices.col_unattended_pwd"), c_os = t(lang, "devices.col_os"), c_ver = t(lang, "devices.col_version"), c_last = t(lang, "devices.col_last_heartbeat"), c_conns = t(lang, "devices.col_conns"), c_auth = t(lang, "devices.col_auth"), c_actions = t(lang, "common.actions"), ); if devices.is_empty() { let _ = write!( s, r##""##, t(lang, "devices.no_devices"), ); } let always_show_pwd = unattended_pwd_always_visible(); for d in &devices { render_device_row(&mut s, lang, d, now, always_show_pwd); } let _ = write!( s, r##"
{c_peer} {c_owner} {c_host} {c_user} {c_pwd} {c_os} {c_ver} {c_last} {c_conns} {c_auth} {c_actions}
{}
{count}
"##, count = tf1(lang, "devices.devices_count", &total.to_string()), ); Ok(s) } /// 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, 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"); // `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), ); // 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"), ) }; // 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), 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), label = t(lang, "devices.mark_managed"), ) }; let _ = write!( s, r##" {id_cell} {owner} {host} {user} {unattended_pwd} {os} {ver} {last} {n} {auth_cell}
···
{connect_web}
{toggle_managed_item}

"##, id_cell = id_cell, id = html_escape(&d.id), owner = html_escape(&d.owner_username), host = html_escape(&hostname), user = if active_user.is_empty() { "—".to_string() } else { html_escape(&active_user) }, unattended_pwd = unattended_pwd_cell, os = html_escape(&os), ver = html_escape(&version_label), last = html_escape(&d.last_heartbeat_at), n = conn_count, auth_cell = auth_cell, toggle_managed_item = toggle_managed_item, connect_web = t(lang, "devices.connect_web"), details = t(lang, "devices.details"), 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 can swap the table back into `#devices-region` without /// re-rendering the whole page wrapper. Same shape as /// `notice_then_table` minus the notice banner. pub async fn list_fragment( Extension(state): Extension>, admin: AuthedUser, lang: Lang, ) -> Result, ApiError> { require_admin(&admin)?; Ok(Html(render_table(&state, lang).await?)) } /// "Back to devices" — refetches the devices table fragment via HTMX /// and swaps it back into `#devices-region`. Used by the detail page. 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) -> 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"), ), }; format!( r##"
{back}
{detail_view}
{header}

{inventory}

{inv}
"##, back = back_button(lang), detail_view = t(lang, "devices.detail_view"), inventory = t(lang, "devices.inventory"), header = header, inv = inventory_section, ) } 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, kind: &str, msg: &str, ) -> Result, ApiError> { let mut html = notice_html(kind, msg); html.push_str(&render_table(state, lang).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())) } }