mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
Gate agent admin UI by OS privileges
Add OS-level admin detection and use it to gate sensitive agent UI and tray actions. Introduce privileges.rs (Windows TokenElevation / Unix geteuid) and expose is_os_admin as a Tauri command; wire it into tray setup to hide admin-only menu items (Settings, Quit) and re-check privileges before executing those actions. Add show_window helper and emit navigate events from the tray; frontend listens for navigate and conditionally renders /settings (shows AdminRequired component for non-admins). Update App.tsx to query is_os_admin on startup and include navigation listener; add AdminRequired component, styles, and i18n keys. Update Cargo.toml with platform deps (windows features + libc for unix). Also add UI/locale assets and CSS for agent lazy-loaded device tabs and a notifications dropdown, plus several web-nodejs route/view/style updates and new task docs describing phase work.
This commit is contained in:
@@ -43,8 +43,13 @@ sha2 = "0.10"
|
||||
windows-sys = { version = "0.59", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Console",
|
||||
"Win32_Security",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
@@ -51,6 +51,15 @@ pub struct AgentSettings {
|
||||
|
||||
// ─────────────────────────── Status & Lifecycle ───────────────────────────
|
||||
|
||||
/// Returns true when the agent process runs with local OS administrator
|
||||
/// privileges. Used by the frontend + tray menu to gate sensitive actions
|
||||
/// (Settings, Quit agent, Unregister) so regular users cannot disable the
|
||||
/// agent without elevation.
|
||||
#[tauri::command]
|
||||
pub fn is_os_admin() -> bool {
|
||||
crate::privileges::is_os_admin()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_agent_status(state: State<'_, AgentState>) -> Result<AgentStatus, String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod privileges;
|
||||
pub mod registration;
|
||||
pub mod sysinfo_collect;
|
||||
|
||||
@@ -59,6 +60,7 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Status & lifecycle
|
||||
commands::is_os_admin,
|
||||
commands::get_agent_status,
|
||||
commands::reconnect_agent,
|
||||
commands::send_diagnostics,
|
||||
@@ -107,41 +109,99 @@ pub fn run() {
|
||||
.expect("Failed to start BetterDesk Agent");
|
||||
}
|
||||
|
||||
/// Minimal system tray setup.
|
||||
/// System tray setup.
|
||||
///
|
||||
/// Menu layout:
|
||||
/// - User items (always visible): Show ID, Help request, Chat, Check connection
|
||||
/// - Admin-gated items (OS admin): Settings, Quit agent
|
||||
///
|
||||
/// Admin detection is cached at setup time. If privilege status changes
|
||||
/// (user elevates mid-session), restart the agent.
|
||||
fn setup_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
|
||||
let show = MenuItemBuilder::with_id("show", "Show").build(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
|
||||
let is_admin = privileges::is_os_admin();
|
||||
info!("Tray setup — OS admin: {}", is_admin);
|
||||
|
||||
let menu = MenuBuilder::new(app).item(&show).separator().item(&quit).build()?;
|
||||
// Always-visible (user) items.
|
||||
let show_id = MenuItemBuilder::with_id("show_id", "Show device ID").build(app)?;
|
||||
let help = MenuItemBuilder::with_id("help_request", "Request help").build(app)?;
|
||||
let chat = MenuItemBuilder::with_id("chat", "Chat").build(app)?;
|
||||
let check = MenuItemBuilder::with_id("check_conn", "Check connection").build(app)?;
|
||||
|
||||
let mut builder = MenuBuilder::new(app)
|
||||
.item(&show_id)
|
||||
.item(&help)
|
||||
.item(&chat)
|
||||
.item(&check);
|
||||
|
||||
// Admin-only items.
|
||||
if is_admin {
|
||||
let sep = PredefinedMenuItem::separator(app)?;
|
||||
let settings = MenuItemBuilder::with_id("settings", "Settings").build(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "Quit agent").build(app)?;
|
||||
builder = builder.item(&sep).item(&settings).item(&quit);
|
||||
}
|
||||
|
||||
let menu = builder.build()?;
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.menu(&menu)
|
||||
.tooltip("BetterDesk Agent")
|
||||
.on_menu_event(move |app, event| match event.id().as_ref() {
|
||||
"show" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
.tooltip(if is_admin {
|
||||
"BetterDesk Agent (admin)"
|
||||
} else {
|
||||
"BetterDesk Agent"
|
||||
})
|
||||
.on_menu_event(move |app, event| {
|
||||
let id = event.id().as_ref();
|
||||
match id {
|
||||
"show_id" => show_window(app, "/"),
|
||||
"help_request" => show_window(app, "/help"),
|
||||
"chat" => show_window(app, "/chat"),
|
||||
"check_conn" => show_window(app, "/?action=reconnect"),
|
||||
// Admin-gated. Re-check privilege before executing to guard
|
||||
// against tampered menu IDs (double-safety).
|
||||
"settings" => {
|
||||
if privileges::is_os_admin() {
|
||||
show_window(app, "/settings");
|
||||
} else {
|
||||
info!("Settings requested but not admin — ignoring");
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
if privileges::is_os_admin() {
|
||||
info!("Quit requested from tray (admin confirmed)");
|
||||
app.exit(0);
|
||||
} else {
|
||||
info!("Quit requested but not admin — ignoring");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
"quit" => {
|
||||
info!("Quit requested from tray");
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let tauri::tray::TrayIconEvent::DoubleClick { .. } = event {
|
||||
if let Some(window) = tray.app_handle().get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
show_window(tray.app_handle(), "/");
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bring the main window to front and navigate to a given route.
|
||||
///
|
||||
/// The route is emitted as a `navigate` event; the SolidJS router listens
|
||||
/// and performs client-side navigation. Falls back to showing the window
|
||||
/// even if navigation fails (e.g. frontend not ready).
|
||||
fn show_window(app: &tauri::AppHandle, route: &str) {
|
||||
use tauri::Emitter;
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
let _ = app.emit("navigate", route.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Detection of local OS administrator privileges.
|
||||
//!
|
||||
//! Used to gate sensitive tray menu items (Settings, Unregister, Quit) so
|
||||
//! regular users cannot disable the agent or reconfigure it without elevation.
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn is_os_admin() -> bool {
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows_sys::Win32::Security::{
|
||||
GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||
|
||||
unsafe {
|
||||
let mut token: HANDLE = std::ptr::null_mut();
|
||||
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut elevation = TOKEN_ELEVATION { TokenIsElevated: 0 };
|
||||
let mut size: u32 = 0;
|
||||
let ok = GetTokenInformation(
|
||||
token,
|
||||
TokenElevation,
|
||||
&mut elevation as *mut _ as *mut _,
|
||||
std::mem::size_of::<TOKEN_ELEVATION>() as u32,
|
||||
&mut size,
|
||||
);
|
||||
CloseHandle(token);
|
||||
|
||||
ok != 0 && elevation.TokenIsElevated != 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn is_os_admin() -> bool {
|
||||
// SAFETY: geteuid() has no preconditions and cannot fail.
|
||||
unsafe { libc::geteuid() == 0 }
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
pub fn is_os_admin() -> bool {
|
||||
false
|
||||
}
|
||||
@@ -1,17 +1,49 @@
|
||||
import { Component, createSignal, onMount } from "solid-js";
|
||||
import { Router, Route } from "@solidjs/router";
|
||||
import { Component, createSignal, onMount, onCleanup, Show } from "solid-js";
|
||||
import { Router, Route, useNavigate } from "@solidjs/router";
|
||||
import StatusPanel from "./components/StatusPanel";
|
||||
import SetupWizard from "./components/SetupWizard";
|
||||
import ChatPanel from "./components/ChatPanel";
|
||||
import HelpRequest from "./components/HelpRequest";
|
||||
import SettingsPanel from "./components/SettingsPanel";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import { t, initI18n } from "./lib/i18n";
|
||||
import AdminRequired from "./components/AdminRequired";
|
||||
import { initI18n } from "./lib/i18n";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
// Component that handles navigation events from the tray menu.
|
||||
// Must be inside <Router> to access useNavigate().
|
||||
const NavigationListener: Component = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
onMount(() => {
|
||||
const unlistenPromise = listen<string>("navigate", (event) => {
|
||||
try {
|
||||
const route = event.payload;
|
||||
if (typeof route === "string" && route.startsWith("/")) {
|
||||
navigate(route);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed navigation payloads — safer to stay on current page.
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(async () => {
|
||||
try {
|
||||
const un = await unlistenPromise;
|
||||
un();
|
||||
} catch {
|
||||
// Cleanup is best-effort on unmount.
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const App: Component = () => {
|
||||
const [ready, setReady] = createSignal(false);
|
||||
const [registered, setRegistered] = createSignal(false);
|
||||
const [isAdmin, setIsAdmin] = createSignal(false);
|
||||
|
||||
onMount(async () => {
|
||||
await initI18n();
|
||||
@@ -21,30 +53,47 @@ const App: Component = () => {
|
||||
} catch {
|
||||
setRegistered(false);
|
||||
}
|
||||
try {
|
||||
const admin = await invoke<boolean>("is_os_admin");
|
||||
setIsAdmin(admin);
|
||||
} catch {
|
||||
setIsAdmin(false);
|
||||
}
|
||||
setReady(true);
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="app-root">
|
||||
{!ready() ? (
|
||||
<div class="app-loading">
|
||||
<span class="material-symbols-rounded spin">sync</span>
|
||||
</div>
|
||||
) : !registered() ? (
|
||||
<SetupWizard onComplete={() => setRegistered(true)} />
|
||||
) : (
|
||||
<div class="app-layout">
|
||||
<Sidebar />
|
||||
<main class="app-main">
|
||||
<Router>
|
||||
<Route path="/" component={StatusPanel} />
|
||||
<Route path="/chat" component={ChatPanel} />
|
||||
<Route path="/help" component={HelpRequest} />
|
||||
<Route path="/settings" component={SettingsPanel} />
|
||||
</Router>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
<Show
|
||||
when={ready()}
|
||||
fallback={
|
||||
<div class="app-loading">
|
||||
<span class="material-symbols-rounded spin">sync</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={registered()}
|
||||
fallback={<SetupWizard onComplete={() => setRegistered(true)} />}
|
||||
>
|
||||
<div class="app-layout app-layout-tray">
|
||||
<main class="app-main app-main-full">
|
||||
<Router>
|
||||
<NavigationListener />
|
||||
<Route path="/" component={StatusPanel} />
|
||||
<Route path="/chat" component={ChatPanel} />
|
||||
<Route path="/help" component={HelpRequest} />
|
||||
<Route
|
||||
path="/settings"
|
||||
component={() =>
|
||||
isAdmin() ? <SettingsPanel /> : <AdminRequired />
|
||||
}
|
||||
/>
|
||||
</Router>
|
||||
</main>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Component } from "solid-js";
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
/// Shown when a non-admin user tries to access a privilege-gated route
|
||||
/// (e.g. /settings). The tray menu already hides these items when the agent
|
||||
/// runs unelevated, but this is a defense-in-depth fallback.
|
||||
const AdminRequired: Component = () => {
|
||||
return (
|
||||
<div class="admin-required">
|
||||
<div class="admin-required-icon">
|
||||
<span class="material-symbols-rounded">admin_panel_settings</span>
|
||||
</div>
|
||||
<h2>{t("admin_required.title")}</h2>
|
||||
<p>{t("admin_required.message")}</p>
|
||||
<p class="admin-required-hint">{t("admin_required.hint")}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminRequired;
|
||||
@@ -5,6 +5,11 @@
|
||||
"help": "Help Request",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"admin_required": {
|
||||
"title": "Administrator rights required",
|
||||
"message": "This section can only be accessed when the agent runs with local administrator privileges.",
|
||||
"hint": "Ask your IT administrator to launch the agent as Administrator, or right-click the installer and choose \"Run as administrator\"."
|
||||
},
|
||||
"status": {
|
||||
"title": "Connection Status",
|
||||
"connected": "Connected",
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
"help": "Prośba o pomoc",
|
||||
"settings": "Ustawienia"
|
||||
},
|
||||
"admin_required": {
|
||||
"title": "Wymagane uprawnienia administratora",
|
||||
"message": "Ten ekran jest dostępny tylko gdy agent jest uruchomiony z uprawnieniami administratora.",
|
||||
"hint": "Poproś administratora IT o uruchomienie agenta jako Administrator lub kliknij instalator prawym przyciskiem i wybierz \"Uruchom jako administrator\"."
|
||||
},
|
||||
"status": {
|
||||
"title": "Status połączenia",
|
||||
"connected": "Połączono",
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
"help": "求助請求",
|
||||
"settings": "設定"
|
||||
},
|
||||
"admin_required": {
|
||||
"title": "需要管理員權限",
|
||||
"message": "此頁面僅在代理以本地管理員權限運行時可用。",
|
||||
"hint": "請 IT 管理員以管理員身份啟動代理,或右鍵點擊安裝程式並選擇「以管理員身份執行」。"
|
||||
},
|
||||
"status": {
|
||||
"title": "連線狀態",
|
||||
"connected": "已連線",
|
||||
|
||||
@@ -93,12 +93,71 @@ a:hover { color: var(--accent-hover); }
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Tray-first layout — no sidebar, main pane fills the window. */
|
||||
.app-layout-tray {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.app-main-full {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ── Admin-required gate ── */
|
||||
.admin-required {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
padding: 32px 24px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-required-icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.admin-required-icon .material-symbols-rounded {
|
||||
font-size: 40px;
|
||||
color: var(--accent, #5b8cff);
|
||||
}
|
||||
|
||||
.admin-required h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-required p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
max-width: 340px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.admin-required-hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Phase 2-5 Implementation Status — 2026-04-10
|
||||
|
||||
This document tracks the pragmatic scaffolding delivered in this session and the
|
||||
work that remains. The goal was to establish the **HTTP/DB contracts and UI
|
||||
plumbing** so frontend, backend and agent work can continue independently.
|
||||
|
||||
## ✅ Phase 2 — Live Agent Introspection
|
||||
|
||||
### Backend (completed previously)
|
||||
- `services/bdRelay.js` — `requestFromDevice(deviceId, type, payload)` promise API
|
||||
with `pendingRequests` Map + 15 s timeout. Handles `command_response` frames
|
||||
from agent to resolve/reject pending promises.
|
||||
- `routes/devices.routes.js` — 9 proxy endpoints:
|
||||
- `GET /api/devices/:id/services`
|
||||
- `GET /api/devices/:id/processes`
|
||||
- `GET /api/devices/:id/events?limit=N`
|
||||
- `GET /api/devices/:id/activity`
|
||||
- `POST /api/devices/:id/files/browse`
|
||||
- `POST /api/devices/:id/files/read` (≤ 1 MB)
|
||||
- `POST /api/devices/:id/screenshot`
|
||||
- `POST /api/devices/:id/terminal/execute` (audit-logged)
|
||||
- `POST /api/devices/:id/rename`
|
||||
- Error mapping: 503 `agent_offline` · 504 `agent_timeout` · 502 other.
|
||||
- Permission model: `device.view` for reads, `device.edit` for mutations.
|
||||
|
||||
### Frontend (completed this session)
|
||||
- `public/js/deviceDetail.js` — 5 new lazy-loaded tabs (services, processes,
|
||||
events, activity, files). Renderers for each data shape. Retry button wired
|
||||
to `_loadAgentTab()`. File browser supports parent navigation + double-click
|
||||
to descend into directories.
|
||||
- `public/css/device-detail.css` — full styling for agent tabs (tables, event
|
||||
list with severity colors, activity bars, file browser, loading spinner,
|
||||
error states).
|
||||
- `lang/{en,pl,zh}.json` — 15 new keys per language under `device_detail`.
|
||||
|
||||
### Agent side (Rust) — **TODO**
|
||||
Agent client needs a signal WS module that:
|
||||
1. Connects to `wss://{server}/ws/bd-signal?device_id=X&token=JWT`.
|
||||
2. Listens for `{type, request_id, payload}` frames.
|
||||
3. Dispatches to capability handlers and replies with
|
||||
`{type: 'command_response', request_id, ok, data?, error?}`.
|
||||
|
||||
Recommended Rust modules under `betterdesk-agent-client/src-tauri/src/`:
|
||||
|
||||
| Module | Responsibility |
|
||||
| ------------------ | ------------------------------------------------------- |
|
||||
| `signal_ws.rs` | tokio-tungstenite client, reconnect, dispatcher |
|
||||
| `caps/services.rs` | `Get-Service` / `systemctl list-units --output=json` |
|
||||
| `caps/processes.rs`| `sysinfo` crate enumeration, top-by-CPU sort |
|
||||
| `caps/events.rs` | `wevtutil` / `journalctl --output=json` |
|
||||
| `caps/files.rs` | `std::fs::read_dir` with canonicalize-based sandbox |
|
||||
| `caps/terminal.rs` | One-shot `std::process::Command` (no PTY yet) |
|
||||
| `caps/screenshot.rs`| `scrap`/`xcap` + `image` crate, base64 PNG/JPEG |
|
||||
| `caps/activity.rs` | Background thread tracking process foreground time |
|
||||
|
||||
Deps to add to `src-tauri/Cargo.toml`:
|
||||
```
|
||||
tokio-tungstenite = { version = "0.23", features = ["rustls-tls-native-roots"] }
|
||||
sysinfo = "0.32" # already present
|
||||
scrap = "0.5" # or xcap
|
||||
image = "0.25"
|
||||
base64 = "0.22"
|
||||
```
|
||||
|
||||
## ✅ Phase 3 — Chat attachments (already done)
|
||||
|
||||
- `routes/chat.routes.js` already implements multipart upload (50 MB cap),
|
||||
path-traversal-safe download, and search proxy. No additional work needed
|
||||
for scaffolding. Contacts/conversations endpoints still **TODO**.
|
||||
|
||||
## ✅ Phase 4 — Operator identity + consent popup
|
||||
|
||||
### Backend (this session)
|
||||
- `services/dbAdapter.js` — 6 new `users` columns (`first_name`, `last_name`,
|
||||
`email`, `phone`, `role_display`, `avatar_url`) migrated for both SQLite and
|
||||
PostgreSQL.
|
||||
- `services/dbAdapter.js` — `updateUserProfile(id, fields)` helper
|
||||
(sanitizes, caps at 200 chars, whitelists allowed keys).
|
||||
- `routes/phase4_5.routes.js`:
|
||||
- `GET /api/users/me/profile` — current profile
|
||||
- `PUT /api/users/me/profile` — update fields
|
||||
- `GET /api/bd/operator-info?session_id=…` — consent-popup payload
|
||||
|
||||
### Agent side — **TODO**
|
||||
- Rust module `remote/consent.rs`: Tauri window popup with operator identity
|
||||
+ granular permission checkboxes (keyboard/clipboard/audio/file_transfer/
|
||||
recording/camera/block). Countdown optional.
|
||||
- Session handshake: server sends `remote_request` to agent over signal WS,
|
||||
agent shows popup, replies `remote_accept` (with granted permissions) or
|
||||
`remote_reject`. Unattended mode skips popup based on `access_policy`.
|
||||
|
||||
### Frontend — **TODO**
|
||||
- Profile-edit page (`/settings/profile`) with avatar upload.
|
||||
|
||||
## ✅ Phase 5 — Agent templates + downloads portal
|
||||
|
||||
### Backend (this session)
|
||||
- `routes/phase4_5.routes.js`:
|
||||
- `GET /api/agent-templates` — list (`enrollment.manage`)
|
||||
- `POST /api/agent-templates` — create, returns enrollment_token
|
||||
- `DELETE /api/agent-templates/:id`
|
||||
- `POST /api/bd/enroll` — public endpoint for agent enrollment
|
||||
- `GET /portal` — branded public download page
|
||||
- `GET /api/portal/installers`— installer metadata JSON
|
||||
- `agent_templates` table auto-created in auth.db on first request
|
||||
(`id, name, description, config_json, enrollment_token, created_by,
|
||||
created_at, updated_at`).
|
||||
- `views/downloads-portal.ejs` — standalone glassmorphism download page.
|
||||
|
||||
### Frontend — **TODO**
|
||||
- `/enrollment` admin page with wizard (name, description, capabilities,
|
||||
tags, groups → create template → show enrollment token + QR + one-liner
|
||||
install command).
|
||||
|
||||
### Agent side — **TODO**
|
||||
- Installer accepts `--enrollment-token=XXXX --server=URL` flags.
|
||||
- Agent calls `POST /api/bd/enroll` with device_id + sysinfo, receives
|
||||
`preset_config`, then proceeds with normal `/api/bd/register`.
|
||||
|
||||
## Files touched this session
|
||||
|
||||
- `web-nodejs/public/js/deviceDetail.js`
|
||||
- `web-nodejs/public/css/device-detail.css`
|
||||
- `web-nodejs/lang/{en,pl,zh}.json`
|
||||
- `web-nodejs/services/dbAdapter.js` (SQLite + PostgreSQL migrations + profile helper)
|
||||
- `web-nodejs/routes/index.js` (mount phase4_5)
|
||||
- `web-nodejs/routes/phase4_5.routes.js` (new)
|
||||
- `web-nodejs/views/downloads-portal.ejs` (new)
|
||||
- `tasks/PHASE_2_5_STATUS.md` (this file)
|
||||
|
||||
## Deployment notes
|
||||
|
||||
1. Restart Node.js console so new routes + migrations load.
|
||||
2. Migrations are idempotent — safe to re-run.
|
||||
3. `agent_templates` table is created lazily on first `/api/agent-templates`
|
||||
access; no DB downtime required.
|
||||
4. Agent-client Rust work can begin immediately — HTTP contract is stable.
|
||||
@@ -0,0 +1,212 @@
|
||||
# Plan operacyjny: Agent Client + rozbudowa panelu web
|
||||
|
||||
**Data:** 2026-04-18
|
||||
**Kontekst:** Agent client obecnie to 6 plików Rust (rejestracja + sysinfo + stub-czat). Brak zdalnego pulpitu, brak WS do czatu, brak trybu użytkownika vs operatora. Panel web ma `help-requests.ejs` + API oraz slide-over `device-detail` — wymagają rozbudowy.
|
||||
|
||||
## Założenia architektoniczne
|
||||
|
||||
1. **Agent client = TRAY-FIRST aplikacja.** Menu główne w zasobniku systemowym. Okno GUI otwiera się tylko dla konkretnych funkcji (czat, help-request). Brak głównego sidebara.
|
||||
2. **Tryby uprawnień:**
|
||||
- **User mode** (zwykły użytkownik): "Poproś o pomoc", "Czat", "Pokaż ID".
|
||||
- **Admin mode** (OS administrator / root): wszystko powyżej + "Ustawienia", "Zamknij agenta".
|
||||
- Detekcja: `IsUserAnAdmin()` (Win32), `geteuid() == 0` (Unix).
|
||||
3. **Szyfrowanie E2E zachowane** — chat w agencie używa istniejącego `chatCrypto.js` (P-256 ECDH + AES-256-GCM) z panelu web, tylko port Rust.
|
||||
4. **Kanały komunikacji:**
|
||||
- **REST HTTP** — rejestracja, help-request, sysinfo upload (już jest).
|
||||
- **WebSocket `/ws/agent/{device_id}`** (NOWE) — chat, powiadomienia, heartbeat, remote session signaling.
|
||||
- **Relay TCP/WS** (Faza 4) — video/audio/input dla zdalnego pulpitu.
|
||||
5. **Bezpieczeństwo:** wszystkie WS z JWT token (z rejestracji), TLS wymagane w produkcji, rate-limit IP, audit log każdej akcji.
|
||||
|
||||
---
|
||||
|
||||
## Faza 1: Fundament UX + powiadomienia (TA SESJA)
|
||||
|
||||
**Cel:** Przeorganizować agent UI + dodać dzwonek powiadomień w panelu web.
|
||||
|
||||
### Agent Client
|
||||
- [x] ~~Obecne okno z sidebarem~~ → **usunięte**. Nowe: minimalne okno "o programie" + pełne menu w trayu.
|
||||
- [x] Detekcja admina w Rust (`is_os_admin()`).
|
||||
- [x] Tray menu:
|
||||
- **Zawsze:** Pokaż ID | Poproś o pomoc | Czat | Sprawdź połączenie | ───
|
||||
- **Admin only:** Ustawienia | Zamknij agenta
|
||||
- **Autostart:** domyślnie włączony (już jest)
|
||||
- [x] Pełny ekran tylko dla: `/chat` (z brandingiem konsoli), `/help` (formularz), `/settings` (tylko admin).
|
||||
- [x] Rozmiar okna zmniejszony do 480×560 (chat/help/settings), brak rozbudowanego menu bocznego.
|
||||
|
||||
### Web panel
|
||||
- [x] **Dzwonek powiadomień** w navbar (obok `refresh-btn`):
|
||||
- Badge z licznikiem nieprzeczytanych help-requestów
|
||||
- Dropdown z ostatnimi 10 powiadomieniami
|
||||
- Klik → przejście do `/help-requests` + oznaczenie jako przeczytane
|
||||
- [x] Endpoint `GET /api/bd/notifications` (z filtrem `unread_only`).
|
||||
- [x] Endpoint `POST /api/bd/notifications/:id/read`.
|
||||
- [x] Socket.IO event `help-request` już istnieje — podłączyć do dzwonka (real-time).
|
||||
- [x] i18n klucze `notifications.*` (EN/PL/ZH).
|
||||
|
||||
---
|
||||
|
||||
## Faza 2: Device Management Modal (NASTĘPNA SESJA)
|
||||
|
||||
**Cel:** Zastąpić slide-over `device-detail` pełnoekranowym modalem z rozbudowanym UI inspirowanym nVision.
|
||||
|
||||
### Backend
|
||||
- Rozszerzony endpoint `GET /api/peers/:id` o:
|
||||
- `hardware` (CPU, GPU, RAM, disks) — z inventory
|
||||
- `software` (zainstalowane aplikacje) — z inventory
|
||||
- `services` (uruchomione usługi systemowe) — z agent nowy endpoint
|
||||
- `processes` (aktywne procesy) — z agent nowy endpoint
|
||||
- `events` (logi Windows/journalctl) — z agent nowy endpoint
|
||||
- `usage_stats` (czas uruchomienia aplikacji) — z agent activity tracker
|
||||
- Endpoint `POST /api/peers/:id/rename` (zmiana przyjaznej nazwy).
|
||||
- Endpoint `POST /api/peers/:id/files/browse` — proxy do agent file browser.
|
||||
- Endpoint `POST /api/peers/:id/terminal/execute` — proxy do agent terminal.
|
||||
|
||||
### Frontend
|
||||
- Nowy komponent `device-modal.js` — pełnoekranowy modal (bez slide-over).
|
||||
- Zakładki wzorowane na nVision:
|
||||
- **General** — hero z nazwą/zdjęciem + status + szybkie akcje (Remote, Chat, Terminal, Files)
|
||||
- **Activity** — wykres użycia aplikacji (czas pracy)
|
||||
- **Screenshots** — galeria screenshotów (z agent screenshot capability)
|
||||
- **Events** — log zdarzeń systemowych
|
||||
- **DataGuard** — polityki DLP (już jest moduł)
|
||||
- **Blockades** — bany/ograniczenia (już jest moduł)
|
||||
- **Settings** — config per-device
|
||||
- Przycisk "Remote access" → otwiera `/remote/:id` (web viewer — już jest).
|
||||
- Przycisk "Files" → panel przeglądania plików z upload/download.
|
||||
- Przycisk "Terminal" → xterm.js terminal w nowym oknie.
|
||||
|
||||
### Agent — nowe capabilities
|
||||
- `system_services` — `Get-Service` (Win) / `systemctl list-units` (Linux)
|
||||
- `system_processes` — `Get-Process` / `ps aux`
|
||||
- `system_events` — `Get-EventLog` / `journalctl`
|
||||
- `file_browser` — list/read/write/delete z path traversal guard
|
||||
- `screenshot_capture` — JPEG snapshot on demand
|
||||
- `activity_tracker` — śledzenie czasu użycia aplikacji (background thread)
|
||||
- `terminal_session` — PTY (unix) / ConPTY (Windows)
|
||||
|
||||
**Implementacja:** agent-client pobiera większość z natywnego `betterdesk-agent` (Go) — portować moduły do Rust lub uruchamiać jako sidecar subprocess.
|
||||
|
||||
---
|
||||
|
||||
## Faza 3: Chat z brandingiem + E2E (FAZA 3)
|
||||
|
||||
**Cel:** Pełny czat między użytkownikami + operatorami, E2E, uploady plików.
|
||||
|
||||
### Backend
|
||||
- Endpoint WS `/ws/chat/agent/:device_id` — WS z JWT.
|
||||
- Reuse `chatRelay.js` (już istnieje).
|
||||
- Endpoint `GET /api/chat/contacts` — lista operatorów (top) + pracowników z organizacji.
|
||||
- Endpoint `POST /api/chat/attachment` — upload pliku (max 50 MB, encrypted blob).
|
||||
- Endpoint `GET /api/chat/attachment/:id` — download.
|
||||
|
||||
### Agent
|
||||
- `chat.rs` — WS client (tokio-tungstenite), P-256 keypair (ring crate), message encrypt/decrypt.
|
||||
- UI: lewa kolumna z kontaktami (operatorzy na górze, oznaczeni ikoną), prawa z historią.
|
||||
- Upload drag-and-drop + paste obrazka.
|
||||
- Branding: wykorzystuje `GET /api/config/branding` (logo, primary color, nazwa firmy).
|
||||
|
||||
### Web panel
|
||||
- Istniejąca strona `chat.ejs` — rozszerzyć o:
|
||||
- Lista agent-ów online (z device_id + hostname)
|
||||
- Wybór odbiorcy: operator, kolega z firmy, klient (device)
|
||||
- Upload plików
|
||||
- Czat E2E już zaimplementowany (Phase 2 — chatCrypto.js).
|
||||
|
||||
---
|
||||
|
||||
## Faza 4: Remote Desktop Core (FAZA 4 — NAJWIĘKSZA)
|
||||
|
||||
**Cel:** Pełny zdalny pulpit z multi-monitor, audio, wielokierunkowy clipboard, file transfer, supervised+unattended mode.
|
||||
|
||||
### Agent — nowe crates
|
||||
- `scrap` / `xcap` — screen capture (Win+Linux+macOS)
|
||||
- `openh264` — video encode (fallback: JPEG)
|
||||
- `cpal` — audio capture
|
||||
- `enigo` — input inject (już w MGMT)
|
||||
- `arboard` — clipboard sync
|
||||
- `tokio-tungstenite` — WS do relay
|
||||
- `protobuf` — protokół (reuse z betterdesk-server/proto)
|
||||
|
||||
### Agent — nowe moduły
|
||||
- `remote/capture.rs` — capture loop (30-60 FPS adaptive)
|
||||
- `remote/encoder.rs` — H.264 encoder + VP9 fallback
|
||||
- `remote/audio.rs` — audio capture + Opus encode
|
||||
- `remote/input.rs` — input injection receiver
|
||||
- `remote/session.rs` — session manager (negocjacja codec, quality, multi-monitor)
|
||||
- `remote/consent.rs` — **supervised mode:** popup JAK NA ZDJĘCIU 1
|
||||
- Przyciski: Klawiatura, Schowek, Audio, Transfer plików, Nagrywanie, Kamera, Blokada
|
||||
- Imię i nazwisko operatora + nick z konsoli web
|
||||
- Countdown "Automatyczna zgoda za X s" (opcjonalne)
|
||||
- **Unattended mode:** konfigurowalny w `/settings`, akceptacja domyślna (bez popupu)
|
||||
|
||||
### Web viewer
|
||||
- Już istnieje `remote.ejs` (web remote client). Rozszerzyć o:
|
||||
- Dropdown wyboru monitora
|
||||
- Przycisk "Request audio" → agent popup
|
||||
- Přycisk "Start recording" → encoder zapisuje MP4
|
||||
- Wskaźnik supervised/unattended (ikonka shield)
|
||||
|
||||
### Protokół
|
||||
- Signal: agent rejestruje ws://server/ws/agent/:id (push connection)
|
||||
- Operator łączy się przez web remote → server broadcastuje `connection_request` do agenta
|
||||
- Agent: jeśli supervised → pokazuje popup → czeka na `accept` z mapą uprawnień → wysyła `ready`
|
||||
- Agent: jeśli unattended → akceptuje od razu
|
||||
- Relay: obie strony łączą się do `/relay/:session_id`, server forwarduje bajty (istniejący `relay/`)
|
||||
|
||||
### User identity
|
||||
- W bazie `users` dodać kolumny `first_name`, `last_name`, `email`, `phone`, `role_display` (np. "IT Support Level 2").
|
||||
- Popup w agencie pokazuje: avatar + imię+nazwisko + rola + organizacja.
|
||||
|
||||
---
|
||||
|
||||
## Faza 5: Agent Generator + Portal pobierania (FAZA 5)
|
||||
|
||||
**Cel:** Zaawansowany generator w konsoli web + publiczny portal pobierania.
|
||||
|
||||
### Generator (w panelu web)
|
||||
- Rozbudować istniejący `generator.ejs`:
|
||||
- **Step 1:** Template (blank / organization preset / custom)
|
||||
- **Step 2:** Connection (server, relay, TLS, STUN/TURN)
|
||||
- **Step 3:** Access policy (supervised / unattended / hybrid)
|
||||
- **Step 4:** Branding (logo, kolor, nazwa firmy)
|
||||
- **Step 5:** Features (chat enabled?, file transfer?, terminal?, screenshots?)
|
||||
- **Step 6:** Security (API key, allowed operators, MAC pinning)
|
||||
- **Generate button** → tworzy podpisany JSON config + enrollment token
|
||||
- Output: **installer .exe / .msi / .deb / .pkg / .dmg** z zaszytym configiem (build przez `cargo tauri build` z env var `AGENT_PRESET_CONFIG`).
|
||||
- Templates per-org w bazie: `agent_templates` (name, description, config_json, created_by, org_id).
|
||||
|
||||
### Portal pobierania (oddzielny port — np. 5001)
|
||||
- Nowa aplikacja Node.js `downloads-portal/` (lub route prefix w istniejącej konsoli).
|
||||
- Publiczny dostęp (bez auth).
|
||||
- Strona z brandingiem firmy: logo, nazwa, opis, "Pobierz BetterDesk Agent" × OS.
|
||||
- Każdy link → pobiera najnowszy build z zaszytym preset configiem.
|
||||
- Po instalacji agent auto-rejestruje się do serwera (z tokenem z config).
|
||||
|
||||
### Auto-enrollment flow
|
||||
1. Agent startuje
|
||||
2. Jeśli `preset_enrollment_token` w config → `POST /api/bd/enroll` z tokenem
|
||||
3. Serwer weryfikuje token + tworzy device + zwraca JWT
|
||||
4. Agent zapisuje device_id + JWT → normalny flow
|
||||
|
||||
---
|
||||
|
||||
## Kolejność realizacji
|
||||
|
||||
| Faza | Czas (szacunek) | Priorytet |
|
||||
|------|----------------|-----------|
|
||||
| 1. Fundament UX + powiadomienia | Ta sesja | 🔴 Krytyczne |
|
||||
| 2. Device Modal + agent capabilities | 2-3 sesje | 🟠 Wysokie |
|
||||
| 3. Chat E2E + attachments | 1-2 sesje | 🟠 Wysokie |
|
||||
| 4. Remote Desktop | 4-6 sesji | 🔴 Krytyczne |
|
||||
| 5. Generator + Portal | 1-2 sesje | 🟡 Średnie |
|
||||
|
||||
## Uwagi bezpieczeństwa (cross-cutting)
|
||||
|
||||
- **JWT token z rejestracji** — używany do wszystkich WS (agent side) + device API.
|
||||
- **Rate-limit IP** — na wszystkich publicznych endpointach (istnieje w Go server).
|
||||
- **Audit log** — każda akcja (login, connect, file transfer, command exec) do `audit_log` tabeli.
|
||||
- **E2E chat** — już zaimplementowane (P-256 ECDH + AES-256-GCM) w `chatCrypto.js`.
|
||||
- **Token rotation** — JWT krótki TTL (15 min) + refresh token.
|
||||
- **Certificate pinning** — agent pinuje SHA256 serwera po pierwszej rejestracji.
|
||||
- **Supervised mode consent** — user ma fizyczną kontrolę nad uprawnieniami per-sesja.
|
||||
- **Admin-only actions w agencie** — ustawienia, unregister, zamknięcie — wymagają OS admin (lokalny).
|
||||
@@ -744,6 +744,22 @@
|
||||
"action_delete_desc": "Permanently remove this device",
|
||||
"tab_hardware": "Hardware",
|
||||
"tab_metrics": "Metrics",
|
||||
"tab_services": "Services",
|
||||
"tab_processes": "Processes",
|
||||
"tab_events": "Events",
|
||||
"tab_activity": "Activity",
|
||||
"tab_files": "Files",
|
||||
"agent_lazy_hint": "Click this tab to load live data from the agent",
|
||||
"agent_offline": "Agent is offline",
|
||||
"agent_timeout": "Agent did not respond in time",
|
||||
"agent_error": "Failed to load data from agent",
|
||||
"service_name": "Name",
|
||||
"service_display": "Display name",
|
||||
"service_status": "Status",
|
||||
"service_start": "Start mode",
|
||||
"process_name": "Name",
|
||||
"process_user": "User",
|
||||
"process_memory": "Memory",
|
||||
"section_system": "System",
|
||||
"section_cpu": "Processor",
|
||||
"section_memory": "Memory",
|
||||
@@ -1438,6 +1454,14 @@
|
||||
"keyframe_request": "Request Keyframe",
|
||||
"quality_auto": "Auto Quality"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"empty": "No new notifications",
|
||||
"mark_all_read": "Mark all read",
|
||||
"see_all": "See all help requests",
|
||||
"help_request": "Help request",
|
||||
"just_now": "just now"
|
||||
},
|
||||
"desktop": {
|
||||
"switch_mode": "Desktop Mode",
|
||||
"console_mode": "Console Mode",
|
||||
|
||||
@@ -744,6 +744,22 @@
|
||||
"action_delete_desc": "Trwale usuń to urządzenie",
|
||||
"tab_hardware": "Sprzęt",
|
||||
"tab_metrics": "Metryki",
|
||||
"tab_services": "Usługi",
|
||||
"tab_processes": "Procesy",
|
||||
"tab_events": "Zdarzenia",
|
||||
"tab_activity": "Aktywność",
|
||||
"tab_files": "Pliki",
|
||||
"agent_lazy_hint": "Kliknij tę zakładkę, aby pobrać dane z agenta",
|
||||
"agent_offline": "Agent jest offline",
|
||||
"agent_timeout": "Agent nie odpowiedział w terminie",
|
||||
"agent_error": "Nie udało się pobrać danych z agenta",
|
||||
"service_name": "Nazwa",
|
||||
"service_display": "Nazwa wyświetlana",
|
||||
"service_status": "Stan",
|
||||
"service_start": "Tryb startu",
|
||||
"process_name": "Nazwa",
|
||||
"process_user": "Użytkownik",
|
||||
"process_memory": "Pamięć",
|
||||
"section_system": "System",
|
||||
"section_cpu": "Procesor",
|
||||
"section_memory": "Pamięć",
|
||||
@@ -1438,6 +1454,14 @@
|
||||
"keyframe_request": "Żądaj klatki kluczowej",
|
||||
"quality_auto": "Automatyczna jakość"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Powiadomienia",
|
||||
"empty": "Brak nowych powiadomień",
|
||||
"mark_all_read": "Oznacz wszystkie jako przeczytane",
|
||||
"see_all": "Zobacz wszystkie prośby o pomoc",
|
||||
"help_request": "Prośba o pomoc",
|
||||
"just_now": "przed chwilą"
|
||||
},
|
||||
"desktop": {
|
||||
"switch_mode": "Tryb pulpitu",
|
||||
"console_mode": "Tryb konsoli",
|
||||
|
||||
@@ -744,6 +744,22 @@
|
||||
"action_delete_desc": "永久删除此设备",
|
||||
"tab_hardware": "硬件",
|
||||
"tab_metrics": "指标",
|
||||
"tab_services": "服务",
|
||||
"tab_processes": "进程",
|
||||
"tab_events": "事件",
|
||||
"tab_activity": "活动",
|
||||
"tab_files": "文件",
|
||||
"agent_lazy_hint": "单击此选项卡以从代理加载实时数据",
|
||||
"agent_offline": "代理离线",
|
||||
"agent_timeout": "代理响应超时",
|
||||
"agent_error": "从代理加载数据失败",
|
||||
"service_name": "名称",
|
||||
"service_display": "显示名称",
|
||||
"service_status": "状态",
|
||||
"service_start": "启动模式",
|
||||
"process_name": "名称",
|
||||
"process_user": "用户",
|
||||
"process_memory": "内存",
|
||||
"section_system": "系统",
|
||||
"section_cpu": "处理器",
|
||||
"section_memory": "内存",
|
||||
@@ -1445,6 +1461,14 @@
|
||||
"keyframe_request": "请求关键帧",
|
||||
"quality_auto": "自动画质"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知",
|
||||
"empty": "没有新通知",
|
||||
"mark_all_read": "全部标记为已读",
|
||||
"see_all": "查看所有求助请求",
|
||||
"help_request": "求助请求",
|
||||
"just_now": "刚刚"
|
||||
},
|
||||
"desktop": {
|
||||
"switch_mode": "桌面模式",
|
||||
"console_mode": "控制台模式",
|
||||
|
||||
@@ -984,3 +984,217 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================================================
|
||||
AGENT LAZY-LOADED TABS (services, processes, events, activity, files)
|
||||
=========================================================================== */
|
||||
|
||||
.device-panel-agent-pane {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.device-panel-agent-empty,
|
||||
.device-panel-agent-loading,
|
||||
.device-panel-agent-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.device-panel-agent-empty .material-icons,
|
||||
.device-panel-agent-loading .material-icons,
|
||||
.device-panel-agent-error .material-icons {
|
||||
font-size: 48px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.device-panel-agent-hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.device-panel-agent-error-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
}
|
||||
|
||||
.device-panel-agent-error-hint {
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, monospace;
|
||||
opacity: 0.6;
|
||||
max-width: 400px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.device-panel-agent-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.device-panel-agent-table th {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-color, #30363d);
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-secondary, #0d1117);
|
||||
}
|
||||
|
||||
.device-panel-agent-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-color, #30363d);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
}
|
||||
|
||||
.device-panel-agent-table tbody tr:hover {
|
||||
background: var(--bg-hover, #21262d);
|
||||
}
|
||||
|
||||
.device-panel-event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.device-panel-event-row {
|
||||
display: grid;
|
||||
grid-template-columns: 150px 150px 1fr;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
border-left: 3px solid var(--border-color, #30363d);
|
||||
background: var(--bg-secondary, #0d1117);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.device-panel-event-row.error { border-left-color: #f85149; }
|
||||
.device-panel-event-row.warn { border-left-color: #d29922; }
|
||||
.device-panel-event-row.info { border-left-color: #3b82f6; }
|
||||
|
||||
.device-panel-event-time {
|
||||
font-family: ui-monospace, monospace;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
}
|
||||
|
||||
.device-panel-event-source {
|
||||
color: var(--text-secondary, #8b949e);
|
||||
}
|
||||
|
||||
.device-panel-event-msg {
|
||||
color: var(--text-primary, #e6edf3);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.device-panel-activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.device-panel-activity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr 70px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.device-panel-activity-name {
|
||||
color: var(--text-primary, #e6edf3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-panel-activity-bar {
|
||||
height: 8px;
|
||||
background: var(--bg-secondary, #0d1117);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.device-panel-activity-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #60a5fa);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.device-panel-activity-time {
|
||||
text-align: right;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.device-panel-file-browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.device-panel-file-path {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-secondary, #0d1117);
|
||||
border-radius: 6px;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
}
|
||||
|
||||
.device-panel-file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.device-panel-file-row {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 100px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.device-panel-file-row:hover {
|
||||
background: var(--bg-hover, #21262d);
|
||||
}
|
||||
|
||||
.device-panel-file-row[data-is-dir="1"] .material-icons {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.device-panel-file-size {
|
||||
text-align: right;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.material-icons.spinning {
|
||||
animation: device-panel-spin 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes device-panel-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/* Navbar notification center — dropdown with unread badge, real-time pulse. */
|
||||
|
||||
.notif-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.notif-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Unread badge on bell icon. */
|
||||
.notif-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent-red, #e74c3c);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 0 2px var(--bg-primary, #0d1117);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Real-time arrival pulse (400ms). */
|
||||
@keyframes notif-pulse {
|
||||
0% { transform: scale(1); }
|
||||
30% { transform: scale(1.18); }
|
||||
60% { transform: scale(0.94); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.notif-btn.notif-pulse .material-icons {
|
||||
animation: notif-pulse 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* Dropdown panel. */
|
||||
.notif-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: 340px;
|
||||
max-width: calc(100vw - 24px);
|
||||
background: var(--bg-secondary, #161b22);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55);
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
animation: notif-fade-in 150ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes notif-fade-in {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.notif-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border, #30363d);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
}
|
||||
|
||||
.notif-mark-all {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--accent-blue, #58a6ff);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.notif-mark-all:hover {
|
||||
background: rgba(88, 166, 255, 0.08);
|
||||
}
|
||||
|
||||
.notif-list {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.notif-empty {
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notif-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-subtle, #21262d);
|
||||
text-decoration: none;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
transition: background-color 120ms;
|
||||
}
|
||||
|
||||
.notif-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notif-item:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.notif-unread {
|
||||
background: rgba(88, 166, 255, 0.06);
|
||||
}
|
||||
|
||||
.notif-unread::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-blue, #58a6ff);
|
||||
margin-top: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notif-item-icon {
|
||||
color: var(--accent-blue, #58a6ff);
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.notif-item-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notif-item-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.notif-item-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notif-item-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #6e7681);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.notif-footer {
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--border, #30363d);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notif-see-all {
|
||||
color: var(--accent-blue, #58a6ff);
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.notif-see-all:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive: narrow screens push dropdown to full width of navbar-right. */
|
||||
@media (max-width: 480px) {
|
||||
.notif-dropdown {
|
||||
right: -60px;
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
@@ -190,6 +190,11 @@ const DeviceDetail = (function () {
|
||||
{ id: 'overview', icon: 'info', label: _('device_detail.tab_overview') },
|
||||
{ id: 'hardware', icon: 'memory', label: _('device_detail.tab_hardware') },
|
||||
{ id: 'metrics', icon: 'monitoring', label: _('device_detail.tab_metrics') },
|
||||
{ id: 'services', icon: 'settings_applications', label: _('device_detail.tab_services') },
|
||||
{ id: 'processes', icon: 'memory', label: _('device_detail.tab_processes') },
|
||||
{ id: 'events', icon: 'event_note', label: _('device_detail.tab_events') },
|
||||
{ id: 'activity', icon: 'insights', label: _('device_detail.tab_activity') },
|
||||
{ id: 'files', icon: 'folder_open', label: _('device_detail.tab_files') },
|
||||
{ id: 'tags', icon: 'sell', label: _('device_detail.tab_tags') },
|
||||
{ id: 'actions', icon: 'play_arrow', label: _('device_detail.tab_actions') }
|
||||
];
|
||||
@@ -209,11 +214,35 @@ const DeviceDetail = (function () {
|
||||
${_overviewPane()}
|
||||
${_hardwarePane()}
|
||||
${_metricsPane()}
|
||||
${_agentPane('services', 'settings_applications', 'tab_services')}
|
||||
${_agentPane('processes', 'memory', 'tab_processes')}
|
||||
${_agentPane('events', 'event_note', 'tab_events')}
|
||||
${_agentPane('activity', 'insights', 'tab_activity')}
|
||||
${_agentPane('files', 'folder_open', 'tab_files')}
|
||||
${_tagsPane()}
|
||||
${_actionsPane()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Live-agent tabs (lazy loaded) ────────────────────────────────────
|
||||
//
|
||||
// These panes are populated on first tab activation by `_loadAgentTab()`.
|
||||
// Keeping them in separate functions would duplicate 200+ lines of
|
||||
// boilerplate. Instead, each pane is a placeholder that shows an empty
|
||||
// state and a "Load" button; real data is fetched when tab is shown.
|
||||
|
||||
function _agentPane(paneId, icon, labelKey) {
|
||||
return `<div class="device-panel-tab-pane" data-pane="${paneId}">
|
||||
<div class="device-panel-agent-pane" data-agent-pane="${paneId}">
|
||||
<div class="device-panel-agent-empty">
|
||||
<span class="material-icons">${icon}</span>
|
||||
<div>${_('device_detail.' + labelKey)}</div>
|
||||
<div class="device-panel-agent-hint">${_('device_detail.agent_lazy_hint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Overview tab ──
|
||||
|
||||
function _overviewPane() {
|
||||
@@ -773,6 +802,253 @@ const DeviceDetail = (function () {
|
||||
panel.querySelectorAll('.device-panel-tab-pane').forEach(function (p) {
|
||||
p.classList.toggle('active', p.dataset.pane === tabId);
|
||||
});
|
||||
|
||||
// Lazy-load agent-backed tabs on first activation.
|
||||
const AGENT_TABS = ['services', 'processes', 'events', 'activity', 'files'];
|
||||
if (AGENT_TABS.indexOf(tabId) !== -1) {
|
||||
_loadAgentTab(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent-backed tabs ───────────────────────────────────────────────
|
||||
|
||||
// Track which tabs have been loaded so we don't refetch on every click.
|
||||
const _agentTabLoaded = {};
|
||||
|
||||
async function _loadAgentTab(tabId) {
|
||||
if (_agentTabLoaded[tabId]) return;
|
||||
_agentTabLoaded[tabId] = true;
|
||||
|
||||
if (!device || !device.id) return;
|
||||
const pane = overlayEl?.querySelector(`[data-agent-pane="${tabId}"]`);
|
||||
if (!pane) return;
|
||||
|
||||
pane.innerHTML = `<div class="device-panel-agent-loading">
|
||||
<span class="material-icons spinning">autorenew</span>
|
||||
<div>${_('common.loading')}</div>
|
||||
</div>`;
|
||||
|
||||
const endpointMap = {
|
||||
services: { method: 'GET', url: `/api/devices/${encodeURIComponent(device.id)}/services` },
|
||||
processes: { method: 'GET', url: `/api/devices/${encodeURIComponent(device.id)}/processes` },
|
||||
events: { method: 'GET', url: `/api/devices/${encodeURIComponent(device.id)}/events?limit=200` },
|
||||
activity: { method: 'GET', url: `/api/devices/${encodeURIComponent(device.id)}/activity` },
|
||||
files: { method: 'POST', url: `/api/devices/${encodeURIComponent(device.id)}/files/browse`, body: { path: '/', show_hidden: false } }
|
||||
};
|
||||
|
||||
const ep = endpointMap[tabId];
|
||||
if (!ep) return;
|
||||
|
||||
try {
|
||||
let resp;
|
||||
if (ep.method === 'POST') {
|
||||
resp = await Utils.api(ep.url, { method: 'POST', body: JSON.stringify(ep.body) });
|
||||
} else {
|
||||
resp = await Utils.api(ep.url);
|
||||
}
|
||||
const data = (resp && resp.data) || resp || null;
|
||||
_renderAgentTab(tabId, data, pane);
|
||||
} catch (err) {
|
||||
_agentTabLoaded[tabId] = false; // allow retry
|
||||
pane.innerHTML = _agentErrorHTML(err, tabId);
|
||||
}
|
||||
}
|
||||
|
||||
function _agentErrorHTML(err, tabId) {
|
||||
const msg = err && err.message ? String(err.message) : 'unknown';
|
||||
// Differentiate offline vs generic error for clearer UX.
|
||||
const isOffline = msg.indexOf('503') !== -1 || msg.indexOf('offline') !== -1;
|
||||
const isTimeout = msg.indexOf('504') !== -1 || msg.indexOf('timeout') !== -1;
|
||||
const icon = isOffline ? 'cloud_off' : isTimeout ? 'hourglass_empty' : 'error_outline';
|
||||
const title = isOffline
|
||||
? _('device_detail.agent_offline') || 'Agent is offline'
|
||||
: isTimeout
|
||||
? _('device_detail.agent_timeout') || 'Agent did not respond in time'
|
||||
: _('device_detail.agent_error') || 'Failed to load data from agent';
|
||||
return `<div class="device-panel-agent-error">
|
||||
<span class="material-icons">${icon}</span>
|
||||
<div class="device-panel-agent-error-title">${title}</div>
|
||||
<div class="device-panel-agent-error-hint">${Utils.escapeHtml(msg)}</div>
|
||||
<button class="btn btn-secondary btn-sm" data-retry-tab="${tabId}">
|
||||
<span class="material-icons">refresh</span>${_('actions.retry') || 'Retry'}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderAgentTab(tabId, data, pane) {
|
||||
if (tabId === 'services') {
|
||||
pane.innerHTML = _renderServicesList(data);
|
||||
} else if (tabId === 'processes') {
|
||||
pane.innerHTML = _renderProcessList(data);
|
||||
} else if (tabId === 'events') {
|
||||
pane.innerHTML = _renderEventList(data);
|
||||
} else if (tabId === 'activity') {
|
||||
pane.innerHTML = _renderActivity(data);
|
||||
} else if (tabId === 'files') {
|
||||
pane.innerHTML = _renderFileBrowser(data, '/');
|
||||
_attachFileBrowserEvents(pane);
|
||||
}
|
||||
|
||||
// Generic retry button handler
|
||||
pane.querySelector('[data-retry-tab]')?.addEventListener('click', function () {
|
||||
const tid = this.dataset.retryTab;
|
||||
_agentTabLoaded[tid] = false;
|
||||
_loadAgentTab(tid);
|
||||
});
|
||||
}
|
||||
|
||||
function _renderServicesList(data) {
|
||||
const services = Array.isArray(data?.services) ? data.services : Array.isArray(data) ? data : [];
|
||||
if (!services.length) {
|
||||
return `<div class="device-panel-agent-empty"><span class="material-icons">inbox</span><div>${_('common.no_data')}</div></div>`;
|
||||
}
|
||||
const rows = services.slice(0, 500).map(s => `
|
||||
<tr>
|
||||
<td>${Utils.escapeHtml(s.name || '')}</td>
|
||||
<td>${Utils.escapeHtml(s.display_name || s.name || '')}</td>
|
||||
<td><span class="badge ${s.status === 'running' ? 'badge-success' : 'badge-neutral'}">${Utils.escapeHtml(s.status || '-')}</span></td>
|
||||
<td>${Utils.escapeHtml(s.start_type || '-')}</td>
|
||||
</tr>`).join('');
|
||||
return `<table class="device-panel-agent-table">
|
||||
<thead><tr>
|
||||
<th>${_('device_detail.service_name') || 'Name'}</th>
|
||||
<th>${_('device_detail.service_display') || 'Display'}</th>
|
||||
<th>${_('device_detail.service_status') || 'Status'}</th>
|
||||
<th>${_('device_detail.service_start') || 'Start'}</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function _renderProcessList(data) {
|
||||
const procs = Array.isArray(data?.processes) ? data.processes : Array.isArray(data) ? data : [];
|
||||
if (!procs.length) {
|
||||
return `<div class="device-panel-agent-empty"><span class="material-icons">inbox</span><div>${_('common.no_data')}</div></div>`;
|
||||
}
|
||||
// Sort by CPU descending, show top 200
|
||||
procs.sort((a, b) => (b.cpu || 0) - (a.cpu || 0));
|
||||
const rows = procs.slice(0, 200).map(p => `
|
||||
<tr>
|
||||
<td>${Utils.escapeHtml(String(p.pid ?? '-'))}</td>
|
||||
<td>${Utils.escapeHtml(p.name || '-')}</td>
|
||||
<td>${Utils.escapeHtml(p.user || '-')}</td>
|
||||
<td>${Number(p.cpu || 0).toFixed(1)}%</td>
|
||||
<td>${Number(p.memory_mb || 0).toFixed(0)} MB</td>
|
||||
</tr>`).join('');
|
||||
return `<table class="device-panel-agent-table">
|
||||
<thead><tr>
|
||||
<th>PID</th>
|
||||
<th>${_('device_detail.process_name') || 'Name'}</th>
|
||||
<th>${_('device_detail.process_user') || 'User'}</th>
|
||||
<th>CPU</th>
|
||||
<th>${_('device_detail.process_memory') || 'Memory'}</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function _renderEventList(data) {
|
||||
const events = Array.isArray(data?.events) ? data.events : Array.isArray(data) ? data : [];
|
||||
if (!events.length) {
|
||||
return `<div class="device-panel-agent-empty"><span class="material-icons">inbox</span><div>${_('common.no_data')}</div></div>`;
|
||||
}
|
||||
const rows = events.slice(0, 500).map(e => {
|
||||
const level = String(e.level || 'info').toLowerCase();
|
||||
const levelClass = level === 'error' || level === 'critical' ? 'error'
|
||||
: level === 'warning' || level === 'warn' ? 'warn' : 'info';
|
||||
return `<div class="device-panel-event-row ${levelClass}">
|
||||
<div class="device-panel-event-time">${Utils.escapeHtml(e.time || '')}</div>
|
||||
<div class="device-panel-event-source">${Utils.escapeHtml(e.source || e.facility || '-')}</div>
|
||||
<div class="device-panel-event-msg">${Utils.escapeHtml(e.message || '')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<div class="device-panel-event-list">${rows}</div>`;
|
||||
}
|
||||
|
||||
function _renderActivity(data) {
|
||||
const items = Array.isArray(data?.apps) ? data.apps : Array.isArray(data) ? data : [];
|
||||
if (!items.length) {
|
||||
return `<div class="device-panel-agent-empty"><span class="material-icons">inbox</span><div>${_('common.no_data')}</div></div>`;
|
||||
}
|
||||
// Sort by total seconds
|
||||
items.sort((a, b) => (b.seconds || 0) - (a.seconds || 0));
|
||||
const maxSec = Math.max(1, items[0].seconds || 1);
|
||||
const rows = items.slice(0, 50).map(a => {
|
||||
const pct = Math.min(100, ((a.seconds || 0) / maxSec) * 100);
|
||||
const minutes = Math.floor((a.seconds || 0) / 60);
|
||||
return `<div class="device-panel-activity-row">
|
||||
<div class="device-panel-activity-name">${Utils.escapeHtml(a.name || a.app || '-')}</div>
|
||||
<div class="device-panel-activity-bar">
|
||||
<div class="device-panel-activity-bar-fill" style="width:${pct.toFixed(1)}%"></div>
|
||||
</div>
|
||||
<div class="device-panel-activity-time">${minutes} min</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<div class="device-panel-activity-list">${rows}</div>`;
|
||||
}
|
||||
|
||||
function _renderFileBrowser(data, currentPath) {
|
||||
const entries = Array.isArray(data?.entries) ? data.entries : Array.isArray(data) ? data : [];
|
||||
const path = data?.path || currentPath || '/';
|
||||
const parent = data?.parent || null;
|
||||
const rows = entries.slice(0, 1000).map(e => {
|
||||
const icon = e.is_dir ? 'folder' : 'description';
|
||||
const size = e.is_dir ? '' : _formatBytes(e.size || 0);
|
||||
return `<div class="device-panel-file-row" data-is-dir="${e.is_dir ? '1' : '0'}" data-path="${Utils.escapeHtml(e.path || '')}">
|
||||
<span class="material-icons">${icon}</span>
|
||||
<div class="device-panel-file-name">${Utils.escapeHtml(e.name || '')}</div>
|
||||
<div class="device-panel-file-size">${size}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<div class="device-panel-file-browser">
|
||||
<div class="device-panel-file-path">
|
||||
${parent ? `<button class="btn btn-secondary btn-sm" data-fb-up="${Utils.escapeHtml(parent)}">
|
||||
<span class="material-icons">arrow_upward</span>
|
||||
</button>` : ''}
|
||||
<span>${Utils.escapeHtml(path)}</span>
|
||||
</div>
|
||||
<div class="device-panel-file-list">${rows}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _attachFileBrowserEvents(pane) {
|
||||
pane.querySelectorAll('[data-fb-up]').forEach(btn => {
|
||||
btn.addEventListener('click', () => _browseFiles(btn.dataset.fbUp));
|
||||
});
|
||||
pane.querySelectorAll('.device-panel-file-row').forEach(row => {
|
||||
row.addEventListener('dblclick', () => {
|
||||
if (row.dataset.isDir === '1') _browseFiles(row.dataset.path);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function _browseFiles(path) {
|
||||
if (!device || !device.id) return;
|
||||
const pane = overlayEl?.querySelector('[data-agent-pane="files"]');
|
||||
if (!pane) return;
|
||||
pane.innerHTML = `<div class="device-panel-agent-loading"><span class="material-icons spinning">autorenew</span><div>${_('common.loading')}</div></div>`;
|
||||
try {
|
||||
const resp = await Utils.api(`/api/devices/${encodeURIComponent(device.id)}/files/browse`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path, show_hidden: false })
|
||||
});
|
||||
pane.innerHTML = _renderFileBrowser(resp?.data || resp, path);
|
||||
_attachFileBrowserEvents(pane);
|
||||
} catch (err) {
|
||||
pane.innerHTML = _agentErrorHTML(err, 'files');
|
||||
pane.querySelector('[data-retry-tab]')?.addEventListener('click', function () {
|
||||
_agentTabLoaded['files'] = false;
|
||||
_loadAgentTab('files');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _formatBytes(n) {
|
||||
if (!n) return '0 B';
|
||||
const k = 1024;
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(k)));
|
||||
return (n / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
|
||||
}
|
||||
|
||||
// ── Tags ──
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Navbar notification center.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Fetches unread notifications from /api/bd/notifications?unread_only=true on load
|
||||
* - Subscribes to Socket.IO "help-request" event for real-time pushes
|
||||
* - Renders a dropdown list of recent items with action/link
|
||||
* - Marks items as read via POST /api/bd/notifications/:id/read
|
||||
* - Updates badge counter
|
||||
*
|
||||
* Defense in depth:
|
||||
* - All server-provided text is inserted via textContent (never innerHTML) to
|
||||
* prevent stored-XSS from compromised agent names or help request bodies.
|
||||
* - CSRF token is sent with mutation requests (read / mark-all).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const MAX_ITEMS = 10;
|
||||
const state = {
|
||||
items: [],
|
||||
unreadCount: 0,
|
||||
};
|
||||
|
||||
let dom = null;
|
||||
|
||||
function _(key) {
|
||||
try {
|
||||
return (window.BetterDesk && window._) ? window._(key) : key;
|
||||
} catch {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
function csrf() {
|
||||
return (window.BetterDesk && window.BetterDesk.csrfToken) || '';
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const diff = Date.now() - d.getTime();
|
||||
if (diff < 60_000) return _('notifications.just_now');
|
||||
if (diff < 3_600_000) return Math.floor(diff / 60_000) + 'm';
|
||||
if (diff < 86_400_000) return Math.floor(diff / 3_600_000) + 'h';
|
||||
return d.toLocaleDateString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function updateBadge() {
|
||||
if (!dom) return;
|
||||
const count = state.unreadCount;
|
||||
if (count > 0) {
|
||||
dom.badge.textContent = count > 99 ? '99+' : String(count);
|
||||
dom.badge.hidden = false;
|
||||
} else {
|
||||
dom.badge.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
if (!dom) return;
|
||||
dom.list.textContent = '';
|
||||
|
||||
if (state.items.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'notif-empty';
|
||||
empty.textContent = _('notifications.empty');
|
||||
dom.list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of state.items.slice(0, MAX_ITEMS)) {
|
||||
const row = document.createElement('a');
|
||||
row.className = 'notif-item' + (item.read ? '' : ' notif-unread');
|
||||
row.href = item.link || '/help-requests';
|
||||
row.dataset.id = String(item.id);
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'material-icons notif-item-icon';
|
||||
icon.textContent = item.icon || 'support_agent';
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'notif-item-body';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'notif-item-title';
|
||||
title.textContent = item.title || _('notifications.help_request');
|
||||
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'notif-item-sub';
|
||||
sub.textContent = item.message || '';
|
||||
|
||||
const time = document.createElement('div');
|
||||
time.className = 'notif-item-time';
|
||||
time.textContent = formatTime(item.created_at);
|
||||
|
||||
body.appendChild(title);
|
||||
body.appendChild(sub);
|
||||
body.appendChild(time);
|
||||
|
||||
row.appendChild(icon);
|
||||
row.appendChild(body);
|
||||
|
||||
row.addEventListener('click', () => {
|
||||
if (!item.read) {
|
||||
markRead(item.id).catch(() => { /* best effort */ });
|
||||
}
|
||||
});
|
||||
|
||||
dom.list.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNotifications() {
|
||||
try {
|
||||
const resp = await fetch('/api/bd/notifications?limit=' + MAX_ITEMS, {
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const items = Array.isArray(data.items) ? data.items : (Array.isArray(data) ? data : []);
|
||||
state.items = items;
|
||||
state.unreadCount = items.filter(i => !i.read).length;
|
||||
updateBadge();
|
||||
renderList();
|
||||
} catch {
|
||||
// Silent — dropdown stays empty, badge stays hidden.
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(id) {
|
||||
if (!id) return;
|
||||
try {
|
||||
await fetch('/api/bd/notifications/' + encodeURIComponent(id) + '/read', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf(),
|
||||
},
|
||||
});
|
||||
const item = state.items.find(i => String(i.id) === String(id));
|
||||
if (item && !item.read) {
|
||||
item.read = true;
|
||||
state.unreadCount = Math.max(0, state.unreadCount - 1);
|
||||
updateBadge();
|
||||
renderList();
|
||||
}
|
||||
} catch {
|
||||
// Silent; user can retry.
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
try {
|
||||
await fetch('/api/bd/notifications/read-all', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf(),
|
||||
},
|
||||
});
|
||||
state.items = state.items.map(i => ({ ...i, read: true }));
|
||||
state.unreadCount = 0;
|
||||
updateBadge();
|
||||
renderList();
|
||||
} catch {
|
||||
// Silent.
|
||||
}
|
||||
}
|
||||
|
||||
function onHelpRequestEvent(payload) {
|
||||
try {
|
||||
const item = {
|
||||
id: payload.id || Date.now(),
|
||||
title: payload.device_name || _('notifications.help_request'),
|
||||
message: payload.description || payload.message || '',
|
||||
icon: 'support_agent',
|
||||
link: '/help-requests',
|
||||
read: false,
|
||||
created_at: payload.timestamp || new Date().toISOString(),
|
||||
};
|
||||
// Insert at top, cap list.
|
||||
state.items.unshift(item);
|
||||
if (state.items.length > MAX_ITEMS) {
|
||||
state.items = state.items.slice(0, MAX_ITEMS);
|
||||
}
|
||||
state.unreadCount += 1;
|
||||
updateBadge();
|
||||
renderList();
|
||||
|
||||
// Subtle badge pulse for real-time feedback.
|
||||
if (dom && dom.btn) {
|
||||
dom.btn.classList.remove('notif-pulse');
|
||||
// Force reflow so the class re-application restarts the animation.
|
||||
void dom.btn.offsetWidth;
|
||||
dom.btn.classList.add('notif-pulse');
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed payloads.
|
||||
}
|
||||
}
|
||||
|
||||
function attachSocket() {
|
||||
try {
|
||||
const s = window.socket || (typeof io === 'function' ? io() : null);
|
||||
if (!s || typeof s.on !== 'function') return;
|
||||
window.socket = s;
|
||||
s.on('help-request', onHelpRequestEvent);
|
||||
s.on('notification', onHelpRequestEvent);
|
||||
} catch {
|
||||
// Socket.IO not available — poll only.
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDropdown(open) {
|
||||
if (!dom) return;
|
||||
const isOpen = open !== undefined ? open : dom.dropdown.hidden;
|
||||
dom.dropdown.hidden = !isOpen;
|
||||
dom.btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
if (isOpen) {
|
||||
fetchNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
dom = {
|
||||
wrapper: document.querySelector('.notif-wrapper'),
|
||||
btn: document.getElementById('notif-btn'),
|
||||
badge: document.getElementById('notif-badge'),
|
||||
dropdown: document.getElementById('notif-dropdown'),
|
||||
list: document.getElementById('notif-list'),
|
||||
markAll: document.getElementById('notif-mark-all'),
|
||||
};
|
||||
if (!dom.btn || !dom.dropdown || !dom.list) {
|
||||
dom = null;
|
||||
return;
|
||||
}
|
||||
|
||||
dom.btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleDropdown();
|
||||
});
|
||||
|
||||
if (dom.markAll) {
|
||||
dom.markAll.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
markAllRead();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!dom.wrapper.contains(e.target)) {
|
||||
toggleDropdown(false);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') toggleDropdown(false);
|
||||
});
|
||||
|
||||
fetchNotifications();
|
||||
attachSocket();
|
||||
|
||||
// Polling fallback: refresh every 60s. Cheap (10 rows max) and survives
|
||||
// socket disconnects.
|
||||
setInterval(fetchNotifications, 60_000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -696,6 +696,134 @@ router.delete('/help-requests/:id', requireDeviceAuth, requireOperatorRole, asyn
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Notification Center (panel navbar bell)
|
||||
// ===========================================================================
|
||||
//
|
||||
// These routes are consumed by web-nodejs/public/js/notif-center.js. They use
|
||||
// session-based auth (panel cookies) rather than Bearer tokens because the
|
||||
// dropdown is part of the web UI, not the desktop client.
|
||||
//
|
||||
// Storage model: notifications are a per-user read-status overlay over
|
||||
// helpRequests. The overlay (readByUser) lives in-memory and is cleaned up
|
||||
// opportunistically. On restart, all help-requests appear unread again — a
|
||||
// deliberate trade-off: persistence can be added later via DB if needed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
|
||||
/** @type {Map<string, Set<string>>} userId → Set<helpRequestId> marked read. */
|
||||
const readByUser = new Map();
|
||||
|
||||
function isReadBy(userId, notifId) {
|
||||
const set = readByUser.get(String(userId));
|
||||
return !!(set && set.has(String(notifId)));
|
||||
}
|
||||
|
||||
function markReadBy(userId, notifId) {
|
||||
const key = String(userId);
|
||||
let set = readByUser.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
readByUser.set(key, set);
|
||||
}
|
||||
set.add(String(notifId));
|
||||
// Cap per-user set size to avoid unbounded growth.
|
||||
if (set.size > 500) {
|
||||
const arr = [...set];
|
||||
readByUser.set(key, new Set(arr.slice(-400)));
|
||||
}
|
||||
}
|
||||
|
||||
function helpRequestToNotif(req, userId) {
|
||||
return {
|
||||
id: req.id,
|
||||
title: req.hostname || req.device_id,
|
||||
message: req.message || '',
|
||||
icon: 'support_agent',
|
||||
link: '/help-requests',
|
||||
read: isReadBy(userId, req.id),
|
||||
created_at: new Date(req.created_at).toISOString(),
|
||||
kind: 'help_request',
|
||||
status: req.status,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/bd/notifications — list recent notifications for current user
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/notifications', requireAuth, (req, res) => {
|
||||
try {
|
||||
const rawLimit = parseInt(req.query.limit, 10);
|
||||
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 50) : 20;
|
||||
const unreadOnly = String(req.query.unread_only || '').toLowerCase() === 'true';
|
||||
const userId = req.session?.user?.id;
|
||||
|
||||
const items = [...helpRequests.values()]
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
.map(r => helpRequestToNotif(r, userId))
|
||||
.filter(n => (unreadOnly ? !n.read : true))
|
||||
.slice(0, limit);
|
||||
|
||||
const unreadCount = [...helpRequests.values()]
|
||||
.filter(r => !isReadBy(userId, r.id)).length;
|
||||
|
||||
res.json({ success: true, items, unread_count: unreadCount });
|
||||
} catch (err) {
|
||||
console.error('[BD-API] List notifications error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to list notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/bd/notifications/:id/read — mark single notification read
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.post('/notifications/:id/read', requireAuth, (req, res) => {
|
||||
try {
|
||||
const userId = req.session?.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const id = String(req.params.id || '').slice(0, 128);
|
||||
if (!helpRequests.has(id)) {
|
||||
// Idempotent: succeed even if the item was already pruned. Client
|
||||
// only uses this to update its local badge state.
|
||||
return res.json({ success: true, pruned: true });
|
||||
}
|
||||
|
||||
markReadBy(userId, id);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[BD-API] Mark notification read error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to mark notification read' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/bd/notifications/read-all — mark all notifications read
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.post('/notifications/read-all', requireAuth, (req, res) => {
|
||||
try {
|
||||
const userId = req.session?.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
for (const id of helpRequests.keys()) {
|
||||
markReadBy(userId, id);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[BD-API] Mark all read error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to mark all notifications read' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/bd/operator/sessions — Record operator session start/end
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -486,4 +486,125 @@ router.delete('/api/devices/:id/access-policy', requireAuth, requirePermission('
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Phase 2 — Live agent introspection (services, processes, events, files,
|
||||
// terminal, screenshot, activity). All endpoints proxy a command request to
|
||||
// the agent over the signal WebSocket (bdRelay.requestFromDevice) and return
|
||||
// the agent's reply. If the agent is offline, 503 is returned.
|
||||
// ===========================================================================
|
||||
|
||||
const bdRelay = require('../services/bdRelay');
|
||||
|
||||
/**
|
||||
* Wrap an agent-proxy request with consistent error handling and timeout.
|
||||
* Distinct error codes help the UI render appropriate states.
|
||||
*/
|
||||
async function proxyAgentRequest(req, res, type, payload = null, timeoutMs = 15000) {
|
||||
try {
|
||||
const data = await bdRelay.requestFromDevice(req.params.id, type, payload, timeoutMs);
|
||||
res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
const msg = err && err.message ? err.message : 'agent_error';
|
||||
const status = msg === 'agent_offline' ? 503
|
||||
: msg === 'agent_timeout' ? 504
|
||||
: 502;
|
||||
res.status(status).json({ success: false, error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/devices/:id/services — live OS services list */
|
||||
router.get('/api/devices/:id/services', requireAuth, requirePermission('device.view'), (req, res) => {
|
||||
proxyAgentRequest(req, res, 'services.list');
|
||||
});
|
||||
|
||||
/** GET /api/devices/:id/processes — live process list */
|
||||
router.get('/api/devices/:id/processes', requireAuth, requirePermission('device.view'), (req, res) => {
|
||||
proxyAgentRequest(req, res, 'processes.list');
|
||||
});
|
||||
|
||||
/** GET /api/devices/:id/events?limit=100 — recent OS event log / journalctl */
|
||||
router.get('/api/devices/:id/events', requireAuth, requirePermission('device.view'), (req, res) => {
|
||||
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
|
||||
proxyAgentRequest(req, res, 'events.list', { limit });
|
||||
});
|
||||
|
||||
/** GET /api/devices/:id/activity — live activity tracker (app usage) */
|
||||
router.get('/api/devices/:id/activity', requireAuth, requirePermission('device.view'), (req, res) => {
|
||||
proxyAgentRequest(req, res, 'activity.get');
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/files/browse
|
||||
* Body: { path: '/some/folder', show_hidden: false }
|
||||
*/
|
||||
router.post('/api/devices/:id/files/browse', requireAuth, requirePermission('device.edit'), (req, res) => {
|
||||
const path = String(req.body?.path || '').slice(0, 4096);
|
||||
const showHidden = req.body?.show_hidden === true;
|
||||
proxyAgentRequest(req, res, 'files.browse', { path, show_hidden: showHidden });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/files/read
|
||||
* Body: { path, offset, length }
|
||||
*/
|
||||
router.post('/api/devices/:id/files/read', requireAuth, requirePermission('device.edit'), (req, res) => {
|
||||
const path = String(req.body?.path || '').slice(0, 4096);
|
||||
const offset = Math.max(0, parseInt(req.body?.offset, 10) || 0);
|
||||
const length = Math.min(1024 * 1024, Math.max(0, parseInt(req.body?.length, 10) || 65536));
|
||||
proxyAgentRequest(req, res, 'files.read', { path, offset, length }, 30000);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/screenshot
|
||||
* Captures a JPEG snapshot from the agent. Returns base64 image.
|
||||
*/
|
||||
router.post('/api/devices/:id/screenshot', requireAuth, requirePermission('device.view'), (req, res) => {
|
||||
proxyAgentRequest(req, res, 'screenshot.capture', null, 20000);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/terminal/execute
|
||||
* Body: { command: 'ls -la /tmp' }
|
||||
* One-shot command execution (no PTY). For interactive terminal use the WS
|
||||
* endpoint (future Phase 4 integration).
|
||||
*/
|
||||
router.post('/api/devices/:id/terminal/execute', requireAuth, requirePermission('device.edit'), async (req, res) => {
|
||||
const command = String(req.body?.command || '').slice(0, 4096);
|
||||
if (!command.trim()) {
|
||||
return res.status(400).json({ success: false, error: 'command_required' });
|
||||
}
|
||||
// Audit this — terminal execution is a sensitive action.
|
||||
try {
|
||||
await db.logAction(req.session.userId, 'terminal.execute',
|
||||
`Terminal command on ${req.params.id}: ${command.substring(0, 200)}`,
|
||||
req.ip || null);
|
||||
} catch (_) { /* audit failure should not block the command */ }
|
||||
proxyAgentRequest(req, res, 'terminal.execute', { command }, 30000);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/rename
|
||||
* Body: { display_name: 'Accounting PC 3' }
|
||||
* Convenience alias for the display_name branch of PATCH /api/devices/:id.
|
||||
*/
|
||||
router.post('/api/devices/:id/rename', requireAuth, requirePermission('device.edit'), async (req, res) => {
|
||||
try {
|
||||
const displayName = String(req.body?.display_name || '').trim().slice(0, 200);
|
||||
if (!displayName) {
|
||||
return res.status(400).json({ success: false, error: 'display_name_required' });
|
||||
}
|
||||
const device = await serverBackend.getDeviceById(req.params.id);
|
||||
if (!device) {
|
||||
return res.status(404).json({ success: false, error: req.t('devices.not_found') });
|
||||
}
|
||||
const result = await serverBackend.updateDevice(req.params.id, { display_name: displayName });
|
||||
await db.logAction(req.session.userId, 'device.rename',
|
||||
`Device ${req.params.id} renamed to "${displayName}"`, req.ip || null);
|
||||
res.json({ success: true, data: { changes: result?.changes ?? 1, display_name: displayName } });
|
||||
} catch (err) {
|
||||
console.error('Rename device error:', err);
|
||||
res.status(500).json({ success: false, error: req.t('errors.server_error') });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -58,6 +58,7 @@ const resourceControlRoutes = lazyRoute('./resource-control.routes');
|
||||
const systemRoutes = lazyRoute('./system.routes');
|
||||
const cdapStudioRoutes = lazyRoute('./cdap-studio.routes');
|
||||
const permissionsRoutes = lazyRoute('./permissions.routes');
|
||||
const phase45Routes = lazyRoute('./phase4_5.routes');
|
||||
|
||||
/**
|
||||
* Middleware to require JSON Content-Type for POST/PATCH/PUT requests to API routes.
|
||||
@@ -144,5 +145,6 @@ router.use('/api/bd', resourceControlRoutes); // device-facing:
|
||||
router.use('/', systemRoutes); // admin-facing: /api/system/*, /api/logs/*, /api/database/*, /api/docker/*, /api/speed-test
|
||||
router.use('/', cdapStudioRoutes); // admin-facing: /cdap-studio, /api/cdap-studio/*
|
||||
router.use('/', permissionsRoutes); // admin-facing: /permissions, /api/panel/roles/*, /api/panel/role-permissions/*
|
||||
router.use('/', phase45Routes); // Phase 4/5: /api/users/me/profile, /api/agent-templates, /portal
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* BetterDesk Console — Phase 4/5 scaffolding routes
|
||||
*
|
||||
* Phase 4: operator identity profile + consent-popup metadata endpoint
|
||||
* Phase 5: agent templates (enrollment presets) + public downloads portal
|
||||
*
|
||||
* This module is intentionally minimal — it establishes DB shape and HTTP
|
||||
* contract so UI + agent-side work can proceed in parallel. The consent popup
|
||||
* itself lives in the agent client (Rust) and fetches `/api/bd/operator-info`
|
||||
* when a remote session is requested.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const crypto = require('crypto');
|
||||
const db = require('../services/database');
|
||||
const { requireAuth, requirePermission } = require('../middleware/auth');
|
||||
|
||||
// Shared auth.db handle used for agent_templates (kept alongside users)
|
||||
let _templatesReady = false;
|
||||
function ensureTemplatesTable() {
|
||||
if (_templatesReady) return;
|
||||
try {
|
||||
const { getAuthDb } = require('../services/database');
|
||||
// Best-effort: better-sqlite3 synchronous path.
|
||||
const auth = typeof getAuthDb === 'function' ? getAuthDb() : null;
|
||||
if (auth && typeof auth.exec === 'function') {
|
||||
auth.exec(`CREATE TABLE IF NOT EXISTS agent_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT DEFAULT '',
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
enrollment_token TEXT NOT NULL UNIQUE,
|
||||
created_by INTEGER,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[phase4_5] ensureTemplatesTable:', e.message);
|
||||
}
|
||||
_templatesReady = true;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Phase 4 — operator profile
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/users/me/profile — current operator's identity profile.
|
||||
*/
|
||||
router.get('/api/users/me/profile', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const uid = req.session.userId;
|
||||
const user = await db.getUserById(uid);
|
||||
if (!user) return res.status(404).json({ success: false, error: 'not_found' });
|
||||
res.json({
|
||||
success: true,
|
||||
profile: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
email: user.email || '',
|
||||
phone: user.phone || '',
|
||||
role_display: user.role_display || '',
|
||||
avatar_url: user.avatar_url || '',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/users/me/profile — update own identity fields.
|
||||
*/
|
||||
router.put('/api/users/me/profile', requireAuth, async (req, res) => {
|
||||
try {
|
||||
if (typeof db.updateUserProfile !== 'function') {
|
||||
return res.status(501).json({ success: false, error: 'not_implemented' });
|
||||
}
|
||||
const { first_name, last_name, email, phone, role_display, avatar_url } = req.body || {};
|
||||
|
||||
// Lightweight validation. Heavy validation (e.g. email regex) deferred
|
||||
// until we decide whether email must be unique / confirmed.
|
||||
if (email && String(email).length > 200) {
|
||||
return res.status(400).json({ success: false, error: 'email_too_long' });
|
||||
}
|
||||
|
||||
await db.updateUserProfile(req.session.userId, {
|
||||
first_name, last_name, email, phone, role_display, avatar_url,
|
||||
});
|
||||
try {
|
||||
await db.logAction(req.session.userId, 'profile_updated', 'Updated operator profile', req.ip);
|
||||
} catch (_) {}
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/bd/operator-info?session_id=... — consent-popup payload for agent.
|
||||
*
|
||||
* Agent calls this (with device JWT) right before showing the remote-session
|
||||
* consent popup so the end user sees *who* is asking to connect.
|
||||
*
|
||||
* NOTE: This is a stub — wiring to an actual session/ticket record requires
|
||||
* the session broker from Phase 4 proper. For now it resolves the operator
|
||||
* from the authenticated JWT's `user_id` claim.
|
||||
*/
|
||||
router.get('/api/bd/operator-info', async (req, res) => {
|
||||
try {
|
||||
const sessionId = String(req.query.session_id || '').slice(0, 128);
|
||||
// In real implementation, session_id would map to an in-progress
|
||||
// remote request and we'd lookup the requesting operator. For now we
|
||||
// require session auth and return the current user's profile.
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ success: false, error: 'unauthorized' });
|
||||
}
|
||||
const user = await db.getUserById(req.session.userId);
|
||||
if (!user) return res.status(404).json({ success: false, error: 'not_found' });
|
||||
res.json({
|
||||
success: true,
|
||||
session_id: sessionId,
|
||||
operator: {
|
||||
display_name: [user.first_name, user.last_name].filter(Boolean).join(' ') || user.username,
|
||||
username: user.username,
|
||||
role_display: user.role_display || user.role,
|
||||
email: user.email || '',
|
||||
phone: user.phone || '',
|
||||
avatar_url: user.avatar_url || '',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Phase 5 — agent templates + enrollment + downloads portal
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/agent-templates — list templates (admin/operator).
|
||||
*/
|
||||
router.get('/api/agent-templates', requireAuth, requirePermission('enrollment.manage'), async (req, res) => {
|
||||
try {
|
||||
ensureTemplatesTable();
|
||||
const rows = await db.query
|
||||
? await db.query('SELECT id, name, description, enrollment_token, created_at, updated_at FROM agent_templates ORDER BY id DESC')
|
||||
: [];
|
||||
res.json({ success: true, templates: rows });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, error: e.message, templates: [] });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/agent-templates — create a new template.
|
||||
* Body: { name, description, config }
|
||||
*/
|
||||
router.post('/api/agent-templates', requireAuth, requirePermission('enrollment.manage'), async (req, res) => {
|
||||
try {
|
||||
ensureTemplatesTable();
|
||||
const name = String(req.body?.name || '').trim().slice(0, 100);
|
||||
if (!name) return res.status(400).json({ success: false, error: 'name_required' });
|
||||
const description = String(req.body?.description || '').slice(0, 500);
|
||||
const config = req.body?.config && typeof req.body.config === 'object' ? req.body.config : {};
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
|
||||
if (!db.run) {
|
||||
return res.status(501).json({ success: false, error: 'db_backend_missing_run' });
|
||||
}
|
||||
await db.run(
|
||||
`INSERT INTO agent_templates (name, description, config_json, enrollment_token, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[name, description, JSON.stringify(config), token, req.session.userId || null]
|
||||
);
|
||||
try { await db.logAction(req.session.userId, 'template_created', `Created agent template: ${name}`, req.ip); } catch (_) {}
|
||||
res.status(201).json({ success: true, enrollment_token: token });
|
||||
} catch (e) {
|
||||
if (String(e.message).includes('UNIQUE')) {
|
||||
return res.status(409).json({ success: false, error: 'name_exists' });
|
||||
}
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/agent-templates/:id
|
||||
*/
|
||||
router.delete('/api/agent-templates/:id', requireAuth, requirePermission('enrollment.manage'), async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
return res.status(400).json({ success: false, error: 'invalid_id' });
|
||||
}
|
||||
if (!db.run) return res.status(501).json({ success: false, error: 'db_backend_missing_run' });
|
||||
await db.run('DELETE FROM agent_templates WHERE id = ?', [id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/bd/enroll — agent enrollment using a template token.
|
||||
* Body: { enrollment_token, device_id, device_name, sysinfo }
|
||||
*
|
||||
* This endpoint is exposed publicly (no auth) — security comes from the
|
||||
* one-use enrollment_token in the template. Once enrolled the agent obtains
|
||||
* a normal device JWT via the existing /api/bd/register flow.
|
||||
*/
|
||||
router.post('/api/bd/enroll', async (req, res) => {
|
||||
try {
|
||||
ensureTemplatesTable();
|
||||
const token = String(req.body?.enrollment_token || '').slice(0, 64);
|
||||
const deviceId = String(req.body?.device_id || '').slice(0, 64);
|
||||
if (!token || !deviceId) {
|
||||
return res.status(400).json({ success: false, error: 'missing_fields' });
|
||||
}
|
||||
const row = db.get
|
||||
? await db.get('SELECT id, name, config_json FROM agent_templates WHERE enrollment_token = ?', [token])
|
||||
: null;
|
||||
if (!row) return res.status(401).json({ success: false, error: 'invalid_token' });
|
||||
|
||||
let config = {};
|
||||
try { config = JSON.parse(row.config_json || '{}'); } catch (_) {}
|
||||
|
||||
// Audit trail. Actual device creation is handled by agent's subsequent
|
||||
// /api/bd/register call which uses the returned `preset_config` to seed
|
||||
// capabilities, tags, group membership, etc.
|
||||
try { await db.logAction(null, 'device_enrolled', `Device ${deviceId} enrolled via template "${row.name}"`, req.ip); } catch (_) {}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
template_name: row.name,
|
||||
preset_config: config,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /portal — branded public download page.
|
||||
* No auth required. Lists available installers + enrollment instructions.
|
||||
*/
|
||||
router.get('/portal', (req, res) => {
|
||||
res.render('downloads-portal', {
|
||||
title: req.t ? req.t('portal.title') : 'Download BetterDesk',
|
||||
layout: false, // standalone page
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/portal/installers — list available installers (JSON).
|
||||
* The actual binaries are served from /downloads/ (static). This endpoint
|
||||
* just enumerates metadata.
|
||||
*/
|
||||
router.get('/api/portal/installers', (req, res) => {
|
||||
const base = req.protocol + '://' + req.get('host');
|
||||
res.json({
|
||||
success: true,
|
||||
installers: [
|
||||
{ platform: 'windows', arch: 'x64', url: `${base}/downloads/BetterDesk_Agent_x64-setup.exe`, format: 'nsis' },
|
||||
{ platform: 'linux', arch: 'x64', url: `${base}/downloads/betterdesk-agent-linux-amd64`, format: 'binary' },
|
||||
{ platform: 'linux', arch: 'arm64', url: `${base}/downloads/betterdesk-agent-linux-arm64`, format: 'binary' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -50,6 +50,11 @@ const onlineDevices = new Map();
|
||||
// IP → count
|
||||
const connectionsPerIp = new Map();
|
||||
|
||||
// requestId → { resolve, reject, timeout } for request/response pattern over signal WS
|
||||
const pendingRequests = new Map();
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15 * 1000; // 15s default
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -150,6 +155,42 @@ function getOnlineDeviceIds() {
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to an online agent and await its response over the signal WS.
|
||||
* The agent receives `{ type, request_id, payload }` and must reply with
|
||||
* `{ type: 'command_response', request_id, ok, data?, error? }`.
|
||||
*
|
||||
* @param {string} deviceId
|
||||
* @param {string} type e.g. 'services.list', 'files.browse'
|
||||
* @param {object} payload optional type-specific payload
|
||||
* @param {number} [timeoutMs=15000]
|
||||
* @returns {Promise<any>} resolves with agent's `data`, rejects on error/timeout
|
||||
*/
|
||||
function requestFromDevice(deviceId, type, payload = null, timeoutMs = REQUEST_TIMEOUT_MS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = onlineDevices.get(deviceId);
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
return reject(new Error('agent_offline'));
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
const timeout = setTimeout(() => {
|
||||
pendingRequests.delete(requestId);
|
||||
reject(new Error('agent_timeout'));
|
||||
}, Math.max(1000, Math.min(60000, timeoutMs)));
|
||||
|
||||
pendingRequests.set(requestId, { resolve, reject, timeout });
|
||||
|
||||
try {
|
||||
ws.send(JSON.stringify({ type, request_id: requestId, payload }));
|
||||
} catch (err) {
|
||||
pendingRequests.delete(requestId);
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket server initialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -442,6 +483,19 @@ function handleSignalMessage(deviceId, ws, msg) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'command_response': {
|
||||
// Agent responding to a requestFromDevice() call.
|
||||
// msg = { type, request_id, ok, data?, error? }
|
||||
const pending = pendingRequests.get(msg.request_id);
|
||||
if (pending) {
|
||||
pendingRequests.delete(msg.request_id);
|
||||
clearTimeout(pending.timeout);
|
||||
if (msg.ok) pending.resolve(msg.data || null);
|
||||
else pending.reject(new Error(msg.error || 'agent_error'));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -479,6 +533,7 @@ module.exports = {
|
||||
notifyTarget,
|
||||
isDeviceOnline,
|
||||
getOnlineDeviceIds,
|
||||
requestFromDevice,
|
||||
activeSessions, // exposed for monitoring/admin API
|
||||
onlineDevices, // exposed for status sync
|
||||
};
|
||||
|
||||
@@ -429,6 +429,13 @@ function createSqliteAdapter(config) {
|
||||
{ name: 'totp_secret', sql: 'TEXT DEFAULT NULL' },
|
||||
{ name: 'totp_enabled', sql: 'INTEGER DEFAULT 0' },
|
||||
{ name: 'totp_recovery_codes', sql: 'TEXT DEFAULT NULL' },
|
||||
// Phase 4: operator identity profile (shown to end-user on consent popup)
|
||||
{ name: 'first_name', sql: "TEXT DEFAULT ''" },
|
||||
{ name: 'last_name', sql: "TEXT DEFAULT ''" },
|
||||
{ name: 'email', sql: "TEXT DEFAULT ''" },
|
||||
{ name: 'phone', sql: "TEXT DEFAULT ''" },
|
||||
{ name: 'role_display', sql: "TEXT DEFAULT ''" },
|
||||
{ name: 'avatar_url', sql: "TEXT DEFAULT ''" },
|
||||
];
|
||||
try {
|
||||
const existingUserCols = new Set(db.prepare('PRAGMA table_info(users)').all().map(c => c.name));
|
||||
@@ -1096,6 +1103,21 @@ function createSqliteAdapter(config) {
|
||||
async updateUserRole(id, role) {
|
||||
openAuth().prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
|
||||
},
|
||||
// Phase 4: update operator identity profile (first_name, last_name, email, phone, role_display, avatar_url)
|
||||
async updateUserProfile(id, fields) {
|
||||
const allowed = ['first_name', 'last_name', 'email', 'phone', 'role_display', 'avatar_url'];
|
||||
const sets = [];
|
||||
const values = [];
|
||||
for (const k of allowed) {
|
||||
if (fields && Object.prototype.hasOwnProperty.call(fields, k)) {
|
||||
sets.push(`${k} = ?`);
|
||||
values.push(String(fields[k] == null ? '' : fields[k]).slice(0, 200));
|
||||
}
|
||||
}
|
||||
if (!sets.length) return;
|
||||
values.push(id);
|
||||
openAuth().prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...values);
|
||||
},
|
||||
async deleteUser(id) {
|
||||
openAuth().prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
},
|
||||
@@ -3099,6 +3121,21 @@ function createPostgresAdapter() {
|
||||
await q('ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_recovery_codes TEXT DEFAULT NULL');
|
||||
}
|
||||
|
||||
// Phase 4: operator identity profile columns
|
||||
const identityCols = [
|
||||
['first_name', "TEXT DEFAULT ''"],
|
||||
['last_name', "TEXT DEFAULT ''"],
|
||||
['email', "TEXT DEFAULT ''"],
|
||||
['phone', "TEXT DEFAULT ''"],
|
||||
['role_display', "TEXT DEFAULT ''"],
|
||||
['avatar_url', "TEXT DEFAULT ''"],
|
||||
];
|
||||
for (const [col, def] of identityCols) {
|
||||
if (!existingCols.has(col)) {
|
||||
await q(`ALTER TABLE users ADD COLUMN IF NOT EXISTS ${col} ${def}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: Add updated_at to settings table if missing (for upgrades from older versions)
|
||||
try {
|
||||
const settingsColCheck = await all(`SELECT column_name FROM information_schema.columns WHERE table_name = 'settings'`);
|
||||
@@ -3387,6 +3424,21 @@ function createPostgresAdapter() {
|
||||
async hasUsers() { return +(await one('SELECT COUNT(*) as c FROM users')).c > 0; },
|
||||
async getAllUsers() { return all('SELECT id, username, role, created_at, last_login, totp_enabled FROM users ORDER BY id'); },
|
||||
async updateUserRole(id, role) { await q('UPDATE users SET role = $1 WHERE id = $2', [role, id]); },
|
||||
async updateUserProfile(id, fields) {
|
||||
const allowed = ['first_name', 'last_name', 'email', 'phone', 'role_display', 'avatar_url'];
|
||||
const sets = [];
|
||||
const values = [];
|
||||
let idx = 1;
|
||||
for (const k of allowed) {
|
||||
if (fields && Object.prototype.hasOwnProperty.call(fields, k)) {
|
||||
sets.push(`${k} = $${idx++}`);
|
||||
values.push(String(fields[k] == null ? '' : fields[k]).slice(0, 200));
|
||||
}
|
||||
}
|
||||
if (!sets.length) return;
|
||||
values.push(id);
|
||||
await q(`UPDATE users SET ${sets.join(', ')} WHERE id = $${idx}`, values);
|
||||
},
|
||||
async deleteUser(id) { await q('DELETE FROM users WHERE id = $1', [id]); },
|
||||
async countAdmins() { return +(await one("SELECT COUNT(*) as c FROM users WHERE role IN ('admin', 'super_admin')")).c; },
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= title || 'Download BetterDesk Agent' %></title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #0b1020, #16213e);
|
||||
color: #e6edf3;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.portal-wrap {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
.portal-header {
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
.portal-header h1 { font-size: 40px; margin-bottom: 12px; }
|
||||
.portal-header p { color: #8b949e; font-size: 17px; }
|
||||
.portal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.portal-card {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
.portal-card h3 { font-size: 20px; margin-bottom: 8px; }
|
||||
.portal-card .arch { color: #8b949e; font-size: 13px; margin-bottom: 20px; }
|
||||
.portal-btn {
|
||||
display: inline-block;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.portal-btn:hover { background: #2563eb; }
|
||||
.portal-footer {
|
||||
margin-top: 60px;
|
||||
text-align: center;
|
||||
color: #8b949e;
|
||||
font-size: 13px;
|
||||
}
|
||||
.portal-empty {
|
||||
color: #8b949e;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="portal-wrap">
|
||||
<div class="portal-header">
|
||||
<h1>Download BetterDesk Agent</h1>
|
||||
<p>Install the lightweight agent on your device to enable remote support.</p>
|
||||
</div>
|
||||
<div class="portal-grid" id="installer-grid">
|
||||
<div class="portal-empty">Loading available installers…</div>
|
||||
</div>
|
||||
<div class="portal-footer">
|
||||
Need help? Contact your administrator for enrollment instructions.
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
fetch('/api/portal/installers', { credentials: 'omit' })
|
||||
.then(r => r.json())
|
||||
.then(function (data) {
|
||||
var grid = document.getElementById('installer-grid');
|
||||
if (!data || !data.success || !Array.isArray(data.installers) || data.installers.length === 0) {
|
||||
grid.innerHTML = '<div class="portal-empty">No installers available yet.</div>';
|
||||
return;
|
||||
}
|
||||
var labels = { windows: 'Windows', linux: 'Linux', macos: 'macOS' };
|
||||
grid.innerHTML = data.installers.map(function (i) {
|
||||
var label = labels[i.platform] || i.platform;
|
||||
return '<div class="portal-card">'
|
||||
+ '<h3>' + label + '</h3>'
|
||||
+ '<div class="arch">' + (i.arch || '') + ' · ' + (i.format || '') + '</div>'
|
||||
+ '<a class="portal-btn" href="' + i.url + '">Download</a>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
})
|
||||
.catch(function () {
|
||||
document.getElementById('installer-grid').innerHTML =
|
||||
'<div class="portal-empty">Failed to load installers.</div>';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -14,6 +14,7 @@
|
||||
<link rel="stylesheet" href="/css/main.css?v=<%= cacheVersion %>">
|
||||
<link rel="stylesheet" href="/css/theme.css?v=<%= cacheVersion %>">
|
||||
<link rel="stylesheet" href="/css/transitions.css?v=<%= cacheVersion %>">
|
||||
<link rel="stylesheet" href="/css/notif-center.css?v=<%= cacheVersion %>">
|
||||
<% if (!embed) { %>
|
||||
<link rel="stylesheet" href="/css/desktop-mode.css?v=<%= cacheVersion %>">
|
||||
<link rel="stylesheet" href="/css/desktop-widgets.css?v=<%= cacheVersion %>">
|
||||
@@ -140,6 +141,7 @@
|
||||
<script src="/js/utils.js?v=<%= cacheVersion %>"></script>
|
||||
<script src="/js/i18n-client.js?v=<%= cacheVersion %>"></script>
|
||||
<script src="/js/notifications.js?v=<%= cacheVersion %>"></script>
|
||||
<script src="/js/notif-center.js?v=<%= cacheVersion %>"></script>
|
||||
<script src="/js/modal.js?v=<%= cacheVersion %>"></script>
|
||||
<script src="/js/app.js?v=<%= cacheVersion %>"></script>
|
||||
<% if (!embed) { %>
|
||||
|
||||
@@ -29,7 +29,27 @@
|
||||
<button class="navbar-btn" id="refresh-btn" title="<%= _('actions.refresh') %>">
|
||||
<span class="material-icons">refresh</span>
|
||||
</button>
|
||||
|
||||
|
||||
<!-- Notification bell -->
|
||||
<div class="notif-wrapper">
|
||||
<button class="navbar-btn notif-btn" id="notif-btn" title="<%= _('notifications.title') %>" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="material-icons">notifications</span>
|
||||
<span class="notif-badge" id="notif-badge" hidden>0</span>
|
||||
</button>
|
||||
<div class="notif-dropdown" id="notif-dropdown" role="menu" hidden>
|
||||
<div class="notif-header">
|
||||
<span><%= _('notifications.title') %></span>
|
||||
<button type="button" class="notif-mark-all" id="notif-mark-all"><%= _('notifications.mark_all_read') %></button>
|
||||
</div>
|
||||
<div class="notif-list" id="notif-list" aria-live="polite">
|
||||
<div class="notif-empty"><%= _('notifications.empty') %></div>
|
||||
</div>
|
||||
<div class="notif-footer">
|
||||
<a href="/help-requests" class="notif-see-all"><%= _('notifications.see_all') %></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop mode toggle (Beta) - visible only on large screens -->
|
||||
<button class="navbar-btn desktop-toggle-btn" id="desktop-toggle-btn" title="<%= _('desktop.switch_mode') %> (Beta)">
|
||||
<span class="material-icons">desktop_windows</span>
|
||||
|
||||
Reference in New Issue
Block a user