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))
|
.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),
|
||||||
|
|||||||
@@ -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 & 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">← 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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user