Improve admin UI (remove unused functions, added hello-agent deployment
build / build-linux-amd64 (push) Successful in 2m2s
build / build-linux-amd64 (push) Successful in 2m2s
This commit is contained in:
@@ -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)"
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user