This commit is contained in:
Binary file not shown.
@@ -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))
|
||||
// Devices
|
||||
.route(
|
||||
"/admin/pages/devices/list-fragment",
|
||||
get(pages::devices::list_fragment),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/:peer_id/detail",
|
||||
get(pages::devices::detail),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/:peer_id/disconnect",
|
||||
post(pages::devices::force_disconnect),
|
||||
|
||||
@@ -99,6 +99,38 @@ pub async fn delete(
|
||||
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 ----------
|
||||
|
||||
/// 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">
|
||||
Connect (web client)
|
||||
</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 & inventory
|
||||
</button>
|
||||
<hr class="border-slate-700 my-1" />
|
||||
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
|
||||
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">← 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
|
||||
/// for the offline tooltip. The exact heartbeat timestamp is already
|
||||
/// shown in the table cell — this is just for the friendly tooltip.
|
||||
|
||||
@@ -640,6 +640,42 @@ impl Database {
|
||||
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> {
|
||||
let row = sqlx::query("SELECT conns FROM device_sysinfo WHERE id = ? LIMIT 1")
|
||||
.bind(peer_id)
|
||||
|
||||
Reference in New Issue
Block a user