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:
@@ -37,12 +37,8 @@
|
|||||||
hx-get="/admin/pages/strategies" hx-target="#main" hx-push-url="#strategies">Strategies</a>
|
hx-get="/admin/pages/strategies" hx-target="#main" hx-push-url="#strategies">Strategies</a>
|
||||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||||
hx-get="/admin/pages/address-books" hx-target="#main" hx-push-url="#address-books">Address books</a>
|
hx-get="/admin/pages/address-books" hx-target="#main" hx-push-url="#address-books">Address books</a>
|
||||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
||||||
hx-get="/admin/pages/oidc" hx-target="#main" hx-push-url="#oidc">OIDC providers</a>
|
|
||||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||||
hx-get="/admin/pages/audit" hx-target="#main" hx-push-url="#audit">Audit log</a>
|
hx-get="/admin/pages/audit" hx-target="#main" hx-push-url="#audit">Audit log</a>
|
||||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
||||||
hx-get="/admin/pages/recordings" hx-target="#main" hx-push-url="#recordings">Recordings</a>
|
|
||||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||||
hx-get="/admin/pages/deploy" hx-target="#main" hx-push-url="#deploy">Deploy</a>
|
hx-get="/admin/pages/deploy" hx-target="#main" hx-push-url="#deploy">Deploy</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -121,6 +121,14 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
|||||||
"/admin/pages/groups/:id/members/:user_id/remove",
|
"/admin/pages/groups/:id/members/:user_id/remove",
|
||||||
post(pages::groups::remove_member),
|
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
|
// Strategies
|
||||||
.route(
|
.route(
|
||||||
"/admin/pages/strategies/create",
|
"/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",
|
"/admin/pages/address-books/:guid/shares/:user_id/remove",
|
||||||
post(pages::address_books::share_remove),
|
post(pages::address_books::share_remove),
|
||||||
)
|
)
|
||||||
.route("/admin/pages/oidc", get(pages::oidc::index))
|
|
||||||
// Self-service profile — cookie-only, no admin gate.
|
// Self-service profile — cookie-only, no admin gate.
|
||||||
.route("/admin/pages/profile", get(pages::profile::index))
|
.route("/admin/pages/profile", get(pages::profile::index))
|
||||||
.route(
|
.route(
|
||||||
@@ -204,8 +211,7 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
|||||||
"/admin/pages/profile/totp/remove",
|
"/admin/pages/profile/totp/remove",
|
||||||
post(pages::profile::totp_remove),
|
post(pages::profile::totp_remove),
|
||||||
)
|
)
|
||||||
.route("/admin/pages/audit", get(pages::audit::index))
|
.route("/admin/pages/audit", get(pages::audit::index));
|
||||||
.route("/admin/pages/recordings", get(pages::recordings::index));
|
|
||||||
hbb_common::log::info!(
|
hbb_common::log::info!(
|
||||||
"admin dashboard mounted at /admin (HTML embedded; --admin-ui-dir is informational)"
|
"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
|
licensed = licensed
|
||||||
);
|
);
|
||||||
let cmd_unix = format!("rustdesk --config {}", licensed);
|
let cmd_unix = format!("rustdesk --config {}", licensed);
|
||||||
|
let cmd_hello = format!("hello-agent.exe --install --config {}", blob);
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
r##"<section class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-4">
|
r##"<section class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-4">
|
||||||
<header>
|
<header>
|
||||||
<h3 class="text-sm font-semibold text-slate-200">Deployment artifact</h3>
|
<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>
|
</header>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -274,6 +275,12 @@ fn render_result(host: &str, key: &str, api: &str, relay: &str, blob: &str) -> S
|
|||||||
{renamed_note}
|
{renamed_note}
|
||||||
</div>
|
</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">
|
<details class="text-xs text-slate-400">
|
||||||
<summary class="cursor-pointer text-slate-300 select-none">Raw blob</summary>
|
<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>
|
<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>"##,
|
</section>"##,
|
||||||
cmd_win = html_escape(&cmd_win),
|
cmd_win = html_escape(&cmd_win),
|
||||||
cmd_unix = html_escape(&cmd_unix),
|
cmd_unix = html_escape(&cmd_unix),
|
||||||
|
cmd_hello = html_escape(&cmd_hello),
|
||||||
renamed = html_escape(&renamed),
|
renamed = html_escape(&renamed),
|
||||||
renamed_note = renamed_note,
|
renamed_note = renamed_note,
|
||||||
blob = html_escape(blob),
|
blob = html_escape(blob),
|
||||||
|
|||||||
@@ -95,8 +95,78 @@ pub async fn remove_member(
|
|||||||
Ok(Html(render_full(&state).await?))
|
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 ----------
|
// ---------- 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(
|
async fn notice_then(
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
kind: &str,
|
kind: &str,
|
||||||
@@ -144,9 +214,14 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
|
|||||||
.device_group_members(g.id)
|
.device_group_members(g.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
.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!(
|
let _ = write!(
|
||||||
s,
|
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">
|
<header class="flex items-center justify-between">
|
||||||
<h3 class="font-semibold">{name}</h3>
|
<h3 class="font-semibold">{name}</h3>
|
||||||
<button class="text-xs text-rose-400 hover:text-rose-300"
|
<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-confirm="Delete group {name}? Members aren't deleted; just unassigned."
|
||||||
hx-target="#groups-region" hx-swap="outerHTML">Delete group</button>
|
hx-target="#groups-region" hx-swap="outerHTML">Delete group</button>
|
||||||
</header>
|
</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,
|
id = g.id,
|
||||||
name = html_escape(&g.name)
|
name = html_escape(&g.name)
|
||||||
);
|
);
|
||||||
if members.is_empty() {
|
if members.is_empty() {
|
||||||
s.push_str(
|
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 {
|
for u in &members {
|
||||||
@@ -203,10 +280,63 @@ async fn render_full(state: &Arc<AppState>) -> Result<String, ApiError> {
|
|||||||
}
|
}
|
||||||
s.push_str(
|
s.push_str(
|
||||||
r##"</select>
|
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>"##,
|
</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("</section>");
|
||||||
}
|
}
|
||||||
s.push_str("</div>");
|
s.push_str("</div>");
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ pub mod connect;
|
|||||||
pub mod deploy;
|
pub mod deploy;
|
||||||
pub mod devices;
|
pub mod devices;
|
||||||
pub mod groups;
|
pub mod groups;
|
||||||
pub mod oidc;
|
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod recordings;
|
|
||||||
pub mod shared;
|
pub mod shared;
|
||||||
pub mod strategies;
|
pub mod strategies;
|
||||||
pub mod users;
|
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+123
-14
@@ -586,6 +586,10 @@ impl Database {
|
|||||||
.bind(peer_id)
|
.bind(peer_id)
|
||||||
.execute(self.pool.get().await?.deref_mut())
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
.await;
|
.await;
|
||||||
|
let _ = sqlx::query("DELETE FROM device_group_peers WHERE peer_id = ?")
|
||||||
|
.bind(peer_id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await;
|
||||||
let _ = sqlx::query("DELETE FROM peer WHERE id = ?")
|
let _ = sqlx::query("DELETE FROM peer WHERE id = ?")
|
||||||
.bind(peer_id)
|
.bind(peer_id)
|
||||||
.execute(self.pool.get().await?.deref_mut())
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
@@ -773,6 +777,10 @@ impl Database {
|
|||||||
.bind(group_id)
|
.bind(group_id)
|
||||||
.execute(self.pool.get().await?.deref_mut())
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
.await;
|
.await;
|
||||||
|
let _ = sqlx::query("DELETE FROM device_group_peers WHERE device_group_id = ?")
|
||||||
|
.bind(group_id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await;
|
||||||
let res = sqlx::query("DELETE FROM device_groups WHERE id = ?")
|
let res = sqlx::query("DELETE FROM device_groups WHERE id = ?")
|
||||||
.bind(group_id)
|
.bind(group_id)
|
||||||
.execute(self.pool.get().await?.deref_mut())
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
@@ -810,6 +818,77 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Devices explicitly added to a group via `device_group_peers`.
|
||||||
|
/// Returns (peer_id, owner_username); owner is empty if the device has
|
||||||
|
/// never been bound to a user (vanilla rustdesk client).
|
||||||
|
pub async fn device_group_peer_members(
|
||||||
|
&self,
|
||||||
|
group_id: i64,
|
||||||
|
) -> ResultType<Vec<(String, String)>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT dgp.peer_id AS pid, COALESCE(u.username, '') AS owner \
|
||||||
|
FROM device_group_peers dgp \
|
||||||
|
LEFT JOIN device_sysinfo ds ON ds.id = dgp.peer_id \
|
||||||
|
LEFT JOIN users u ON u.id = ds.user_id \
|
||||||
|
WHERE dgp.device_group_id = ? \
|
||||||
|
GROUP BY dgp.peer_id \
|
||||||
|
ORDER BY dgp.peer_id",
|
||||||
|
)
|
||||||
|
.bind(group_id)
|
||||||
|
.fetch_all(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
(
|
||||||
|
r.try_get::<String, _>("pid").unwrap_or_default(),
|
||||||
|
r.try_get::<String, _>("owner").unwrap_or_default(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_group_add_peer(
|
||||||
|
&self,
|
||||||
|
group_id: i64,
|
||||||
|
peer_id: &str,
|
||||||
|
) -> ResultType<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO device_group_peers(device_group_id, peer_id) VALUES(?, ?)",
|
||||||
|
)
|
||||||
|
.bind(group_id)
|
||||||
|
.bind(peer_id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_group_remove_peer(
|
||||||
|
&self,
|
||||||
|
group_id: i64,
|
||||||
|
peer_id: &str,
|
||||||
|
) -> ResultType<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM device_group_peers WHERE device_group_id = ? AND peer_id = ?",
|
||||||
|
)
|
||||||
|
.bind(group_id)
|
||||||
|
.bind(peer_id)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if `peer_id` exists in `device_sysinfo`. Used to validate before
|
||||||
|
/// inserting into `device_group_peers` so the admin UI can show a
|
||||||
|
/// helpful error instead of accepting a typo silently.
|
||||||
|
pub async fn peer_exists(&self, peer_id: &str) -> ResultType<bool> {
|
||||||
|
let row = sqlx::query("SELECT 1 FROM device_sysinfo WHERE id = ? LIMIT 1")
|
||||||
|
.bind(peer_id)
|
||||||
|
.fetch_optional(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(row.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn strategies_list_all(&self) -> ResultType<Vec<StrategyRow>> {
|
pub async fn strategies_list_all(&self) -> ResultType<Vec<StrategyRow>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT id, name, modified_at, config_options_json, extra_json \
|
"SELECT id, name, modified_at, config_options_json, extra_json \
|
||||||
@@ -1944,7 +2023,8 @@ impl Database {
|
|||||||
limit: i64,
|
limit: i64,
|
||||||
) -> ResultType<(i64, Vec<PeerListRow>)> {
|
) -> ResultType<(i64, Vec<PeerListRow>)> {
|
||||||
// Common select: device_sysinfo joined to its owner. We pick the
|
// Common select: device_sysinfo joined to its owner. We pick the
|
||||||
// alphabetically-first device-group name as the surfaced group.
|
// alphabetically-first device-group name as the surfaced group,
|
||||||
|
// considering both owner-based and direct peer-group membership.
|
||||||
let where_clause = if is_admin {
|
let where_clause = if is_admin {
|
||||||
"1 = 1"
|
"1 = 1"
|
||||||
} else {
|
} else {
|
||||||
@@ -1952,6 +2032,11 @@ impl Database {
|
|||||||
SELECT m2.user_id FROM device_group_members m1 \
|
SELECT m2.user_id FROM device_group_members m1 \
|
||||||
JOIN device_group_members m2 USING(device_group_id) \
|
JOIN device_group_members m2 USING(device_group_id) \
|
||||||
WHERE m1.user_id = ? \
|
WHERE m1.user_id = ? \
|
||||||
|
) OR ds.id IN ( \
|
||||||
|
SELECT dgp.peer_id FROM device_group_peers dgp \
|
||||||
|
JOIN device_group_members dgm \
|
||||||
|
ON dgm.device_group_id = dgp.device_group_id \
|
||||||
|
WHERE dgm.user_id = ? \
|
||||||
))"
|
))"
|
||||||
};
|
};
|
||||||
let count_sql = format!(
|
let count_sql = format!(
|
||||||
@@ -1965,8 +2050,14 @@ impl Database {
|
|||||||
COALESCE(u.status, 1) AS owner_status, \
|
COALESCE(u.status, 1) AS owner_status, \
|
||||||
ds.payload AS sysinfo, \
|
ds.payload AS sysinfo, \
|
||||||
( SELECT dg.name FROM device_groups dg \
|
( SELECT dg.name FROM device_groups dg \
|
||||||
JOIN device_group_members mm ON mm.device_group_id = dg.id \
|
WHERE dg.id IN ( \
|
||||||
WHERE mm.user_id = ds.user_id ORDER BY dg.name LIMIT 1 \
|
SELECT device_group_id FROM device_group_members \
|
||||||
|
WHERE user_id = ds.user_id \
|
||||||
|
UNION \
|
||||||
|
SELECT device_group_id FROM device_group_peers \
|
||||||
|
WHERE peer_id = ds.id \
|
||||||
|
) \
|
||||||
|
ORDER BY dg.name LIMIT 1 \
|
||||||
) AS device_group_name \
|
) AS device_group_name \
|
||||||
FROM device_sysinfo ds \
|
FROM device_sysinfo ds \
|
||||||
LEFT JOIN users u ON u.id = ds.user_id \
|
LEFT JOIN users u ON u.id = ds.user_id \
|
||||||
@@ -1981,6 +2072,7 @@ impl Database {
|
|||||||
.try_get("c")?
|
.try_get("c")?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query(&count_sql)
|
sqlx::query(&count_sql)
|
||||||
|
.bind(viewer_id)
|
||||||
.bind(viewer_id)
|
.bind(viewer_id)
|
||||||
.bind(viewer_id)
|
.bind(viewer_id)
|
||||||
.fetch_one(self.pool.get().await?.deref_mut())
|
.fetch_one(self.pool.get().await?.deref_mut())
|
||||||
@@ -1995,6 +2087,7 @@ impl Database {
|
|||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query(&list_sql)
|
sqlx::query(&list_sql)
|
||||||
|
.bind(viewer_id)
|
||||||
.bind(viewer_id)
|
.bind(viewer_id)
|
||||||
.bind(viewer_id)
|
.bind(viewer_id)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
@@ -2214,21 +2307,22 @@ impl Database {
|
|||||||
{
|
{
|
||||||
return Ok(s);
|
return Ok(s);
|
||||||
}
|
}
|
||||||
// Look up the device's owner; without an owner there's nothing to
|
// Look up the device's owner. May be NULL on a vanilla rustdesk
|
||||||
// join on, so we stop here.
|
// client that never bound to a user — in that case we still want to
|
||||||
let owner = sqlx::query(
|
// honor any direct device-group membership before giving up.
|
||||||
|
let owner_id_opt: Option<i64> = sqlx::query(
|
||||||
"SELECT user_id FROM device_sysinfo WHERE id = ? AND user_id IS NOT NULL LIMIT 1",
|
"SELECT user_id FROM device_sysinfo WHERE id = ? AND user_id IS NOT NULL LIMIT 1",
|
||||||
)
|
)
|
||||||
.bind(peer_id)
|
.bind(peer_id)
|
||||||
.fetch_optional(self.pool.get().await?.deref_mut())
|
.fetch_optional(self.pool.get().await?.deref_mut())
|
||||||
.await?;
|
.await?
|
||||||
let Some(owner_row) = owner else {
|
.and_then(|r| r.try_get("user_id").ok());
|
||||||
return Ok(ResolvedStrategy::default());
|
let owner_id_str = owner_id_opt.unwrap_or(0).to_string();
|
||||||
};
|
|
||||||
let owner_id: i64 = owner_row.try_get("user_id")?;
|
|
||||||
let owner_id_str = owner_id.to_string();
|
|
||||||
// Device-group assignment: any strategy assigned to a group that the
|
// Device-group assignment: any strategy assigned to a group that the
|
||||||
// owner is a member of.
|
// owner is a user-member of, OR that the peer itself is directly a
|
||||||
|
// member of via `device_group_peers`. The owner_id placeholder binds
|
||||||
|
// to 0 for ownerless peers — `device_group_members.user_id` is never
|
||||||
|
// 0, so the first branch of the UNION matches nothing in that case.
|
||||||
if let Some(s) = self
|
if let Some(s) = self
|
||||||
.strategy_lookup(
|
.strategy_lookup(
|
||||||
"SELECT s.modified_at, s.config_options_json, s.extra_json \
|
"SELECT s.modified_at, s.config_options_json, s.extra_json \
|
||||||
@@ -2236,14 +2330,19 @@ impl Database {
|
|||||||
JOIN strategy_assignments sa ON sa.strategy_id = s.id \
|
JOIN strategy_assignments sa ON sa.strategy_id = s.id \
|
||||||
WHERE sa.device_group_id IN ( \
|
WHERE sa.device_group_id IN ( \
|
||||||
SELECT device_group_id FROM device_group_members WHERE user_id = ? \
|
SELECT device_group_id FROM device_group_members WHERE user_id = ? \
|
||||||
|
UNION \
|
||||||
|
SELECT device_group_id FROM device_group_peers WHERE peer_id = ? \
|
||||||
) \
|
) \
|
||||||
ORDER BY sa.priority DESC LIMIT 1",
|
ORDER BY sa.priority DESC LIMIT 1",
|
||||||
&[&owner_id_str],
|
&[&owner_id_str, peer_id],
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(s);
|
return Ok(s);
|
||||||
}
|
}
|
||||||
|
if owner_id_opt.is_none() {
|
||||||
|
return Ok(ResolvedStrategy::default());
|
||||||
|
}
|
||||||
// User assignment.
|
// User assignment.
|
||||||
if let Some(s) = self
|
if let Some(s) = self
|
||||||
.strategy_lookup(
|
.strategy_lookup(
|
||||||
@@ -2989,6 +3088,16 @@ const M2_SCHEMA: &[&str] = &[
|
|||||||
PRIMARY KEY (device_group_id, user_id)
|
PRIMARY KEY (device_group_id, user_id)
|
||||||
)",
|
)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_dgm_user ON device_group_members(user_id)",
|
"CREATE INDEX IF NOT EXISTS idx_dgm_user ON device_group_members(user_id)",
|
||||||
|
// Direct device membership in a group. Independent of owner-based
|
||||||
|
// membership (device_group_members) — a group can contain users,
|
||||||
|
// peers, or both. Peer is keyed by id (text) since device_sysinfo
|
||||||
|
// is keyed on (id, uuid) and operators identify devices by id alone.
|
||||||
|
"CREATE TABLE IF NOT EXISTS device_group_peers (
|
||||||
|
device_group_id INTEGER NOT NULL,
|
||||||
|
peer_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (device_group_id, peer_id)
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_dgp_peer ON device_group_peers(peer_id)",
|
||||||
// SQLite forbids expressions in PRIMARY KEY constraints, so we use a
|
// SQLite forbids expressions in PRIMARY KEY constraints, so we use a
|
||||||
// unique index over the COALESCEd tuple to enforce one share per
|
// unique index over the COALESCEd tuple to enforce one share per
|
||||||
// (ab, user) and one per (ab, group). NULLs collapse to 0 so two NULL
|
// (ab, user) and one per (ab, group). NULLs collapse to 0 so two NULL
|
||||||
|
|||||||
Reference in New Issue
Block a user