diff --git a/src/api/admin/pages/devices.rs b/src/api/admin/pages/devices.rs index aa296b8..de1299b 100644 --- a/src/api/admin/pages/devices.rs +++ b/src/api/admin/pages/devices.rs @@ -548,6 +548,21 @@ fn render_inventory_table(inv: &serde_json::Value) -> String { ) }; + let nics_html = render_nics(inv.get("network_interfaces")); + let wifi_html = render_wifi(inv.get("wifi_current"), inv.get("wifi_nearby")); + 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() { + r##"— (lookup failed or blocked)"##.to_string() + } else { + format!( + r##"{}"##, + html_escape(public_ip_raw) + ) + }; + format!( r##"
@@ -573,6 +588,15 @@ fn render_inventory_table(inv: &serde_json::Value) -> String { {disks}
+
+

Network interfaces

+ {nics} +
+ {wifi} +
+

Public IP (egress, last lookup)

+ {public_ip} +

BitLocker recovery key (system drive)

{bl} @@ -590,10 +614,173 @@ fn render_inventory_table(inv: &serde_json::Value) -> String { cpu_lc = row("CPU logical cores", "cpu_cores_logical"), ram = row("RAM (GB)", "ram_gb"), disks = disks_html, + nics = nics_html, + wifi = wifi_html, + public_ip = public_ip_html, 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(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 = String::from( + r##"
"##, + ); + 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("
NameDescriptionMACStatusIPv4IPv6Mbps
{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( + 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}
+
Signal
{sig}
+
Authentication
{auth}
+
Cipher
{cipher}
+
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, + ) + } else { + r##"
Not connected to a Wi-Fi network.
"##.to_string() + }; + + let nearby_html = if let Some(arr) = nearby_arr { + let mut s = String::from( + r##"
+ Nearby SSIDs ({n}) +
"##, + ); + s = s.replace("{n}", &arr.len().to_string()); + 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("
SSIDAuthenticationCipherSignal
{ssid}{auth}{cipher}{sig}%
"); + s + } else { + String::new() + }; + + format!( + r##"
+

Wi-Fi (current connection)

+ {current} + {nearby} +
"##, + current = current_html, + nearby = if nearby_html.is_empty() { + String::new() + } else { + format!("
{}
", nearby_html) + }, + ) +} + /// 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.