diff --git a/src/api/admin/i18n.rs b/src/api/admin/i18n.rs
index 4a340aa..ca7f4e5 100644
--- a/src/api/admin/i18n.rs
+++ b/src/api/admin/i18n.rs
@@ -999,6 +999,35 @@ pub fn t(lang: Lang, key: &str) -> &'static str {
"SSID-uri din apropiere ({0})",
"SSID cercanos ({0})",
),
+ "devices.installed_software" => (
+ "Installed software",
+ "Installierte Software",
+ "Logiciels installés",
+ "Software instalat",
+ "Software instalado",
+ ),
+ "devices.installed_software_count" => (
+ "Show {0} entries",
+ "{0} Einträge anzeigen",
+ "Afficher {0} entrées",
+ "Afișați {0} intrări",
+ "Mostrar {0} entradas",
+ ),
+ "devices.col_software_name" => ("Name", "Name", "Nom", "Nume", "Nombre"),
+ "devices.col_software_version" => (
+ "Version",
+ "Version",
+ "Version",
+ "Versiune",
+ "Versión",
+ ),
+ "devices.col_software_publisher" => (
+ "Publisher",
+ "Herausgeber",
+ "Éditeur",
+ "Editor",
+ "Editor",
+ ),
"devices.public_ip" => (
"Public IP (egress, last lookup)",
"Öffentliche IP (Egress, letzte Abfrage)",
diff --git a/src/api/admin/pages/devices.rs b/src/api/admin/pages/devices.rs
index f195eaa..e21e8db 100644
--- a/src/api/admin/pages/devices.rs
+++ b/src/api/admin/pages/devices.rs
@@ -618,6 +618,7 @@ fn render_inventory_table(lang: Lang, inv: &serde_json::Value) -> String {
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())
@@ -668,6 +669,7 @@ fn render_inventory_table(lang: Lang, inv: &serde_json::Value) -> String {
{l_pip}
{public_ip}
+ {software}
{l_bl}
{bl}
@@ -688,6 +690,7 @@ fn render_inventory_table(lang: Lang, inv: &serde_json::Value) -> String {
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"),
@@ -871,6 +874,75 @@ fn render_wifi(
)
}
+/// 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}
+ | {c_name} | {c_version} | {c_publisher} |
{rows}
+
+
"##,
+ 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.