web UI and web client improvements.
build / build-linux-amd64 (push) Successful in 2m1s

This commit is contained in:
2026-05-07 22:19:23 +02:00
parent 8ad3f43d21
commit c1eaac1cb3
6 changed files with 308 additions and 35 deletions
+106 -4
View File
@@ -13,6 +13,11 @@ 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,
@@ -96,12 +101,30 @@ pub async fn delete(
// ---------- 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
@@ -115,7 +138,9 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
<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">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>
@@ -125,11 +150,11 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
);
if devices.is_empty() {
s.push_str(
r##"<tr><td colspan="7" class="px-3 py-4 text-slate-500 text-center text-xs">No devices have heartbeated yet.</td></tr>"##,
r##"<tr><td colspan="9" 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);
render_device_row(&mut s, d, now);
}
let _ = write!(
s,
@@ -141,7 +166,7 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
Ok(s)
}
fn render_device_row(s: &mut String, d: &DashboardDeviceRow) {
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 {
@@ -152,17 +177,72 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow) {
.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)),
)
};
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">
<td class="px-3 py-2 font-mono text-slate-200">{id}</td>
{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 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">
@@ -196,15 +276,37 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow) {
</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)
},
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,