//! 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>, admin: AuthedUser, ) -> Result, ApiError> { require_admin(&admin)?; let table = render_table(&state).await?; Ok(Html(format!( r##"

Devices

Force-disconnect / force-sysinfo are delivered on the peer's next heartbeat tick (~15 s).

{table}
"## ))) } pub async fn force_disconnect( Extension(state): Extension>, admin: AuthedUser, Path(peer_id): Path, ) -> Result, 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>, admin: AuthedUser, Path(peer_id): Path, ) -> Result, 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>, admin: AuthedUser, Path(peer_id): Path, ) -> Result, 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) -> (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::::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) -> Result { 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 `
` popover inside a , and the // wrapper's clipping was hiding the bottom half of the menu. let _ = write!( s, r##"
"## ); if devices.is_empty() { s.push_str( r##""##, ); } for d in &devices { render_device_row(&mut s, d, now); } let _ = write!( s, r##"
Peer ID Owner Hostname User Unattended pwd OS Version Last heartbeat Conns Actions
No devices have heartbeated yet.
{total} device(s).
"## ); Ok(s) } fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTime) { 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 " 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::>(&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##"{pw}"##, pw = html_escape(&d.unattended_password), set_at = html_escape(&d.unattended_password_set_at), ) } else { r##""##.to_string() }; let id_cell = format!( r##" {id} "##, tt = html_escape(&tooltip), dot = dot_class, id = html_escape(&d.id), ); let _ = write!( s, r##" {id_cell} {owner} {host} {user} {unattended_pwd} {os} {ver} {last} {n}
···
Connect (web client)

"##, 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, kind: &str, msg: &str, ) -> Result, 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##"
{msg}
"##, 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())) } }