1108 lines
45 KiB
Rust
1108 lines
45 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, Lang};
|
|
use crate::api::error::ApiError;
|
|
use crate::api::middleware::AuthedUser;
|
|
use crate::api::state::AppState;
|
|
use crate::database::DashboardDeviceRow;
|
|
use axum::extract::{Extension, Path};
|
|
use axum::response::Html;
|
|
use std::fmt::Write as _;
|
|
use std::sync::Arc;
|
|
|
|
const PAGE_SIZE: i64 = 100;
|
|
|
|
/// Devices that have heartbeated within this many seconds are considered
|
|
/// online. Clients heartbeat every 15s (hbb_common::config::REG_INTERVAL),
|
|
/// so 45s allows up to two missed beats before we flip the dot to red.
|
|
const ONLINE_THRESHOLD_SECS: i64 = 45;
|
|
|
|
pub async fn index(
|
|
Extension(state): Extension<Arc<AppState>>,
|
|
admin: AuthedUser,
|
|
lang: Lang,
|
|
) -> Result<Html<String>, ApiError> {
|
|
require_admin(&admin)?;
|
|
let table = render_table(&state, lang).await?;
|
|
Ok(Html(format!(
|
|
r##"<div class="space-y-6">
|
|
<header class="flex items-center justify-between">
|
|
<h2 class="text-lg font-semibold">{heading}</h2>
|
|
<p class="text-xs text-slate-500">{tagline}</p>
|
|
</header>
|
|
<section id="devices-region">
|
|
{table}
|
|
</section>
|
|
</div>"##,
|
|
heading = t(lang, "devices.heading"),
|
|
tagline = t(lang, "devices.tagline"),
|
|
table = table,
|
|
)))
|
|
}
|
|
|
|
pub async fn force_disconnect(
|
|
Extension(state): Extension<Arc<AppState>>,
|
|
admin: AuthedUser,
|
|
lang: Lang,
|
|
Path(peer_id): Path<String>,
|
|
) -> Result<Html<String>, ApiError> {
|
|
require_admin(&admin)?;
|
|
let conns = state
|
|
.db
|
|
.device_sysinfo_get_conns(&peer_id)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
state
|
|
.db
|
|
.heartbeat_command_queue(&peer_id, "disconnect", Some(&conns))
|
|
.await
|
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
notice_then_table(
|
|
&state,
|
|
lang,
|
|
"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>,
|
|
) -> Result<Html<String>, ApiError> {
|
|
require_admin(&admin)?;
|
|
state
|
|
.db
|
|
.heartbeat_command_queue(&peer_id, "sysinfo", None)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
notice_then_table(
|
|
&state,
|
|
lang,
|
|
"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>,
|
|
) -> Result<Html<String>, ApiError> {
|
|
require_admin(&admin)?;
|
|
let ok = state
|
|
.db
|
|
.device_delete(&peer_id)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
let msg = if ok {
|
|
tf1(lang, "devices.deleted", &peer_id)
|
|
} else {
|
|
t(lang, "devices.already_gone").to_string()
|
|
};
|
|
notice_then_table(&state, lang, 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>,
|
|
) -> Result<Html<String>, ApiError> {
|
|
require_admin(&admin)?;
|
|
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,
|
|
"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, "ok", &tf1(lang, key, &peer_id)).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) => render_detail(lang, &d),
|
|
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) -> Result<String, ApiError> {
|
|
let (total, devices) = state
|
|
.db
|
|
.devices_list_all(0, PAGE_SIZE)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
let now = chrono::Utc::now();
|
|
let mut s = String::new();
|
|
// No `overflow-hidden` on the table wrapper: the per-row action menu is
|
|
// an absolutely-positioned `<details>` popover inside a <td>, and the
|
|
// wrapper's clipping was hiding the bottom half of the menu.
|
|
let _ = write!(
|
|
s,
|
|
r##"<div class="rounded-md border border-slate-800 bg-slate-900">
|
|
<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>
|
|
<th class="text-left font-medium px-3 py-2">{c_owner}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_host}</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_pwd}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_os}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_ver}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_last}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_conns}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_auth}</th>
|
|
<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_peer = t(lang, "devices.col_peer_id"),
|
|
c_owner = t(lang, "devices.col_owner"),
|
|
c_host = t(lang, "devices.col_hostname"),
|
|
c_user = t(lang, "devices.col_user"),
|
|
c_pwd = t(lang, "devices.col_unattended_pwd"),
|
|
c_os = t(lang, "devices.col_os"),
|
|
c_ver = t(lang, "devices.col_version"),
|
|
c_last = t(lang, "devices.col_last_heartbeat"),
|
|
c_conns = t(lang, "devices.col_conns"),
|
|
c_auth = t(lang, "devices.col_auth"),
|
|
c_actions = t(lang, "common.actions"),
|
|
);
|
|
if devices.is_empty() {
|
|
let _ = write!(
|
|
s,
|
|
r##"<tr><td colspan="11" class="px-3 py-4 text-slate-500 text-center text-xs">{}</td></tr>"##,
|
|
t(lang, "devices.no_devices"),
|
|
);
|
|
}
|
|
let always_show_pwd = unattended_pwd_always_visible();
|
|
for d in &devices {
|
|
render_device_row(&mut s, lang, d, now, always_show_pwd);
|
|
}
|
|
let _ = write!(
|
|
s,
|
|
r##"</tbody>
|
|
</table>
|
|
<div class="px-3 py-2 text-xs text-slate-500 border-t border-slate-800">{count}</div>
|
|
</div>"##,
|
|
count = tf1(lang, "devices.devices_count", &total.to_string()),
|
|
);
|
|
Ok(s)
|
|
}
|
|
|
|
/// 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,
|
|
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");
|
|
// `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),
|
|
);
|
|
|
|
// 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"),
|
|
)
|
|
};
|
|
// 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"
|
|
hx-target="#devices-region" hx-swap="innerHTML"
|
|
hx-confirm="{confirm}">
|
|
{label}
|
|
</button>"##,
|
|
id = html_escape(&d.id),
|
|
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"
|
|
hx-target="#devices-region" hx-swap="innerHTML">
|
|
{label}
|
|
</button>"##,
|
|
id = html_escape(&d.id),
|
|
label = t(lang, "devices.mark_managed"),
|
|
)
|
|
};
|
|
|
|
let _ = write!(
|
|
s,
|
|
r##"<tr class="hover:bg-slate-800/40">
|
|
{id_cell}
|
|
<td class="px-3 py-2 text-slate-300">{owner}</td>
|
|
<td class="px-3 py-2 text-slate-400">{host}</td>
|
|
<td class="px-3 py-2 text-slate-300">{user}</td>
|
|
<td class="px-3 py-2 whitespace-nowrap">{unattended_pwd}</td>
|
|
<td class="px-3 py-2 text-slate-400">{os}</td>
|
|
<td class="px-3 py-2 text-slate-400 whitespace-nowrap">{ver}</td>
|
|
<td class="px-3 py-2 text-slate-500 text-xs">{last}</td>
|
|
<td class="px-3 py-2 text-slate-400">{n}</td>
|
|
{auth_cell}
|
|
<td class="px-3 py-2">
|
|
<details class="text-right relative">
|
|
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
|
|
<div class="absolute right-2 mt-1 z-10 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">
|
|
{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">
|
|
{details}
|
|
</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"
|
|
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"
|
|
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"
|
|
hx-target="#devices-region" hx-swap="innerHTML"
|
|
hx-confirm="{confirm_delete}">
|
|
{delete_device}
|
|
</button>
|
|
</div>
|
|
</details>
|
|
</td>
|
|
</tr>"##,
|
|
id_cell = id_cell,
|
|
id = html_escape(&d.id),
|
|
owner = html_escape(&d.owner_username),
|
|
host = html_escape(&hostname),
|
|
user = if active_user.is_empty() {
|
|
"—".to_string()
|
|
} else {
|
|
html_escape(&active_user)
|
|
},
|
|
unattended_pwd = unattended_pwd_cell,
|
|
os = html_escape(&os),
|
|
ver = html_escape(&version_label),
|
|
last = html_escape(&d.last_heartbeat_at),
|
|
n = conn_count,
|
|
auth_cell = auth_cell,
|
|
toggle_managed_item = toggle_managed_item,
|
|
connect_web = t(lang, "devices.connect_web"),
|
|
details = t(lang, "devices.details"),
|
|
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 can swap the table back into `#devices-region` without
|
|
/// re-rendering the whole page wrapper. Same shape as
|
|
/// `notice_then_table` minus the notice banner.
|
|
pub async fn list_fragment(
|
|
Extension(state): Extension<Arc<AppState>>,
|
|
admin: AuthedUser,
|
|
lang: Lang,
|
|
) -> Result<Html<String>, ApiError> {
|
|
require_admin(&admin)?;
|
|
Ok(Html(render_table(&state, lang).await?))
|
|
}
|
|
|
|
/// "Back to devices" — refetches the devices table fragment via HTMX
|
|
/// and swaps it back into `#devices-region`. Used by the detail page.
|
|
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">{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) -> 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"),
|
|
),
|
|
};
|
|
|
|
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">{inventory}</h3>
|
|
{inv}
|
|
</div>"##,
|
|
back = back_button(lang),
|
|
detail_view = t(lang, "devices.detail_view"),
|
|
inventory = t(lang, "devices.inventory"),
|
|
header = header,
|
|
inv = inventory_section,
|
|
)
|
|
}
|
|
|
|
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,
|
|
kind: &str,
|
|
msg: &str,
|
|
) -> Result<Html<String>, ApiError> {
|
|
let mut html = notice_html(kind, msg);
|
|
html.push_str(&render_table(state, lang).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()))
|
|
}
|
|
}
|