This commit is contained in:
+187
-103
@@ -2,6 +2,7 @@
|
||||
//! 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;
|
||||
@@ -21,25 +22,30 @@ 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).await?;
|
||||
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">Devices</h2>
|
||||
<p class="text-xs text-slate-500">Force-disconnect / force-sysinfo are delivered on the peer's next heartbeat tick (~15 s).</p>
|
||||
<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>"##
|
||||
</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)?;
|
||||
@@ -55,8 +61,9 @@ pub async fn force_disconnect(
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then_table(
|
||||
&state,
|
||||
lang,
|
||||
"ok",
|
||||
&format!("Queued disconnect for {} (conns={})", peer_id, conns),
|
||||
&tf2(lang, "devices.queued_disconnect", &peer_id, &conns),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -64,6 +71,7 @@ pub async fn force_disconnect(
|
||||
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)?;
|
||||
@@ -74,8 +82,9 @@ pub async fn force_sysinfo(
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then_table(
|
||||
&state,
|
||||
lang,
|
||||
"ok",
|
||||
&format!("Queued sysinfo refresh for {}", peer_id),
|
||||
&tf1(lang, "devices.queued_sysinfo", &peer_id),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -83,6 +92,7 @@ pub async fn force_sysinfo(
|
||||
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)?;
|
||||
@@ -92,11 +102,11 @@ pub async fn delete(
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let msg = if ok {
|
||||
format!("Deleted device {}.", peer_id)
|
||||
tf1(lang, "devices.deleted", &peer_id)
|
||||
} else {
|
||||
"Device already gone.".to_string()
|
||||
t(lang, "devices.already_gone").to_string()
|
||||
};
|
||||
notice_then_table(&state, if ok { "ok" } else { "error" }, &msg).await
|
||||
notice_then_table(&state, lang, if ok { "ok" } else { "error" }, &msg).await
|
||||
}
|
||||
|
||||
/// Per-device detail page: hardware / OS inventory reported by hello-agent
|
||||
@@ -107,6 +117,7 @@ pub async fn delete(
|
||||
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)?;
|
||||
@@ -116,15 +127,17 @@ pub async fn detail(
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let html = match row {
|
||||
Some(d) => render_detail(&d),
|
||||
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 with peer ID <code class="font-mono">{id}</code> in the dashboard.
|
||||
{no_device} <code class="font-mono">{id}</code> {in_dashboard}
|
||||
</div>
|
||||
</div>"##,
|
||||
back = back_button(),
|
||||
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),
|
||||
),
|
||||
};
|
||||
@@ -150,7 +163,7 @@ fn online_state(last_heartbeat_at: &str, now: chrono::DateTime<chrono::Utc>) ->
|
||||
}
|
||||
}
|
||||
|
||||
async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
async fn render_table(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
|
||||
let (total, devices) = state
|
||||
.db
|
||||
.devices_list_all(0, PAGE_SIZE)
|
||||
@@ -167,39 +180,57 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
<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">Peer ID</th>
|
||||
<th class="text-left font-medium px-3 py-2">Owner</th>
|
||||
<th class="text-left font-medium px-3 py-2">Hostname</th>
|
||||
<th class="text-left font-medium px-3 py-2">User</th>
|
||||
<th class="text-left font-medium px-3 py-2">Unattended pwd</th>
|
||||
<th class="text-left font-medium px-3 py-2">OS</th>
|
||||
<th class="text-left font-medium px-3 py-2">Version</th>
|
||||
<th class="text-left font-medium px-3 py-2">Last heartbeat</th>
|
||||
<th class="text-left font-medium px-3 py-2">Conns</th>
|
||||
<th class="text-right font-medium px-3 py-2 w-1">Actions</th>
|
||||
<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-right font-medium px-3 py-2 w-1">{c_actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">"##
|
||||
<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_actions = t(lang, "common.actions"),
|
||||
);
|
||||
if devices.is_empty() {
|
||||
s.push_str(
|
||||
r##"<tr><td colspan="10" class="px-3 py-4 text-slate-500 text-center text-xs">No devices have heartbeated yet.</td></tr>"##,
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<tr><td colspan="10" class="px-3 py-4 text-slate-500 text-center text-xs">{}</td></tr>"##,
|
||||
t(lang, "devices.no_devices"),
|
||||
);
|
||||
}
|
||||
for d in &devices {
|
||||
render_device_row(&mut s, d, now);
|
||||
render_device_row(&mut s, lang, d, now);
|
||||
}
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"</tbody>
|
||||
</table>
|
||||
<div class="px-3 py-2 text-xs text-slate-500 border-t border-slate-800">{total} device(s).</div>
|
||||
</div>"##
|
||||
<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)
|
||||
}
|
||||
|
||||
fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTime<chrono::Utc>) {
|
||||
fn render_device_row(
|
||||
s: &mut String,
|
||||
lang: Lang,
|
||||
d: &DashboardDeviceRow,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
) {
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null);
|
||||
let pick = |k: &str| -> String {
|
||||
@@ -246,14 +277,14 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi
|
||||
let (dot_class, tooltip) = if is_online {
|
||||
(
|
||||
"bg-emerald-400",
|
||||
format!("Online — last heartbeat {}s ago", age_secs),
|
||||
tf1(lang, "devices.online", &age_secs.to_string()),
|
||||
)
|
||||
} else if age_secs == i64::MAX {
|
||||
("bg-slate-500", "No heartbeat recorded".to_string())
|
||||
("bg-slate-500", t(lang, "devices.no_heartbeat").to_string())
|
||||
} else {
|
||||
(
|
||||
"bg-rose-500",
|
||||
format!("Offline — last heartbeat {} ago", fmt_age(age_secs)),
|
||||
tf1(lang, "devices.offline", &fmt_age(age_secs)),
|
||||
)
|
||||
};
|
||||
// Per-boot unattended-access password reported by hello-agent. Visible
|
||||
@@ -304,31 +335,31 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi
|
||||
<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 client)
|
||||
{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 & inventory
|
||||
{details}
|
||||
</button>
|
||||
<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="Disconnect all active sessions on {id}?">
|
||||
Force disconnect
|
||||
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 refresh
|
||||
{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="Delete {id}? This removes the dashboard row and the rendezvous identity. Audit logs and recordings are kept.">
|
||||
Delete device
|
||||
hx-confirm="{confirm_delete}">
|
||||
{delete_device}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
@@ -347,7 +378,14 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi
|
||||
os = html_escape(&os),
|
||||
ver = html_escape(&version_label),
|
||||
last = html_escape(&d.last_heartbeat_at),
|
||||
n = conn_count
|
||||
n = conn_count,
|
||||
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"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -361,19 +399,22 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi
|
||||
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).await?))
|
||||
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() -> String {
|
||||
r##"<button class="text-xs text-sky-300 hover:text-sky-200"
|
||||
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">← Back to devices</button>"##
|
||||
.to_string()
|
||||
hx-swap="innerHTML">{label}</button>"##,
|
||||
label = t(lang, "devices.back"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Pretty-print a JSON value for the inventory table cells. Strings are
|
||||
@@ -391,7 +432,7 @@ fn fmt_inv_value(v: Option<&serde_json::Value>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_detail(d: &DashboardDeviceRow) -> 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 {
|
||||
@@ -426,20 +467,29 @@ fn render_detail(d: &DashboardDeviceRow) -> String {
|
||||
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 <code class="font-mono text-sky-300">{id}</code></h2>
|
||||
<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">Hostname</dt><dd class="text-slate-200">{host}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">Owner</dt><dd class="text-slate-200">{owner}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">Active user</dt><dd class="text-slate-200">{user}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">Agent</dt><dd class="text-slate-200">{ident}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">OS (runtime)</dt><dd class="text-slate-200">{os_rt}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">Last heartbeat</dt><dd class="text-slate-200">{last}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">CPU (runtime)</dt><dd class="text-slate-200">{cpu_rt}</dd></div>
|
||||
<div><dt class="text-xs text-slate-500">Memory (runtime)</dt><dd class="text-slate-200">{mem_rt}</dd></div>
|
||||
<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 }),
|
||||
@@ -463,22 +513,24 @@ fn render_detail(d: &DashboardDeviceRow) -> String {
|
||||
// hello-agent's main.rs; absence means vanilla rustdesk.
|
||||
let inventory_section = if agent_name == "HelloAgent" {
|
||||
match parsed.get("inventory") {
|
||||
Some(inv) if inv.is_object() => render_inventory_table(inv),
|
||||
Some(inv) if inv.is_object() => render_inventory_table(lang, inv),
|
||||
_ => format!(
|
||||
r##"<div class="rounded-md border border-amber-700/50 bg-amber-900/20 p-3 text-sm text-amber-300">
|
||||
Inventory data not yet reported. The agent collects it on startup and
|
||||
uploads on the next sysinfo cycle (≤120 s).
|
||||
</div>"##
|
||||
{msg}
|
||||
</div>"##,
|
||||
msg = t(lang, "devices.inventory_pending"),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
format!(
|
||||
r##"<div class="rounded-md border border-slate-700 bg-slate-900 p-3 text-sm text-slate-400">
|
||||
Inventory data is only reported by HelloAgent. This device is running
|
||||
<span class="text-slate-200">{ident}</span>; the standard RustDesk client
|
||||
does not collect hardware or BitLocker inventory.
|
||||
{msg}
|
||||
</div>"##,
|
||||
ident = html_escape(&identity_label),
|
||||
msg = tf1(
|
||||
lang,
|
||||
"devices.inventory_unsupported",
|
||||
&format!("<span class=\"text-slate-200\">{}</span>", html_escape(&identity_label))
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -486,19 +538,21 @@ fn render_detail(d: &DashboardDeviceRow) -> String {
|
||||
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 class="text-xs text-slate-500">{detail_view}</div>
|
||||
</div>
|
||||
{header}
|
||||
<h3 class="text-sm font-semibold text-slate-300 mt-4">Inventory</h3>
|
||||
<h3 class="text-sm font-semibold text-slate-300 mt-4">{inventory}</h3>
|
||||
{inv}
|
||||
</div>"##,
|
||||
back = back_button(),
|
||||
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(inv: &serde_json::Value) -> 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">
|
||||
@@ -513,8 +567,12 @@ fn render_inventory_table(inv: &serde_json::Value) -> String {
|
||||
// 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 = String::from(
|
||||
r##"<table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">Name</th><th class="text-left font-medium px-2 py-1">Model</th><th class="text-right font-medium px-2 py-1">Size (GB)</th><th class="text-left font-medium px-2 py-1">Media</th></tr></thead><tbody>"##,
|
||||
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"));
|
||||
@@ -540,7 +598,10 @@ fn render_inventory_table(inv: &serde_json::Value) -> String {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let bl_html = if bl_key_raw.is_empty() {
|
||||
r##"<span class="text-slate-500">— (not reported; non-Pro SKU, not encrypted, or no admin rights)</span>"##.to_string()
|
||||
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>"##,
|
||||
@@ -548,14 +609,17 @@ fn render_inventory_table(inv: &serde_json::Value) -> String {
|
||||
)
|
||||
};
|
||||
|
||||
let nics_html = render_nics(inv.get("network_interfaces"));
|
||||
let wifi_html = render_wifi(inv.get("wifi_current"), inv.get("wifi_nearby"));
|
||||
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 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() {
|
||||
r##"<span class="text-slate-500">— (lookup failed or blocked)</span>"##.to_string()
|
||||
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>"##,
|
||||
@@ -583,40 +647,44 @@ fn render_inventory_table(inv: &serde_json::Value) -> String {
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs uppercase text-slate-500 mb-1">Disks</h4>
|
||||
<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">Network interfaces</h4>
|
||||
<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">Public IP (egress, last lookup)</h4>
|
||||
<h4 class="text-xs uppercase text-slate-500 mb-1">{l_pip}</h4>
|
||||
{public_ip}
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs uppercase text-slate-500 mb-1">BitLocker recovery key (system drive)</h4>
|
||||
<h4 class="text-xs uppercase text-slate-500 mb-1">{l_bl}</h4>
|
||||
{bl}
|
||||
</div>
|
||||
</div>"##,
|
||||
sn = row("Serial number", "serial_number"),
|
||||
mfr = row("Manufacturer", "manufacturer"),
|
||||
model = row("Model", "model"),
|
||||
dom = row("Windows domain", "domain"),
|
||||
os_d = row("OS distribution", "os_distro"),
|
||||
os_r = row("OS release", "os_release"),
|
||||
cpu_m = row("CPU model", "cpu_model"),
|
||||
cpu_s = row("CPU speed (GHz)", "cpu_speed_ghz"),
|
||||
cpu_pc = row("CPU physical cores", "cpu_cores_physical"),
|
||||
cpu_lc = row("CPU logical cores", "cpu_cores_logical"),
|
||||
ram = row("RAM (GB)", "ram_gb"),
|
||||
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,
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -624,15 +692,18 @@ fn render_inventory_table(inv: &serde_json::Value) -> String {
|
||||
/// 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(nics: Option<&serde_json::Value>) -> String {
|
||||
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 = String::from(
|
||||
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">Name</th><th class="text-left font-medium px-2 py-1">Description</th><th class="text-left font-medium px-2 py-1">MAC</th><th class="text-left font-medium px-2 py-1">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>"##,
|
||||
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"));
|
||||
@@ -687,6 +758,7 @@ fn render_ip_list(v: Option<&serde_json::Value>) -> String {
|
||||
/// 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 {
|
||||
@@ -726,10 +798,10 @@ fn render_wifi(
|
||||
<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">Signal</dt><dd class="text-slate-200">{sig}</dd></div>
|
||||
<div><dt class="text-slate-500">Authentication</dt><dd class="text-slate-300">{auth}</dd></div>
|
||||
<div><dt class="text-slate-500">Cipher</dt><dd class="text-slate-300">{cipher}</dd></div>
|
||||
<div><dt class="text-slate-500">Rate</dt><dd class="text-slate-300">{rate}</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")),
|
||||
@@ -738,18 +810,28 @@ fn render_wifi(
|
||||
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 {
|
||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-3 text-xs text-slate-500">Not connected to a Wi-Fi network.</div>"##.to_string()
|
||||
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 = String::from(
|
||||
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 SSIDs ({n})</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">Authentication</th><th class="text-left font-medium px-2 py-1">Cipher</th><th class="text-right font-medium px-2 py-1">Signal</th></tr></thead><tbody>"##,
|
||||
<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"),
|
||||
);
|
||||
s = s.replace("{n}", &arr.len().to_string());
|
||||
for net in arr {
|
||||
let _ = write!(
|
||||
&mut s,
|
||||
@@ -768,10 +850,11 @@ fn render_wifi(
|
||||
|
||||
format!(
|
||||
r##"<div>
|
||||
<h4 class="text-xs uppercase text-slate-500 mb-1">Wi-Fi (current connection)</h4>
|
||||
<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()
|
||||
@@ -798,11 +881,12 @@ fn fmt_age(secs: i64) -> String {
|
||||
|
||||
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).await?);
|
||||
html.push_str(&render_table(state, lang).await?);
|
||||
Ok(Html(html))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user