Files
rustdesk-server/src/api/admin/pages/devices.rs
T
mike d941ae9739
build / build-linux-amd64 (push) Successful in 1m56s
Fix horizontal scrolling in admin UI
2026-05-25 00:19:14 +02:00

2258 lines
84 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::admin::i18n::{t, tf1, tf2, tf3, Lang};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use crate::database::{DashboardDeviceRow, LoginEventRow, MetricsSampleRow, PerfEventRow};
use axum::extract::{Extension, Form, Path, Query};
use axum::response::Html;
use serde::Deserialize;
use std::collections::HashSet;
use std::fmt::Write as _;
use std::sync::Arc;
// ---------- pagination / search prefs ----------
const PAGE_SIZE_DEFAULT: i64 = 100;
const PAGE_SIZE_OPTIONS: &[i64] = &[25, 50, 100, 500];
const DEVICE_PAGE_SIZE_KEY: &str = "devices.page_size";
const DEVICE_COLS_HIDE_KEY: &str = "devices.cols_hide";
/// 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;
#[derive(Debug, Deserialize, Default)]
pub struct DevicesListParams {
#[serde(default)]
pub page: Option<i64>,
#[serde(default)]
pub q: Option<String>,
}
impl DevicesListParams {
fn page_or_first(&self) -> i64 {
self.page.unwrap_or(1).max(1)
}
fn query(&self) -> &str {
match &self.q {
Some(s) => s.trim(),
None => "",
}
}
}
/// Per-user column visibility for the devices table. The peer-id cell
/// and actions menu aren't user-toggleable, so they're absent here.
#[derive(Clone, Copy, Debug)]
pub struct DeviceColumns {
pub owner: bool,
pub host: bool,
pub serial: bool,
pub user: bool,
pub unattended_pwd: bool,
pub os: bool,
pub version: bool,
pub last_heartbeat: bool,
pub conns: bool,
pub auth: bool,
}
impl Default for DeviceColumns {
fn default() -> Self {
Self {
owner: true,
host: true,
serial: true,
user: true,
unattended_pwd: true,
os: true,
version: true,
last_heartbeat: true,
conns: true,
auth: true,
}
}
}
impl DeviceColumns {
fn from_hidden_csv(raw: &str) -> Self {
let hidden: HashSet<&str> = raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
Self {
owner: !hidden.contains("owner"),
host: !hidden.contains("host"),
serial: !hidden.contains("serial"),
user: !hidden.contains("user"),
unattended_pwd: !hidden.contains("unattended_pwd"),
os: !hidden.contains("os"),
version: !hidden.contains("version"),
last_heartbeat: !hidden.contains("last_heartbeat"),
conns: !hidden.contains("conns"),
auth: !hidden.contains("auth"),
}
}
fn to_hidden_csv(self) -> String {
let mut hidden: Vec<&str> = Vec::new();
if !self.owner { hidden.push("owner"); }
if !self.host { hidden.push("host"); }
if !self.serial { hidden.push("serial"); }
if !self.user { hidden.push("user"); }
if !self.unattended_pwd { hidden.push("unattended_pwd"); }
if !self.os { hidden.push("os"); }
if !self.version { hidden.push("version"); }
if !self.last_heartbeat { hidden.push("last_heartbeat"); }
if !self.conns { hidden.push("conns"); }
if !self.auth { hidden.push("auth"); }
hidden.join(",")
}
fn visible_count(self) -> i64 {
self.owner as i64
+ self.host as i64
+ self.serial as i64
+ self.user as i64
+ self.unattended_pwd as i64
+ self.os as i64
+ self.version as i64
+ self.last_heartbeat as i64
+ self.conns as i64
+ self.auth as i64
}
}
async fn load_device_columns(state: &Arc<AppState>, user_id: i64) -> DeviceColumns {
match state.db.user_pref_get(user_id, DEVICE_COLS_HIDE_KEY).await {
Ok(Some(raw)) => DeviceColumns::from_hidden_csv(&raw),
_ => DeviceColumns::default(),
}
}
async fn load_device_page_size(state: &Arc<AppState>, user_id: i64) -> i64 {
let raw = state
.db
.user_pref_get(user_id, DEVICE_PAGE_SIZE_KEY)
.await
.ok()
.flatten()
.and_then(|s| s.parse::<i64>().ok());
match raw {
Some(v) if PAGE_SIZE_OPTIONS.contains(&v) => v,
_ => PAGE_SIZE_DEFAULT,
}
}
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Query(pg): Query<DevicesListParams>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let cols = load_device_columns(&state, admin.user_id).await;
let page_size = load_device_page_size(&state, admin.user_id).await;
let page = pg.page_or_first();
let q = pg.query();
let table = render_table(&state, lang, cols, q, page, page_size).await?;
let columns_popover = render_columns_popover(lang, cols);
Ok(Html(format!(
r##"<div class="space-y-6">
<header class="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 class="text-lg font-semibold">{heading}</h2>
<p class="text-xs text-slate-500 mt-0.5">{tagline}</p>
</div>
<div class="flex items-center gap-2 flex-wrap">
<input
id="devices-search"
type="search"
name="q"
value="{q_value}"
placeholder="{search_placeholder}"
autocomplete="off"
class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5 text-sm w-56 focus:outline-none focus:border-sky-600"
hx-get="/admin/pages/devices/list-fragment"
hx-trigger="input changed delay:200ms, search"
hx-target="#devices-region"
hx-swap="innerHTML"
/>
{columns_popover}
</div>
</header>
<section id="devices-region">
{table}
</section>
</div>"##,
heading = t(lang, "devices.heading"),
tagline = t(lang, "devices.tagline"),
search_placeholder = t(lang, "devices.search_placeholder"),
q_value = html_escape(q),
columns_popover = columns_popover,
table = table,
)))
}
pub async fn force_disconnect(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>,
Query(pg): Query<DevicesListParams>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let cols = load_device_columns(&state, admin.user_id).await;
let page_size = load_device_page_size(&state, admin.user_id).await;
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,
lang,
cols,
pg.query(),
pg.page_or_first(),
page_size,
"ok",
&tf2(lang, "devices.queued_disconnect", &peer_id, &conns),
)
.await
}
pub async fn force_sysinfo(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>,
Query(pg): Query<DevicesListParams>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let cols = load_device_columns(&state, admin.user_id).await;
let page_size = load_device_page_size(&state, admin.user_id).await;
state
.db
.heartbeat_command_queue(&peer_id, "sysinfo", None)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then_table(
&state,
lang,
cols,
pg.query(),
pg.page_or_first(),
page_size,
"ok",
&tf1(lang, "devices.queued_sysinfo", &peer_id),
)
.await
}
pub async fn delete(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>,
Query(pg): Query<DevicesListParams>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let cols = load_device_columns(&state, admin.user_id).await;
let page_size = load_device_page_size(&state, admin.user_id).await;
let ok = state
.db
.device_delete(&peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let msg = if ok {
tf1(lang, "devices.deleted", &peer_id)
} else {
t(lang, "devices.already_gone").to_string()
};
notice_then_table(
&state,
lang,
cols,
pg.query(),
pg.page_or_first(),
page_size,
if ok { "ok" } else { "error" },
&msg,
)
.await
}
/// Flip `peer.managed` between 0 and 1. Same effect as calling the JSON
/// API `PUT /api/peers/:id/managed`, but rendered as an HTMX action so the
/// table refreshes in place. The handler reads the current value, flips
/// it, and writes back — this avoids a stale-toggle race where the row
/// the admin clicked on showed a stale state (e.g. TOFU just promoted it
/// in the background) and a "set to N" command would no-op silently.
pub async fn toggle_managed(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>,
Query(pg): Query<DevicesListParams>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let cols = load_device_columns(&state, admin.user_id).await;
let page_size = load_device_page_size(&state, admin.user_id).await;
let q = pg.query();
let page = pg.page_or_first();
let row = state
.db
.peer_get_auth(&peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let (_pk, was_managed) = match row {
Some(r) => r,
None => {
return notice_then_table(
&state,
lang,
cols,
q,
page,
page_size,
"error",
&tf1(lang, "devices.managed_no_peer", &peer_id),
)
.await;
}
};
let new_value = !was_managed;
state
.db
.peer_set_managed(&peer_id, new_value)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
hbb_common::log::info!(
"admin {} set peer {} managed={} via dashboard",
admin.name,
peer_id,
new_value
);
let key = if new_value {
"devices.managed_now_on"
} else {
"devices.managed_now_off"
};
notice_then_table(
&state,
lang,
cols,
q,
page,
page_size,
"ok",
&tf1(lang, key, &peer_id),
)
.await
}
// ---------- column visibility / page size POST handlers ----------
#[derive(Debug, Deserialize)]
pub struct ColumnsForm {
pub col: String,
pub visible: String,
#[serde(default)]
pub q: Option<String>,
}
pub async fn set_columns(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Form(form): Form<ColumnsForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let mut cols = load_device_columns(&state, admin.user_id).await;
let visible = form.visible == "1" || form.visible.eq_ignore_ascii_case("true");
match form.col.as_str() {
"owner" => cols.owner = visible,
"host" => cols.host = visible,
"serial" => cols.serial = visible,
"user" => cols.user = visible,
"unattended_pwd" => cols.unattended_pwd = visible,
"os" => cols.os = visible,
"version" => cols.version = visible,
"last_heartbeat" => cols.last_heartbeat = visible,
"conns" => cols.conns = visible,
"auth" => cols.auth = visible,
_ => {}
}
state
.db
.user_pref_set(admin.user_id, DEVICE_COLS_HIDE_KEY, &cols.to_hidden_csv())
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let page_size = load_device_page_size(&state, admin.user_id).await;
let q = form.q.as_deref().unwrap_or("").trim();
Ok(Html(
render_table(&state, lang, cols, q, 1, page_size).await?,
))
}
#[derive(Debug, Deserialize)]
pub struct PageSizeForm {
pub size: i64,
#[serde(default)]
pub q: Option<String>,
}
pub async fn set_page_size(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Form(form): Form<PageSizeForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
if !PAGE_SIZE_OPTIONS.contains(&form.size) {
return Err(ApiError::BadRequest("invalid page size".into()));
}
state
.db
.user_pref_set(
admin.user_id,
DEVICE_PAGE_SIZE_KEY,
&form.size.to_string(),
)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let cols = load_device_columns(&state, admin.user_id).await;
let q = form.q.as_deref().unwrap_or("").trim();
Ok(Html(
render_table(&state, lang, cols, q, 1, form.size).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,
lang: Lang,
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) => {
// Login events are surfaced on the detail page below the
// inventory. We cap at 50 — that's a few weeks of typical
// logon/logoff activity on a single-user box and stays
// skimmable. Deeper history can be added later behind a
// pagination control if anyone asks for it.
let events = state
.db
.login_events_for_peer(&d.id, 50)
.await
.unwrap_or_default();
// Performance: pull the most recent metrics sample for the
// "right now" card, plus 24 h of samples for the sparkline,
// plus the most recent perf events (boot/shutdown/memory-
// exhaustion etc.) for the "recent slow events" table.
// All three are best-effort — none of them is required for
// the detail page to render meaningfully.
let metrics_latest = state
.db
.metrics_latest(&d.id)
.await
.unwrap_or_default();
let since_24h = chrono::Utc::now().timestamp() - 24 * 3600;
let metrics_24h = state
.db
.metrics_samples_since(&d.id, since_24h)
.await
.unwrap_or_default();
let perf_events = state
.db
.perf_events_for_peer(&d.id, 20)
.await
.unwrap_or_default();
render_detail(
lang,
&d,
&events,
metrics_latest.as_ref(),
&metrics_24h,
&perf_events,
)
}
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} <code class="font-mono">{id}</code> {in_dashboard}
</div>
</div>"##,
back = back_button(lang),
no_device = t(lang, "devices.no_device_with_id"),
in_dashboard = t(lang, "devices.in_dashboard"),
id = html_escape(&peer_id),
),
};
Ok(Html(html))
}
// ---------- 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>,
lang: Lang,
cols: DeviceColumns,
q: &str,
page: i64,
page_size: i64,
) -> Result<String, ApiError> {
// Probe the filtered total so we can clamp the requested page before
// doing the offset query (e.g. asking for page 5 after a delete that
// left only 1 page should land on page 1, not show an empty list).
let (total, _) = state
.db
.devices_list_filtered(q, 0, 0)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let total_pages = if total == 0 { 1 } else { (total + page_size - 1) / page_size };
let page = page.clamp(1, total_pages);
let offset = (page - 1) * page_size;
let (_, devices) = state
.db
.devices_list_filtered(q, offset, page_size)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let now = chrono::Utc::now();
let mut s = String::new();
// `overflow-x-auto` on the inner wrapper gives the wide table a
// horizontal scrollbar instead of pushing past the viewport. The
// action-menu popover compensates for the implicit `overflow-y: auto`
// (CSS spec) by switching to `position: fixed` on toggle — see
// `actionMenuToggle` in admin_ui/index.html.
let _ = write!(
s,
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-x-auto">
<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">{c_peer}</th>"##,
c_peer = t(lang, "devices.col_peer_id"),
);
if cols.owner {
let _ = write!(s, r##"
<th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_owner"));
}
if cols.host {
let _ = write!(s, r##"
<th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_hostname"));
}
if cols.serial {
let _ = write!(s, r##"
<th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_serial"));
}
if cols.user {
let _ = write!(s, r##"
<th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_user"));
}
if cols.unattended_pwd {
let _ = write!(s, r##"
<th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_unattended_pwd"));
}
if cols.os {
let _ = write!(s, r##"
<th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_os"));
}
if cols.version {
let _ = write!(s, r##"
<th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_version"));
}
if cols.last_heartbeat {
let _ = write!(s, r##"
<th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_last_heartbeat"));
}
if cols.conns {
let _ = write!(s, r##"
<th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_conns"));
}
if cols.auth {
let _ = write!(s, r##"
<th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_auth"));
}
let _ = write!(
s,
r##"
<th class="text-right font-medium px-3 py-2 w-1">{c_actions}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">"##,
c_actions = t(lang, "common.actions"),
);
if devices.is_empty() {
// colspan = peer column + visible toggle columns + actions column.
let colspan = 1 + cols.visible_count() + 1;
let empty_msg = if q.is_empty() {
t(lang, "devices.no_devices")
} else {
t(lang, "devices.no_match")
};
let _ = write!(
s,
r##"<tr><td colspan="{colspan}" class="px-3 py-6 text-slate-500 text-center text-xs">{empty}</td></tr>"##,
colspan = colspan,
empty = empty_msg,
);
} else {
let always_show_pwd = unattended_pwd_always_visible();
for d in &devices {
render_device_row(&mut s, lang, cols, q, page, d, now, always_show_pwd);
}
}
s.push_str("</tbody>\n</table></div>");
s.push_str(&render_pagination_footer(lang, q, page, page_size, total, total_pages));
Ok(s)
}
fn render_columns_popover(lang: Lang, cols: DeviceColumns) -> String {
let items: [(&str, bool, &str); 10] = [
("owner", cols.owner, t(lang, "devices.col_owner")),
("host", cols.host, t(lang, "devices.col_hostname")),
("serial", cols.serial, t(lang, "devices.col_serial")),
("user", cols.user, t(lang, "devices.col_user")),
("unattended_pwd", cols.unattended_pwd, t(lang, "devices.col_unattended_pwd")),
("os", cols.os, t(lang, "devices.col_os")),
("version", cols.version, t(lang, "devices.col_version")),
("last_heartbeat", cols.last_heartbeat, t(lang, "devices.col_last_heartbeat")),
("conns", cols.conns, t(lang, "devices.col_conns")),
("auth", cols.auth, t(lang, "devices.col_auth")),
];
let mut checkboxes = String::new();
for (id, visible, label) in items {
let checked = if visible { " checked" } else { "" };
let _ = write!(
checkboxes,
r##"<label class="flex items-center gap-2 px-2 py-1 text-xs hover:bg-slate-800 rounded cursor-pointer">
<input type="checkbox" data-col="{id}"{checked} onchange="devicesColumnToggle(this)">
<span class="text-slate-300">{label}</span>
</label>"##,
id = id,
checked = checked,
label = html_escape(label),
);
}
format!(
r##"<details class="relative">
<summary class="cursor-pointer list-none select-none bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-300">
{columns_label} ▾
</summary>
<div class="absolute right-0 mt-1 z-10 w-56 bg-slate-900 border border-slate-700 rounded shadow-lg p-1 space-y-0.5">
{checkboxes}
</div>
</details>"##,
columns_label = t(lang, "devices.columns"),
checkboxes = checkboxes,
)
}
fn render_pagination_footer(
lang: Lang,
q: &str,
page: i64,
page_size: i64,
total: i64,
total_pages: i64,
) -> String {
let from = if total == 0 { 0 } else { (page - 1) * page_size + 1 };
let to = ((page * page_size).min(total)).max(0);
let showing = tf3(
lang,
"devices.showing_range",
&from.to_string(),
&to.to_string(),
&total.to_string(),
);
let mut size_options = String::new();
for &n in PAGE_SIZE_OPTIONS {
let selected = if n == page_size { " selected" } else { "" };
let _ = write!(
size_options,
r##"<option value="{n}"{selected}>{n}</option>"##,
n = n,
selected = selected,
);
}
let q_param = if q.is_empty() {
String::new()
} else {
format!("&q={}", url_encode(q))
};
let prev_disabled = page <= 1;
let next_disabled = page >= total_pages;
let prev_attrs = if prev_disabled {
" disabled".to_string()
} else {
format!(
r##" hx-get="/admin/pages/devices/list-fragment?page={p}{q_param}" hx-target="#devices-region" hx-swap="innerHTML""##,
p = page - 1,
q_param = q_param,
)
};
let next_attrs = if next_disabled {
" disabled".to_string()
} else {
format!(
r##" hx-get="/admin/pages/devices/list-fragment?page={p}{q_param}" hx-target="#devices-region" hx-swap="innerHTML""##,
p = page + 1,
q_param = q_param,
)
};
let btn_base = "px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 border border-slate-700 \
disabled:opacity-40 disabled:hover:bg-slate-800 disabled:cursor-not-allowed";
format!(
r##"<div class="flex flex-wrap items-center justify-between gap-3 text-xs text-slate-400 pt-3 px-1">
<div class="flex items-center gap-3">
<span>{showing}</span>
<label class="flex items-center gap-2">
<span>{per_page_label}:</span>
<select onchange="devicesPageSize(this.value)" class="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs">
{size_options}
</select>
</label>
</div>
<div class="flex items-center gap-2">
<button class="{btn}"{prev_attrs}>← {prev}</button>
<span class="text-slate-500 tabular-nums">{page} / {total_pages}</span>
<button class="{btn}"{next_attrs}>{next} →</button>
</div>
</div>"##,
showing = html_escape(&showing),
per_page_label = t(lang, "devices.per_page"),
size_options = size_options,
prev = t(lang, "common.prev"),
next = t(lang, "common.next"),
btn = btn_base,
prev_attrs = prev_attrs,
next_attrs = next_attrs,
page = page,
total_pages = total_pages,
)
}
/// Percent-encode characters that aren't safe in a URL query value.
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*b as char);
}
_ => {
let _ = write!(out, "%{:02X}", b);
}
}
}
out
}
/// Resolves the `--unattended-pwd-visibility` setting (env key
/// `UNATTENDED-PWD-VISIBILITY`, also settable via `.env`). Returns `true`
/// when the admin UI should surface the unattended password even while an
/// interactive user is logged in. Default (`logged-out`, or any
/// unrecognised value) keeps the original behaviour: shown only when nobody
/// is logged in.
fn unattended_pwd_always_visible() -> bool {
crate::common::get_arg_or("unattended-pwd-visibility", "logged-out".to_owned())
.trim()
.eq_ignore_ascii_case("always")
}
fn render_device_row(
s: &mut String,
lang: Lang,
cols: DeviceColumns,
q: &str,
page: i64,
d: &DashboardDeviceRow,
now: chrono::DateTime<chrono::Utc>,
always_show_pwd: bool,
) {
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");
// Hardware serial number — only present when the agent (e.g.
// hello-agent) ships an inventory blob alongside the sysinfo
// payload. Vanilla rustdesk doesn't, so most rows render a dash.
let serial_number = parsed
.get("inventory")
.and_then(|inv| inv.get("serial_number"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
// `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",
tf1(lang, "devices.online", &age_secs.to_string()),
)
} else if age_secs == i64::MAX {
("bg-slate-500", t(lang, "devices.no_heartbeat").to_string())
} else {
(
"bg-rose-500",
tf1(lang, "devices.offline", &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)
// — unless `--unattended-pwd-visibility=always` overrides (b), 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
&& (always_show_pwd || 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),
);
// `?page={page}&q={q}` appended to every per-row action so the
// re-rendered table comes back showing the same filtered page.
let q_param = if q.is_empty() {
String::new()
} else {
format!("&q={}", url_encode(q))
};
// Auth toggle: the menu entry's label flips based on current state,
// and only the off→on transition needs no confirm (it strengthens
// security). on→off removes the signature requirement and reintroduces
// the spoofing surface, so we require a confirm on that direction.
let toggle_managed_item = if d.managed {
format!(
r##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/toggle-managed?page={page}{q_param}"
hx-target="#devices-region" hx-swap="innerHTML"
hx-confirm="{confirm}">
{label}
</button>"##,
id = html_escape(&d.id),
page = page,
q_param = q_param,
confirm = html_escape(&tf1(lang, "devices.confirm_managed_off", &d.id)),
label = t(lang, "devices.mark_unsigned"),
)
} else {
format!(
r##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/toggle-managed?page={page}{q_param}"
hx-target="#devices-region" hx-swap="innerHTML">
{label}
</button>"##,
id = html_escape(&d.id),
page = page,
q_param = q_param,
label = t(lang, "devices.mark_managed"),
)
};
// Open <tr> + the always-on peer id cell.
let _ = write!(s, r##"<tr class="hover:bg-slate-800/40">
{id_cell}"##, id_cell = id_cell);
if cols.owner {
let _ = write!(
s,
r##"
<td class="px-3 py-2 text-slate-300">{}</td>"##,
html_escape(&d.owner_username),
);
}
if cols.host {
let _ = write!(
s,
r##"
<td class="px-3 py-2 text-slate-400">{}</td>"##,
html_escape(&hostname),
);
}
if cols.serial {
let cell = if serial_number.is_empty() {
r##"<span class="text-slate-600">—</span>"##.to_string()
} else {
format!(
r##"<span class="font-mono text-xs text-slate-300">{}</span>"##,
html_escape(&serial_number),
)
};
let _ = write!(
s,
r##"
<td class="px-3 py-2 whitespace-nowrap">{}</td>"##,
cell,
);
}
if cols.user {
let user_cell = if active_user.is_empty() {
"".to_string()
} else {
html_escape(&active_user)
};
let _ = write!(
s,
r##"
<td class="px-3 py-2 text-slate-300">{}</td>"##,
user_cell,
);
}
if cols.unattended_pwd {
let _ = write!(
s,
r##"
<td class="px-3 py-2 whitespace-nowrap">{}</td>"##,
unattended_pwd_cell,
);
}
if cols.os {
let _ = write!(
s,
r##"
<td class="px-3 py-2 text-slate-400">{}</td>"##,
html_escape(&os),
);
}
if cols.version {
let _ = write!(
s,
r##"
<td class="px-3 py-2 text-slate-400 whitespace-nowrap">{}</td>"##,
html_escape(&version_label),
);
}
if cols.last_heartbeat {
let _ = write!(
s,
r##"
<td class="px-3 py-2 text-slate-500 text-xs">{}</td>"##,
html_escape(&d.last_heartbeat_at),
);
}
if cols.conns {
let _ = write!(
s,
r##"
<td class="px-3 py-2 text-slate-400">{}</td>"##,
conn_count,
);
}
if cols.auth {
// Auth badge: `Signed` (emerald) when peer.managed=1 — heartbeat /
// sysinfo posts must carry a valid Ed25519 signature; `—` (slate) when
// managed=0 and the device still posts unsigned bodies. The tooltip
// gives the operator the one-line explanation so they know what
// flipping the flag will do.
let auth_cell = if d.managed {
format!(
r##"<td class="px-3 py-2 whitespace-nowrap">
<span class="inline-flex items-center gap-1 rounded border border-emerald-700/50 bg-emerald-900/30 px-2 py-0.5 text-xs text-emerald-300" title="{tt}">{label}</span>
</td>"##,
tt = html_escape(t(lang, "devices.auth_signed_tooltip")),
label = t(lang, "devices.auth_signed"),
)
} else {
format!(
r##"<td class="px-3 py-2 whitespace-nowrap">
<span class="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-800/40 px-2 py-0.5 text-xs text-slate-400" title="{tt}">{label}</span>
</td>"##,
tt = html_escape(t(lang, "devices.auth_unsigned_tooltip")),
label = t(lang, "devices.auth_unsigned"),
)
};
let _ = write!(s, "\n {}", auth_cell);
}
let _ = write!(
s,
r##"
<td class="px-3 py-2">
<details class="text-right relative" ontoggle="actionMenuToggle(this)">
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
<div data-action-menu class="absolute right-2 mt-1 z-50 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"
data-confirm="{confirm_connect}"
onclick="return confirmFromDataAttr(this)">
{connect_web}
</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"
hx-push-url="#devices/{id}">
{details}
</button>
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-get="/admin/pages/devices/{id}/exec"
hx-target="#devices-region" hx-swap="innerHTML"
hx-push-url="#devices/{id}/exec">
{run_command}
</button>
<hr class="border-slate-700 my-1" />
{toggle_managed_item}
<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?page={page}{q_param}"
hx-target="#devices-region" hx-swap="innerHTML"
hx-confirm="{confirm_disc}">
{force_disc}
</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?page={page}{q_param}"
hx-target="#devices-region" hx-swap="innerHTML">
{force_sysinfo}
</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?page={page}{q_param}"
hx-target="#devices-region" hx-swap="innerHTML"
hx-confirm="{confirm_delete}">
{delete_device}
</button>
</div>
</details>
</td>
</tr>"##,
id = html_escape(&d.id),
page = page,
q_param = q_param,
toggle_managed_item = toggle_managed_item,
connect_web = t(lang, "devices.connect_web"),
confirm_connect = html_escape(&tf1(lang, "devices.confirm_connect", &d.id)),
details = t(lang, "devices.details"),
run_command = t(lang, "devices.run_command"),
confirm_disc = html_escape(&tf1(lang, "devices.confirm_disconnect", &d.id)),
force_disc = t(lang, "devices.force_disconnect"),
force_sysinfo = t(lang, "devices.force_sysinfo"),
confirm_delete = html_escape(&tf1(lang, "devices.confirm_delete", &d.id)),
delete_device = t(lang, "devices.delete_device"),
);
}
// ---------- 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, the search input, and the pagination buttons can all swap
/// the table back into `#devices-region` without re-rendering the
/// whole page wrapper. Accepts `?page=N&q=...` so the same handler
/// drives both navigation and filtering.
pub async fn list_fragment(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Query(pg): Query<DevicesListParams>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let cols = load_device_columns(&state, admin.user_id).await;
let page_size = load_device_page_size(&state, admin.user_id).await;
Ok(Html(
render_table(&state, lang, cols, pg.query(), pg.page_or_first(), page_size).await?,
))
}
/// "Back to devices" — refetches the devices table fragment via HTMX
/// and swaps it back into `#devices-region`. Used by the detail page.
/// `hx-push-url="#devices"` resets the address bar to the list-level
/// hash so a subsequent refresh lands on the list, not the detail.
fn back_button(lang: Lang) -> String {
format!(
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"
hx-push-url="#devices">{label}</button>"##,
label = t(lang, "devices.back"),
)
}
/// 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(
lang: Lang,
d: &DashboardDeviceRow,
login_events: &[LoginEventRow],
metrics_latest: Option<&MetricsSampleRow>,
metrics_24h: &[MetricsSampleRow],
perf_events: &[PerfEventRow],
) -> 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_label} <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">{l_host}</dt><dd class="text-slate-200">{host}</dd></div>
<div><dt class="text-xs text-slate-500">{l_owner}</dt><dd class="text-slate-200">{owner}</dd></div>
<div><dt class="text-xs text-slate-500">{l_user}</dt><dd class="text-slate-200">{user}</dd></div>
<div><dt class="text-xs text-slate-500">{l_agent}</dt><dd class="text-slate-200">{ident}</dd></div>
<div><dt class="text-xs text-slate-500">{l_os_rt}</dt><dd class="text-slate-200">{os_rt}</dd></div>
<div><dt class="text-xs text-slate-500">{l_last}</dt><dd class="text-slate-200">{last}</dd></div>
<div><dt class="text-xs text-slate-500">{l_cpu_rt}</dt><dd class="text-slate-200">{cpu_rt}</dd></div>
<div><dt class="text-xs text-slate-500">{l_mem_rt}</dt><dd class="text-slate-200">{mem_rt}</dd></div>
</dl>
</div>"##,
device_label = t(lang, "devices.device_label"),
l_host = t(lang, "devices.col_hostname"),
l_owner = t(lang, "devices.col_owner"),
l_user = t(lang, "devices.detail_active_user"),
l_agent = t(lang, "devices.detail_agent"),
l_os_rt = t(lang, "devices.detail_os_runtime"),
l_last = t(lang, "devices.col_last_heartbeat"),
l_cpu_rt = t(lang, "devices.detail_cpu_runtime"),
l_mem_rt = t(lang, "devices.detail_memory_runtime"),
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 — rendered whenever the device's sysinfo payload
// contains a populated `inventory` object, regardless of which client
// sent it. We used to gate this on `agent_name == "HelloAgent"` (the
// explicit rebrand identity stamped by hello-agent), but that filter
// (a) broke silently when hello-agent's APP_NAME changed casing, and
// (b) hid any inventory data shipped by a future client variant that
// didn't carry the exact same agent_name string. Rendering on presence
// (`inv.is_object()`) instead of identity makes the page robust to both.
// The "pending" message covers the only remaining absence case: an
// agent that doesn't (or doesn't yet) report inventory.
let inventory_section = match parsed.get("inventory") {
Some(inv) if inv.is_object() => render_inventory_table(lang, inv),
_ => format!(
r##"<div class="rounded-md border border-slate-700 bg-slate-900 p-3 text-sm text-slate-400">
{msg}
</div>"##,
msg = t(lang, "devices.inventory_pending"),
),
};
let login_section = render_login_events(lang, login_events);
let perf_section = render_performance(lang, metrics_latest, metrics_24h, perf_events);
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">{performance}</h3>
{perf}
<h3 class="text-sm font-semibold text-slate-300 mt-4">{inventory}</h3>
{inv}
<h3 class="text-sm font-semibold text-slate-300 mt-4">{login_history}</h3>
{login}
</div>"##,
back = back_button(lang),
detail_view = t(lang, "devices.detail_view"),
performance = t(lang, "devices.performance"),
perf = perf_section,
inventory = t(lang, "devices.inventory"),
header = header,
inv = inventory_section,
login_history = t(lang, "devices.login_history"),
login = login_section,
)
}
/// Top-level Performance section: snapshot card, two sparklines (CPU /
/// memory), and a recent-events table. The whole thing is omitted in
/// favour of a "no data yet" panel when the agent hasn't reported.
fn render_performance(
lang: Lang,
latest: Option<&MetricsSampleRow>,
series: &[MetricsSampleRow],
events: &[PerfEventRow],
) -> String {
if latest.is_none() && series.is_empty() && events.is_empty() {
return format!(
r##"<div class="rounded-md border border-slate-700 bg-slate-900 p-3 text-sm text-slate-400">
{msg}
</div>"##,
msg = t(lang, "devices.perf_none"),
);
}
let snapshot = render_perf_snapshot(lang, latest);
let cpu_chart = render_sparkline(
lang,
series.iter().map(|s| (s.at, s.cpu_pct)).collect(),
100.0,
true,
t(lang, "devices.perf_cpu"),
);
let mem_chart = {
// Mem is reported as MB used / MB total; chart uses % so the
// y-axis stays comparable to the CPU panel.
let series_pct: Vec<(i64, f64)> = series
.iter()
.filter(|s| s.mem_total_mb > 0)
.map(|s| {
let pct = 100.0 * (s.mem_used_mb as f64) / (s.mem_total_mb as f64);
(s.at, pct)
})
.collect();
render_sparkline(lang, series_pct, 100.0, true, t(lang, "devices.perf_mem"))
};
let events_section = render_perf_events_table(lang, events);
format!(
r##"<div class="space-y-4">
{snapshot}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{cpu}
{mem}
</div>
{events}
</div>"##,
snapshot = snapshot,
cpu = cpu_chart,
mem = mem_chart,
events = events_section,
)
}
/// "Right now" card — the most recent metrics sample. Drawn as a 4-up
/// stat tile so the supporter can glance at CPU / memory / top
/// processes without reading a chart. Falls back to a thin "no live
/// data" pill when the agent has never reported.
fn render_perf_snapshot(lang: Lang, latest: Option<&MetricsSampleRow>) -> String {
let Some(s) = latest else {
return format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-3 text-xs text-slate-500">
{msg}
</div>"##,
msg = t(lang, "devices.perf_no_live"),
);
};
let now = chrono::Utc::now().timestamp();
let age = (now - s.at).max(0);
let age_str = fmt_age(age);
let cpu_color = pct_color(s.cpu_pct);
let mem_pct = if s.mem_total_mb > 0 {
100.0 * (s.mem_used_mb as f64) / (s.mem_total_mb as f64)
} else {
0.0
};
let mem_color = pct_color(mem_pct);
let mem_used_gb = (s.mem_used_mb as f64) / 1024.0;
let mem_total_gb = (s.mem_total_mb as f64) / 1024.0;
let top_cpu = if s.top_cpu_name.is_empty() {
"".to_string()
} else {
format!(
"{name} <span class=\"text-xs text-slate-400\">{pct:.0}%</span>",
name = html_escape(&s.top_cpu_name),
pct = s.top_cpu_pct,
)
};
let top_mem = if s.top_mem_name.is_empty() {
"".to_string()
} else {
let mb = s.top_mem_mb;
let mem_disp = if mb >= 1024 {
format!("{:.1} GB", (mb as f64) / 1024.0)
} else {
format!("{} MB", mb)
};
format!(
"{name} <span class=\"text-xs text-slate-400\">{disp}</span>",
name = html_escape(&s.top_mem_name),
disp = html_escape(&mem_disp),
)
};
let uptime_str = if s.uptime_secs > 0 {
fmt_age(s.uptime_secs)
} else {
"".to_string()
};
format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-4">
<div class="flex items-baseline justify-between mb-3">
<h4 class="text-sm font-semibold text-slate-200">{l_now}</h4>
<span class="text-xs text-slate-500" title="{at_full} UTC">{l_age}</span>
</div>
<dl class="grid grid-cols-2 gap-x-6 gap-y-3 text-sm md:grid-cols-4">
<div>
<dt class="text-xs text-slate-500">{l_cpu}</dt>
<dd class="text-lg font-semibold {cpu_cls} tabular-nums">{cpu:.0}%</dd>
</div>
<div>
<dt class="text-xs text-slate-500">{l_mem}</dt>
<dd class="text-lg font-semibold {mem_cls} tabular-nums">{mem_pct:.0}%</dd>
<dd class="text-xs text-slate-500 tabular-nums">{used:.1} / {total:.1} GB</dd>
</div>
<div>
<dt class="text-xs text-slate-500">{l_top_cpu}</dt>
<dd class="text-slate-200 font-mono text-xs truncate" title="{top_cpu_raw}">{top_cpu}</dd>
</div>
<div>
<dt class="text-xs text-slate-500">{l_top_mem}</dt>
<dd class="text-slate-200 font-mono text-xs truncate" title="{top_mem_raw}">{top_mem}</dd>
</div>
<div>
<dt class="text-xs text-slate-500">{l_uptime}</dt>
<dd class="text-slate-300 tabular-nums">{uptime}</dd>
</div>
<div>
<dt class="text-xs text-slate-500">{l_procs}</dt>
<dd class="text-slate-300 tabular-nums">{procs}</dd>
</div>
</dl>
</div>"##,
l_now = t(lang, "devices.perf_now"),
l_age = tf1(lang, "devices.perf_sampled_ago", &age_str),
at_full = html_escape(&fmt_unix_utc(s.at)),
l_cpu = t(lang, "devices.perf_cpu"),
cpu_cls = cpu_color,
cpu = s.cpu_pct,
l_mem = t(lang, "devices.perf_mem"),
mem_cls = mem_color,
mem_pct = mem_pct,
used = mem_used_gb,
total = mem_total_gb,
l_top_cpu = t(lang, "devices.perf_top_cpu"),
top_cpu_raw = html_escape(&s.top_cpu_name),
top_cpu = top_cpu,
l_top_mem = t(lang, "devices.perf_top_mem"),
top_mem_raw = html_escape(&s.top_mem_name),
top_mem = top_mem,
l_uptime = t(lang, "devices.perf_uptime"),
uptime = html_escape(&uptime_str),
l_procs = t(lang, "devices.perf_proc_count"),
procs = s.proc_count,
)
}
/// Color-code a percentage value (0100) — green up to 60, amber up to
/// 85, red above. Used for the snapshot stat tiles so the supporter
/// can spot a wedged-laptop at a glance.
fn pct_color(pct: f64) -> &'static str {
if pct >= 85.0 {
"text-rose-400"
} else if pct >= 60.0 {
"text-amber-300"
} else {
"text-emerald-300"
}
}
/// Render an inline-SVG sparkline. `series` is a (unix-seconds, value)
/// vector; `max_y` clamps the y-axis (so two side-by-side charts share
/// a scale); `bucketed = true` downsamples by averaging into 96 buckets
/// so the polyline string stays short for a wide time window.
fn render_sparkline(
lang: Lang,
series: Vec<(i64, f64)>,
max_y: f64,
bucketed: bool,
title: &str,
) -> String {
const WIDTH: f64 = 600.0;
const HEIGHT: f64 = 80.0;
const PAD: f64 = 4.0;
if series.is_empty() {
return format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-3">
<h4 class="text-xs uppercase text-slate-500 mb-1">{title}</h4>
<div class="text-xs text-slate-500">{msg}</div>
</div>"##,
title = html_escape(title),
msg = t(lang, "devices.perf_no_chart"),
);
}
let points = if bucketed && series.len() > 96 {
downsample_avg(&series, 96)
} else {
series.clone()
};
let min_x = points.first().map(|p| p.0).unwrap_or(0);
let max_x = points.last().map(|p| p.0).unwrap_or(0);
let span_x = (max_x - min_x).max(1) as f64;
let plot_w = WIDTH - 2.0 * PAD;
let plot_h = HEIGHT - 2.0 * PAD;
let mut path = String::new();
let mut area = String::new();
let mut peak: f64 = 0.0;
let mut last: f64 = 0.0;
for (i, (t, v)) in points.iter().enumerate() {
let x = PAD + plot_w * ((t - min_x) as f64) / span_x;
let y_norm = (v / max_y).clamp(0.0, 1.0);
let y = PAD + plot_h * (1.0 - y_norm);
if i == 0 {
path.push_str(&format!("M{:.1},{:.1}", x, y));
area.push_str(&format!("M{:.1},{:.1}", x, PAD + plot_h));
area.push_str(&format!(" L{:.1},{:.1}", x, y));
} else {
path.push_str(&format!(" L{:.1},{:.1}", x, y));
area.push_str(&format!(" L{:.1},{:.1}", x, y));
}
peak = peak.max(*v);
last = *v;
}
let last_x = PAD + plot_w;
area.push_str(&format!(" L{:.1},{:.1} Z", last_x, PAD + plot_h));
// Hours-from-now labels: oldest point's age, "now" on the right.
let span_secs = (max_x - min_x).max(0);
let span_label = if span_secs >= 3600 {
format!("-{}h", span_secs / 3600)
} else if span_secs >= 60 {
format!("-{}m", span_secs / 60)
} else {
format!("-{}s", span_secs)
};
format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-3">
<div class="flex items-baseline justify-between mb-1">
<h4 class="text-xs uppercase text-slate-500">{title}</h4>
<span class="text-[11px] text-slate-500 tabular-nums">{l_peak} <span class="text-slate-300">{peak:.0}%</span> &nbsp; {l_now} <span class="text-slate-300">{last:.0}%</span></span>
</div>
<svg viewBox="0 0 {w} {h}" preserveAspectRatio="none" class="w-full h-20">
<line x1="{pad}" y1="{ymid:.1}" x2="{xend:.1}" y2="{ymid:.1}" stroke="#1f2937" stroke-width="1" stroke-dasharray="2,3"/>
<path d="{area}" fill="#0ea5e9" fill-opacity="0.10" stroke="none"/>
<path d="{path}" fill="none" stroke="#38bdf8" stroke-width="1.5" stroke-linejoin="round"/>
</svg>
<div class="flex justify-between text-[10px] text-slate-500 mt-1 tabular-nums">
<span>{older}</span>
<span>{l_now_short}</span>
</div>
</div>"##,
title = html_escape(title),
l_peak = t(lang, "devices.perf_peak"),
peak = peak,
l_now = t(lang, "devices.perf_latest"),
last = last,
w = WIDTH,
h = HEIGHT,
pad = PAD,
ymid = PAD + plot_h * 0.5,
xend = WIDTH - PAD,
area = area,
path = path,
older = html_escape(&span_label),
l_now_short = t(lang, "devices.perf_now_short"),
)
}
/// Mean-pool a (timestamp, value) series down to `target` buckets,
/// keeping the bucket-mean timestamp as the bucket's x. Empty buckets
/// are dropped so the resulting polyline doesn't draw zero-lines for
/// stretches where the agent was offline.
fn downsample_avg(series: &[(i64, f64)], target: usize) -> Vec<(i64, f64)> {
if series.len() <= target {
return series.to_vec();
}
let min_x = series.first().map(|p| p.0).unwrap_or(0);
let max_x = series.last().map(|p| p.0).unwrap_or(0);
let span = (max_x - min_x).max(1);
let bucket_secs = (span as usize) / target.max(1);
let bucket_secs = bucket_secs.max(1) as i64;
let mut buckets: Vec<(i64, f64, usize)> = Vec::with_capacity(target);
let mut current_bucket: i64 = -1;
for (t, v) in series {
let b = (t - min_x) / bucket_secs;
if b != current_bucket {
buckets.push((*t, *v, 1));
current_bucket = b;
} else if let Some(last) = buckets.last_mut() {
last.1 += *v;
last.2 += 1;
}
}
buckets
.into_iter()
.map(|(t, sum, n)| (t, sum / (n as f64)))
.collect()
}
/// Recent perf-events table — boot/shutdown/sleep degradation, memory
/// exhaustion, BSODs, unexpected reboots. Empty list → a neutral
/// "nothing flagged yet" panel so the heading still has a body.
fn render_perf_events_table(lang: Lang, events: &[PerfEventRow]) -> String {
if events.is_empty() {
return format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-3 text-xs text-slate-500">
{msg}
</div>"##,
msg = t(lang, "devices.perf_events_none"),
);
}
// Collapsed by default — the table is information-dense and most
// operators only look at it when they're chasing a specific
// complaint. The Wi-Fi-nearby section uses the same `<details>`
// pattern earlier on this page.
let mut s = format!(
r##"<details class="rounded-md border border-slate-800 bg-slate-900">
<summary class="cursor-pointer px-3 py-2 text-xs uppercase text-slate-400 hover:text-slate-200 select-none">{l_events} <span class="text-slate-500">({n})</span></summary>
<div class="border-t border-slate-800 overflow-hidden">
<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">{c_when}</th>
<th class="text-left font-medium px-3 py-2">{c_source}</th>
<th class="text-left font-medium px-3 py-2">{c_summary}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">"##,
l_events = t(lang, "devices.perf_events_heading"),
n = events.len(),
c_when = t(lang, "devices.perf_events_col_when"),
c_source = t(lang, "devices.perf_events_col_source"),
c_summary = t(lang, "devices.perf_events_col_summary"),
);
for ev in events {
let when = fmt_unix_utc(ev.at);
let (level_cls, _level_label) = match ev.level {
1 => ("bg-rose-900/40 text-rose-300 border-rose-800", "critical"),
2 => ("bg-rose-900/30 text-rose-300 border-rose-900", "error"),
3 => ("bg-amber-900/40 text-amber-300 border-amber-800", "warning"),
_ => ("bg-slate-800 text-slate-300 border-slate-700", "info"),
};
let source_label = match ev.provider.as_str() {
"diag-perf" => t(lang, "devices.perf_src_diag_perf"),
"res-exh" => t(lang, "devices.perf_src_res_exh"),
"system" => t(lang, "devices.perf_src_system"),
other => other,
};
let _ = write!(
s,
r##"<tr class="hover:bg-slate-800/40 align-top">
<td class="px-3 py-2 font-mono text-xs text-slate-300 whitespace-nowrap">{when}</td>
<td class="px-3 py-2">
<span class="inline-block text-[11px] px-1.5 py-0.5 rounded border {lvl_cls}">{src} · {eid}</span>
</td>
<td class="px-3 py-2 text-slate-200 text-xs">{summary}</td>
</tr>"##,
when = html_escape(&when),
lvl_cls = level_cls,
src = html_escape(source_label),
eid = ev.event_id,
summary = html_escape(&ev.summary),
);
}
s.push_str("</tbody></table></div></details>");
s
}
/// Render the per-device login history table. Empty input → a neutral
/// "no events yet" panel so the heading still has something under it.
/// We render the agent-reported `at` in the standard SQLite UTC format
/// (no per-locale fiddling) and only flag a "received_at differs from at"
/// case as a tooltip — clock skew on the agent side is the only place that
/// matters operationally.
fn render_login_events(lang: Lang, events: &[LoginEventRow]) -> String {
if events.is_empty() {
return format!(
r##"<div class="rounded-md border border-slate-700 bg-slate-900 p-3 text-sm text-slate-400">
{msg}
</div>"##,
msg = t(lang, "devices.login_none"),
);
}
let mut s = format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<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">{c_when}</th>
<th class="text-left font-medium px-3 py-2">{c_event}</th>
<th class="text-left font-medium px-3 py-2">{c_user}</th>
<th class="text-left font-medium px-3 py-2">{c_session}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">"##,
c_when = t(lang, "devices.login_col_when"),
c_event = t(lang, "devices.login_col_event"),
c_user = t(lang, "devices.login_col_user"),
c_session = t(lang, "devices.login_col_session"),
);
for ev in events {
let when = fmt_unix_utc(ev.at);
// Surface clock-skew >5 min as a tooltip — that's the threshold the
// signed-API gate also uses, so anything above that is already on
// the operator's radar via 401s.
let skew = ev.received_at - ev.at;
let when_attr = if skew.abs() > 300 {
format!(
r##" title="received {recv} UTC (clock skew {skew:+}s)""##,
recv = html_escape(&fmt_unix_utc(ev.received_at)),
skew = skew,
)
} else {
String::new()
};
let (badge_class, badge_label) = match ev.kind.as_str() {
"logon" => (
"bg-emerald-900/40 text-emerald-300 border-emerald-800",
t(lang, "devices.login_kind_logon"),
),
"logoff" => (
"bg-slate-800 text-slate-300 border-slate-700",
t(lang, "devices.login_kind_logoff"),
),
_ => (
"bg-amber-900/40 text-amber-300 border-amber-800",
// Unknown kind — display the raw string so an operator
// running a newer agent against an older server can still
// see what was reported.
"",
),
};
let badge_text = if badge_label.is_empty() {
html_escape(&ev.kind)
} else {
badge_label.to_string()
};
let user_display = if ev.domain.is_empty() {
html_escape(if ev.username.is_empty() { "" } else { &ev.username })
} else if ev.username.is_empty() {
html_escape(&ev.domain)
} else {
html_escape(&format!("{}\\{}", ev.domain, ev.username))
};
let session_kind = if ev.session_kind.is_empty() {
"".to_string()
} else {
html_escape(&ev.session_kind)
};
let _ = write!(
s,
r##"<tr class="hover:bg-slate-800/40">
<td class="px-3 py-2 font-mono text-xs text-slate-300 whitespace-nowrap"{when_attr}>{when}</td>
<td class="px-3 py-2"><span class="inline-block text-[11px] px-1.5 py-0.5 rounded border {bc}">{bt}</span></td>
<td class="px-3 py-2 text-slate-200">{user}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{sk} #{sid}</td>
</tr>"##,
when_attr = when_attr,
when = html_escape(&when),
bc = badge_class,
bt = badge_text,
user = user_display,
sk = session_kind,
sid = ev.session_id,
);
}
s.push_str("</tbody></table></div>");
s
}
/// Format a unix epoch as `YYYY-MM-DD HH:MM:SS` UTC. Matches the format
/// SQLite's `current_timestamp` produces, so all the other timestamps on
/// the device detail page line up visually with login-event rows.
fn fmt_unix_utc(ts: i64) -> String {
use chrono::TimeZone;
chrono::Utc
.timestamp_opt(ts, 0)
.single()
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| ts.to_string())
}
fn render_inventory_table(lang: Lang, 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 = format!(
r##"<table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">{c_name}</th><th class="text-left font-medium px-2 py-1">{c_model}</th><th class="text-right font-medium px-2 py-1">{c_size}</th><th class="text-left font-medium px-2 py-1">{c_media}</th></tr></thead><tbody>"##,
c_name = t(lang, "devices.disk_name"),
c_model = t(lang, "devices.disk_model"),
c_size = t(lang, "devices.disk_size"),
c_media = t(lang, "devices.disk_media"),
);
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() {
format!(
r##"<span class="text-slate-500">{}</span>"##,
t(lang, "devices.bitlocker_unavailable"),
)
} 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)
)
};
let nics_html = render_nics(lang, inv.get("network_interfaces"));
let wifi_html = render_wifi(lang, inv.get("wifi_current"), inv.get("wifi_nearby"));
let software_html = render_installed_software(lang, inv.get("installed_software"));
let public_ip_raw = inv
.get("public_ip")
.and_then(|v| v.as_str())
.unwrap_or("");
let public_ip_html = if public_ip_raw.is_empty() {
format!(
r##"<span class="text-slate-500">{}</span>"##,
t(lang, "devices.public_ip_failed"),
)
} else {
format!(
r##"<code class="font-mono text-xs text-sky-300 bg-slate-950 px-2 py-1 rounded border border-slate-800 select-all">{}</code>"##,
html_escape(public_ip_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">{l_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">{l_nics}</h4>
{nics}
</div>
{wifi}
<div>
<h4 class="text-xs uppercase text-slate-500 mb-1">{l_pip}</h4>
{public_ip}
</div>
{software}
<div>
<h4 class="text-xs uppercase text-slate-500 mb-1">{l_bl}</h4>
{bl}
</div>
</div>"##,
sn = row(t(lang, "devices.serial_number"), "serial_number"),
mfr = row(t(lang, "devices.manufacturer"), "manufacturer"),
model = row(t(lang, "devices.model"), "model"),
dom = row(t(lang, "devices.windows_domain"), "domain"),
os_d = row(t(lang, "devices.os_distro"), "os_distro"),
os_r = row(t(lang, "devices.os_release"), "os_release"),
cpu_m = row(t(lang, "devices.cpu_model"), "cpu_model"),
cpu_s = row(t(lang, "devices.cpu_speed"), "cpu_speed_ghz"),
cpu_pc = row(t(lang, "devices.cpu_phys_cores"), "cpu_cores_physical"),
cpu_lc = row(t(lang, "devices.cpu_logical_cores"), "cpu_cores_logical"),
ram = row(t(lang, "devices.ram_gb"), "ram_gb"),
disks = disks_html,
nics = nics_html,
wifi = wifi_html,
public_ip = public_ip_html,
software = software_html,
l_disks = t(lang, "devices.disks"),
l_nics = t(lang, "devices.network_interfaces"),
l_pip = t(lang, "devices.public_ip"),
l_bl = t(lang, "devices.bitlocker"),
bl = bl_html,
)
}
/// Render the network-interfaces array as a table (one row per NIC).
/// Wi-Fi NICs get a small badge so the operator can spot them at a glance
/// next to the Wi-Fi-current section. Empty input → dash.
fn render_nics(lang: Lang, nics: Option<&serde_json::Value>) -> String {
let arr = match nics {
Some(serde_json::Value::Array(a)) if !a.is_empty() => a,
_ => {
return r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-2"><span class="text-slate-500">—</span></div>"##.to_string();
}
};
let mut s = format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden p-2"><table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">{c_name}</th><th class="text-left font-medium px-2 py-1">{c_desc}</th><th class="text-left font-medium px-2 py-1">MAC</th><th class="text-left font-medium px-2 py-1">{c_status}</th><th class="text-left font-medium px-2 py-1">IPv4</th><th class="text-left font-medium px-2 py-1">IPv6</th><th class="text-right font-medium px-2 py-1">Mbps</th></tr></thead><tbody>"##,
c_name = t(lang, "devices.disk_name"),
c_desc = t(lang, "devices.nic_description"),
c_status = t(lang, "devices.nic_status"),
);
for nic in arr {
let name = fmt_inv_value(nic.get("name"));
let descr = fmt_inv_value(nic.get("description"));
let mac = fmt_inv_value(nic.get("mac"));
let status = fmt_inv_value(nic.get("status"));
let speed = fmt_inv_value(nic.get("speed_mbps"));
let is_wifi = nic
.get("is_wifi")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let wifi_badge = if is_wifi {
r##" <span class="ml-1 inline-block text-[10px] px-1 py-0 rounded bg-sky-900/50 text-sky-300">Wi-Fi</span>"##
} else {
""
};
let ipv4 = render_ip_list(nic.get("ipv4"));
let ipv6 = render_ip_list(nic.get("ipv6"));
let _ = write!(
&mut s,
r##"<tr class="border-t border-slate-800 align-top"><td class="px-2 py-1 text-slate-200">{name}{badge}</td><td class="px-2 py-1 text-slate-400">{descr}</td><td class="px-2 py-1 font-mono text-slate-300">{mac}</td><td class="px-2 py-1 text-slate-400">{status}</td><td class="px-2 py-1 font-mono text-slate-300">{ipv4}</td><td class="px-2 py-1 font-mono text-slate-300 break-all">{ipv6}</td><td class="px-2 py-1 text-right font-mono text-slate-200">{speed}</td></tr>"##,
name = name,
badge = wifi_badge,
descr = descr,
mac = mac,
status = status,
ipv4 = ipv4,
ipv6 = ipv6,
speed = speed,
);
}
s.push_str("</tbody></table></div>");
s
}
/// Render an array-of-strings IP list as a `<br>`-separated block, or
/// dash when the array is empty / missing. Each address is HTML-escaped.
fn render_ip_list(v: Option<&serde_json::Value>) -> String {
match v {
Some(serde_json::Value::Array(a)) if !a.is_empty() => a
.iter()
.filter_map(|x| x.as_str())
.map(html_escape)
.collect::<Vec<_>>()
.join("<br>"),
_ => "".to_string(),
}
}
/// Render the Wi-Fi section: current connection (if any) and the
/// nearby-SSID scan (collapsed by default to keep the page short).
/// Returns an empty string when neither is present, so the surrounding
/// detail page can omit the heading entirely.
fn render_wifi(
lang: Lang,
current: Option<&serde_json::Value>,
nearby: Option<&serde_json::Value>,
) -> String {
let has_current = matches!(current, Some(v) if v.is_object());
let nearby_arr = match nearby {
Some(serde_json::Value::Array(a)) if !a.is_empty() => Some(a),
_ => None,
};
if !has_current && nearby_arr.is_none() {
return String::new();
}
let current_html = if let Some(c) = current.filter(|v| v.is_object()) {
// Rate: native API returns rx/tx in Kbps; render the higher of
// the two as Mbps. Most APs report identical rx/tx, so a single
// figure is plenty for the operator's "is this slow" question.
let rate_mbps = {
let rx = c.get("rx_kbps").and_then(|v| v.as_u64()).unwrap_or(0);
let tx = c.get("tx_kbps").and_then(|v| v.as_u64()).unwrap_or(0);
let max = rx.max(tx);
if max == 0 {
"".to_string()
} else {
format!("{} Mbps", max / 1000)
}
};
let signal_with_dbm = {
let pct = c.get("signal_pct").and_then(|v| v.as_u64()).unwrap_or(0);
let dbm = c.get("rssi_dbm").and_then(|v| v.as_i64());
match dbm {
Some(d) => format!("{pct}% ({d} dBm)"),
None => format!("{pct}%"),
}
};
format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-3">
<dl class="grid grid-cols-2 gap-x-6 gap-y-1 text-xs md:grid-cols-3">
<div><dt class="text-slate-500">SSID</dt><dd class="text-slate-200 font-mono">{ssid}</dd></div>
<div><dt class="text-slate-500">BSSID</dt><dd class="text-slate-300 font-mono">{bssid}</dd></div>
<div><dt class="text-slate-500">{l_signal}</dt><dd class="text-slate-200">{sig}</dd></div>
<div><dt class="text-slate-500">{l_auth}</dt><dd class="text-slate-300">{auth}</dd></div>
<div><dt class="text-slate-500">{l_cipher}</dt><dd class="text-slate-300">{cipher}</dd></div>
<div><dt class="text-slate-500">{l_rate}</dt><dd class="text-slate-300">{rate}</dd></div>
</dl>
</div>"##,
ssid = fmt_inv_value(c.get("ssid")),
bssid = fmt_inv_value(c.get("bssid")),
sig = signal_with_dbm,
auth = fmt_inv_value(c.get("auth")),
cipher = fmt_inv_value(c.get("cipher")),
rate = rate_mbps,
l_signal = t(lang, "devices.wifi_signal"),
l_auth = t(lang, "devices.wifi_auth"),
l_cipher = t(lang, "devices.wifi_cipher"),
l_rate = t(lang, "devices.wifi_rate"),
)
} else {
format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-3 text-xs text-slate-500">{}</div>"##,
t(lang, "devices.wifi_not_connected"),
)
};
let nearby_html = if let Some(arr) = nearby_arr {
let mut s = format!(
r##"<details class="rounded-md border border-slate-800 bg-slate-900">
<summary class="cursor-pointer px-3 py-2 text-xs text-slate-400 hover:text-slate-200 select-none">{nearby_label}</summary>
<div class="p-2"><table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">SSID</th><th class="text-left font-medium px-2 py-1">{l_auth}</th><th class="text-left font-medium px-2 py-1">{l_cipher}</th><th class="text-right font-medium px-2 py-1">{l_signal}</th></tr></thead><tbody>"##,
nearby_label = tf1(lang, "devices.wifi_nearby", &arr.len().to_string()),
l_auth = t(lang, "devices.wifi_auth"),
l_cipher = t(lang, "devices.wifi_cipher"),
l_signal = t(lang, "devices.wifi_signal"),
);
for net in arr {
let _ = write!(
&mut s,
r##"<tr class="border-t border-slate-800"><td class="px-2 py-1 font-mono text-slate-300">{ssid}</td><td class="px-2 py-1 text-slate-400">{auth}</td><td class="px-2 py-1 text-slate-400">{cipher}</td><td class="px-2 py-1 text-right font-mono text-slate-200">{sig}%</td></tr>"##,
ssid = fmt_inv_value(net.get("ssid")),
auth = fmt_inv_value(net.get("auth")),
cipher = fmt_inv_value(net.get("cipher")),
sig = fmt_inv_value(net.get("signal_pct")),
);
}
s.push_str("</tbody></table></div></details>");
s
} else {
String::new()
};
format!(
r##"<div>
<h4 class="text-xs uppercase text-slate-500 mb-1">{wifi_current}</h4>
{current}
{nearby}
</div>"##,
wifi_current = t(lang, "devices.wifi_current"),
current = current_html,
nearby = if nearby_html.is_empty() {
String::new()
} else {
format!("<div class=\"mt-2\">{}</div>", nearby_html)
},
)
}
/// Render the `installed_software` array as a collapsed `<details>` table.
///
/// hello-agent enumerates the Windows Add/Remove Programs registry hives
/// and uploads the result under `inventory.installed_software` as a sorted
/// array of `{name, version, publisher, install_date, bitness}` objects.
/// We render it collapsed by default because the list is routinely
/// 100-300 entries on a real machine — expanding it inline would push
/// the rest of the inventory off the screen. The summary line carries
/// the entry count so the operator can see "is there anything here"
/// without opening it.
///
/// Returns an empty string when the field is absent or empty so the
/// surrounding template can drop the entire block — agents that don't
/// (or can't) report installed software don't get a stray empty header.
fn render_installed_software(lang: Lang, sw: Option<&serde_json::Value>) -> String {
let arr = match sw {
Some(serde_json::Value::Array(a)) if !a.is_empty() => a,
_ => return String::new(),
};
let mut rows = String::new();
for entry in arr {
let name = fmt_inv_value(entry.get("name"));
let version = fmt_inv_value(entry.get("version"));
let publisher = fmt_inv_value(entry.get("publisher"));
// Bitness is a small badge next to the name when present, matching
// the Wi-Fi badge on the NICs table. We treat the absence of the
// field as "unknown" and render nothing rather than a "?" — the
// page doesn't owe the operator a verdict it can't actually make.
let bitness_raw = entry
.get("bitness")
.and_then(|v| v.as_str())
.unwrap_or("");
let bitness_badge = match bitness_raw {
"64" => {
r##" <span class="ml-1 inline-block text-[10px] px-1 py-0 rounded bg-slate-800 text-slate-400">x64</span>"##
}
"32" => {
r##" <span class="ml-1 inline-block text-[10px] px-1 py-0 rounded bg-slate-800 text-slate-500">x86</span>"##
}
_ => "",
};
let _ = write!(
&mut rows,
r##"<tr class="border-t border-slate-800 align-top"><td class="px-2 py-1 text-slate-200">{name}{badge}</td><td class="px-2 py-1 font-mono text-slate-300">{version}</td><td class="px-2 py-1 text-slate-400">{publisher}</td></tr>"##,
name = name,
badge = bitness_badge,
version = version,
publisher = publisher,
);
}
format!(
r##"<div>
<h4 class="text-xs uppercase text-slate-500 mb-1">{label}</h4>
<details class="rounded-md border border-slate-800 bg-slate-900">
<summary class="cursor-pointer px-3 py-2 text-xs text-slate-400 hover:text-slate-200 select-none">{count_label}</summary>
<div class="p-2 overflow-x-auto"><table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">{c_name}</th><th class="text-left font-medium px-2 py-1">{c_version}</th><th class="text-left font-medium px-2 py-1">{c_publisher}</th></tr></thead><tbody>{rows}</tbody></table></div>
</details>
</div>"##,
label = t(lang, "devices.installed_software"),
count_label = tf1(lang, "devices.installed_software_count", &arr.len().to_string()),
c_name = t(lang, "devices.col_software_name"),
c_version = t(lang, "devices.col_software_version"),
c_publisher = t(lang, "devices.col_software_publisher"),
rows = rows,
)
}
/// 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>,
lang: Lang,
cols: DeviceColumns,
q: &str,
page: i64,
page_size: i64,
kind: &str,
msg: &str,
) -> Result<Html<String>, ApiError> {
let mut html = notice_html(kind, msg);
html.push_str(&render_table(state, lang, cols, q, page, page_size).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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn require_admin(u: &AuthedUser) -> Result<(), ApiError> {
if u.is_admin {
Ok(())
} else {
Err(ApiError::Forbidden("admin required".into()))
}
}