Implement filters and column management in admin UI lists
build / build-linux-amd64 (push) Successful in 1m53s

This commit is contained in:
2026-05-22 18:40:40 +02:00
parent 6a0b698384
commit 840958aa0c
7 changed files with 1869 additions and 203 deletions
+95 -6
View File
@@ -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>
+120
View File
@@ -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)
}
+21
View File
@@ -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
View File
@@ -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))
}
File diff suppressed because it is too large Load Diff
+31 -5
View File
@@ -112,15 +112,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:
+176
View File
@@ -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;