2258 lines
84 KiB
Rust
2258 lines
84 KiB
Rust
//! Devices page — list devices currently / recently registered, with
|
||
//! force-disconnect (queues a `heartbeat_commands` row consumed on the
|
||
//! peer's next /api/heartbeat tick) and force-sysinfo refresh.
|
||
|
||
use crate::api::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 (0–100) — 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> {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('&', "&")
|
||
.replace('<', "<")
|
||
.replace('>', ">")
|
||
.replace('"', """)
|
||
}
|
||
|
||
fn require_admin(u: &AuthedUser) -> Result<(), ApiError> {
|
||
if u.is_admin {
|
||
Ok(())
|
||
} else {
|
||
Err(ApiError::Forbidden("admin required".into()))
|
||
}
|
||
}
|