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:
+95
-6
@@ -75,11 +75,24 @@
|
||||
<div id="toast"
|
||||
class="fixed bottom-4 right-4 max-w-sm space-y-2 pointer-events-none"></div>
|
||||
|
||||
<!-- Load fragment + highlight active link based on the URL hash. -->
|
||||
<!-- Load fragment + highlight active link based on the URL hash.
|
||||
Sub-routes like #users/new map to dedicated fragment URLs but
|
||||
keep the parent section's nav-link highlighted. -->
|
||||
<script>
|
||||
// Hash → fragment URL for routes that aren't owned by a sidebar
|
||||
// nav-link (e.g. forms on their own page). The first path segment
|
||||
// also tells us which nav-link to highlight.
|
||||
const SUB_ROUTES = {
|
||||
'#users/new': '/admin/pages/users/new',
|
||||
};
|
||||
function topLevelHash(hash) {
|
||||
const slash = hash.indexOf('/');
|
||||
return slash === -1 ? hash : hash.slice(0, slash);
|
||||
}
|
||||
function linkForHash() {
|
||||
const hash = location.hash || '#users';
|
||||
return document.querySelector('.nav-link[hx-push-url="' + hash + '"]')
|
||||
const top = topLevelHash(hash);
|
||||
return document.querySelector('.nav-link[hx-push-url="' + top + '"]')
|
||||
|| document.querySelector('.nav-link[hx-push-url="#users"]');
|
||||
}
|
||||
function refreshActive() {
|
||||
@@ -89,10 +102,16 @@
|
||||
});
|
||||
}
|
||||
function loadFromHash() {
|
||||
const link = linkForHash();
|
||||
if (link) {
|
||||
htmx.ajax('GET', link.getAttribute('hx-get'),
|
||||
{ target: '#main', swap: 'innerHTML' });
|
||||
const hash = location.hash || '#users';
|
||||
const subUrl = SUB_ROUTES[hash];
|
||||
if (subUrl) {
|
||||
htmx.ajax('GET', subUrl, { target: '#main', swap: 'innerHTML' });
|
||||
} else {
|
||||
const link = linkForHash();
|
||||
if (link) {
|
||||
htmx.ajax('GET', link.getAttribute('hx-get'),
|
||||
{ target: '#main', swap: 'innerHTML' });
|
||||
}
|
||||
}
|
||||
refreshActive();
|
||||
}
|
||||
@@ -116,6 +135,76 @@
|
||||
if (!d.contains(e.target)) d.removeAttribute('open');
|
||||
});
|
||||
});
|
||||
|
||||
// Read the current value of the users-search input (if present).
|
||||
// Used by usersColumnToggle/usersPageSize so a column or page-size
|
||||
// change preserves the active filter.
|
||||
function usersSearchValue() {
|
||||
const el = document.getElementById('users-search');
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
// Users table column-visibility toggle. The popover in the page header
|
||||
// emits checkboxes with onchange="usersColumnToggle(this)" — we POST
|
||||
// the new state to the server (which persists it in user_prefs) and
|
||||
// swap in the re-rendered table so the popover stays open.
|
||||
function usersColumnToggle(input) {
|
||||
const col = input.dataset.col;
|
||||
if (!col) return;
|
||||
htmx.ajax('POST', '/admin/pages/users/columns', {
|
||||
target: '#users-region',
|
||||
swap: 'innerHTML',
|
||||
values: {
|
||||
col: col,
|
||||
visible: input.checked ? '1' : '0',
|
||||
q: usersSearchValue(),
|
||||
},
|
||||
});
|
||||
}
|
||||
window.usersColumnToggle = usersColumnToggle;
|
||||
|
||||
// Users table per-page selector. Driven by the <select> in the
|
||||
// pagination footer — POSTs to persist the choice and re-renders the
|
||||
// table at page 1 (size change shifts which rows are on which page).
|
||||
function usersPageSize(size) {
|
||||
htmx.ajax('POST', '/admin/pages/users/page-size', {
|
||||
target: '#users-region',
|
||||
swap: 'innerHTML',
|
||||
values: { size: size, q: usersSearchValue() },
|
||||
});
|
||||
}
|
||||
window.usersPageSize = usersPageSize;
|
||||
|
||||
// Devices table — mirrors the users helpers above. Same persistence
|
||||
// model (per-user prefs in `user_prefs`) and the same fragment-swap
|
||||
// approach so the columns popover and search input stay put while
|
||||
// pagination/columns/page-size all preserve the active filter.
|
||||
function devicesSearchValue() {
|
||||
const el = document.getElementById('devices-search');
|
||||
return el ? el.value : '';
|
||||
}
|
||||
function devicesColumnToggle(input) {
|
||||
const col = input.dataset.col;
|
||||
if (!col) return;
|
||||
htmx.ajax('POST', '/admin/pages/devices/columns', {
|
||||
target: '#devices-region',
|
||||
swap: 'innerHTML',
|
||||
values: {
|
||||
col: col,
|
||||
visible: input.checked ? '1' : '0',
|
||||
q: devicesSearchValue(),
|
||||
},
|
||||
});
|
||||
}
|
||||
window.devicesColumnToggle = devicesColumnToggle;
|
||||
function devicesPageSize(size) {
|
||||
htmx.ajax('POST', '/admin/pages/devices/page-size', {
|
||||
target: '#devices-region',
|
||||
swap: 'innerHTML',
|
||||
values: { size: size, q: devicesSearchValue() },
|
||||
});
|
||||
}
|
||||
window.devicesPageSize = devicesPageSize;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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
+109
-13
@@ -74,7 +74,15 @@ pub async fn verify(
|
||||
// Partial headers: someone tried to sign but messed up the request.
|
||||
// Don't fall through to legacy — treat as an outright failure so we
|
||||
// don't silently downgrade a misconfigured agent.
|
||||
_ => return Err(ApiError::Unauthorized),
|
||||
_ => {
|
||||
hbb_common::log::warn!(
|
||||
"signed API {}: partial headers (id={:?}, sig_present={})",
|
||||
path,
|
||||
id_hdr,
|
||||
sig_hdr.map(|s| !s.is_empty()).unwrap_or(false),
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse "v1.<ts>.<b64>".
|
||||
@@ -83,14 +91,40 @@ pub async fn verify(
|
||||
let ts_s = parts.next().unwrap_or("");
|
||||
let sig_b64 = parts.next().unwrap_or("");
|
||||
if ver != SIG_VERSION || ts_s.is_empty() || sig_b64.is_empty() {
|
||||
hbb_common::log::warn!(
|
||||
"signed API {} from {}: malformed signature header (ver={:?})",
|
||||
path, id_hdr, ver,
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
let ts: i64 = ts_s.parse().map_err(|_| ApiError::Unauthorized)?;
|
||||
let ts: i64 = match ts_s.parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
hbb_common::log::warn!(
|
||||
"signed API {} from {}: bad timestamp {:?}",
|
||||
path, id_hdr, ts_s,
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
};
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
if (now - ts).abs() > SKEW_TOLERANCE_SECS {
|
||||
hbb_common::log::warn!(
|
||||
"signed API {} from {}: clock skew {}s exceeds {}s tolerance",
|
||||
path, id_hdr, (now - ts).abs(), SKEW_TOLERANCE_SECS,
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
let sig_bytes = base64::decode(sig_b64).map_err(|_| ApiError::Unauthorized)?;
|
||||
let sig_bytes = match base64::decode(sig_b64) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
hbb_common::log::warn!(
|
||||
"signed API {} from {}: base64 decode failed: {}",
|
||||
path, id_hdr, e,
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
};
|
||||
|
||||
// Replay check before the expensive crypto. The (id, ts, sig-prefix)
|
||||
// tuple is unique per request from a non-broken agent.
|
||||
@@ -102,6 +136,10 @@ pub async fn verify(
|
||||
let mut cache = REPLAY.lock().unwrap();
|
||||
cache.retain(|_, exp| *exp > now);
|
||||
if cache.contains_key(&replay_key) {
|
||||
hbb_common::log::warn!(
|
||||
"signed API {} from {}: replay rejected",
|
||||
path, id_hdr,
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
if cache.len() < REPLAY_CACHE_MAX {
|
||||
@@ -112,15 +150,41 @@ pub async fn verify(
|
||||
}
|
||||
|
||||
// Look up the peer's pk and managed flag in one query.
|
||||
let (pk_bytes, managed) = state
|
||||
let row = state
|
||||
.db
|
||||
.peer_get_auth(id_hdr)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let (pk_bytes, managed) = match row {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
// Early-boot race: the agent generates its keypair and starts
|
||||
// signing API requests before its `--server` child has done
|
||||
// the rendezvous RegisterPk handshake that creates the peer
|
||||
// row. Returning Unauthorized here would leave brand-new
|
||||
// agents stuck — the retry loop is designed around the
|
||||
// ID_NOT_FOUND response from the handler, not a hard auth
|
||||
// failure. Fall through to legacy so the handler can answer
|
||||
// ID_NOT_FOUND; the next retry after RegisterPk completes
|
||||
// will validate normally and TOFU-promote.
|
||||
hbb_common::log::debug!(
|
||||
"signed API request for unregistered peer {} — pre-rendezvous race, \
|
||||
deferring to legacy path",
|
||||
id_hdr,
|
||||
);
|
||||
return Ok(AuthOutcome::LegacyUnsigned);
|
||||
}
|
||||
};
|
||||
if pk_bytes.is_empty() {
|
||||
// No PK registered — rendezvous hasn't completed. Can't verify.
|
||||
return Err(ApiError::Unauthorized);
|
||||
// Peer row exists (rendezvous touched it) but no PK yet — same
|
||||
// race as above, mid-handshake. Defer to legacy; the handler's
|
||||
// `enforce_managed_for_id` still protects this peer if it was
|
||||
// somehow flagged managed=1 with no pk.
|
||||
hbb_common::log::debug!(
|
||||
"signed API request for peer {} with empty pk — deferring to legacy path",
|
||||
id_hdr,
|
||||
);
|
||||
return Ok(AuthOutcome::LegacyUnsigned);
|
||||
}
|
||||
|
||||
// Build the canonical signed message:
|
||||
@@ -136,11 +200,37 @@ pub async fn verify(
|
||||
msg.push(b'\n');
|
||||
msg.extend_from_slice(body_sha.as_ref());
|
||||
|
||||
let pk = sodiumoxide::crypto::sign::PublicKey::from_slice(&pk_bytes)
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
let sig = sodiumoxide::crypto::sign::Signature::from_bytes(&sig_bytes)
|
||||
.map_err(|_| ApiError::Unauthorized)?;
|
||||
let pk = match sodiumoxide::crypto::sign::PublicKey::from_slice(&pk_bytes) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
hbb_common::log::warn!(
|
||||
"signed API {} from {}: stored pk ({}B) is not a valid Ed25519 public key",
|
||||
path, id_hdr, pk_bytes.len(),
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
};
|
||||
let sig = match sodiumoxide::crypto::sign::Signature::from_bytes(&sig_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
hbb_common::log::warn!(
|
||||
"signed API {} from {}: signature length {} is not the Ed25519 size",
|
||||
path, id_hdr, sig_bytes.len(),
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
};
|
||||
if !sodiumoxide::crypto::sign::verify_detached(&sig, &msg, &pk) {
|
||||
// The agent's keypair doesn't match the pk stored in `peer`.
|
||||
// Usually this means a config was wiped/regenerated on the agent
|
||||
// side without the server's row being cleared — the next
|
||||
// successful RegisterPk handshake will fix it.
|
||||
hbb_common::log::warn!(
|
||||
"signed API {} from {}: signature does NOT verify against stored pk \
|
||||
(agent's keypair differs from the one rendezvous registered) \
|
||||
— managed={}",
|
||||
path, id_hdr, managed,
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
|
||||
@@ -178,7 +268,13 @@ pub async fn enforce_managed_for_id(
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
match row {
|
||||
Some((_, true)) => Err(ApiError::Unauthorized),
|
||||
Some((_, true)) => {
|
||||
hbb_common::log::warn!(
|
||||
"rejecting unsigned API request for managed peer {}",
|
||||
id,
|
||||
);
|
||||
Err(ApiError::Unauthorized)
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
+176
@@ -444,6 +444,12 @@ impl Database {
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
}
|
||||
// M6 schema: per-user UI preferences (key/value bag).
|
||||
for stmt in M6_SCHEMA {
|
||||
sqlx::query(stmt)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
}
|
||||
// Soft-ALTERs run after schema creation. SQLite < 3.35 lacks
|
||||
// `ADD COLUMN IF NOT EXISTS`; swallow the duplicate-column error
|
||||
// so re-runs are idempotent. Newly-added soft alters get appended
|
||||
@@ -585,6 +591,53 @@ impl Database {
|
||||
Ok((total, rows.into_iter().map(row_to_user).collect()))
|
||||
}
|
||||
|
||||
/// Same shape as `users_list_all` but with a free-text filter over
|
||||
/// username + display_name + email + note (concatenated and lowered,
|
||||
/// case-insensitive substring match). Empty `q` returns the full set.
|
||||
/// Returned `total` is the *filtered* row count so pagination math
|
||||
/// stays correct.
|
||||
pub async fn users_list_filtered(
|
||||
&self,
|
||||
q: &str,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
) -> ResultType<(i64, Vec<UserRow>)> {
|
||||
let q = q.trim();
|
||||
if q.is_empty() {
|
||||
return self.users_list_all(offset, limit).await;
|
||||
}
|
||||
// `_` and `%` are LIKE wildcards — escape them so a literal
|
||||
// underscore in a username doesn't match every row.
|
||||
let escaped = q
|
||||
.to_lowercase()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_");
|
||||
let needle = format!("%{}%", escaped);
|
||||
// The schema declares username/display_name/email/note as NOT NULL
|
||||
// DEFAULT '' so the concat is always a real string.
|
||||
let haystack = "LOWER(username || ' ' || display_name || ' ' || email || ' ' || note)";
|
||||
let total: i64 = sqlx::query(&format!(
|
||||
"SELECT COUNT(*) AS c FROM users WHERE {haystack} LIKE ? ESCAPE '\\'",
|
||||
haystack = haystack,
|
||||
))
|
||||
.bind(&needle)
|
||||
.fetch_one(self.pool.get().await?.deref_mut())
|
||||
.await?
|
||||
.try_get("c")?;
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT id, username, password_hash, display_name, email, note, avatar, status, is_admin, oidc_subject \
|
||||
FROM users WHERE {haystack} LIKE ? ESCAPE '\\' ORDER BY username LIMIT ? OFFSET ?",
|
||||
haystack = haystack,
|
||||
))
|
||||
.bind(&needle)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok((total, rows.into_iter().map(row_to_user).collect()))
|
||||
}
|
||||
|
||||
pub async fn user_set_status(&self, id: i64, status: i64) -> ResultType<bool> {
|
||||
let res = sqlx::query("UPDATE users SET status = ?, updated_at = current_timestamp WHERE id = ?")
|
||||
.bind(status)
|
||||
@@ -718,6 +771,81 @@ impl Database {
|
||||
Ok((total, data))
|
||||
}
|
||||
|
||||
/// Same shape as `devices_list_all` but filters by a free-text
|
||||
/// substring match (case-insensitive) over peer id, owner username,
|
||||
/// and the raw sysinfo payload (so hostname, OS, version, agent name
|
||||
/// — anything serialised into the JSON — all become searchable).
|
||||
/// Empty `q` short-circuits to the unfiltered query. Returned
|
||||
/// `total` is the *filtered* row count so pagination math stays
|
||||
/// correct.
|
||||
pub async fn devices_list_filtered(
|
||||
&self,
|
||||
q: &str,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
) -> ResultType<(i64, Vec<DashboardDeviceRow>)> {
|
||||
let q = q.trim();
|
||||
if q.is_empty() {
|
||||
return self.devices_list_all(offset, limit).await;
|
||||
}
|
||||
let escaped = q
|
||||
.to_lowercase()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_");
|
||||
let needle = format!("%{}%", escaped);
|
||||
// payload is the verbatim JSON string the agent uploaded, so a
|
||||
// grep-style LIKE matches hostnames/usernames/OS/version/etc.
|
||||
// without needing JSON functions or per-field indexing.
|
||||
let haystack = "LOWER(ds.id || ' ' || COALESCE(u.username, '') || ' ' || COALESCE(ds.payload, ''))";
|
||||
let total: i64 = sqlx::query(&format!(
|
||||
"SELECT COUNT(*) AS c FROM device_sysinfo ds \
|
||||
LEFT JOIN users u ON u.id = ds.user_id \
|
||||
WHERE {haystack} LIKE ? ESCAPE '\\'",
|
||||
haystack = haystack,
|
||||
))
|
||||
.bind(&needle)
|
||||
.fetch_one(self.pool.get().await?.deref_mut())
|
||||
.await?
|
||||
.try_get("c")?;
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT ds.id AS pid, ds.uuid AS puuid, \
|
||||
COALESCE(u.username, '') AS owner_username, \
|
||||
ds.last_heartbeat_at AS last_hb, \
|
||||
ds.payload AS payload, \
|
||||
ds.conns AS conns, \
|
||||
COALESCE(ds.unattended_password, '') AS u_pw, \
|
||||
COALESCE(ds.unattended_password_set_at, '') AS u_pw_at, \
|
||||
COALESCE(p.managed, 0) AS managed \
|
||||
FROM device_sysinfo ds \
|
||||
LEFT JOIN users u ON u.id = ds.user_id \
|
||||
LEFT JOIN peer p ON p.id = ds.id \
|
||||
WHERE {haystack} LIKE ? ESCAPE '\\' \
|
||||
ORDER BY ds.last_heartbeat_at DESC LIMIT ? OFFSET ?",
|
||||
haystack = haystack,
|
||||
))
|
||||
.bind(&needle)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
let data = rows
|
||||
.into_iter()
|
||||
.map(|r| DashboardDeviceRow {
|
||||
id: r.try_get("pid").unwrap_or_default(),
|
||||
uuid: r.try_get("puuid").unwrap_or_default(),
|
||||
owner_username: r.try_get("owner_username").unwrap_or_default(),
|
||||
last_heartbeat_at: r.try_get("last_hb").unwrap_or_default(),
|
||||
sysinfo_payload: r.try_get("payload").unwrap_or_default(),
|
||||
conns_json: r.try_get("conns").unwrap_or_default(),
|
||||
unattended_password: r.try_get("u_pw").unwrap_or_default(),
|
||||
unattended_password_set_at: r.try_get("u_pw_at").unwrap_or_default(),
|
||||
managed: r.try_get::<i64, _>("managed").unwrap_or(0) != 0,
|
||||
})
|
||||
.collect();
|
||||
Ok((total, data))
|
||||
}
|
||||
|
||||
/// Fetch a single device row for the per-device detail page. Same
|
||||
/// shape and join as `devices_list_all`, just keyed on peer id.
|
||||
/// Returns `None` for an unknown / never-heartbeated peer id so the
|
||||
@@ -1250,6 +1378,40 @@ impl Database {
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
/// Look up a per-user UI preference. Returns `None` if unset.
|
||||
pub async fn user_pref_get(
|
||||
&self,
|
||||
user_id: i64,
|
||||
key: &str,
|
||||
) -> ResultType<Option<String>> {
|
||||
let row = sqlx::query("SELECT value FROM user_prefs WHERE user_id = ? AND key = ?")
|
||||
.bind(user_id)
|
||||
.bind(key)
|
||||
.fetch_optional(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(row.map(|r| r.try_get::<String, _>("value").unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// Upsert a per-user UI preference.
|
||||
pub async fn user_pref_set(
|
||||
&self,
|
||||
user_id: i64,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> ResultType<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO user_prefs(user_id, key, value) VALUES(?, ?, ?) \
|
||||
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value, \
|
||||
updated_at = current_timestamp",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn user_insert(&self, u: NewUser<'_>) -> ResultType<i64> {
|
||||
let admin_int: i64 = if u.is_admin { 1 } else { 0 };
|
||||
let res = sqlx::query(
|
||||
@@ -3693,6 +3855,20 @@ const M5_SCHEMA: &[&str] = &[
|
||||
"CREATE INDEX IF NOT EXISTS idx_exec_status ON exec_history(status, issued_at)",
|
||||
];
|
||||
|
||||
/// M6: per-user UI preferences. Generic key/value bag so future preferences
|
||||
/// (e.g. per-page sort order, density, default filters) don't need their own
|
||||
/// migration. Keys are namespaced by the consumer ("users.cols_hide", …).
|
||||
const M6_SCHEMA: &[&str] = &[
|
||||
"CREATE TABLE IF NOT EXISTS user_prefs (
|
||||
user_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME NOT NULL DEFAULT(current_timestamp),
|
||||
PRIMARY KEY (user_id, key),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use hbb_common::tokio;
|
||||
|
||||
Reference in New Issue
Block a user