Implement asset inventory
build / build-linux-amd64 (push) Successful in 2m6s

This commit is contained in:
2026-05-08 23:06:04 +02:00
parent 9d53999eea
commit 0dda056bda
4 changed files with 324 additions and 0 deletions
BIN
View File
Binary file not shown.
+8
View File
@@ -90,6 +90,14 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
) )
.route("/admin/pages/users/:id/delete", post(pages::users::delete)) .route("/admin/pages/users/:id/delete", post(pages::users::delete))
// Devices // Devices
.route(
"/admin/pages/devices/list-fragment",
get(pages::devices::list_fragment),
)
.route(
"/admin/pages/devices/:peer_id/detail",
get(pages::devices::detail),
)
.route( .route(
"/admin/pages/devices/:peer_id/disconnect", "/admin/pages/devices/:peer_id/disconnect",
post(pages::devices::force_disconnect), post(pages::devices::force_disconnect),
+280
View File
@@ -99,6 +99,38 @@ pub async fn delete(
notice_then_table(&state, if ok { "ok" } else { "error" }, &msg).await notice_then_table(&state, if ok { "ok" } else { "error" }, &msg).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<Arc<AppState>>,
admin: AuthedUser,
Path(peer_id): Path<String>,
) -> Result<Html<String>, 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(&d),
None => format!(
r##"<div class="space-y-4">
{back}
<div class="rounded border border-rose-700/50 bg-rose-900/30 p-3 text-sm text-rose-300">
No device with peer ID <code class="font-mono">{id}</code> in the dashboard.
</div>
</div>"##,
back = back_button(),
id = html_escape(&peer_id),
),
};
Ok(Html(html))
}
// ---------- helpers ---------- // ---------- helpers ----------
/// Compute online/offline state from a SQLite `current_timestamp` string /// Compute online/offline state from a SQLite `current_timestamp` string
@@ -274,6 +306,11 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi
href="/admin/connect/{id}" target="_blank" rel="noopener"> href="/admin/connect/{id}" target="_blank" rel="noopener">
Connect (web client) Connect (web client)
</a> </a>
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-get="/admin/pages/devices/{id}/detail"
hx-target="#devices-region" hx-swap="innerHTML">
Details &amp; inventory
</button>
<hr class="border-slate-700 my-1" /> <hr class="border-slate-700 my-1" />
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded" <button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/disconnect" hx-post="/admin/pages/devices/{id}/disconnect"
@@ -314,6 +351,249 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi
); );
} }
// ---------- 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<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
Ok(Html(render_table(&state).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() -> String {
r##"<button class="text-xs text-sky-300 hover:text-sky-200"
hx-get="/admin/pages/devices/list-fragment"
hx-target="#devices-region"
hx-swap="innerHTML">&larr; Back to devices</button>"##
.to_string()
}
/// 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(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##"<div class="rounded-md border border-slate-800 bg-slate-900 p-4">
<div class="flex items-baseline justify-between">
<h2 class="text-lg font-semibold">Device <code class="font-mono text-sky-300">{id}</code></h2>
<span class="text-xs text-slate-500">UUID <code class="font-mono">{uuid}</code></span>
</div>
<dl class="mt-3 grid grid-cols-2 gap-x-6 gap-y-1 text-sm md:grid-cols-3">
<div><dt class="text-xs text-slate-500">Hostname</dt><dd class="text-slate-200">{host}</dd></div>
<div><dt class="text-xs text-slate-500">Owner</dt><dd class="text-slate-200">{owner}</dd></div>
<div><dt class="text-xs text-slate-500">Active user</dt><dd class="text-slate-200">{user}</dd></div>
<div><dt class="text-xs text-slate-500">Agent</dt><dd class="text-slate-200">{ident}</dd></div>
<div><dt class="text-xs text-slate-500">OS (runtime)</dt><dd class="text-slate-200">{os_rt}</dd></div>
<div><dt class="text-xs text-slate-500">Last heartbeat</dt><dd class="text-slate-200">{last}</dd></div>
<div><dt class="text-xs text-slate-500">CPU (runtime)</dt><dd class="text-slate-200">{cpu_rt}</dd></div>
<div><dt class="text-xs text-slate-500">Memory (runtime)</dt><dd class="text-slate-200">{mem_rt}</dd></div>
</dl>
</div>"##,
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 — only present when the agent reports it. We key
// gating off `agent_name == "HelloAgent"` because the regular RustDesk
// client doesn't ship the inventory collector and would never populate
// this. `agent_name` is the explicit rebrand identity stamped by
// hello-agent's main.rs; absence means vanilla rustdesk.
let inventory_section = if agent_name == "HelloAgent" {
match parsed.get("inventory") {
Some(inv) if inv.is_object() => render_inventory_table(inv),
_ => format!(
r##"<div class="rounded-md border border-amber-700/50 bg-amber-900/20 p-3 text-sm text-amber-300">
Inventory data not yet reported. The agent collects it on startup and
uploads on the next sysinfo cycle (≤120 s).
</div>"##
),
}
} else {
format!(
r##"<div class="rounded-md border border-slate-700 bg-slate-900 p-3 text-sm text-slate-400">
Inventory data is only reported by HelloAgent. This device is running
<span class="text-slate-200">{ident}</span>; the standard RustDesk client
does not collect hardware or BitLocker inventory.
</div>"##,
ident = html_escape(&identity_label),
)
};
format!(
r##"<div class="space-y-4">
<div class="flex items-center justify-between">
{back}
<div class="text-xs text-slate-500">Detail view</div>
</div>
{header}
<h3 class="text-sm font-semibold text-slate-300 mt-4">Inventory</h3>
{inv}
</div>"##,
back = back_button(),
header = header,
inv = inventory_section,
)
}
fn render_inventory_table(inv: &serde_json::Value) -> String {
let row = |label: &str, key: &str| {
format!(
r##"<tr class="border-b border-slate-800">
<th class="text-left text-xs uppercase text-slate-500 px-3 py-2 w-1/3">{label}</th>
<td class="px-3 py-2 text-slate-200 font-mono text-xs">{val}</td>
</tr>"##,
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 = String::from(
r##"<table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">Name</th><th class="text-left font-medium px-2 py-1">Model</th><th class="text-right font-medium px-2 py-1">Size (GB)</th><th class="text-left font-medium px-2 py-1">Media</th></tr></thead><tbody>"##,
);
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##"<tr class="border-t border-slate-800"><td class="px-2 py-1 font-mono text-slate-300">{name}</td><td class="px-2 py-1 text-slate-300">{model}</td><td class="px-2 py-1 text-right font-mono text-slate-200">{size}</td><td class="px-2 py-1 text-slate-400">{media}</td></tr>"##,
);
}
s.push_str("</tbody></table>");
s
}
_ => r##"<span class="text-slate-500">—</span>"##.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() {
r##"<span class="text-slate-500">— (not reported; non-Pro SKU, not encrypted, or no admin rights)</span>"##.to_string()
} else {
format!(
r##"<code class="block font-mono text-xs text-amber-300 bg-slate-950 px-2 py-1 rounded border border-slate-800 select-all break-all">{}</code>"##,
html_escape(bl_key_raw)
)
};
format!(
r##"<div class="space-y-4">
<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<table class="w-full text-sm">
<tbody>
{sn}
{mfr}
{model}
{dom}
{os_d}
{os_r}
{cpu_m}
{cpu_s}
{cpu_pc}
{cpu_lc}
{ram}
</tbody>
</table>
</div>
<div>
<h4 class="text-xs uppercase text-slate-500 mb-1">Disks</h4>
<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden p-2">
{disks}
</div>
</div>
<div>
<h4 class="text-xs uppercase text-slate-500 mb-1">BitLocker recovery key (system drive)</h4>
{bl}
</div>
</div>"##,
sn = row("Serial number", "serial_number"),
mfr = row("Manufacturer", "manufacturer"),
model = row("Model", "model"),
dom = row("Windows domain", "domain"),
os_d = row("OS distribution", "os_distro"),
os_r = row("OS release", "os_release"),
cpu_m = row("CPU model", "cpu_model"),
cpu_s = row("CPU speed (GHz)", "cpu_speed_ghz"),
cpu_pc = row("CPU physical cores", "cpu_cores_physical"),
cpu_lc = row("CPU logical cores", "cpu_cores_logical"),
ram = row("RAM (GB)", "ram_gb"),
disks = disks_html,
bl = bl_html,
)
}
/// Render an elapsed-seconds count as a short "Xs / Xm / Xh / Xd" string /// Render an elapsed-seconds count as a short "Xs / Xm / Xh / Xd" string
/// for the offline tooltip. The exact heartbeat timestamp is already /// for the offline tooltip. The exact heartbeat timestamp is already
/// shown in the table cell — this is just for the friendly tooltip. /// shown in the table cell — this is just for the friendly tooltip.
+36
View File
@@ -640,6 +640,42 @@ impl Database {
Ok((total, data)) Ok((total, data))
} }
/// Fetch a single device row for the per-device detail page. Same
/// shape and join as `devices_list_all`, just keyed on peer id.
/// Returns `None` for an unknown / never-heartbeated peer id so the
/// handler can render a 404-style notice instead of failing the
/// request.
pub async fn device_get_by_id(
&self,
peer_id: &str,
) -> ResultType<Option<DashboardDeviceRow>> {
let row = sqlx::query(
"SELECT ds.id AS pid, ds.uuid AS puuid, \
COALESCE(u.username, '') AS owner_username, \
ds.last_heartbeat_at AS last_hb, \
ds.payload AS payload, \
ds.conns AS conns, \
COALESCE(ds.unattended_password, '') AS u_pw, \
COALESCE(ds.unattended_password_set_at, '') AS u_pw_at \
FROM device_sysinfo ds \
LEFT JOIN users u ON u.id = ds.user_id \
WHERE ds.id = ? LIMIT 1",
)
.bind(peer_id)
.fetch_optional(self.pool.get().await?.deref_mut())
.await?;
Ok(row.map(|r| DashboardDeviceRow {
id: r.try_get("pid").unwrap_or_default(),
uuid: r.try_get("puuid").unwrap_or_default(),
owner_username: r.try_get("owner_username").unwrap_or_default(),
last_heartbeat_at: r.try_get("last_hb").unwrap_or_default(),
sysinfo_payload: r.try_get("payload").unwrap_or_default(),
conns_json: r.try_get("conns").unwrap_or_default(),
unattended_password: r.try_get("u_pw").unwrap_or_default(),
unattended_password_set_at: r.try_get("u_pw_at").unwrap_or_default(),
}))
}
pub async fn device_sysinfo_get_conns(&self, peer_id: &str) -> ResultType<String> { pub async fn device_sysinfo_get_conns(&self, peer_id: &str) -> ResultType<String> {
let row = sqlx::query("SELECT conns FROM device_sysinfo WHERE id = ? LIMIT 1") let row = sqlx::query("SELECT conns FROM device_sysinfo WHERE id = ? LIMIT 1")
.bind(peer_id) .bind(peer_id)