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##"
| Name | Description | MAC | Status | IPv4 | IPv6 | Mbps |
"##,
+ );
+ 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}{badge} | {descr} | {mac} | {status} | {ipv4} | {ipv6} | {speed} |
"##,
+ name = name,
+ badge = wifi_badge,
+ descr = descr,
+ mac = mac,
+ status = status,
+ ipv4 = ipv4,
+ ipv6 = ipv6,
+ speed = speed,
+ );
+ }
+ s.push_str("
");
+ 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})
+ | SSID | Authentication | Cipher | Signal |
"##,
+ );
+ s = s.replace("{n}", &arr.len().to_string());
+ for net in arr {
+ let _ = write!(
+ &mut s,
+ r##"| {ssid} | {auth} | {cipher} | {sig}% |
"##,
+ 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("
");
+ 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.