Implement filters and column management in admin UI lists
build / build-linux-amd64 (push) Successful in 1m52s
build / build-linux-amd64 (push) Successful in 1m52s
This commit is contained in:
@@ -350,6 +350,76 @@ pub fn t(lang: Lang, key: &str) -> &'static str {
|
||||
"Creează utilizator",
|
||||
"Crear usuario",
|
||||
),
|
||||
"users.add_user" => (
|
||||
"Add user",
|
||||
"Benutzer hinzufügen",
|
||||
"Ajouter un utilisateur",
|
||||
"Adaugă utilizator",
|
||||
"Añadir usuario",
|
||||
),
|
||||
"users.new_user_heading" => (
|
||||
"New user",
|
||||
"Neuer Benutzer",
|
||||
"Nouvel utilisateur",
|
||||
"Utilizator nou",
|
||||
"Nuevo usuario",
|
||||
),
|
||||
"users.columns" => (
|
||||
"Columns",
|
||||
"Spalten",
|
||||
"Colonnes",
|
||||
"Coloane",
|
||||
"Columnas",
|
||||
),
|
||||
"users.per_page" => (
|
||||
"Per page",
|
||||
"Pro Seite",
|
||||
"Par page",
|
||||
"Pe pagină",
|
||||
"Por página",
|
||||
),
|
||||
"users.showing_range" => (
|
||||
"Showing {0}\u{2013}{1} of {2}",
|
||||
"{0}\u{2013}{1} von {2}",
|
||||
"{0}\u{2013}{1} sur {2}",
|
||||
"{0}\u{2013}{1} din {2}",
|
||||
"{0}\u{2013}{1} de {2}",
|
||||
),
|
||||
"users.no_users" => (
|
||||
"No users yet.",
|
||||
"Noch keine Benutzer.",
|
||||
"Aucun utilisateur pour l'instant.",
|
||||
"Niciun utilizator încă.",
|
||||
"Aún no hay usuarios.",
|
||||
),
|
||||
"users.no_match" => (
|
||||
"No users match the search.",
|
||||
"Keine Benutzer entsprechen der Suche.",
|
||||
"Aucun utilisateur ne correspond à la recherche.",
|
||||
"Niciun utilizator nu corespunde căutării.",
|
||||
"Ningún usuario coincide con la búsqueda.",
|
||||
),
|
||||
"users.search_placeholder" => (
|
||||
"Search users\u{2026}",
|
||||
"Benutzer suchen\u{2026}",
|
||||
"Rechercher des utilisateurs\u{2026}",
|
||||
"Caută utilizatori\u{2026}",
|
||||
"Buscar usuarios\u{2026}",
|
||||
),
|
||||
"common.prev" => (
|
||||
"Previous",
|
||||
"Zurück",
|
||||
"Précédent",
|
||||
"Anterior",
|
||||
"Anterior",
|
||||
),
|
||||
"common.next" => (
|
||||
"Next",
|
||||
"Weiter",
|
||||
"Suivant",
|
||||
"Următor",
|
||||
"Siguiente",
|
||||
),
|
||||
"users.username" => (
|
||||
"username",
|
||||
"Benutzername",
|
||||
@@ -703,6 +773,48 @@ pub fn t(lang: Lang, key: &str) -> &'static str {
|
||||
"Niciun dispozitiv nu a trimis încă heartbeat.",
|
||||
"Ningún dispositivo ha enviado heartbeat aún.",
|
||||
),
|
||||
"devices.no_match" => (
|
||||
"No devices match the search.",
|
||||
"Keine Geräte entsprechen der Suche.",
|
||||
"Aucun appareil ne correspond à la recherche.",
|
||||
"Niciun dispozitiv nu corespunde căutării.",
|
||||
"Ningún dispositivo coincide con la búsqueda.",
|
||||
),
|
||||
"devices.search_placeholder" => (
|
||||
"Search devices\u{2026}",
|
||||
"Geräte suchen\u{2026}",
|
||||
"Rechercher des appareils\u{2026}",
|
||||
"Caută dispozitive\u{2026}",
|
||||
"Buscar dispositivos\u{2026}",
|
||||
),
|
||||
"devices.showing_range" => (
|
||||
"Showing {0}\u{2013}{1} of {2}",
|
||||
"{0}\u{2013}{1} von {2}",
|
||||
"{0}\u{2013}{1} sur {2}",
|
||||
"{0}\u{2013}{1} din {2}",
|
||||
"{0}\u{2013}{1} de {2}",
|
||||
),
|
||||
"devices.columns" => (
|
||||
"Columns",
|
||||
"Spalten",
|
||||
"Colonnes",
|
||||
"Coloane",
|
||||
"Columnas",
|
||||
),
|
||||
"devices.per_page" => (
|
||||
"Per page",
|
||||
"Pro Seite",
|
||||
"Par page",
|
||||
"Pe pagină",
|
||||
"Por página",
|
||||
),
|
||||
"devices.col_serial" => (
|
||||
"Serial",
|
||||
"Seriennr.",
|
||||
"N° de série",
|
||||
"Nr. serie",
|
||||
"N.º de serie",
|
||||
),
|
||||
"devices.online" => (
|
||||
"Online — last heartbeat {0}s ago",
|
||||
"Online — letzter Heartbeat vor {0}s",
|
||||
@@ -2347,3 +2459,11 @@ pub fn tf1(lang: Lang, key: &str, a: &str) -> String {
|
||||
pub fn tf2(lang: Lang, key: &str, a: &str, b: &str) -> String {
|
||||
t(lang, key).replace("{0}", a).replace("{1}", b)
|
||||
}
|
||||
|
||||
/// Three-arg formatted lookup. Replaces `{0}`, `{1}`, and `{2}`.
|
||||
pub fn tf3(lang: Lang, key: &str, a: &str, b: &str, c: &str) -> String {
|
||||
t(lang, key)
|
||||
.replace("{0}", a)
|
||||
.replace("{1}", b)
|
||||
.replace("{2}", c)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,19 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
||||
.route("/admin/login/oidc/:provider", get(oidc_login::start_login))
|
||||
// Page fragments — one per sidebar entry.
|
||||
.route("/admin/pages/users", get(pages::users::index))
|
||||
.route(
|
||||
"/admin/pages/users/table-fragment",
|
||||
get(pages::users::table_fragment),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/users/columns",
|
||||
post(pages::users::set_columns),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/users/page-size",
|
||||
post(pages::users::set_page_size),
|
||||
)
|
||||
.route("/admin/pages/users/new", get(pages::users::new_form))
|
||||
.route("/admin/pages/users/create", post(pages::users::create))
|
||||
.route(
|
||||
"/admin/pages/users/:id/update-info",
|
||||
@@ -97,6 +110,14 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
||||
"/admin/pages/devices/list-fragment",
|
||||
get(pages::devices::list_fragment),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/columns",
|
||||
post(pages::devices::set_columns),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/page-size",
|
||||
post(pages::devices::set_page_size),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/:peer_id/detail",
|
||||
get(pages::devices::detail),
|
||||
|
||||
+674
-101
@@ -2,35 +2,191 @@
|
||||
//! 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::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;
|
||||
use axum::extract::{Extension, Path};
|
||||
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;
|
||||
|
||||
const PAGE_SIZE: i64 = 100;
|
||||
// ---------- 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 table = render_table(&state, lang).await?;
|
||||
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">
|
||||
<h2 class="text-lg font-semibold">{heading}</h2>
|
||||
<p class="text-xs text-slate-500">{tagline}</p>
|
||||
<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}
|
||||
@@ -38,6 +194,9 @@ pub async fn index(
|
||||
</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,
|
||||
)))
|
||||
}
|
||||
@@ -47,8 +206,11 @@ pub async fn force_disconnect(
|
||||
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)
|
||||
@@ -62,6 +224,10 @@ pub async fn force_disconnect(
|
||||
notice_then_table(
|
||||
&state,
|
||||
lang,
|
||||
cols,
|
||||
pg.query(),
|
||||
pg.page_or_first(),
|
||||
page_size,
|
||||
"ok",
|
||||
&tf2(lang, "devices.queued_disconnect", &peer_id, &conns),
|
||||
)
|
||||
@@ -73,8 +239,11 @@ pub async fn force_sysinfo(
|
||||
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)
|
||||
@@ -83,6 +252,10 @@ pub async fn force_sysinfo(
|
||||
notice_then_table(
|
||||
&state,
|
||||
lang,
|
||||
cols,
|
||||
pg.query(),
|
||||
pg.page_or_first(),
|
||||
page_size,
|
||||
"ok",
|
||||
&tf1(lang, "devices.queued_sysinfo", &peer_id),
|
||||
)
|
||||
@@ -94,8 +267,11 @@ pub async fn delete(
|
||||
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)
|
||||
@@ -106,7 +282,17 @@ pub async fn delete(
|
||||
} else {
|
||||
t(lang, "devices.already_gone").to_string()
|
||||
};
|
||||
notice_then_table(&state, lang, if ok { "ok" } else { "error" }, &msg).await
|
||||
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
|
||||
@@ -120,8 +306,13 @@ pub async fn toggle_managed(
|
||||
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)
|
||||
@@ -133,6 +324,10 @@ pub async fn toggle_managed(
|
||||
return notice_then_table(
|
||||
&state,
|
||||
lang,
|
||||
cols,
|
||||
q,
|
||||
page,
|
||||
page_size,
|
||||
"error",
|
||||
&tf1(lang, "devices.managed_no_peer", &peer_id),
|
||||
)
|
||||
@@ -156,7 +351,94 @@ pub async fn toggle_managed(
|
||||
} else {
|
||||
"devices.managed_now_off"
|
||||
};
|
||||
notice_then_table(&state, lang, "ok", &tf1(lang, key, &peer_id)).await
|
||||
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
|
||||
@@ -213,10 +495,28 @@ fn online_state(last_heartbeat_at: &str, now: chrono::DateTime<chrono::Utc>) ->
|
||||
}
|
||||
}
|
||||
|
||||
async fn render_table(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
|
||||
let (total, devices) = state
|
||||
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_all(0, PAGE_SIZE)
|
||||
.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();
|
||||
@@ -230,52 +530,222 @@ async fn render_table(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiEr
|
||||
<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-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_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() {
|
||||
// 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="11" class="px-3 py-4 text-slate-500 text-center text-xs">{}</td></tr>"##,
|
||||
t(lang, "devices.no_devices"),
|
||||
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),
|
||||
);
|
||||
}
|
||||
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()),
|
||||
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(),
|
||||
);
|
||||
Ok(s)
|
||||
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
|
||||
@@ -293,6 +763,9 @@ fn unattended_pwd_always_visible() -> bool {
|
||||
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,
|
||||
@@ -307,6 +780,15 @@ fn render_device_row(
|
||||
.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
|
||||
@@ -385,28 +867,14 @@ fn render_device_row(
|
||||
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"),
|
||||
)
|
||||
// `?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!(
|
||||
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"),
|
||||
)
|
||||
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
|
||||
@@ -414,40 +882,147 @@ fn render_device_row(
|
||||
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-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"
|
||||
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##"<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}
|
||||
r##"
|
||||
<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>
|
||||
@@ -470,19 +1045,19 @@ fn render_device_row(
|
||||
{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-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"
|
||||
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"
|
||||
hx-post="/admin/pages/devices/{id}/delete?page={page}{q_param}"
|
||||
hx-target="#devices-region" hx-swap="innerHTML"
|
||||
hx-confirm="{confirm_delete}">
|
||||
{delete_device}
|
||||
@@ -491,21 +1066,9 @@ fn render_device_row(
|
||||
</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,
|
||||
page = page,
|
||||
q_param = q_param,
|
||||
toggle_managed_item = toggle_managed_item,
|
||||
connect_web = t(lang, "devices.connect_web"),
|
||||
details = t(lang, "devices.details"),
|
||||
@@ -522,16 +1085,22 @@ fn render_device_row(
|
||||
|
||||
/// 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.
|
||||
/// 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)?;
|
||||
Ok(Html(render_table(&state, lang).await?))
|
||||
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
|
||||
@@ -1075,11 +1644,15 @@ fn fmt_age(secs: i64) -> String {
|
||||
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).await?);
|
||||
html.push_str(&render_table(state, lang, cols, q, page, page_size).await?);
|
||||
Ok(Html(html))
|
||||
}
|
||||
|
||||
|
||||
+752
-91
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user