Impement software inventory
build / build-linux-amd64 (push) Successful in 1m56s

This commit is contained in:
2026-05-21 23:56:06 +02:00
parent cd461a4507
commit 21b25bcc1b
2 changed files with 101 additions and 0 deletions
+29
View File
@@ -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)",
+72
View File
@@ -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 {
<h4 class="text-xs uppercase text-slate-500 mb-1">{l_pip}</h4>
{public_ip}
</div>
{software}
<div>
<h4 class="text-xs uppercase text-slate-500 mb-1">{l_bl}</h4>
{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 `<details>` 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##" <span class="ml-1 inline-block text-[10px] px-1 py-0 rounded bg-slate-800 text-slate-400">x64</span>"##
}
"32" => {
r##" <span class="ml-1 inline-block text-[10px] px-1 py-0 rounded bg-slate-800 text-slate-500">x86</span>"##
}
_ => "",
};
let _ = write!(
&mut rows,
r##"<tr class="border-t border-slate-800 align-top"><td class="px-2 py-1 text-slate-200">{name}{badge}</td><td class="px-2 py-1 font-mono text-slate-300">{version}</td><td class="px-2 py-1 text-slate-400">{publisher}</td></tr>"##,
name = name,
badge = bitness_badge,
version = version,
publisher = publisher,
);
}
format!(
r##"<div>
<h4 class="text-xs uppercase text-slate-500 mb-1">{label}</h4>
<details class="rounded-md border border-slate-800 bg-slate-900">
<summary class="cursor-pointer px-3 py-2 text-xs text-slate-400 hover:text-slate-200 select-none">{count_label}</summary>
<div class="p-2 overflow-x-auto"><table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">{c_name}</th><th class="text-left font-medium px-2 py-1">{c_version}</th><th class="text-left font-medium px-2 py-1">{c_publisher}</th></tr></thead><tbody>{rows}</tbody></table></div>
</details>
</div>"##,
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.