This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user