Improve admin UI (remove unused functions, added hello-agent deployment
build / build-linux-amd64 (push) Successful in 2m2s

This commit is contained in:
2026-05-09 14:14:58 +02:00
parent f7c359a8a0
commit a7b3e83f02
8 changed files with 275 additions and 188 deletions
+9 -3
View File
@@ -121,6 +121,14 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
"/admin/pages/groups/:id/members/:user_id/remove",
post(pages::groups::remove_member),
)
.route(
"/admin/pages/groups/:id/peers/add",
post(pages::groups::add_peer),
)
.route(
"/admin/pages/groups/:id/peers/:peer_id/remove",
post(pages::groups::remove_peer),
)
// Strategies
.route(
"/admin/pages/strategies/create",
@@ -181,7 +189,6 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
"/admin/pages/address-books/:guid/shares/:user_id/remove",
post(pages::address_books::share_remove),
)
.route("/admin/pages/oidc", get(pages::oidc::index))
// Self-service profile — cookie-only, no admin gate.
.route("/admin/pages/profile", get(pages::profile::index))
.route(
@@ -204,8 +211,7 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
"/admin/pages/profile/totp/remove",
post(pages::profile::totp_remove),
)
.route("/admin/pages/audit", get(pages::audit::index))
.route("/admin/pages/recordings", get(pages::recordings::index));
.route("/admin/pages/audit", get(pages::audit::index));
hbb_common::log::info!(
"admin dashboard mounted at /admin (HTML embedded; --admin-ui-dir is informational)"
);
+9 -1
View File
@@ -253,12 +253,13 @@ fn render_result(host: &str, key: &str, api: &str, relay: &str, blob: &str) -> S
licensed = licensed
);
let cmd_unix = format!("rustdesk --config {}", licensed);
let cmd_hello = format!("hello-agent.exe --install --config {}", blob);
format!(
r##"<section class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-4">
<header>
<h3 class="text-sm font-semibold text-slate-200">Deployment artifact</h3>
<p class="text-xs text-slate-500 mt-1">Pick whichever path fits your rollout. Both produce the same client config.</p>
<p class="text-xs text-slate-500 mt-1">Pick whichever path fits your rollout. All three produce the same client config.</p>
</header>
<div>
@@ -274,6 +275,12 @@ fn render_result(host: &str, key: &str, api: &str, relay: &str, blob: &str) -> S
{renamed_note}
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1">C. HelloAgent (Windows, MDM one-liner)</label>
<pre class="text-xs bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{cmd_hello}</pre>
<p class="text-xs text-slate-500 mt-1">Headless agent — registers the Windows service and imports this config in a single command. Run elevated.</p>
</div>
<details class="text-xs text-slate-400">
<summary class="cursor-pointer text-slate-300 select-none">Raw blob</summary>
<pre class="mt-2 bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{blob}</pre>
@@ -281,6 +288,7 @@ fn render_result(host: &str, key: &str, api: &str, relay: &str, blob: &str) -> S
</section>"##,
cmd_win = html_escape(&cmd_win),
cmd_unix = html_escape(&cmd_unix),
cmd_hello = html_escape(&cmd_hello),
renamed = html_escape(&renamed),
renamed_note = renamed_note,
blob = html_escape(blob),
+134 -4
View File
@@ -95,8 +95,78 @@ pub async fn remove_member(
Ok(Html(render_full(&state).await?))
}
#[derive(Debug, Deserialize)]
pub struct PeerForm {
pub peer_id: String,
}
pub async fn add_peer(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(id): Path<i64>,
Form(form): Form<PeerForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let peer_id = form.peer_id.trim();
if peer_id.is_empty() {
return notice_then(&state, "error", "Device ID required").await;
}
let exists = state
.db
.peer_exists(peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if !exists {
return notice_then(
&state,
"error",
&format!("No device '{}' has reported in yet.", peer_id),
)
.await;
}
state
.db
.device_group_add_peer(id, peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(render_full(&state).await?))
}
pub async fn remove_peer(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path((id, peer_id)): Path<(i64, String)>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
state
.db
.device_group_remove_peer(id, &peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(render_full(&state).await?))
}
// ---------- rendering ----------
/// Minimal percent-encoder for path segments. Peer IDs are usually digits,
/// but the schema allows arbitrary text — encode anything outside the
/// unreserved set so a literal `/` or `?` in a peer id can't break routing.
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);
}
_ => {
use std::fmt::Write;
let _ = write!(out, "%{:02X}", b);
}
}
}
out
}
async fn notice_then(
state: &Arc<AppState>,
kind: &str,
@@ -144,9 +214,14 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
.device_group_members(g.id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let peer_members = state
.db
.device_group_peer_members(g.id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let _ = write!(
s,
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3">
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-4">
<header class="flex items-center justify-between">
<h3 class="font-semibold">{name}</h3>
<button class="text-xs text-rose-400 hover:text-rose-300"
@@ -154,13 +229,15 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
hx-confirm="Delete group {name}? Members aren't deleted; just unassigned."
hx-target="#groups-region" hx-swap="outerHTML">Delete group</button>
</header>
<ul class="text-sm divide-y divide-slate-800">"##,
<div>
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1">Users</h4>
<ul class="text-sm divide-y divide-slate-800">"##,
id = g.id,
name = html_escape(&g.name)
);
if members.is_empty() {
s.push_str(
r##"<li class="py-2 text-slate-500 text-xs">No members yet.</li>"##,
r##"<li class="py-2 text-slate-500 text-xs">No user members yet.</li>"##,
);
}
for u in &members {
@@ -203,10 +280,63 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
}
s.push_str(
r##"</select>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">Add member</button>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">Add user</button>
</form>"##,
);
}
s.push_str("</div>");
// ---- Devices section ----
let _ = write!(
s,
r##"<div>
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1">Devices</h4>
<ul class="text-sm divide-y divide-slate-800">"##
);
if peer_members.is_empty() {
s.push_str(
r##"<li class="py-2 text-slate-500 text-xs">No devices added directly. (Devices owned by user members above are also visible to them.)</li>"##,
);
}
for (peer_id, owner) in &peer_members {
let owner_label = if owner.is_empty() {
String::from(r##"<span class="text-slate-500">unowned</span>"##)
} else {
format!(
r##"<span class="text-slate-500">owner: {}</span>"##,
html_escape(owner)
)
};
let _ = write!(
s,
r##"<li class="py-2 flex items-center justify-between">
<span class="font-mono text-slate-200">{pid}</span>
<span class="text-xs flex items-center gap-3">
{owner_label}
<button class="text-slate-400 hover:text-rose-300"
hx-post="/admin/pages/groups/{gid}/peers/{pid_url}/remove"
hx-target="#groups-region" hx-swap="outerHTML">Remove</button>
</span>
</li>"##,
pid = html_escape(peer_id),
pid_url = url_encode(peer_id),
gid = g.id,
owner_label = owner_label,
);
}
s.push_str("</ul>");
let _ = write!(
s,
r##"<form class="flex gap-2 text-sm pt-2 border-t border-slate-800"
hx-post="/admin/pages/groups/{id}/peers/add"
hx-target="#groups-region" hx-swap="outerHTML">
<input name="peer_id" placeholder="Device ID (e.g. 123456789)" required
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono"/>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">Add device</button>
</form></div>"##,
id = g.id
);
s.push_str("</section>");
}
s.push_str("</div>");
-2
View File
@@ -7,9 +7,7 @@ pub mod connect;
pub mod deploy;
pub mod devices;
pub mod groups;
pub mod oidc;
pub mod profile;
pub mod recordings;
pub mod shared;
pub mod strategies;
pub mod users;
-73
View File
@@ -1,73 +0,0 @@
//! OIDC providers — read-only listing of what's currently in
//! `oidc_providers`. Editing providers is operator-side via the
//! `--oidc-config` TOML or hand-inserted SQL; the dashboard surfaces them
//! so admins can confirm what's wired up without leaving the UI.
use super::shared::{html_escape, require_admin};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::Extension;
use axum::response::Html;
use std::fmt::Write as _;
use std::sync::Arc;
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let providers = state
.db
.oidc_provider_list_enabled()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let mut s = String::new();
s.push_str(
r##"<div class="space-y-4">
<header>
<h2 class="text-lg font-semibold">OIDC providers</h2>
<p class="text-xs text-slate-500 mt-1">Read-only. Add/edit via <code>--oidc-config</code> TOML at startup, or by inserting into the <code>oidc_providers</code> table.</p>
</header>"##,
);
if providers.is_empty() {
s.push_str(
r##"<p class="text-slate-500 text-sm">No OIDC providers configured.</p></div>"##,
);
return Ok(Html(s));
}
s.push_str(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<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">Name</th>
<th class="text-left font-medium px-3 py-2">Display name</th>
<th class="text-left font-medium px-3 py-2">Issuer</th>
<th class="text-left font-medium px-3 py-2">Client ID</th>
<th class="text-left font-medium px-3 py-2">Scopes</th>
<th class="text-left font-medium px-3 py-2">Redirect</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
);
for p in &providers {
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 font-medium text-slate-200">{name}</td>
<td class="px-3 py-2 text-slate-300">{display}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{issuer}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{client_id}</td>
<td class="px-3 py-2 text-slate-400 text-xs">{scopes}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{redirect}</td>
</tr>"##,
name = html_escape(&p.name),
display = html_escape(p.display_name.as_deref().unwrap_or("")),
issuer = html_escape(&p.issuer_url),
client_id = html_escape(&p.client_id),
scopes = html_escape(&p.scopes),
redirect = html_escape(&p.redirect_url),
);
}
s.push_str("</tbody></table></div></div>");
Ok(Html(s))
}
-87
View File
@@ -1,87 +0,0 @@
//! Recordings — list-only. Adding a streaming download handler is a
//! follow-up; for now the operator looks at the filenames + sizes and
//! pulls files from `--recording-dir` directly.
use super::shared::{fmt_unix, html_escape, require_admin};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::Extension;
use axum::response::Html;
use std::fmt::Write as _;
use std::sync::Arc;
const PAGE_SIZE: i64 = 200;
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let rows = state
.db
.recordings_list(PAGE_SIZE)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let mut s = String::new();
s.push_str(
r##"<div class="space-y-4">
<header>
<h2 class="text-lg font-semibold">Recordings</h2>
<p class="text-xs text-slate-500 mt-1">Files live under <code>--recording-dir</code>. Pull them with <code>scp</code> / <code>rsync</code> for now; an in-browser download is coming.</p>
</header>"##,
);
if rows.is_empty() {
s.push_str(
r##"<p class="text-slate-500 text-sm">No session recordings yet.</p></div>"##,
);
return Ok(Html(s));
}
s.push_str(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<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">Filename</th>
<th class="text-left font-medium px-3 py-2">Peer</th>
<th class="text-left font-medium px-3 py-2">Size</th>
<th class="text-left font-medium px-3 py-2">State</th>
<th class="text-left font-medium px-3 py-2">Started</th>
<th class="text-left font-medium px-3 py-2">Finished</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
);
for r in &rows {
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 font-mono text-slate-200 text-xs">{file}</td>
<td class="px-3 py-2 font-mono text-slate-300">{peer}</td>
<td class="px-3 py-2 text-slate-400">{size}</td>
<td class="px-3 py-2 text-slate-400">{state}</td>
<td class="px-3 py-2 text-slate-500 text-xs">{started}</td>
<td class="px-3 py-2 text-slate-500 text-xs">{finished}</td>
</tr>"##,
file = html_escape(&r.filename),
peer = html_escape(&r.peer_id),
size = human_size(r.size),
state = html_escape(&r.state),
started = html_escape(&fmt_unix(r.started_at)),
finished = html_escape(&r.finished_at.map(fmt_unix).unwrap_or_else(|| "".into()))
);
}
s.push_str("</tbody></table></div></div>");
Ok(Html(s))
}
fn human_size(bytes: i64) -> String {
let b = bytes as f64;
if bytes < 1024 {
format!("{} B", bytes)
} else if b < 1024.0 * 1024.0 {
format!("{:.1} KiB", b / 1024.0)
} else if b < 1024.0 * 1024.0 * 1024.0 {
format!("{:.1} MiB", b / (1024.0 * 1024.0))
} else {
format!("{:.2} GiB", b / (1024.0 * 1024.0 * 1024.0))
}
}