370 lines
13 KiB
Rust
370 lines
13 KiB
Rust
//! Devices page — list devices currently / recently registered, with
|
|
//! force-disconnect (queues a `heartbeat_commands` row consumed on the
|
|
//! peer's next /api/heartbeat tick) and force-sysinfo refresh.
|
|
|
|
use crate::api::error::ApiError;
|
|
use crate::api::middleware::AuthedUser;
|
|
use crate::api::state::AppState;
|
|
use crate::database::DashboardDeviceRow;
|
|
use axum::extract::{Extension, Path};
|
|
use axum::response::Html;
|
|
use std::fmt::Write as _;
|
|
use std::sync::Arc;
|
|
|
|
const PAGE_SIZE: i64 = 100;
|
|
|
|
/// Devices that have heartbeated within this many seconds are considered
|
|
/// online. Clients heartbeat every 15s (hbb_common::config::REG_INTERVAL),
|
|
/// so 45s allows up to two missed beats before we flip the dot to red.
|
|
const ONLINE_THRESHOLD_SECS: i64 = 45;
|
|
|
|
pub async fn index(
|
|
Extension(state): Extension<Arc<AppState>>,
|
|
admin: AuthedUser,
|
|
) -> Result<Html<String>, ApiError> {
|
|
require_admin(&admin)?;
|
|
let table = render_table(&state).await?;
|
|
Ok(Html(format!(
|
|
r##"<div class="space-y-6">
|
|
<header class="flex items-center justify-between">
|
|
<h2 class="text-lg font-semibold">Devices</h2>
|
|
<p class="text-xs text-slate-500">Force-disconnect / force-sysinfo are delivered on the peer's next heartbeat tick (~15 s).</p>
|
|
</header>
|
|
<section id="devices-region">
|
|
{table}
|
|
</section>
|
|
</div>"##
|
|
)))
|
|
}
|
|
|
|
pub async fn force_disconnect(
|
|
Extension(state): Extension<Arc<AppState>>,
|
|
admin: AuthedUser,
|
|
Path(peer_id): Path<String>,
|
|
) -> Result<Html<String>, ApiError> {
|
|
require_admin(&admin)?;
|
|
let conns = state
|
|
.db
|
|
.device_sysinfo_get_conns(&peer_id)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
state
|
|
.db
|
|
.heartbeat_command_queue(&peer_id, "disconnect", Some(&conns))
|
|
.await
|
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
notice_then_table(
|
|
&state,
|
|
"ok",
|
|
&format!("Queued disconnect for {} (conns={})", peer_id, conns),
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn force_sysinfo(
|
|
Extension(state): Extension<Arc<AppState>>,
|
|
admin: AuthedUser,
|
|
Path(peer_id): Path<String>,
|
|
) -> Result<Html<String>, ApiError> {
|
|
require_admin(&admin)?;
|
|
state
|
|
.db
|
|
.heartbeat_command_queue(&peer_id, "sysinfo", None)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
notice_then_table(
|
|
&state,
|
|
"ok",
|
|
&format!("Queued sysinfo refresh for {}", peer_id),
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn delete(
|
|
Extension(state): Extension<Arc<AppState>>,
|
|
admin: AuthedUser,
|
|
Path(peer_id): Path<String>,
|
|
) -> Result<Html<String>, ApiError> {
|
|
require_admin(&admin)?;
|
|
let ok = state
|
|
.db
|
|
.device_delete(&peer_id)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
let msg = if ok {
|
|
format!("Deleted device {}.", peer_id)
|
|
} else {
|
|
"Device already gone.".to_string()
|
|
};
|
|
notice_then_table(&state, if ok { "ok" } else { "error" }, &msg).await
|
|
}
|
|
|
|
// ---------- helpers ----------
|
|
|
|
/// Compute online/offline state from a SQLite `current_timestamp` string
|
|
/// ("YYYY-MM-DD HH:MM:SS" in UTC). Returns `(is_online, age_seconds)` —
|
|
/// the age is also useful for the tooltip text. On parse failure we fall
|
|
/// back to "offline" with `i64::MAX`, which is the safe direction (better
|
|
/// to show a stale row as offline than fake online).
|
|
fn online_state(last_heartbeat_at: &str, now: chrono::DateTime<chrono::Utc>) -> (bool, i64) {
|
|
let parsed = chrono::NaiveDateTime::parse_from_str(last_heartbeat_at, "%Y-%m-%d %H:%M:%S");
|
|
match parsed {
|
|
Ok(naive) => {
|
|
let last = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive, chrono::Utc);
|
|
let age = (now - last).num_seconds().max(0);
|
|
(age <= ONLINE_THRESHOLD_SECS, age)
|
|
}
|
|
Err(_) => (false, i64::MAX),
|
|
}
|
|
}
|
|
|
|
async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
|
|
let (total, devices) = state
|
|
.db
|
|
.devices_list_all(0, PAGE_SIZE)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
let now = chrono::Utc::now();
|
|
let mut s = String::new();
|
|
// No `overflow-hidden` on the table wrapper: the per-row action menu is
|
|
// an absolutely-positioned `<details>` popover inside a <td>, and the
|
|
// wrapper's clipping was hiding the bottom half of the menu.
|
|
let _ = write!(
|
|
s,
|
|
r##"<div class="rounded-md border border-slate-800 bg-slate-900">
|
|
<table class="w-full text-sm">
|
|
<thead class="text-xs uppercase text-slate-500 bg-slate-950">
|
|
<tr>
|
|
<th class="text-left font-medium px-3 py-2">Peer ID</th>
|
|
<th class="text-left font-medium px-3 py-2">Owner</th>
|
|
<th class="text-left font-medium px-3 py-2">Hostname</th>
|
|
<th class="text-left font-medium px-3 py-2">User</th>
|
|
<th class="text-left font-medium px-3 py-2">Unattended pwd</th>
|
|
<th class="text-left font-medium px-3 py-2">OS</th>
|
|
<th class="text-left font-medium px-3 py-2">Version</th>
|
|
<th class="text-left font-medium px-3 py-2">Last heartbeat</th>
|
|
<th class="text-left font-medium px-3 py-2">Conns</th>
|
|
<th class="text-right font-medium px-3 py-2 w-1">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-800">"##
|
|
);
|
|
if devices.is_empty() {
|
|
s.push_str(
|
|
r##"<tr><td colspan="10" class="px-3 py-4 text-slate-500 text-center text-xs">No devices have heartbeated yet.</td></tr>"##,
|
|
);
|
|
}
|
|
for d in &devices {
|
|
render_device_row(&mut s, d, now);
|
|
}
|
|
let _ = write!(
|
|
s,
|
|
r##"</tbody>
|
|
</table>
|
|
<div class="px-3 py-2 text-xs text-slate-500 border-t border-slate-800">{total} device(s).</div>
|
|
</div>"##
|
|
);
|
|
Ok(s)
|
|
}
|
|
|
|
fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTime<chrono::Utc>) {
|
|
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 hostname = pick("hostname");
|
|
// `username` is the active console user reported by the agent's
|
|
// sysinfo. The agent suppresses the field when nobody is logged in
|
|
// (or when it's literally "SYSTEM" on Windows), so an empty value
|
|
// here means "no interactive user" — render that as a dash.
|
|
let active_user = pick("username");
|
|
let os = pick("os");
|
|
// Version label. The sysinfo upload always carries `version` (the
|
|
// embedded rustdesk core version, e.g. "1.4.6"). Rebrands like
|
|
// hello-agent additionally stamp `agent_name` + `agent_version` —
|
|
// when present we surface those instead so the admin sees
|
|
// "Hello Agent 0.1.0" rather than the embedded core version.
|
|
// Fallback "RustDesk <ver>" is the right default for vanilla
|
|
// installs (they don't override the agent fields).
|
|
let version_label = {
|
|
let core_ver = pick("version");
|
|
let agent_name = pick("agent_name");
|
|
let agent_ver = pick("agent_version");
|
|
if !agent_name.is_empty() {
|
|
if !agent_ver.is_empty() {
|
|
format!("{agent_name} {agent_ver}")
|
|
} else {
|
|
agent_name
|
|
}
|
|
} else if !core_ver.is_empty() {
|
|
format!("RustDesk {core_ver}")
|
|
} else {
|
|
"—".to_string()
|
|
}
|
|
};
|
|
let conn_count = serde_json::from_str::<Vec<i64>>(&d.conns_json)
|
|
.map(|v| v.len())
|
|
.unwrap_or(0);
|
|
let (is_online, age_secs) = online_state(&d.last_heartbeat_at, now);
|
|
let (dot_class, tooltip) = if is_online {
|
|
(
|
|
"bg-emerald-400",
|
|
format!("Online — last heartbeat {}s ago", age_secs),
|
|
)
|
|
} else if age_secs == i64::MAX {
|
|
("bg-slate-500", "No heartbeat recorded".to_string())
|
|
} else {
|
|
(
|
|
"bg-rose-500",
|
|
format!("Offline — last heartbeat {} ago", fmt_age(age_secs)),
|
|
)
|
|
};
|
|
// Per-boot unattended-access password reported by hello-agent. Visible
|
|
// only when (a) the device is online (offline rows show stale data),
|
|
// (b) no interactive user is logged in (otherwise the supporter
|
|
// should be using the per-session approval popup, not the password),
|
|
// and (c) the agent has actually reported one (vanilla rustdesk
|
|
// never will). Otherwise show a neutral dash so the column lines up.
|
|
let unattended_pwd_cell = if is_online
|
|
&& active_user.is_empty()
|
|
&& !d.unattended_password.is_empty()
|
|
{
|
|
format!(
|
|
r##"<code class="font-mono text-xs text-amber-300 bg-slate-950 px-1.5 py-0.5 rounded border border-slate-800" title="Reported {set_at} UTC">{pw}</code>"##,
|
|
pw = html_escape(&d.unattended_password),
|
|
set_at = html_escape(&d.unattended_password_set_at),
|
|
)
|
|
} else {
|
|
r##"<span class="text-slate-600">—</span>"##.to_string()
|
|
};
|
|
|
|
let id_cell = format!(
|
|
r##"<td class="px-3 py-2 font-mono text-slate-200 whitespace-nowrap">
|
|
<span class="inline-flex items-center gap-2" title="{tt}">
|
|
<span class="inline-block w-2 h-2 rounded-full {dot}"></span>
|
|
<span>{id}</span>
|
|
</span>
|
|
</td>"##,
|
|
tt = html_escape(&tooltip),
|
|
dot = dot_class,
|
|
id = html_escape(&d.id),
|
|
);
|
|
let _ = write!(
|
|
s,
|
|
r##"<tr class="hover:bg-slate-800/40">
|
|
{id_cell}
|
|
<td class="px-3 py-2 text-slate-300">{owner}</td>
|
|
<td class="px-3 py-2 text-slate-400">{host}</td>
|
|
<td class="px-3 py-2 text-slate-300">{user}</td>
|
|
<td class="px-3 py-2 whitespace-nowrap">{unattended_pwd}</td>
|
|
<td class="px-3 py-2 text-slate-400">{os}</td>
|
|
<td class="px-3 py-2 text-slate-400 whitespace-nowrap">{ver}</td>
|
|
<td class="px-3 py-2 text-slate-500 text-xs">{last}</td>
|
|
<td class="px-3 py-2 text-slate-400">{n}</td>
|
|
<td class="px-3 py-2">
|
|
<details class="text-right relative">
|
|
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
|
|
<div class="absolute right-2 mt-1 z-10 w-56 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
|
|
<a class="block w-full text-left px-2 py-1 text-xs text-sky-300 hover:bg-sky-900/40 rounded"
|
|
href="/admin/connect/{id}" target="_blank" rel="noopener">
|
|
Connect (web client)
|
|
</a>
|
|
<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"
|
|
hx-target="#devices-region" hx-swap="innerHTML"
|
|
hx-confirm="Disconnect all active sessions on {id}?">
|
|
Force disconnect
|
|
</button>
|
|
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
|
|
hx-post="/admin/pages/devices/{id}/sysinfo-refresh"
|
|
hx-target="#devices-region" hx-swap="innerHTML">
|
|
Force sysinfo refresh
|
|
</button>
|
|
<hr class="border-slate-700 my-1" />
|
|
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/40 rounded"
|
|
hx-post="/admin/pages/devices/{id}/delete"
|
|
hx-target="#devices-region" hx-swap="innerHTML"
|
|
hx-confirm="Delete {id}? This removes the dashboard row and the rendezvous identity. Audit logs and recordings are kept.">
|
|
Delete device
|
|
</button>
|
|
</div>
|
|
</details>
|
|
</td>
|
|
</tr>"##,
|
|
id_cell = id_cell,
|
|
id = html_escape(&d.id),
|
|
owner = html_escape(&d.owner_username),
|
|
host = html_escape(&hostname),
|
|
user = if active_user.is_empty() {
|
|
"—".to_string()
|
|
} else {
|
|
html_escape(&active_user)
|
|
},
|
|
unattended_pwd = unattended_pwd_cell,
|
|
os = html_escape(&os),
|
|
ver = html_escape(&version_label),
|
|
last = html_escape(&d.last_heartbeat_at),
|
|
n = conn_count
|
|
);
|
|
}
|
|
|
|
/// 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.
|
|
fn fmt_age(secs: i64) -> String {
|
|
if secs < 60 {
|
|
format!("{}s", secs)
|
|
} else if secs < 3600 {
|
|
format!("{}m", secs / 60)
|
|
} else if secs < 86_400 {
|
|
format!("{}h", secs / 3600)
|
|
} else {
|
|
format!("{}d", secs / 86_400)
|
|
}
|
|
}
|
|
|
|
async fn notice_then_table(
|
|
state: &Arc<AppState>,
|
|
kind: &str,
|
|
msg: &str,
|
|
) -> Result<Html<String>, ApiError> {
|
|
let mut html = notice_html(kind, msg);
|
|
html.push_str(&render_table(state).await?);
|
|
Ok(Html(html))
|
|
}
|
|
|
|
fn notice_html(kind: &str, msg: &str) -> String {
|
|
let (border, bg, text) = match kind {
|
|
"ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"),
|
|
_ => ("rose-700/50", "rose-900/30", "rose-300"),
|
|
};
|
|
format!(
|
|
r##"<div class="rounded border border-{border} bg-{bg} p-3 mb-4 text-sm text-{text}">{msg}</div>"##,
|
|
border = border,
|
|
bg = bg,
|
|
text = text,
|
|
msg = html_escape(msg),
|
|
)
|
|
}
|
|
|
|
fn html_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
}
|
|
|
|
fn require_admin(u: &AuthedUser) -> Result<(), ApiError> {
|
|
if u.is_admin {
|
|
Ok(())
|
|
} else {
|
|
Err(ApiError::Forbidden("admin required".into()))
|
|
}
|
|
}
|