feat(server-management): add server management service and terminal proxy

- Implemented server management service providing resource snapshots, file browser, service control, and audit logging.
- Added terminal proxy for WebSocket-backed PTY, allowing browser-based shell access with user authentication and role-based access control.
- Created server management view with tabs for overview, terminal, file management, and services, including UI elements for displaying system metrics and managing files/services.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
UNITRONIX
2026-05-07 03:09:03 +02:00
parent f6d553e4b9
commit a8d3259d43
63 changed files with 5915 additions and 207 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ tauri-plugin-notification = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "time", "macros", "process", "io-util"] }
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "time", "macros", "process", "io-util", "signal"] }
log = "0.4"
env_logger = "0.11"
anyhow = "1"
@@ -22,13 +22,19 @@ pub fn sync_os_autostart(app: &AppHandle, enabled: bool) {
let manager = app.autolaunch();
match manager.is_enabled() {
Ok(current) if current == enabled => {
Ok(current) if current == enabled && !enabled => {
info!(
"[autostart] Already {} — no change",
if enabled { "enabled" } else { "disabled" }
);
return;
}
Ok(current) if current == enabled && enabled => {
info!("[autostart] Refreshing enabled OS registration");
if let Err(e) = manager.disable() {
warn!("[autostart] Failed to refresh registration: {}", e);
}
}
Ok(_) => {}
Err(e) => {
warn!("[autostart] Failed to query state: {}", e);
@@ -1,6 +1,7 @@
use crate::cdap_client::{CdapClient, CdapStatus};
use crate::cdap_client::CdapClient;
use crate::config::AgentConfig;
use crate::registration;
use crate::sidecar::{SidecarConfig, SidecarStatus};
use crate::sysinfo_collect::SystemSnapshot;
use log::info;
use serde::{Deserialize, Serialize};
@@ -12,8 +13,14 @@ use tauri::Manager;
pub struct AgentState {
pub config: Mutex<AgentConfig>,
pub chat_history: Mutex<Vec<ChatMessage>>,
/// Native CDAP WebSocket client (replaces Go sidecar).
/// Native CDAP WebSocket client kept for the lightweight telemetry path.
/// The managed Go sidecar below is the active runtime for full remote
/// desktop parity.
pub cdap: CdapClient,
/// Managed Go agent sidecar. This is the active runtime for remote desktop
/// parity because it contains desktop streaming, input, monitor, and
/// consent handlers that the native Rust CDAP client does not yet provide.
pub sidecar: crate::sidecar::SidecarManager,
}
/// Chat message structure.
@@ -86,8 +93,9 @@ pub fn is_os_admin() -> bool {
/// Called from the overflow menu "Close agent" button or the quit dialog.
#[tauri::command]
pub fn quit_app(app: tauri::AppHandle) {
// Stop the sidecar gracefully before exit.
// Stop background runtimes gracefully before exit.
let state = app.state::<AgentState>();
state.sidecar.stop();
state.cdap.stop();
app.exit(0);
}
@@ -665,7 +673,7 @@ pub async fn discover_lan_servers() -> Result<Vec<DiscoveredLanServer>, String>
// ─────────────────────────── CDAP client control ───────────────────────────
async fn build_cdap_config(state: &AgentState) -> Result<crate::cdap_client::CdapConfig, String> {
async fn build_sidecar_config(state: &AgentState) -> Result<SidecarConfig, String> {
let mut config = {
let config = state.config.lock().map_err(|e| e.to_string())?;
if !config.is_registered() {
@@ -682,57 +690,81 @@ async fn build_cdap_config(state: &AgentState) -> Result<crate::cdap_client::Cda
shared.server_address = config.server_address.clone();
}
Ok(config.to_cdap_config())
Ok(config.to_sidecar_config())
}
/// Returns the current status of the native CDAP client.
/// Returns the current status of the managed Go CDAP sidecar.
#[tauri::command]
pub fn get_sidecar_status(state: State<'_, AgentState>) -> CdapStatus {
state.cdap.status()
pub fn get_sidecar_status(state: State<'_, AgentState>) -> SidecarStatus {
state.sidecar.status()
}
/// Start or restart the native CDAP client.
/// Start or restart the managed Go CDAP sidecar.
#[tauri::command]
pub async fn start_sidecar(_app: tauri::AppHandle, state: State<'_, AgentState>) -> Result<CdapStatus, String> {
let cdap_cfg = build_cdap_config(&state).await?;
state.cdap.stop();
state.cdap.start(&cdap_cfg).map_err(|e| e.to_string())?;
info!("CDAP client started via IPC command");
Ok(state.cdap.status())
pub async fn start_sidecar(
app: tauri::AppHandle,
state: State<'_, AgentState>,
) -> Result<SidecarStatus, String> {
if !crate::privileges::is_os_admin() {
return Err("Administrator privileges are required to control the CDAP agent".to_string());
}
let sidecar_cfg = build_sidecar_config(&state).await?;
state.sidecar.stop();
state
.sidecar
.start(&sidecar_cfg, app)
.map_err(|e| e.to_string())?;
info!("CDAP sidecar started via IPC command");
Ok(state.sidecar.status())
}
/// Stop the CDAP client.
#[tauri::command]
pub fn stop_sidecar(state: State<'_, AgentState>) -> CdapStatus {
state.cdap.stop();
info!("CDAP client stopped via IPC command");
state.cdap.status()
pub fn stop_sidecar(state: State<'_, AgentState>) -> Result<SidecarStatus, String> {
if !crate::privileges::is_os_admin() {
return Err("Administrator privileges are required to stop the CDAP agent".to_string());
}
state.sidecar.stop();
info!("CDAP sidecar stopped via IPC command");
Ok(state.sidecar.status())
}
/// Restart the CDAP client (re-reads current config).
#[tauri::command]
pub async fn restart_sidecar(app: tauri::AppHandle, state: State<'_, AgentState>) -> Result<CdapStatus, String> {
pub async fn restart_sidecar(
app: tauri::AppHandle,
state: State<'_, AgentState>,
) -> Result<SidecarStatus, String> {
start_sidecar(app, state).await
}
/// Legacy command — redirects to CDAP restart.
#[tauri::command]
pub async fn restart_agent_service(app: tauri::AppHandle, state: State<'_, AgentState>) -> Result<(), String> {
pub async fn restart_agent_service(
app: tauri::AppHandle,
state: State<'_, AgentState>,
) -> Result<(), String> {
start_sidecar(app, state).await.map(|_| ())
}
/// No-op — consent is now handled natively inside cdap_client.rs.
/// Forward a supervised-session consent response to the Go sidecar.
#[tauri::command]
pub fn answer_consent(
_state: State<'_, AgentState>,
_session_id: String,
_granted: bool,
state: State<'_, AgentState>,
session_id: String,
granted: bool,
) -> Result<(), String> {
state.sidecar.send_consent(&session_id, granted);
Ok(())
}
#[tauri::command]
pub fn unregister_device(state: State<'_, AgentState>) -> Result<(), String> {
state.sidecar.stop();
state.cdap.stop();
let mut config = state.config.lock().map_err(|e| e.to_string())?;
let old_id = config.device_id.clone();
@@ -207,10 +207,26 @@ impl AgentConfig {
}
}
/// Build a `SidecarConfig` — kept for backward compatibility, delegates to CdapConfig.
#[deprecated(note = "Use to_cdap_config() — sidecar is replaced by native CDAP client")]
pub fn to_sidecar_config(&self) -> crate::cdap_client::CdapConfig {
self.to_cdap_config()
/// Build a `SidecarConfig` for the bundled Go agent runtime.
pub fn to_sidecar_config(&self) -> crate::sidecar::SidecarConfig {
let data_dir = directories::ProjectDirs::from("com", "betterdesk", "agent")
.map(|d| d.data_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
crate::sidecar::SidecarConfig {
server_address: self.server_address.clone(),
device_id: self.device_id.clone(),
device_name: self.device_name.clone(),
api_key: self.api_key.clone(),
auth_token: self.auth_token.clone(),
allow_terminal: self.allow_terminal,
allow_file_browser: self.allow_file_browser,
allow_clipboard: self.allow_clipboard,
allow_screen_capture: self.allow_screen_capture,
require_consent: self.require_consent,
data_dir,
cdap_port: self.cdap_port,
}
}
/// Store credentials securely via OS keyring.
+123 -28
View File
@@ -14,6 +14,7 @@ pub mod commands;
pub mod config;
pub mod privileges;
pub mod registration;
pub mod sidecar;
pub mod sysinfo_collect;
use log::info;
@@ -28,9 +29,9 @@ use tauri::Manager;
#[allow(dead_code)]
struct TrayState(tauri::tray::TrayIcon<tauri::Wry>);
async fn resolve_cdap_config_from_state(
async fn resolve_config_from_state(
app: &tauri::AppHandle,
) -> Option<cdap_client::CdapConfig> {
) -> Option<config::AgentConfig> {
let mut config = {
let state = app.try_state::<commands::AgentState>()?;
let guard = state.config.lock().ok()?;
@@ -48,7 +49,7 @@ async fn resolve_cdap_config_from_state(
}
}
Some(config.to_cdap_config())
Some(config)
}
/// Spawn a background task that sends `POST /api/heartbeat` every 12 seconds.
@@ -169,6 +170,86 @@ fn push_sysinfo_refresh(app: &tauri::AppHandle) {
});
}
fn notify_agent_ready(app: &tauri::AppHandle) {
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
let (registered, cdap_running, language) = app_handle
.try_state::<commands::AgentState>()
.map(|state| {
let (registered, language) = state
.config
.lock()
.map(|config| (config.is_registered(), config.language.clone()))
.unwrap_or((false, "en".to_string()));
(registered, state.sidecar.is_running(), language)
})
.unwrap_or((false, false, "en".to_string()));
if !registered {
return;
}
let body = match (language.as_str(), cdap_running) {
("pl", true) => "Agent działa w tle, a CDAP jest połączony.",
("pl", false) => "Agent działa w tle. CDAP połączy się ponownie automatycznie.",
("zh" | "zh-TW", true) => "代理正在背景執行,CDAP 已連線。",
("zh" | "zh-TW", false) => "代理正在背景執行。CDAP 會自動重新連線。",
(_, true) => "Agent is running in the background and CDAP is connected.",
(_, false) => "Agent is running in the background. CDAP will reconnect automatically.",
};
use tauri_plugin_notification::NotificationExt;
if let Err(e) = app_handle
.notification()
.builder()
.title("BetterDesk Agent")
.body(body)
.show()
{
log::debug!("Startup notification skipped: {}", e);
}
});
}
#[cfg(unix)]
fn install_shutdown_signal_handlers(app: &tauri::AppHandle) {
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
use tokio::signal::unix::{signal, SignalKind};
let mut sigterm = match signal(SignalKind::terminate()) {
Ok(signal) => signal,
Err(e) => {
log::warn!("Could not install SIGTERM handler: {}", e);
return;
}
};
let mut sigint = match signal(SignalKind::interrupt()) {
Ok(signal) => signal,
Err(e) => {
log::warn!("Could not install SIGINT handler: {}", e);
return;
}
};
tokio::select! {
_ = sigterm.recv() => log::info!("SIGTERM received — exiting for system shutdown/restart"),
_ = sigint.recv() => log::info!("SIGINT received — exiting"),
}
if let Some(state) = app_handle.try_state::<commands::AgentState>() {
state.sidecar.stop();
state.cdap.stop();
}
app_handle.exit(0);
});
}
#[cfg(not(unix))]
fn install_shutdown_signal_handlers(_app: &tauri::AppHandle) {}
/// Entry point — called from main.rs.
pub fn run() {
// WebKitGTK Wayland workaround: prevent Gdk "Error 71 (Protocol error)
@@ -209,7 +290,7 @@ pub fn run() {
);
let cdap_client = cdap_client::CdapClient::new();
let cdap_client_clone = cdap_client.clone();
let sidecar_manager = sidecar::SidecarManager::new();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
@@ -229,6 +310,7 @@ pub fn run() {
config: Mutex::new(settings),
chat_history: Mutex::new(Vec::new()),
cdap: cdap_client,
sidecar: sidecar_manager,
})
.invoke_handler(tauri::generate_handler![
// Status & lifecycle
@@ -280,6 +362,8 @@ pub fn run() {
let tray = setup_tray(app.handle())?;
app.manage(TrayState(tray));
install_shutdown_signal_handlers(app.handle());
let is_autostart = std::env::args().any(|a| a == "--autostart");
// On first run (device not registered yet): show the window so the
@@ -287,7 +371,9 @@ pub fn run() {
// click the tray icon.
if !is_registered && !is_autostart {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
@@ -298,19 +384,22 @@ pub fn run() {
}
}
// Auto-start native CDAP client if device is registered and setting is on.
// Auto-start the managed Go CDAP sidecar if device is registered and setting is on.
if auto_start {
let app_handle = app.handle().clone();
let cdap = cdap_client_clone.clone();
tauri::async_runtime::spawn(async move {
info!("[cdap] Auto-starting native CDAP client...");
let Some(cdap_cfg) = resolve_cdap_config_from_state(&app_handle).await else {
info!("[cdap] Auto-start skipped: device not registered");
info!("[sidecar] Auto-starting Go CDAP agent...");
let Some(mut config) = resolve_config_from_state(&app_handle).await else {
info!("[sidecar] Auto-start skipped: device not registered");
return;
};
if let Err(e) = cdap.start(&cdap_cfg) {
log::warn!("[cdap] Auto-start failed: {}", e);
registration::normalize_server_origin_best_effort(&mut config).await;
let sidecar_cfg = config.to_sidecar_config();
if let Some(state) = app_handle.try_state::<commands::AgentState>() {
if let Err(e) = state.sidecar.start(&sidecar_cfg, app_handle.clone()) {
log::warn!("[sidecar] Auto-start failed: {}", e);
}
}
});
}
@@ -326,6 +415,8 @@ pub fn run() {
push_sysinfo_refresh(app.handle());
}
notify_agent_ready(app.handle());
// Start the bd-signal WS client — answers operator-initiated
// introspection requests (services, processes, files, screenshot,
// terminal). Idempotent: silently no-ops until registered.
@@ -341,12 +432,9 @@ pub fn run() {
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
use tauri::Emitter;
api.prevent_close();
info!("Close requested from window chrome");
let _ = window.set_focus();
let _ = window.app_handle().emit("request-quit", ());
info!("Close requested from window chrome — hiding to tray");
let _ = window.hide();
}
})
.run(tauri::generate_context!())
@@ -377,7 +465,8 @@ fn setup_tray(
let chat = MenuItemBuilder::with_id("chat", "Chat").build(app)?;
let check = MenuItemBuilder::with_id("check_conn", "Check connection").build(app)?;
// Sidecar control (visible to all — non-admin gets informational deny).
// CDAP control is admin-only; regular users must not be able to interrupt
// the background connection from the tray menu.
let sep1 = PredefinedMenuItem::separator(app)?;
let sidecar_toggle = MenuItemBuilder::with_id("sidecar_toggle", "Restart CDAP agent").build(app)?;
@@ -386,13 +475,17 @@ fn setup_tray(
let settings = MenuItemBuilder::with_id("settings", "Settings").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit agent").build(app)?;
let builder = MenuBuilder::new(app)
let mut builder = MenuBuilder::new(app)
.item(&show_id)
.item(&help)
.item(&chat)
.item(&check)
.item(&sep1)
.item(&sidecar_toggle)
.item(&check);
if is_admin {
builder = builder.item(&sep1).item(&sidecar_toggle);
}
let builder = builder
.item(&sep2)
.item(&settings)
.item(&quit);
@@ -423,20 +516,22 @@ fn setup_tray(
"chat" => show_window(app, "/chat"),
"check_conn" => show_window(app, "/?action=reconnect"),
"sidecar_toggle" => {
// Restart the native CDAP client on demand.
// Restart the managed Go CDAP sidecar on demand.
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
let Some(cdap_cfg) = resolve_cdap_config_from_state(&app_handle).await else {
info!("[tray] CDAP restart: device not registered");
let Some(mut config) = resolve_config_from_state(&app_handle).await else {
info!("[tray] CDAP sidecar restart: device not registered");
return;
};
registration::normalize_server_origin_best_effort(&mut config).await;
let sidecar_cfg = config.to_sidecar_config();
if let Some(state) = app_handle.try_state::<commands::AgentState>() {
state.cdap.stop();
if let Err(e) = state.cdap.start(&cdap_cfg) {
log::warn!("[tray] CDAP restart failed: {}", e);
state.sidecar.stop();
if let Err(e) = state.sidecar.start(&sidecar_cfg, app_handle.clone()) {
log::warn!("[tray] CDAP sidecar restart failed: {}", e);
} else {
info!("[tray] CDAP client restarted");
info!("[tray] CDAP sidecar restarted");
}
}
});
@@ -70,15 +70,15 @@ pub struct SidecarStatus {
/// Fields match `betterdesk-agent/agent/config.go`.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct GoAgentConfig {
server: String, // ws://host:21122/cdap
auth_method: String, // api_key | device_token | user_password
server: String, // ws://host:21122/cdap
auth_method: String, // api_key | device_token | user_password
#[serde(skip_serializing_if = "String::is_empty")]
api_key: String,
#[serde(skip_serializing_if = "String::is_empty")]
device_token: String,
device_id: String,
device_name: String,
device_type: String, // os_agent | desktop | custom
device_type: String, // os_agent | desktop | custom
#[serde(skip_serializing_if = "Vec::is_empty")]
tags: Vec<String>,
@@ -86,6 +86,7 @@ struct GoAgentConfig {
file_browser: bool,
clipboard: bool,
screenshot: bool,
require_consent: bool,
heartbeat_sec: u32,
reconnect_sec: u32,
@@ -108,6 +109,7 @@ pub struct SidecarConfig {
pub allow_file_browser: bool,
pub allow_clipboard: bool,
pub allow_screen_capture: bool,
pub require_consent: bool,
pub data_dir: PathBuf,
pub cdap_port: u16,
}
@@ -129,7 +131,15 @@ impl SidecarConfig {
} else {
host.to_string()
};
let ws_scheme = if parsed.scheme() == "https" { "wss" } else { "ws" };
// CDAP runs on its own gateway port. Do not inherit the HTTP API
// scheme: a server can expose HTTPS on 21114 while CDAP on 21122 is
// plain WS. Operators can explicitly opt into WSS for CDAP with
// BETTERDESK_CDAP_TLS=1 when the server is started with --tls-cdap.
let ws_scheme = if std::env::var("BETTERDESK_CDAP_TLS").as_deref() == Ok("1") {
"wss"
} else {
"ws"
};
format!("{}://{}:{}/cdap", ws_scheme, host_part, self.cdap_port)
} else {
format!("ws://{}:{}/cdap", addr, self.cdap_port)
@@ -160,6 +170,7 @@ struct Inner {
binary_path: Mutex<PathBuf>,
cdap_url: Mutex<String>,
config_path: Mutex<PathBuf>,
app_handle: Mutex<Option<tauri::AppHandle>>,
/// When set to true the monitor loop will not restart.
stop_requested: AtomicBool,
}
@@ -176,6 +187,7 @@ impl SidecarManager {
binary_path: Mutex::new(PathBuf::new()),
cdap_url: Mutex::new(String::new()),
config_path: Mutex::new(PathBuf::new()),
app_handle: Mutex::new(None),
stop_requested: AtomicBool::new(false),
}),
}
@@ -185,11 +197,12 @@ impl SidecarManager {
/// Write Go config and spawn the sidecar. Starts the monitor task.
/// Safe to call again after stop — creates a fresh process.
pub fn start(&self, cfg: &SidecarConfig) -> Result<()> {
pub fn start(&self, cfg: &SidecarConfig, app: tauri::AppHandle) -> Result<()> {
let inner = &self.inner;
// Abort any previous stop state.
inner.stop_requested.store(false, Ordering::SeqCst);
*inner.app_handle.lock().unwrap() = Some(app.clone());
// Locate the Go binary.
let binary = find_binary(&cfg.data_dir)?;
@@ -206,6 +219,7 @@ impl SidecarManager {
// Spawn the process.
self.spawn_process(&binary, &config_path)?;
self.start_stdout_reader(app);
// Start monitor task (async).
let manager = self.clone();
@@ -337,6 +351,7 @@ impl SidecarManager {
fn terminate_child(&self) {
let mut guard = self.inner.child.lock().unwrap();
self.inner.child_stdin.lock().unwrap().take();
if let Some(mut child) = guard.take() {
#[cfg(unix)]
{
@@ -421,6 +436,8 @@ impl SidecarManager {
if let Err(e) = self.spawn_process(binary, config_path) {
error!("[sidecar] Restart failed: {}", e);
} else if let Some(app) = self.inner.app_handle.lock().unwrap().clone() {
self.start_stdout_reader(app);
}
}
}
@@ -453,21 +470,29 @@ fn find_binary(data_dir: &PathBuf) -> Result<PathBuf> {
if p.is_file() {
return Ok(p);
}
warn!("[sidecar] BETTERDESK_AGENT_BIN set but file not found: {}", p.display());
warn!(
"[sidecar] BETTERDESK_AGENT_BIN set but file not found: {}",
p.display()
);
}
// 2. Same directory as the Tauri executable.
// 2. Same directory as the Tauri executable. Packaged Tauri externalBin
// files may include the target triple, while dev installs often use the
// plain name.
if let Ok(exe) = std::env::current_exe() {
let candidate = exe.parent().unwrap_or(&exe).join(bin_name);
if candidate.is_file() {
return Ok(candidate);
let exe_dir = exe.parent().unwrap_or(&exe);
for candidate in binary_candidates(exe_dir, bin_name) {
if candidate.is_file() {
return Ok(candidate);
}
}
}
// 3. App data directory (downloaded/extracted binary).
let candidate = data_dir.join(bin_name);
if candidate.is_file() {
return Ok(candidate);
for candidate in binary_candidates(data_dir, bin_name) {
if candidate.is_file() {
return Ok(candidate);
}
}
// 4. System PATH.
@@ -504,6 +529,35 @@ fn find_binary(data_dir: &PathBuf) -> Result<PathBuf> {
))
}
fn binary_candidates(dir: &std::path::Path, bin_name: &str) -> Vec<PathBuf> {
let mut out = vec![dir.join(bin_name)];
let prefix = if cfg!(windows) {
"betterdesk-agent-"
} else {
"betterdesk-agent-"
};
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let matches = if cfg!(windows) {
name.starts_with(prefix) && name.ends_with(".exe")
} else {
name.starts_with(prefix)
};
if matches {
out.push(path);
}
}
}
out
}
// ── Config writer ──────────────────────────────────────────────────────────
/// Write the Go-format JSON config file consumed by betterdesk-agent.
@@ -543,6 +597,7 @@ fn write_go_config(path: &PathBuf, cfg: &SidecarConfig) -> Result<()> {
file_browser: cfg.allow_file_browser,
clipboard: cfg.allow_clipboard,
screenshot: cfg.allow_screen_capture,
require_consent: cfg.require_consent,
heartbeat_sec: 15,
reconnect_sec: 5,
max_reconnect: 300,
@@ -18,8 +18,11 @@
"width": 480,
"height": 520,
"minWidth": 400,
"minHeight": 460,
"minHeight": 520,
"resizable": true,
"minimizable": true,
"maximizable": true,
"closable": true,
"fullscreen": false,
"center": true,
"decorations": true,
+21 -5
View File
@@ -71,9 +71,18 @@ const BottomNav: Component<BottomNavProps> = (props) => {
const isActive = (path: string) =>
path === "/" ? currentPath() === "/" : currentPath().startsWith(path);
// Close overflow menu on outside click
// Close overflow menu on outside click. Solid delegates click handlers at the
// document level, so the native listener must explicitly ignore clicks inside
// this overflow area instead of relying on stopPropagation timing.
onMount(() => {
const handler = () => setShowMenu(false);
const handler = (event: MouseEvent) => {
const target = event.target as Element | null;
if (target?.closest(".bottom-nav-overflow")) {
return;
}
setShowMenu(false);
};
const syncPath = () => setCurrentPath(getCurrentHashPath());
document.addEventListener("click", handler, { capture: true });
@@ -95,7 +104,7 @@ const BottomNav: Component<BottomNavProps> = (props) => {
{mainTabs.map((tab) => (
<button
class={`bottom-nav-item ${isActive(tab.path) ? "active" : ""}`}
onClick={() => navigateHashRoute(tab.path)}
onClick={() => { setShowMenu(false); navigateHashRoute(tab.path); }}
>
<span class="material-symbols-rounded">{tab.icon}</span>
<span class="bottom-nav-label">{tab.label()}</span>
@@ -105,7 +114,10 @@ const BottomNav: Component<BottomNavProps> = (props) => {
{/* Settings / overflow */}
<div class="bottom-nav-overflow" onClick={(e) => e.stopPropagation()}>
<button
class={`bottom-nav-item ${isActive("/settings") ? "active" : ""}`}
class={`bottom-nav-item ${showMenu() || isActive("/settings") ? "active" : ""}`}
type="button"
aria-haspopup="menu"
aria-expanded={showMenu()}
onClick={() => setShowMenu((v) => !v)}
>
<span class="material-symbols-rounded">more_vert</span>
@@ -113,10 +125,12 @@ const BottomNav: Component<BottomNavProps> = (props) => {
</button>
<Show when={showMenu()}>
<div class="overflow-menu">
<div class="overflow-menu" role="menu">
{/* Settings — always visible, SudoAuthDialog gates controls inside */}
<button
class="overflow-menu-item"
type="button"
role="menuitem"
onClick={() => { navigateHashRoute("/settings"); setShowMenu(false); }}
>
<span class="material-symbols-rounded">settings</span>
@@ -127,6 +141,8 @@ const BottomNav: Component<BottomNavProps> = (props) => {
{/* Close — always visible; non-admin gets sudo dialog */}
<button
class="overflow-menu-item overflow-menu-item-danger"
type="button"
role="menuitem"
onClick={() => { props.onQuit(); setShowMenu(false); }}
>
<span class="material-symbols-rounded">power_settings_new</span>
@@ -32,6 +32,7 @@ const StatusPanel: Component = () => {
const [diagFeedback, setDiagFeedback] = createSignal<"" | "ok" | "error">("");
const [sidecarAction, setSidecarAction] = createSignal<"" | "busy">("");
const [sidecarError, setSidecarError] = createSignal("");
const [isAdmin, setIsAdmin] = createSignal(false);
let initialSnapshotLogged = false;
let pollInterval: ReturnType<typeof setInterval>;
@@ -67,6 +68,9 @@ const StatusPanel: Component = () => {
onMount(() => {
frontendLog("debug", "status", "Status panel mounted");
invoke<boolean>("is_os_admin")
.then(setIsAdmin)
.catch((error) => frontendLog("warn", "status", "is_os_admin failed", error));
fetchStatus();
pollInterval = setInterval(fetchStatus, 5000);
});
@@ -266,37 +270,42 @@ const StatusPanel: Component = () => {
</div>
)}
<div class="sidecar-actions">
{!sidecar()?.running ? (
<button
class="btn btn-primary btn-sm"
onClick={startSidecar}
disabled={sidecarAction() === "busy"}
>
<span class="material-symbols-rounded">play_arrow</span>
{t("status.sidecar_start")}
</button>
) : (
<>
<Show
when={isAdmin()}
fallback={<div class="sidecar-managed-hint">{t("status.sidecar_managed_hint")}</div>}
>
<div class="sidecar-actions">
{!sidecar()?.running ? (
<button
class="btn btn-secondary btn-sm"
onClick={restartSidecar}
class="btn btn-primary btn-sm"
onClick={startSidecar}
disabled={sidecarAction() === "busy"}
>
<span class="material-symbols-rounded">refresh</span>
{t("status.sidecar_restart")}
<span class="material-symbols-rounded">play_arrow</span>
{t("status.sidecar_start")}
</button>
<button
class="btn btn-danger btn-sm"
onClick={stopSidecar}
disabled={sidecarAction() === "busy"}
>
<span class="material-symbols-rounded">stop</span>
{t("status.sidecar_stop")}
</button>
</>
)}
</div>
) : (
<>
<button
class="btn btn-secondary btn-sm"
onClick={restartSidecar}
disabled={sidecarAction() === "busy"}
>
<span class="material-symbols-rounded">refresh</span>
{t("status.sidecar_restart")}
</button>
<button
class="btn btn-danger btn-sm"
onClick={stopSidecar}
disabled={sidecarAction() === "busy"}
>
<span class="material-symbols-rounded">stop</span>
{t("status.sidecar_stop")}
</button>
</>
)}
</div>
</Show>
<Show when={sidecarError()}>
<div class="form-error">{sidecarError()}</div>
+2 -1
View File
@@ -58,7 +58,8 @@
"sidecar_restarts_hint": "Number of automatic restarts",
"sidecar_start": "Start",
"sidecar_stop": "Stop",
"sidecar_restart": "Restart"
"sidecar_restart": "Restart",
"sidecar_managed_hint": "CDAP runs in the background and can only be controlled by an administrator."
},
"setup": {
"title": "Server Setup",
@@ -57,6 +57,7 @@
"sidecar_start": "Uruchom",
"sidecar_stop": "Zatrzymaj",
"sidecar_restart": "Restartuj",
"sidecar_managed_hint": "CDAP działa w tle i może być kontrolowany tylko przez administratora.",
"diagnostics_sent": "Diagnostyka wysłana na serwer",
"diagnostics_error": "Nie udało się wysłać diagnostyki"
},
+10 -1
View File
@@ -41,7 +41,16 @@
"reconnect": "重新連線",
"send_diagnostics": "傳送診斷資訊",
"diagnostics_sent": "診斷資訊已傳送至伺服器",
"diagnostics_error": "傳送診斷資訊失敗"
"diagnostics_error": "傳送診斷資訊失敗",
"sidecar_title": "CDAP 代理",
"sidecar_running": "執行中",
"sidecar_stopped": "已停止",
"sidecar_not_configured": "未設定",
"sidecar_restarts_hint": "自動重新啟動次數",
"sidecar_start": "啟動",
"sidecar_stop": "停止",
"sidecar_restart": "重新啟動",
"sidecar_managed_hint": "CDAP 在背景執行,只有管理員可以控制。"
},
"setup": {
"title": "伺服器設定",
+51 -15
View File
@@ -35,11 +35,18 @@
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
height: 100%;
}
body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
font-size: clamp(14px, 0.92rem, 16px);
line-height: 1.5;
overflow: hidden;
user-select: none;
@@ -110,7 +117,8 @@ a:hover { color: var(--accent-hover); }
.app-layout-tray {
display: flex;
flex-direction: column;
height: 100%;
height: 100dvh;
min-height: 0;
}
.app-layout-tray .app-main-full {
@@ -125,8 +133,15 @@ a:hover { color: var(--accent-hover); }
justify-content: space-around;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
padding: 0.35rem 0;
min-height: clamp(4.25rem, 12vh, 5.25rem);
padding: 0.35rem 0.4rem max(0.45rem, env(safe-area-inset-bottom));
flex-shrink: 0;
overflow-x: auto;
scrollbar-width: none;
}
.bottom-nav::-webkit-scrollbar {
display: none;
}
.bottom-nav-item {
@@ -134,7 +149,8 @@ a:hover { color: var(--accent-hover); }
flex-direction: column;
align-items: center;
gap: 0.18rem;
padding: 0.45rem 0.75rem;
flex: 1 1 0;
padding: 0.45rem 0.5rem;
border: none;
background: none;
color: var(--text-muted);
@@ -142,8 +158,8 @@ a:hover { color: var(--accent-hover); }
border-radius: var(--radius);
transition: color var(--transition), background var(--transition);
font-family: inherit;
min-width: 4rem;
min-height: 3.5rem;
min-width: 0;
min-height: clamp(3.5rem, 9vh, 4.25rem);
}
.bottom-nav-item:hover {
color: var(--text-secondary);
@@ -161,9 +177,12 @@ a:hover { color: var(--accent-hover); }
}
.bottom-nav-label {
font-size: 0.72rem;
font-size: clamp(0.72rem, 2.7vw, 0.82rem);
font-weight: 500;
line-height: 1.2;
line-height: 1.15;
max-width: 100%;
overflow-wrap: anywhere;
text-align: center;
}
.app-main {
@@ -176,7 +195,7 @@ a:hover { color: var(--accent-hover); }
width: 100%;
height: 100%;
overflow-y: auto;
padding: 20px;
padding: clamp(14px, 4vw, 20px);
}
/* ── Admin-required gate (legacy) ── */
@@ -260,19 +279,25 @@ a:hover { color: var(--accent-hover); }
/* ── Overflow / "more" menu ── */
.bottom-nav-overflow {
position: relative;
flex: 1 1 0;
min-width: 0;
}
.bottom-nav-overflow > .bottom-nav-item {
width: 100%;
}
.overflow-menu {
position: absolute;
bottom: calc(100% + 6px);
right: 4px;
position: fixed;
right: max(10px, env(safe-area-inset-right));
bottom: calc(clamp(4.25rem, 12vh, 5.25rem) + max(10px, env(safe-area-inset-bottom)));
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
min-width: 180px;
padding: 4px;
z-index: 500;
width: min(220px, calc(100vw - 20px));
padding: 6px;
z-index: 2000;
animation: fadeUp 120ms ease;
}
@@ -322,6 +347,17 @@ a:hover { color: var(--accent-hover); }
margin: 4px 8px;
}
.sidecar-managed-hint {
margin-top: 0.75rem;
padding: 0.65rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(139, 148, 158, 0.08);
color: var(--text-secondary);
font-size: 0.82rem;
line-height: 1.35;
}
/* ── Sidebar ── */
.sidebar {
width: var(--sidebar-width);
+1 -1
View File
@@ -339,7 +339,7 @@ func (a *Agent) dispatch(msg *Message) {
// ── Desktop (Screenshot + Streaming) ──
case "desktop_start":
a.handleDesktopStart(msg)
case "desktop_stop":
case "desktop_stop", "desktop_end":
a.handleDesktopStop(msg)
case "desktop_input":
a.handleDesktopInput(msg)
+5
View File
@@ -41,5 +41,10 @@ func (a *Agent) handleDesktopInput(msg *Message) {
if err := injectInput(&evt); err != nil {
log.Printf("[input] Injection failed (%s): %v", evt.Type, err)
_ = a.sendMessage("desktop_input_error", map[string]any{
"session_id": evt.SessionID,
"type": evt.Type,
"message": err.Error(),
})
}
}
+75 -18
View File
@@ -11,17 +11,29 @@ import (
// injectInput injects a keyboard or mouse event on Linux.
//
// Strategy:
// 1. X11 or XWayland session (DISPLAY set) → use xdotool
// 2. Pure Wayland without XWayland (DISPLAY not set) → use ydotool
// (requires ydotoold daemon to be running)
//
// Most Wayland compositors (GNOME, KDE, sway) run XWayland, so path 1
// is taken even on Wayland desktops in most real-world setups.
// 1. Wayland session → prefer ydotool, because xdotool only reaches XWayland windows.
// 2. X11 or XWayland fallback → use xdotool.
func injectInput(evt *InputEvent) error {
if isWaylandSession() {
if commandExists("ydotool") {
if err := injectInputWayland(evt); err == nil {
return nil
} else if !hasX11Display() {
return err
}
}
if hasX11Display() {
return injectInputX11(evt)
}
return fmt.Errorf("Wayland input injection requires ydotool and a running ydotoold daemon")
}
if hasX11Display() {
return injectInputX11(evt)
}
return injectInputWayland(evt)
if commandExists("ydotool") {
return injectInputWayland(evt)
}
return fmt.Errorf("no supported input backend found (install xdotool for X11 or ydotool for Wayland)")
}
// ── X11 / XWayland path (xdotool) ────────────────────────────────────────
@@ -40,21 +52,31 @@ func injectInputX11(evt *InputEvent) error {
return xdotool("click", fmt.Sprintf("%d", linuxMouseButton(evt.Button)))
case "mouse_down":
if err := xdotool("mousemove", "--sync",
fmt.Sprintf("%d", evt.X), fmt.Sprintf("%d", evt.Y)); err != nil {
return err
}
return xdotool("mousedown", fmt.Sprintf("%d", linuxMouseButton(evt.Button)))
case "mouse_up":
if err := xdotool("mousemove", "--sync",
fmt.Sprintf("%d", evt.X), fmt.Sprintf("%d", evt.Y)); err != nil {
return err
}
return xdotool("mouseup", fmt.Sprintf("%d", linuxMouseButton(evt.Button)))
case "mouse_scroll":
if evt.DeltaY < 0 {
return xdotool("click", "--repeat", fmt.Sprintf("%d", abs(evt.DeltaY)), "4")
} else if evt.DeltaY > 0 {
return xdotool("click", "--repeat", fmt.Sprintf("%d", abs(evt.DeltaY)), "5")
stepsY := scrollSteps(evt.DeltaY)
stepsX := scrollSteps(evt.DeltaX)
if stepsY < 0 {
return xdotool("click", "--repeat", fmt.Sprintf("%d", abs(stepsY)), "4")
} else if stepsY > 0 {
return xdotool("click", "--repeat", fmt.Sprintf("%d", abs(stepsY)), "5")
}
if evt.DeltaX < 0 {
return xdotool("click", "--repeat", fmt.Sprintf("%d", abs(evt.DeltaX)), "6")
} else if evt.DeltaX > 0 {
return xdotool("click", "--repeat", fmt.Sprintf("%d", abs(evt.DeltaX)), "7")
if stepsX < 0 {
return xdotool("click", "--repeat", fmt.Sprintf("%d", abs(stepsX)), "6")
} else if stepsX > 0 {
return xdotool("click", "--repeat", fmt.Sprintf("%d", abs(stepsX)), "7")
}
return nil
@@ -87,6 +109,11 @@ func xdotool(args ...string) error {
return exec.Command(path, args...).Run()
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
// ── Pure-Wayland path (ydotool) ──────────────────────────────────────────
// injectInputWayland uses ydotool for input injection on pure-Wayland sessions.
@@ -113,18 +140,28 @@ func injectInputWayland(evt *InputEvent) error {
return ydotoolClick(evt.Button)
case "mouse_down":
if err := ydotool("mousemove",
"-x", fmt.Sprintf("%d", evt.X),
"-y", fmt.Sprintf("%d", evt.Y)); err != nil {
return err
}
return ydotoolMouseDown(evt.Button)
case "mouse_up":
if err := ydotool("mousemove",
"-x", fmt.Sprintf("%d", evt.X),
"-y", fmt.Sprintf("%d", evt.Y)); err != nil {
return err
}
return ydotoolMouseUp(evt.Button)
case "mouse_scroll":
// ydotool scroll: positive DeltaY = scroll down
if evt.DeltaY != 0 {
if steps := scrollSteps(evt.DeltaY); steps != 0 {
// ydotool scroll button clicks: 4=wheel-up, 5=wheel-down
btn := "5"
repeat := evt.DeltaY
if evt.DeltaY < 0 {
repeat := steps
if steps < 0 {
btn = "4"
repeat = -repeat
}
@@ -174,6 +211,26 @@ func ydotool(args ...string) error {
return exec.Command(path, args...).Run()
}
func scrollSteps(delta int) int {
if delta == 0 {
return 0
}
steps := abs(delta)
if steps > 12 {
steps = (steps + 79) / 80
}
if steps < 1 {
steps = 1
}
if steps > 10 {
steps = 10
}
if delta < 0 {
return -steps
}
return steps
}
// ydotoolClick sends a full button click (down + up) for a CDAP button number.
// CDAP: 1=left, 2=right, 3=middle. ydotool uses the same numbering as xdotool.
func ydotoolClick(button int) error {
+2 -1
View File
@@ -61,7 +61,8 @@ func (s *tfaSessionStore) put(secret string, sess *tfaSession) {
}
// Limit total sessions to prevent memory exhaustion
if len(s.sessions) >= 1000 {
return // defer s.mu.Unlock() handles the unlock
s.mu.Unlock()
return
}
s.sessions[secret] = sess
}
+49 -2
View File
@@ -300,11 +300,11 @@ func (g *Gateway) EndDesktopSession(ctx context.Context, sessionID, reason strin
}
data, _ := json.Marshal(endPayload)
msg := &Message{
Type: "desktop_end",
Type: "desktop_stop",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: data,
}
ds.deviceConn.WriteMessage(ctx, msg)
_ = ds.deviceConn.WriteMessage(ctx, msg)
endMsg, _ := json.Marshal(map[string]string{
"type": "end",
@@ -326,3 +326,50 @@ func (g *Gateway) EndDesktopSession(ctx context.Context, sessionID, reason strin
})
}
}
// HandleDesktopInputError forwards input injection failures from the device to
// the browser session so operators get an actionable error instead of silent no-op input.
func (g *Gateway) HandleDesktopInputError(ctx context.Context, _ *DeviceConn, msg *Message) {
var payload struct {
SessionID string `json:"session_id"`
Type string `json:"type"`
Message string `json:"message"`
}
if err := json.Unmarshal(msg.Payload, &payload); err != nil || payload.SessionID == "" {
return
}
val, ok := g.desktopSessions.Load(payload.SessionID)
if !ok {
return
}
ds := val.(*DesktopSession)
if ds.closed.Load() {
return
}
text := payload.Message
if text == "" {
text = "remote input injection failed"
}
out, _ := json.Marshal(map[string]string{
"type": "error",
"session_id": payload.SessionID,
"error": text,
})
ds.mu.Lock()
_ = ds.browser.Write(ctx, websocket.MessageText, out)
ds.mu.Unlock()
}
func (g *Gateway) cleanupDeviceSessions(deviceID, reason string) {
g.desktopSessions.Range(func(key, value any) bool {
ds, ok := value.(*DesktopSession)
if !ok || ds.DeviceID != deviceID {
return true
}
g.EndDesktopSession(context.Background(), ds.ID, reason)
g.desktopSessions.Delete(key)
return true
})
}
+4
View File
@@ -353,6 +353,8 @@ func (g *Gateway) messageLoop(ctx context.Context, dc *DeviceConn) {
g.handleDesktopFrame(ctx, dc, msg)
case "desktop_end":
g.handleDesktopEnd(ctx, dc, msg)
case "desktop_input_error":
g.HandleDesktopInputError(ctx, dc, msg)
case "video_frame":
g.handleVideoFrame(ctx, dc, msg)
case "video_end":
@@ -476,6 +478,8 @@ func (g *Gateway) removeDevice(dc *DeviceConn) {
return true
})
g.cleanupDeviceSessions(dc.ID, "device disconnected")
// Update peer status to OFFLINE
if err := g.db.UpdatePeerStatus(dc.ID, "OFFLINE", dc.ClientIP); err != nil {
log.Printf("[cdap] %s: failed to set offline: %v", dc.ID, err)
+11
View File
@@ -1041,6 +1041,17 @@ function Install-NodeJsConsole {
Pop-Location
return $false
}
# Best-effort install of node-pty for Server Management terminal (BETA).
# Optional native module — falls back to pipe spawn if build fails.
Print-Step "Installing optional node-pty (Server Management terminal - BETA)..."
$ptyOutput = npm install --no-audit --no-fund --no-save node-pty 2>&1
if ($LASTEXITCODE -eq 0) {
Print-Success "node-pty installed (real PTY available)"
} else {
Print-Warning "node-pty install failed - Server Management terminal will use pipe fallback"
$ptyOutput | Select-Object -Last 5 | ForEach-Object { Write-Host "[node-pty] $_" }
}
# Create data directory for databases
$dataDir = Join-Path $script:CONSOLE_PATH "data"
+17
View File
@@ -1467,6 +1467,23 @@ install_nodejs_console() {
fi
rm -f "$npm_log"
echo ""
# Best-effort install of node-pty for Server Management terminal (BETA).
# node-pty is an optional dependency: if the native build fails the
# console falls back to plain pipe spawn (no PTY).
print_step "Installing optional node-pty (Server Management terminal — BETA)..."
if npm install --no-audit --no-fund --no-save node-pty >>"$npm_log" 2>&1; then
print_success "node-pty installed (real PTY available)"
else
print_warn "node-pty install failed — Server Management terminal will use pipe fallback"
fi
rm -f "$npm_log"
# Server Management Terminal sudo hint (BETA — manual step, NOT automated):
# echo 'betterdesk-console ALL=(ALL) NOPASSWD: /usr/bin/systemctl, /usr/bin/journalctl' \
# | sudo tee /etc/sudoers.d/betterdesk-console
# The installer never modifies sudoers; admins opt in manually.
echo ""
# Create data directory for databases
mkdir -p "$CONSOLE_PATH/data"
@@ -0,0 +1,122 @@
# BetterDesk Agent Client Finalization Plan — 2026-05-06
## Current Runtime Decision
The selected stable architecture is:
- Tauri remains the endpoint UI, tray, setup wizard, policy surface, notification layer, and consent broker.
- The bundled Go `betterdesk-agent` is the active CDAP and remote-session engine.
- The native Rust `CdapClient` remains available for lightweight telemetry/future migration work, but it is no longer the preferred runtime for remote desktop parity.
This path is lower risk for the final agent because `betterdesk-agent/agent/desktop*.go`, `input*.go`, monitor handling, Wayland capture support, consent handling, terminal, file browser, clipboard, heartbeat, and reconnect already exist in the Go agent codebase.
## Fixes Applied In This Pass
1. Window close no longer opens a sudo quit prompt. Closing the window hides the agent to tray, while explicit quit still goes through the guarded quit flow.
2. Linux SIGTERM/SIGINT handling was added so system shutdown or restart can terminate the agent cleanly without waiting for sudo UI.
3. CDAP start/stop/restart controls are now administrator-only at the IPC layer.
4. The Status page hides CDAP controls from non-admin users and shows a managed background-service hint instead.
5. The tray menu no longer exposes the CDAP restart action to non-admin users.
6. The registered agent posts a system notification after startup, reporting that the background agent is running and whether CDAP is connected/reconnecting.
7. Bottom navigation scaling was adjusted for high-DPI / high-system-scale displays using stable minimum heights, `100dvh`, safe-area padding, and responsive label sizing.
8. Tauri window controls are explicitly marked minimizable, maximizable, closable, and resizable in `tauri.conf.json`.
9. The managed Go sidecar was wired back into `AgentState` as the active CDAP runtime.
10. Sidecar config now passes `require_consent` into the Go agent JSON config.
11. Sidecar stdout consent reader now restarts after sidecar process recovery, preserving supervised-session prompts after crashes/restarts.
12. Sidecar binary discovery now supports Tauri externalBin target-triple filenames such as `betterdesk-agent-x86_64-unknown-linux-gnu`.
13. Unregister and explicit quit now stop both the Go sidecar and the native Rust CDAP client to avoid stale background connections.
## Final Agent Requirements
The final BetterDesk Client Agent must support:
- Supervised access: user consent prompt before a session if policy requires it.
- Unattended access: policy-controlled connection without user consent when explicitly enabled.
- RDClient feature parity: high-resolution streaming, remote input, clipboard sync, file transfer, monitor selection, quality presets, reconnect, and audit events.
- RustDesk desktop client compatibility where protocol boundaries allow it.
- Linux X11 and Wayland, Windows, macOS, and later Android.
- Hardware-accelerated codecs where available, with safe software fallback.
- Background resilience after reboot, including autostart, CDAP reconnect, heartbeat, sysinfo refresh, and operator-visible status.
- User tamper resistance: regular users must not be able to stop CDAP, unregister the device, or disable critical modules.
## Recommended Implementation Path
### Phase A — Stabilize One Real Remote Runtime
Status: started. The Go sidecar is now the selected runtime for CDAP and remote-session work.
Remaining work:
- Validate the packaged sidecar path on Windows, Linux, and macOS installers.
- Make sidecar health and reconnect status visible in the Status page.
- Add structured sidecar logs to diagnostics.
- Confirm no duplicate device sessions are created when heartbeat, bd-signal, and sidecar run together.
### Phase B — Supervised/Unattended Policy Contract
Add explicit policy fields from server to agent:
- `allow_remote_desktop`
- `require_consent`
- `allow_unattended`
- `allowed_operators`
- `session_recording_policy`
- `max_resolution`
- `max_fps`
- `codec_policy`
The agent must enforce policy locally, not only in the web console.
### Phase C — Remote Desktop Capability Matrix
Linux:
- X11: x11grab or native capture backend, xdotool/enigo input.
- Wayland: xdg-desktop-portal + PipeWire capture, compositor-safe input path via ydotool/ydotoold or portal-backed remote desktop where available.
- GPU: VAAPI / NVENC / AMF detection through ffmpeg capability probing.
Windows:
- Capture: DXGI Desktop Duplication or Windows Graphics Capture.
- Input: SendInput.
- GPU: Media Foundation / D3D11 / NVENC / AMF where available.
macOS:
- Capture: ScreenCaptureKit on modern macOS, AVFoundation fallback.
- Input: CGEvent with Accessibility permission.
- GPU: VideoToolbox H.264/HEVC.
Android later:
- Capture: MediaProjection.
- Input: Accessibility service or enterprise device-owner mode.
- Transport: CDAP-compatible mobile channel with Android lifecycle constraints.
### Phase D — Protocol Completeness
The agent must handle and test these CDAP message families:
- `desktop_start`, `desktop_stop`, `desktop_frame`, `desktop_input`
- `codec_offer`, `codec_answer`, `keyframe_request`
- `quality_report`, `quality_update`
- `monitor_list`, `monitor_select`
- `clipboard_get`, `clipboard_set`, `clipboard_update`
- `file_list`, `file_read`, `file_write`, `file_delete`
- `audio_start`, `audio_frame`, `audio_end`
- `consent_request`, `consent_granted`, `consent_denied`
### Phase E — Tests Before “Final” Label
Required acceptance tests:
1. Linux KDE Wayland: reboot, autostart, CDAP reconnect, notification, supervised session, unattended session, input control.
2. Linux X11: same as above plus xdotool path.
3. Windows 10/11: reboot/autostart, UAC-safe background mode, screen capture, input, tray, notifications.
4. macOS: permissions prompts, screen recording, accessibility, launch agent, notification.
5. Web console and RDClient: same device, same status, same capabilities, no duplicate online records.
6. RustDesk desktop client compatibility: login, address book/ID visibility, relay, connection negotiation, encryption, and failure diagnostics.
## Immediate Next Code Step
Finish the sidecar runtime hardening: packaged binary verification, sidecar diagnostics, UI health reporting, and end-to-end supervised/unattended session tests against the web console. Until these pass, the agent should not be described as a final RDClient/RustDesk-compatible remote desktop agent.
+77 -1
View File
@@ -80,7 +80,8 @@
"toggle_theme": "Toggle theme",
"sdk_studio": "SDK Studio",
"login": "Login",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "إدارة الخادم"
},
"auth": {
"login": "Login",
@@ -3007,5 +3008,80 @@
"preview_secondary": "Secondary",
"reset": "إعادة تعيين",
"done": "تم"
},
"server_mgmt": {
"title": "إدارة الخادم",
"subtitle": "Cockpit-like control plane for the BetterDesk console host (BETA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "نظرة عامة",
"tab_terminal": "الطرفية",
"tab_files": "الملفات",
"tab_services": "الخدمات",
"cpu": "CPU",
"memory": "الذاكرة",
"load": "Load average",
"disks": "الأقراص",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "وقت التشغيل",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "اتصال",
"term_disconnect": "قطع الاتصال",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Name",
"files_size": "Size",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Service",
"svc_state": "State",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Start",
"svc_stop": "Stop",
"svc_restart": "Restart",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -79,7 +79,8 @@
"toggle_theme": "Toggle theme",
"sdk_studio": "SDK Studio",
"login": "Login",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Správa serveru"
},
"auth": {
"login": "Login",
@@ -3006,5 +3007,80 @@
"preview_secondary": "Secondary",
"reset": "Reset",
"done": "Done"
},
"server_mgmt": {
"title": "Správa serveru",
"subtitle": "Cockpit-like control plane for the BetterDesk console host (BETA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "Přehled",
"tab_terminal": "Terminál",
"tab_files": "Soubory",
"tab_services": "Služby",
"cpu": "CPU",
"memory": "Paměť",
"load": "Load average",
"disks": "Disky",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "Doba běhu",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Připojit",
"term_disconnect": "Odpojit",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Name",
"files_size": "Size",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Služba",
"svc_state": "Stav",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Spustit",
"svc_stop": "Zastavit",
"svc_restart": "Restartovat",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -79,7 +79,8 @@
"toggle_theme": "Toggle theme",
"sdk_studio": "SDK Studio",
"login": "Login",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Serveradministration"
},
"auth": {
"login": "Login",
@@ -3006,5 +3007,80 @@
"preview_secondary": "Secondary",
"reset": "Reset",
"done": "Done"
},
"server_mgmt": {
"title": "Serveradministration",
"subtitle": "Cockpit-like control plane for the BetterDesk console host (BETA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "Oversigt",
"tab_terminal": "Terminal",
"tab_files": "Filer",
"tab_services": "Tjenester",
"cpu": "CPU",
"memory": "Hukommelse",
"load": "Load average",
"disks": "Diske",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "Oppetid",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Forbind",
"term_disconnect": "Afbryd",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Name",
"files_size": "Size",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Service",
"svc_state": "State",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Start",
"svc_stop": "Stop",
"svc_restart": "Genstart",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -73,7 +73,8 @@
"help": "Help",
"expand_sidebar": "Expand sidebar",
"login": "Anmelden",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Server-Verwaltung"
},
"auth": {
"login": "Anmelden",
@@ -3000,5 +3001,80 @@
"preview_secondary": "Secondary",
"reset": "Zurücksetzen",
"done": "Fertig"
},
"server_mgmt": {
"title": "Server-Verwaltung",
"subtitle": "Cockpit-ähnliche Steuerung für den BetterDesk-Konsolen-Host (BETA).",
"beta_tooltip": "Beta-Funktion — in aktiver Entwicklung.",
"notice_title": "Beta-Funktion",
"notice_body": "Diese Werkzeuge laufen auf dem Host, auf dem der BetterDesk-Konsolenprozess ausgeführt wird. Alle Operationen werden auditiert.",
"tab_overview": "Übersicht",
"tab_terminal": "Terminal",
"tab_files": "Dateien",
"tab_services": "Dienste",
"cpu": "CPU",
"memory": "Speicher",
"load": "Last (Durchschnitt)",
"disks": "Festplatten",
"history": "Verlauf (letzte 60 Werte)",
"host_info": "Host-Info",
"cores": "Kerne",
"uptime": "Laufzeit",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Verbinden",
"term_disconnect": "Trennen",
"term_clear": "Löschen",
"term_status": "Status",
"term_connecting": "Verbinden…",
"term_connected": "Verbunden",
"term_disconnected": "Getrennt",
"term_error": "Verbindungsfehler",
"term_warning": "Befehle werden als Konsolen-Prozessbenutzer ausgeführt. Sudoers-Regel hinzufügen für Erhöhung.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Name",
"files_size": "Größe",
"files_perms": "Modus",
"files_mtime": "Geändert",
"files_rename": "Umbenennen",
"files_rename_prompt": "New name:",
"files_empty": "Leeres Verzeichnis",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Dienst",
"svc_state": "Status",
"svc_description": "Beschreibung",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Start",
"svc_stop": "Stopp",
"svc_restart": "Neustart",
"svc_reload": "Neu laden",
"svc_enable": "Aktivieren",
"svc_disable": "Deaktivieren",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -73,7 +73,8 @@
"chat": "Chat",
"help": "Help",
"expand_sidebar": "Expand sidebar",
"login": "Login"
"login": "Login",
"server_management": "Server Management"
},
"auth": {
"login": "Login",
@@ -3000,5 +3001,80 @@
"preview_secondary": "Secondary",
"reset": "Reset",
"done": "Done"
},
"server_mgmt": {
"title": "Server Management",
"subtitle": "Cockpit-like control plane for the BetterDesk console host (BETA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "Overview",
"tab_terminal": "Terminal",
"tab_files": "Files",
"tab_services": "Services",
"cpu": "CPU",
"memory": "Memory",
"load": "Load average",
"disks": "Disks",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "Uptime",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Connect",
"term_disconnect": "Disconnect",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Name",
"files_size": "Size",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Service",
"svc_state": "State",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Start",
"svc_stop": "Stop",
"svc_restart": "Restart",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -73,7 +73,8 @@
"help": "Help",
"expand_sidebar": "Expand sidebar",
"login": "Iniciar sesión",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Gestión del servidor"
},
"auth": {
"login": "Iniciar sesión",
@@ -3000,5 +3001,80 @@
"preview_secondary": "Secondary",
"reset": "Restablecer",
"done": "Listo"
},
"server_mgmt": {
"title": "Gestión del servidor",
"subtitle": "Panel tipo Cockpit para el host de la consola BetterDesk (BETA).",
"beta_tooltip": "Función beta — en desarrollo activo.",
"notice_title": "Función beta",
"notice_body": "Estas herramientas se ejecutan en el host donde corre el proceso de la consola BetterDesk. Todas las operaciones son auditadas.",
"tab_overview": "Resumen",
"tab_terminal": "Terminal",
"tab_files": "Archivos",
"tab_services": "Servicios",
"cpu": "CPU",
"memory": "Memoria",
"load": "Carga promedio",
"disks": "Discos",
"history": "Historial (últimas 60 muestras)",
"host_info": "Info del host",
"cores": "núcleos",
"uptime": "Tiempo activo",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Conectar",
"term_disconnect": "Desconectar",
"term_clear": "Limpiar",
"term_status": "Status",
"term_connecting": "Conectando…",
"term_connected": "Conectado",
"term_disconnected": "Desconectado",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Nombre",
"files_size": "Tamaño",
"files_perms": "Mode",
"files_mtime": "Modificado",
"files_rename": "Renombrar",
"files_rename_prompt": "New name:",
"files_empty": "Directorio vacío",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Servicio",
"svc_state": "Estado",
"svc_description": "Descripción",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Iniciar",
"svc_stop": "Detener",
"svc_restart": "Reiniciar",
"svc_reload": "Recargar",
"svc_enable": "Habilitar",
"svc_disable": "Deshabilitar",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -79,7 +79,8 @@
"toggle_theme": "Toggle theme",
"sdk_studio": "SDK Studio",
"login": "Login",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Palvelimen hallinta"
},
"auth": {
"login": "Login",
@@ -3006,5 +3007,80 @@
"preview_secondary": "Secondary",
"reset": "Reset",
"done": "Done"
},
"server_mgmt": {
"title": "Palvelimen hallinta",
"subtitle": "Cockpit-like control plane for the BetterDesk console host (BETA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "Yleiskatsaus",
"tab_terminal": "Pääte",
"tab_files": "Tiedostot",
"tab_services": "Palvelut",
"cpu": "CPU",
"memory": "Muisti",
"load": "Load average",
"disks": "Levyt",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "Käyntiaika",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Yhdistä",
"term_disconnect": "Katkaise",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Name",
"files_size": "Size",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Service",
"svc_state": "State",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Start",
"svc_stop": "Stop",
"svc_restart": "Restart",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -74,7 +74,8 @@
"help": "Aide",
"expand_sidebar": "Développer la barre latérale",
"login": "Connexion",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Gestion du serveur"
},
"auth": {
"login": "Connexion",
@@ -3001,5 +3002,80 @@
"preview_secondary": "Secondary",
"reset": "Réinitialiser",
"done": "Terminé"
},
"server_mgmt": {
"title": "Gestion du serveur",
"subtitle": "Panneau de type Cockpit pour l'hôte de la console BetterDesk (BÊTA).",
"beta_tooltip": "Fonction bêta — en développement actif.",
"notice_title": "Fonction bêta",
"notice_body": "Ces outils s'exécutent sur l'hôte où tourne le processus de la console BetterDesk. Toutes les opérations sont auditées.",
"tab_overview": "Aperçu",
"tab_terminal": "Terminal",
"tab_files": "Fichiers",
"tab_services": "Services",
"cpu": "CPU",
"memory": "Mémoire",
"load": "Charge moyenne",
"disks": "Disques",
"history": "Historique (60 derniers échantillons)",
"host_info": "Infos hôte",
"cores": "cœurs",
"uptime": "Temps d'activité",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Connecter",
"term_disconnect": "Déconnecter",
"term_clear": "Effacer",
"term_status": "Status",
"term_connecting": "Connexion…",
"term_connected": "Connecté",
"term_disconnected": "Déconnecté",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Nom",
"files_size": "Taille",
"files_perms": "Mode",
"files_mtime": "Modifié",
"files_rename": "Renommer",
"files_rename_prompt": "New name:",
"files_empty": "Répertoire vide",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Service",
"svc_state": "État",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Démarrer",
"svc_stop": "Arrêter",
"svc_restart": "Redémarrer",
"svc_reload": "Recharger",
"svc_enable": "Activer",
"svc_disable": "Désactiver",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -79,7 +79,8 @@
"toggle_theme": "Toggle theme",
"sdk_studio": "SDK Studio",
"login": "Login",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "सर्वर प्रबंधन"
},
"auth": {
"login": "Login",
@@ -3006,5 +3007,80 @@
"preview_secondary": "Secondary",
"reset": "रीसेट",
"done": "पूर्ण"
},
"server_mgmt": {
"title": "सर्वर प्रबंधन",
"subtitle": "Cockpit-like control plane for the BetterDesk console host (BETA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "अवलोकन",
"tab_terminal": "टर्मिनल",
"tab_files": "फ़ाइलें",
"tab_services": "सेवाएं",
"cpu": "CPU",
"memory": "मेमोरी",
"load": "Load average",
"disks": "डिस्क",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "अपटाइम",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Connect",
"term_disconnect": "Disconnect",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Name",
"files_size": "Size",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Service",
"svc_state": "State",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Start",
"svc_stop": "Stop",
"svc_restart": "Restart",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -79,7 +79,8 @@
"toggle_theme": "Toggle theme",
"sdk_studio": "SDK Studio",
"login": "Login",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Kiszolgáló-kezelés"
},
"auth": {
"login": "Login",
@@ -3006,5 +3007,80 @@
"preview_secondary": "Secondary",
"reset": "Reset",
"done": "Done"
},
"server_mgmt": {
"title": "Kiszolgáló-kezelés",
"subtitle": "Cockpit-like control plane for the BetterDesk console host (BETA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "Áttekintés",
"tab_terminal": "Terminál",
"tab_files": "Fájlok",
"tab_services": "Szolgáltatások",
"cpu": "CPU",
"memory": "Memória",
"load": "Load average",
"disks": "Lemezek",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "Üzemidő",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Csatlakozás",
"term_disconnect": "Lecsatlakozás",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Name",
"files_size": "Size",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Service",
"svc_state": "State",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Start",
"svc_stop": "Stop",
"svc_restart": "Restart",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -79,7 +79,8 @@
"toggle_theme": "Toggle theme",
"sdk_studio": "SDK Studio",
"login": "Login",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Manajemen Server"
},
"auth": {
"login": "Login",
@@ -3006,5 +3007,80 @@
"preview_secondary": "Secondary",
"reset": "Reset",
"done": "Done"
},
"server_mgmt": {
"title": "Manajemen Server",
"subtitle": "Cockpit-like control plane for the BetterDesk console host (BETA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "Ikhtisar",
"tab_terminal": "Terminal",
"tab_files": "Berkas",
"tab_services": "Layanan",
"cpu": "CPU",
"memory": "Memori",
"load": "Load average",
"disks": "Disk",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "Waktu aktif",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Hubungkan",
"term_disconnect": "Putuskan",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Name",
"files_size": "Size",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Service",
"svc_state": "State",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Start",
"svc_stop": "Stop",
"svc_restart": "Restart",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -75,7 +75,8 @@
"toggle_theme": "Cambia tema",
"sdk_studio": "SDK Studio",
"login": "Accedi",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Gestione server"
},
"auth": {
"login": "Accedi",
@@ -3150,5 +3151,80 @@
"preview_secondary": "Secondary",
"reset": "Ripristina",
"done": "Fine"
},
"server_mgmt": {
"title": "Gestione server",
"subtitle": "Pannello in stile Cockpit per l'host della console BetterDesk (BETA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "Panoramica",
"tab_terminal": "Terminale",
"tab_files": "File",
"tab_services": "Servizi",
"cpu": "CPU",
"memory": "Memoria",
"load": "Carico medio",
"disks": "Dischi",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "Tempo di attività",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Connetti",
"term_disconnect": "Disconnetti",
"term_clear": "Pulisci",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connesso",
"term_disconnected": "Disconnesso",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Nome",
"files_size": "Dimensione",
"files_perms": "Mode",
"files_mtime": "Modificato",
"files_rename": "Rinomina",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Servizio",
"svc_state": "Stato",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Avvia",
"svc_stop": "Ferma",
"svc_restart": "Riavvia",
"svc_reload": "Reload",
"svc_enable": "Abilita",
"svc_disable": "Disabilita",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -73,7 +73,8 @@
"toggle_theme": "Toggle theme",
"sdk_studio": "SDK Studio",
"login": "Login",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "サーバー管理"
},
"auth": {
"login": "Login",
@@ -3000,5 +3001,80 @@
"preview_secondary": "Secondary",
"reset": "リセット",
"done": "完了"
},
"server_mgmt": {
"title": "サーバー管理",
"subtitle": "BetterDesk コンソールホスト用の Cockpit 風コントロールパネル(ベータ)。",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "概要",
"tab_terminal": "ターミナル",
"tab_files": "ファイル",
"tab_services": "サービス",
"cpu": "CPU",
"memory": "メモリ",
"load": "Load average",
"disks": "ディスク",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "稼働時間",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "接続",
"term_disconnect": "切断",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "名前",
"files_size": "サイズ",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "サービス",
"svc_state": "状態",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "開始",
"svc_stop": "停止",
"svc_restart": "再起動",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -73,7 +73,8 @@
"toggle_theme": "Toggle theme",
"sdk_studio": "SDK Studio",
"login": "Login",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "서버 관리"
},
"auth": {
"login": "Login",
@@ -3000,5 +3001,80 @@
"preview_secondary": "Secondary",
"reset": "초기화",
"done": "완료"
},
"server_mgmt": {
"title": "서버 관리",
"subtitle": "BetterDesk 콘솔 호스트용 Cockpit 스타일 제어판(베타).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "개요",
"tab_terminal": "터미널",
"tab_files": "파일",
"tab_services": "서비스",
"cpu": "CPU",
"memory": "메모리",
"load": "Load average",
"disks": "디스크",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "가동 시간",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "연결",
"term_disconnect": "연결 해제",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "이름",
"files_size": "크기",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "서비스",
"svc_state": "상태",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "시작",
"svc_stop": "중지",
"svc_restart": "재시작",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -79,7 +79,8 @@
"toggle_theme": "Toggle theme",
"sdk_studio": "SDK Studio",
"login": "Login",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Serveradministrasjon"
},
"auth": {
"login": "Login",
@@ -3006,5 +3007,80 @@
"preview_secondary": "Secondary",
"reset": "Reset",
"done": "Done"
},
"server_mgmt": {
"title": "Serveradministrasjon",
"subtitle": "Cockpit-like control plane for the BetterDesk console host (BETA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "Oversikt",
"tab_terminal": "Terminal",
"tab_files": "Filer",
"tab_services": "Tjenester",
"cpu": "CPU",
"memory": "Minne",
"load": "Load average",
"disks": "Disker",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "Oppetid",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Koble til",
"term_disconnect": "Koble fra",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Name",
"files_size": "Size",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Service",
"svc_state": "State",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Start",
"svc_stop": "Stop",
"svc_restart": "Restart",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -75,7 +75,8 @@
"toggle_theme": "Thema wisselen",
"sdk_studio": "SDK Studio",
"login": "Inloggen",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Serverbeheer"
},
"auth": {
"login": "Inloggen",
@@ -3143,5 +3144,80 @@
"preview_secondary": "Secondary",
"reset": "Resetten",
"done": "Gereed"
},
"server_mgmt": {
"title": "Serverbeheer",
"subtitle": "Cockpit-achtig paneel voor de BetterDesk-consolehost (BÈTA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "Overzicht",
"tab_terminal": "Terminal",
"tab_files": "Bestanden",
"tab_services": "Services",
"cpu": "CPU",
"memory": "Geheugen",
"load": "Load average",
"disks": "Schijven",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "Uptime",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Verbinden",
"term_disconnect": "Verbinding verbreken",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Naam",
"files_size": "Grootte",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Service",
"svc_state": "Status",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Starten",
"svc_stop": "Stoppen",
"svc_restart": "Herstarten",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -73,7 +73,8 @@
"help": "Pomoc",
"expand_sidebar": "Rozwiń pasek boczny",
"toggle_theme": "Toggle theme",
"login": "Logowanie"
"login": "Logowanie",
"server_management": "Zarządzanie serwerem"
},
"auth": {
"login": "Zaloguj",
@@ -3000,5 +3001,80 @@
"preview_secondary": "Drugorzędna",
"reset": "Resetuj",
"done": "Gotowe"
},
"server_mgmt": {
"title": "Zarządzanie serwerem",
"subtitle": "Panel typu Cockpit dla hosta konsoli BetterDesk (BETA).",
"beta_tooltip": "Funkcja beta — w aktywnym rozwoju.",
"notice_title": "Funkcja w wersji beta",
"notice_body": "Te narzędzia działają na hoście, na którym uruchomiony jest proces konsoli BetterDesk. Wszystkie operacje są audytowane. Zakres funkcji będzie rozszerzany w kolejnych wydaniach.",
"tab_overview": "Przegląd",
"tab_terminal": "Terminal",
"tab_files": "Pliki",
"tab_services": "Usługi",
"cpu": "CPU",
"memory": "Pamięć",
"load": "Średnie obciążenie",
"disks": "Dyski",
"history": "Historia (ostatnie 60 próbek)",
"host_info": "Informacje o hoście",
"cores": "rdzeni",
"uptime": "Czas pracy",
"no_disks": "Brak danych o dyskach",
"info_hostname": "Nazwa hosta",
"info_platform": "Platforma",
"info_kernel": "Jądro",
"info_node": "Node.js",
"info_process_id": "ID procesu",
"info_pty_available": "PTY dostępne",
"term_connect": "Połącz",
"term_disconnect": "Rozłącz",
"term_clear": "Wyczyść",
"term_status": "Status",
"term_connecting": "Łączenie…",
"term_connected": "Połączono",
"term_disconnected": "Rozłączono",
"term_error": "Błąd połączenia",
"term_warning": "Polecenia wykonywane są jako użytkownik procesu konsoli. Dodaj regułę sudoers, aby umożliwić eskalację uprawnień.",
"term_hint": "Wskazówka: wpisz `sudo -i`, aby uzyskać uprawnienia root (wymaga konfiguracji sudoers).",
"term_lib_failed": "Nie udało się załadować biblioteki terminala",
"files_up": "Poziom wyżej",
"files_go": "Idź",
"files_mkdir": "Nowy folder",
"files_name": "Nazwa",
"files_size": "Rozmiar",
"files_perms": "Uprawnienia",
"files_mtime": "Zmodyfikowano",
"files_rename": "Zmień nazwę",
"files_rename_prompt": "Nowa nazwa:",
"files_empty": "Pusty katalog",
"files_error": "Nie udało się odczytać katalogu",
"read_failed": "Nie udało się odczytać pliku",
"save_failed": "Nie udało się zapisać pliku",
"rename_failed": "Nie udało się zmienić nazwy",
"delete_failed": "Nie udało się usunąć",
"delete_confirm": "Usunąć tę pozycję?",
"mkdir_prompt": "Nazwa nowego folderu:",
"mkdir_failed": "Nie udało się utworzyć folderu",
"file_saved": "Plik zapisany",
"binary_warn": "Plik binarny — tylko podgląd",
"encoding_utf8": "Tekst UTF-8",
"truncated": "obcięty",
"full": "pełny",
"svc_search_placeholder": "Szukaj usług…",
"svc_name": "Usługa",
"svc_state": "Stan",
"svc_description": "Opis",
"svc_empty": "Nie znaleziono usług",
"svc_load_failed": "Nie udało się załadować usług",
"svc_action_failed": "Akcja nie powiodła się",
"svc_confirm": "Uruchomić",
"svc_start": "Start",
"svc_stop": "Stop",
"svc_restart": "Restart",
"svc_reload": "Przeładuj",
"svc_enable": "Włącz",
"svc_disable": "Wyłącz",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -75,7 +75,8 @@
"toggle_theme": "Alternar tema",
"sdk_studio": "SDK Studio",
"login": "Entrar",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Gestão do servidor"
},
"auth": {
"login": "Entrar",
@@ -3143,5 +3144,80 @@
"preview_secondary": "Secondary",
"reset": "Repor",
"done": "Concluído"
},
"server_mgmt": {
"title": "Gestão do servidor",
"subtitle": "Painel tipo Cockpit para o host da console BetterDesk (BETA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "Visão geral",
"tab_terminal": "Terminal",
"tab_files": "Arquivos",
"tab_services": "Serviços",
"cpu": "CPU",
"memory": "Memória",
"load": "Load average",
"disks": "Discos",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "Tempo ativo",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Conectar",
"term_disconnect": "Desconectar",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Nome",
"files_size": "Tamanho",
"files_perms": "Mode",
"files_mtime": "Modificado",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Serviço",
"svc_state": "Estado",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Iniciar",
"svc_stop": "Parar",
"svc_restart": "Reiniciar",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -79,7 +79,8 @@
"toggle_theme": "Toggle theme",
"sdk_studio": "SDK Studio",
"login": "Login",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Gestionare server"
},
"auth": {
"login": "Login",
@@ -3006,5 +3007,80 @@
"preview_secondary": "Secondary",
"reset": "Reset",
"done": "Done"
},
"server_mgmt": {
"title": "Gestionare server",
"subtitle": "Cockpit-like control plane for the BetterDesk console host (BETA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "Prezentare",
"tab_terminal": "Terminal",
"tab_files": "Fișiere",
"tab_services": "Servicii",
"cpu": "CPU",
"memory": "Memorie",
"load": "Load average",
"disks": "Discuri",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "Timp de funcționare",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Conectează",
"term_disconnect": "Deconectează",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Name",
"files_size": "Size",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Service",
"svc_state": "State",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Start",
"svc_stop": "Stop",
"svc_restart": "Restart",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -79,7 +79,8 @@
"toggle_theme": "Toggle theme",
"sdk_studio": "SDK Studio",
"login": "Login",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Serverhantering"
},
"auth": {
"login": "Login",
@@ -3006,5 +3007,80 @@
"preview_secondary": "Secondary",
"reset": "Reset",
"done": "Done"
},
"server_mgmt": {
"title": "Serverhantering",
"subtitle": "Cockpit-like control plane for the BetterDesk console host (BETA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "Översikt",
"tab_terminal": "Terminal",
"tab_files": "Filer",
"tab_services": "Tjänster",
"cpu": "CPU",
"memory": "Minne",
"load": "Load average",
"disks": "Diskar",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "Drifttid",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Anslut",
"term_disconnect": "Koppla från",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Name",
"files_size": "Size",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Service",
"svc_state": "State",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Starta",
"svc_stop": "Stoppa",
"svc_restart": "Starta om",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -79,7 +79,8 @@
"toggle_theme": "Toggle theme",
"sdk_studio": "SDK Studio",
"login": "Login",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "จัดการเซิร์ฟเวอร์"
},
"auth": {
"login": "Login",
@@ -3006,5 +3007,80 @@
"preview_secondary": "Secondary",
"reset": "Reset",
"done": "Done"
},
"server_mgmt": {
"title": "จัดการเซิร์ฟเวอร์",
"subtitle": "Cockpit-like control plane for the BetterDesk console host (BETA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "ภาพรวม",
"tab_terminal": "เทอร์มินัล",
"tab_files": "ไฟล์",
"tab_services": "บริการ",
"cpu": "CPU",
"memory": "หน่วยความจำ",
"load": "Load average",
"disks": "ดิสก์",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "เวลาทำงาน",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Connect",
"term_disconnect": "Disconnect",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Name",
"files_size": "Size",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Service",
"svc_state": "State",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Start",
"svc_stop": "Stop",
"svc_restart": "Restart",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -79,7 +79,8 @@
"toggle_theme": "Toggle theme",
"sdk_studio": "SDK Studio",
"login": "Login",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Sunucu Yönetimi"
},
"auth": {
"login": "Login",
@@ -3006,5 +3007,80 @@
"preview_secondary": "Secondary",
"reset": "Sıfırla",
"done": "Bitti"
},
"server_mgmt": {
"title": "Sunucu Yönetimi",
"subtitle": "Cockpit-like control plane for the BetterDesk console host (BETA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "Genel Bakış",
"tab_terminal": "Terminal",
"tab_files": "Dosyalar",
"tab_services": "Hizmetler",
"cpu": "CPU",
"memory": "Bellek",
"load": "Load average",
"disks": "Diskler",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "Çalışma süresi",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Bağlan",
"term_disconnect": "Bağlantıyı kes",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Name",
"files_size": "Size",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Service",
"svc_state": "State",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Başlat",
"svc_stop": "Durdur",
"svc_restart": "Yeniden başlat",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -79,7 +79,8 @@
"toggle_theme": "Toggle theme",
"sdk_studio": "SDK Studio",
"login": "Login",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Керування сервером"
},
"auth": {
"login": "Login",
@@ -3006,5 +3007,80 @@
"preview_secondary": "Secondary",
"reset": "Скинути",
"done": "Готово"
},
"server_mgmt": {
"title": "Керування сервером",
"subtitle": "Панель типу Cockpit для хоста консолі BetterDesk (БЕТА).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "Огляд",
"tab_terminal": "Термінал",
"tab_files": "Файли",
"tab_services": "Служби",
"cpu": "CPU",
"memory": "Памʼять",
"load": "Load average",
"disks": "Диски",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "Час роботи",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Зʼєднати",
"term_disconnect": "Розʼєднати",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Назва",
"files_size": "Розмір",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Служба",
"svc_state": "Стан",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Запустити",
"svc_stop": "Зупинити",
"svc_restart": "Перезапустити",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -79,7 +79,8 @@
"toggle_theme": "Toggle theme",
"sdk_studio": "SDK Studio",
"login": "Login",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "Quản lý máy chủ"
},
"auth": {
"login": "Login",
@@ -3006,5 +3007,80 @@
"preview_secondary": "Secondary",
"reset": "Reset",
"done": "Done"
},
"server_mgmt": {
"title": "Quản lý máy chủ",
"subtitle": "Cockpit-like control plane for the BetterDesk console host (BETA).",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "Tổng quan",
"tab_terminal": "Cửa sổ dòng lệnh",
"tab_files": "Tệp",
"tab_services": "Dịch vụ",
"cpu": "CPU",
"memory": "Bộ nhớ",
"load": "Load average",
"disks": "Ổ đĩa",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "Thời gian hoạt động",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "Kết nối",
"term_disconnect": "Ngắt kết nối",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "Name",
"files_size": "Size",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "Service",
"svc_state": "State",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "Start",
"svc_stop": "Stop",
"svc_restart": "Restart",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -73,7 +73,8 @@
"toggle_theme": "切換主題",
"sdk_studio": "SDK Studio",
"login": "登入",
"permissions": "Permissions"
"permissions": "Permissions",
"server_management": "伺服器管理"
},
"auth": {
"login": "登入",
@@ -3007,5 +3008,80 @@
"preview_secondary": "Secondary",
"reset": "重設",
"done": "完成"
},
"server_mgmt": {
"title": "伺服器管理",
"subtitle": "類似 Cockpit 的 BetterDesk 主控台主機管理面板(測試版)。",
"beta_tooltip": "Beta feature — under active development.",
"notice_title": "Beta feature",
"notice_body": "These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.",
"tab_overview": "概覽",
"tab_terminal": "終端機",
"tab_files": "檔案",
"tab_services": "服務",
"cpu": "CPU",
"memory": "記憶體",
"load": "Load average",
"disks": "磁碟",
"history": "History (last 60 samples)",
"host_info": "Host info",
"cores": "cores",
"uptime": "運行時間",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "連線",
"term_disconnect": "中斷連線",
"term_clear": "Clear",
"term_status": "Status",
"term_connecting": "Connecting…",
"term_connected": "Connected",
"term_disconnected": "Disconnected",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "名稱",
"files_size": "大小",
"files_perms": "Mode",
"files_mtime": "Modified",
"files_rename": "Rename",
"files_rename_prompt": "New name:",
"files_empty": "Empty directory",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "服務",
"svc_state": "狀態",
"svc_description": "Description",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "啟動",
"svc_stop": "停止",
"svc_restart": "重新啟動",
"svc_reload": "Reload",
"svc_enable": "Enable",
"svc_disable": "Disable",
"svc_status": "Status"
}
}
+77 -1
View File
@@ -73,7 +73,8 @@
"expand_sidebar": "展开侧边栏",
"toggle_theme": "切换主题",
"sdk_studio": "SDK Studio",
"login": "登录"
"login": "登录",
"server_management": "服务器管理"
},
"auth": {
"login": "登录",
@@ -3007,5 +3008,80 @@
"preview_secondary": "Secondary",
"reset": "重置",
"done": "完成"
},
"server_mgmt": {
"title": "服务器管理",
"subtitle": "类似 Cockpit 的 BetterDesk 控制台主机管理面板(测试版)。",
"beta_tooltip": "测试版功能 — 正在积极开发中。",
"notice_title": "测试版功能",
"notice_body": "这些工具在运行 BetterDesk 控制台进程的主机上执行。所有操作均被审计。",
"tab_overview": "概览",
"tab_terminal": "终端",
"tab_files": "文件",
"tab_services": "服务",
"cpu": "CPU",
"memory": "内存",
"load": "负载平均值",
"disks": "磁盘",
"history": "历史记录(最近 60 个样本)",
"host_info": "主机信息",
"cores": "核心",
"uptime": "运行时间",
"no_disks": "No disk data available",
"info_hostname": "Hostname",
"info_platform": "Platform",
"info_kernel": "Kernel",
"info_node": "Node.js",
"info_process_id": "Process ID",
"info_pty_available": "PTY available",
"term_connect": "连接",
"term_disconnect": "断开",
"term_clear": "清除",
"term_status": "Status",
"term_connecting": "连接中…",
"term_connected": "已连接",
"term_disconnected": "已断开",
"term_error": "Connection error",
"term_warning": "Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.",
"term_hint": "Tip: type `sudo -i` to escalate (requires sudoers configuration).",
"term_lib_failed": "Failed to load terminal library",
"files_up": "Up one level",
"files_go": "Go",
"files_mkdir": "New folder",
"files_name": "名称",
"files_size": "大小",
"files_perms": "权限",
"files_mtime": "修改时间",
"files_rename": "重命名",
"files_rename_prompt": "New name:",
"files_empty": "空目录",
"files_error": "Failed to list directory",
"read_failed": "Failed to read file",
"save_failed": "Failed to save file",
"rename_failed": "Rename failed",
"delete_failed": "Delete failed",
"delete_confirm": "Delete this entry?",
"mkdir_prompt": "New folder name:",
"mkdir_failed": "Failed to create folder",
"file_saved": "File saved",
"binary_warn": "Binary file — preview only",
"encoding_utf8": "UTF-8 text",
"truncated": "truncated",
"full": "full",
"svc_search_placeholder": "Search services…",
"svc_name": "服务",
"svc_state": "状态",
"svc_description": "说明",
"svc_empty": "No services found",
"svc_load_failed": "Failed to load services",
"svc_action_failed": "Action failed",
"svc_confirm": "Run",
"svc_start": "启动",
"svc_stop": "停止",
"svc_restart": "重启",
"svc_reload": "重新加载",
"svc_enable": "启用",
"svc_disable": "禁用",
"svc_status": "Status"
}
}
+4 -4
View File
@@ -12,8 +12,8 @@ const config = require('../config/config');
* Allow WebSocket connections (ws:// or wss:// depending on mode)
*/
const connectSources = config.httpsEnabled
? ["'self'", "wss:"]
: ["'self'", "ws:"];
? ["'self'", "wss:", "https://cdn.jsdelivr.net"]
: ["'self'", "ws:", "https://cdn.jsdelivr.net"];
function buildHelmetMiddleware(req, res) {
const nonce = crypto.randomBytes(16).toString('base64');
@@ -21,7 +21,7 @@ function buildHelmetMiddleware(req, res) {
res.locals.cspNonce = nonce;
const scriptSources = ["'self'", `'nonce-${nonce}'`];
const scriptSources = ["'self'", `'nonce-${nonce}'`, "https://cdn.jsdelivr.net"];
if (isRemoteViewerPage) {
// The remote viewer still depends on protobuf.js runtime code generation.
scriptSources.push("'unsafe-eval'");
@@ -35,7 +35,7 @@ function buildHelmetMiddleware(req, res) {
// Allow inline event handlers (onclick=, onchange=, etc.) used by
// several admin panel pages. <script> tags still require nonce.
scriptSrcAttr: ["'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdn.jsdelivr.net"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
imgSrc: ["'self'", "data:", "blob:"],
mediaSrc: ["'self'", "blob:"],
+2 -1
View File
@@ -39,7 +39,8 @@
"ws": "^8.19.0"
},
"optionalDependencies": {
"pg": "^8.13.0"
"pg": "^8.13.0",
"node-pty": "^1.0.0"
},
"overrides": {
"tar": "^7.5.11",
+569
View File
@@ -0,0 +1,569 @@
/* BetterDesk Console — Server Management page (BETA) */
.server-mgmt-page {
padding: 0;
}
/* btn-tertiary — used heavily on this page; not defined in main.css/tokens.css */
.server-mgmt-page .btn-tertiary,
.btn.btn-tertiary {
background: transparent;
color: var(--color-text, #e6edf3);
border: 1px solid var(--color-border, #30363d);
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 12.5px;
display: inline-flex;
align-items: center;
gap: 4px;
line-height: 1;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.server-mgmt-page .btn-tertiary:hover:not(:disabled),
.btn.btn-tertiary:hover:not(:disabled) {
background: var(--color-surface-hover, #1f2933);
border-color: #58a6ff;
color: #58a6ff;
}
.server-mgmt-page .btn-tertiary:disabled,
.btn.btn-tertiary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.server-mgmt-page .btn-tertiary .material-icons,
.btn.btn-tertiary .material-icons {
font-size: 16px;
}
.server-mgmt-header h1 {
display: flex;
align-items: center;
gap: 0.5rem;
}
.server-mgmt-header h1 .material-icons {
font-size: 28px;
color: var(--color-primary, #58a6ff);
}
.beta-badge {
display: inline-block;
padding: 2px 8px;
margin-left: 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
background: linear-gradient(135deg, #ff7b72, #d29922);
color: #fff;
vertical-align: middle;
}
.server-mgmt-notice {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 12px 16px;
margin: 0 0 16px 0;
border-radius: 8px;
background: rgba(210, 153, 34, 0.08);
border: 1px solid rgba(210, 153, 34, 0.4);
color: var(--color-text, #e6edf3);
}
.server-mgmt-notice .material-icons {
color: #d29922;
flex-shrink: 0;
}
.server-mgmt-notice strong {
display: block;
margin-bottom: 2px;
font-size: 13px;
}
.server-mgmt-notice p {
margin: 0;
font-size: 12.5px;
color: var(--color-text-muted, #8b949e);
line-height: 1.45;
}
/* Full-width page. Main navigation lives in the sidebar flyout. */
.server-mgmt-layout {
display: block;
width: 100%;
}
.server-mgmt-tabs {
display: none;
}
.server-mgmt-content {
width: 100%;
min-width: 0;
}
.sm-tab-btn {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 6px;
background: transparent;
border: 1px solid transparent;
color: var(--color-text, #e6edf3);
cursor: pointer;
font-size: 13.5px;
font-weight: 500;
text-align: left;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.sm-tab-btn:hover {
background: var(--color-surface-hover, #1f2933);
}
.sm-tab-btn.active {
background: rgba(88, 166, 255, 0.12);
border-color: rgba(88, 166, 255, 0.4);
color: var(--color-primary, #58a6ff);
}
.sm-tab-btn .material-icons {
font-size: 20px;
}
.sm-tab-panel {
animation: smFadeIn 0.18s ease;
}
@keyframes smFadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Overview ── */
.sm-overview-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.sm-card {
padding: 14px 16px;
border-radius: 8px;
background: var(--color-surface, #161b22);
border: 1px solid var(--color-border, #30363d);
min-width: 0;
}
.sm-card-wide {
grid-column: span 3;
}
.sm-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.sm-card-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.sm-card-header .material-icons {
color: var(--color-text-muted, #8b949e);
font-size: 20px;
}
.sm-gauge {
margin-bottom: 8px;
}
.sm-gauge-bar {
height: 10px;
border-radius: 5px;
background: var(--color-surface-alt, #0d1117);
overflow: hidden;
}
.sm-gauge-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #3fb950, #d29922 60%, #ff7b72);
transition: width 0.4s ease;
}
.sm-gauge-value {
margin-top: 6px;
font-size: 22px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.sm-meta {
font-size: 12px;
color: var(--color-text-muted, #8b949e);
}
.sm-load-row {
display: flex;
gap: 16px;
margin-bottom: 6px;
font-size: 18px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.sm-load-row .sm-stat-label {
display: block;
font-size: 11px;
font-weight: 400;
color: var(--color-text-muted, #8b949e);
margin-bottom: 2px;
}
.sm-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 6px 18px;
font-size: 13px;
}
.sm-info-grid .sm-info-key {
color: var(--color-text-muted, #8b949e);
margin-right: 6px;
}
.sm-info-grid .sm-info-val {
font-family: 'Cascadia Code', 'Fira Code', monospace;
}
.sm-disk-row {
display: grid;
grid-template-columns: 1fr 1fr 110px;
gap: 12px;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--color-border, #30363d);
}
.sm-disk-row:last-child { border-bottom: none; }
.sm-disk-mount {
font-family: 'Cascadia Code', monospace;
font-size: 13px;
}
.sm-disk-bar {
height: 8px;
border-radius: 4px;
background: var(--color-surface-alt, #0d1117);
overflow: hidden;
}
.sm-disk-bar-fill {
height: 100%;
background: linear-gradient(90deg, #3fb950, #d29922 70%, #ff7b72);
}
.sm-disk-stat {
text-align: right;
font-variant-numeric: tabular-nums;
font-size: 12.5px;
color: var(--color-text-muted, #8b949e);
}
.sm-history-wrap {
width: 100%;
}
.sm-history-wrap canvas {
width: 100%;
height: 120px;
display: block;
}
.sm-history-legend {
display: flex;
gap: 16px;
margin-top: 6px;
font-size: 12px;
color: var(--color-text-muted, #8b949e);
}
.sm-history-legend .dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
margin-right: 4px;
vertical-align: middle;
}
.sm-history-legend .dot-cpu { background: #58a6ff; }
.sm-history-legend .dot-mem { background: #d29922; }
/* ── Terminal ── */
.sm-terminal-toolbar {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.sm-term-status {
margin-left: auto;
font-size: 12.5px;
color: var(--color-text-muted, #8b949e);
}
.sm-term-status.connected { color: #3fb950; }
.sm-term-status.error { color: #ff7b72; }
.sm-terminal-warning {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
background: rgba(255, 123, 114, 0.08);
border: 1px solid rgba(255, 123, 114, 0.3);
color: var(--color-text, #e6edf3);
font-size: 12.5px;
margin-bottom: 8px;
}
.sm-terminal-warning .material-icons {
color: #ff7b72;
}
.sm-terminal-host {
width: 100%;
height: 520px;
border-radius: 8px;
background: #0d1117;
padding: 8px;
border: 1px solid var(--color-border, #30363d);
}
.sm-term-hint {
margin-top: 6px;
font-size: 12px;
color: var(--color-text-muted, #8b949e);
}
/* ── Files ── */
.sm-files-toolbar {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 10px;
}
.sm-files-toolbar .spacer { flex: 1; }
.sm-files-path {
flex: 1;
min-width: 200px;
font-family: 'Cascadia Code', monospace;
font-size: 13px;
}
.sm-files-table-wrapper {
border-radius: 8px;
border: 1px solid var(--color-border, #30363d);
overflow: auto;
max-height: 540px;
}
.sm-files-table {
width: 100%;
border-collapse: collapse;
}
.sm-files-table tbody tr {
cursor: pointer;
}
.sm-files-table tbody tr:hover {
background: var(--color-surface-hover, #1f2933);
}
.sm-files-table .material-icons {
color: var(--color-text-muted, #8b949e);
font-size: 20px;
vertical-align: middle;
}
.sm-files-table .sm-file-icon-dir { color: #58a6ff; }
.sm-files-table .sm-file-icon-link { color: #d29922; }
.sm-files-table .sm-file-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
}
.sm-files-table .sm-file-actions .btn {
padding: 4px 8px;
font-size: 12px;
}
.sm-files-table .sm-file-actions .material-icons {
font-size: 16px;
}
.sm-file-editor {
margin-top: 12px;
border-radius: 8px;
border: 1px solid var(--color-border, #30363d);
background: var(--color-surface, #161b22);
overflow: hidden;
}
.sm-file-editor-header {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border, #30363d);
background: var(--color-surface-alt, #0d1117);
}
.sm-file-editor-header .spacer { flex: 1; }
.sm-file-editor-path {
font-family: 'Cascadia Code', monospace;
font-size: 13px;
color: var(--color-text, #e6edf3);
}
.sm-file-textarea {
width: 100%;
min-height: 380px;
padding: 10px 12px;
background: #0d1117;
color: #e6edf3;
border: none;
resize: vertical;
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 13px;
line-height: 1.5;
outline: none;
}
.sm-file-meta {
margin: 0;
padding: 6px 12px;
border-top: 1px solid var(--color-border, #30363d);
font-size: 11.5px;
color: var(--color-text-muted, #8b949e);
}
/* ── Services ── */
.sm-services-toolbar {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 10px;
}
.sm-services-search {
flex: 1;
min-width: 240px;
}
.sm-services-count {
font-size: 12.5px;
color: var(--color-text-muted, #8b949e);
}
.sm-services-table-wrapper {
border-radius: 8px;
border: 1px solid var(--color-border, #30363d);
overflow: auto;
max-height: 620px;
}
.sm-services-table {
width: 100%;
border-collapse: collapse;
}
.sm-services-table .sm-svc-name {
font-family: 'Cascadia Code', monospace;
font-size: 13px;
word-break: break-all;
}
.sm-svc-state {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
padding: 2px 8px;
border-radius: 12px;
background: rgba(110, 118, 129, 0.2);
color: var(--color-text-muted, #8b949e);
}
.sm-svc-state.active {
background: rgba(63, 185, 80, 0.15);
color: #3fb950;
}
.sm-svc-state.failed {
background: rgba(255, 123, 114, 0.15);
color: #ff7b72;
}
.sm-svc-actions {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.sm-svc-actions .btn {
padding: 3px 8px;
font-size: 12px;
}
.sm-svc-actions .material-icons {
font-size: 14px;
}
/* ── Responsive ── */
@media (max-width: 980px) {
.sm-overview-grid {
grid-template-columns: 1fr 1fr;
}
.sm-card-wide {
grid-column: span 2;
}
}
@media (max-width: 640px) {
.sm-overview-grid {
grid-template-columns: 1fr;
}
.sm-card-wide {
grid-column: span 1;
}
.sm-tab-btn .sm-tab-label {
display: none;
}
.sm-tab-btn {
padding: 8px;
}
}
+5
View File
@@ -214,6 +214,11 @@
case 'error':
console.error('[CDAPDesktop] Error:', msg.error);
if (session.overlay) {
session.overlay.classList.remove('hidden');
const label = session.overlay.querySelector('span:last-child');
if (label) label.textContent = msg.error || 'Remote desktop error';
}
break;
case 'end':
+689
View File
@@ -0,0 +1,689 @@
/**
* BetterDesk Console Server Management page (BETA)
* Tabs: overview, terminal, files, services
*/
(function () {
'use strict';
// ── i18n helpers ─────────────────────────────────────────────────────
function t(key, fallback) {
if (window.i18n && typeof window.i18n.t === 'function') return window.i18n.t(key, fallback);
return fallback || key;
}
function getCsrfToken() {
return (window.BetterDesk && window.BetterDesk.csrfToken) || '';
}
async function api(path, options = {}) {
const opts = Object.assign({ method: 'GET', headers: {} }, options);
opts.headers = Object.assign({}, opts.headers || {});
if (opts.body && typeof opts.body !== 'string') {
opts.body = JSON.stringify(opts.body);
opts.headers['Content-Type'] = 'application/json';
}
if (opts.method && opts.method !== 'GET') {
opts.headers['x-csrf-token'] = getCsrfToken();
}
opts.credentials = 'same-origin';
const res = await fetch(path, opts);
const text = await res.text();
let data;
try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; }
if (!res.ok || data.success === false) {
const err = new Error(data.error || `HTTP ${res.status}`);
err.data = data;
err.status = res.status;
throw err;
}
return data;
}
function formatBytes(n) {
if (!Number.isFinite(n) || n < 0) return '';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i ? 1 : 0)} ${units[i]}`;
}
function formatUptime(s) {
if (!Number.isFinite(s) || s < 0) return '';
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
if (d) return `${d}d ${h}h ${m}m`;
if (h) return `${h}h ${m}m`;
return `${m}m`;
}
function escapeHtml(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function showToast(msg, type) {
if (window.Toast && typeof window.Toast[type || 'info'] === 'function') {
window.Toast[type || 'info']('', msg);
} else {
console.log('[server-mgmt]', type || 'info', msg);
}
}
// ── Tab switching ────────────────────────────────────────────────────
function activateTab(target) {
const buttons = document.querySelectorAll('.sm-tab-btn');
const panels = document.querySelectorAll('.sm-tab-panel');
let resolved = target;
if (!document.getElementById(`sm-panel-${resolved}`)) resolved = 'overview';
buttons.forEach((b) => b.classList.toggle('active', b.getAttribute('data-tab') === resolved));
panels.forEach((p) => {
const match = p.id === `sm-panel-${resolved}`;
p.classList.toggle('active', match);
p.hidden = !match;
});
if (resolved === 'overview') overview.start();
else overview.stop();
if (resolved === 'files' && !filesView.loaded) filesView.refresh();
if (resolved === 'services' && !servicesView.loaded) servicesView.refresh();
if (resolved === 'terminal') {
terminalView.fit();
// Auto-connect on first visit (BETA convenience)
if (!terminalView.hasConnected()) terminalView.connect();
}
// Reflect in URL without reload
try {
const url = new URL(window.location.href);
url.searchParams.set('tab', resolved);
window.history.replaceState({}, '', url.toString());
} catch (_) { /* ignore */ }
}
function initTabs() {
const buttons = document.querySelectorAll('.sm-tab-btn');
buttons.forEach((btn) => {
btn.addEventListener('click', () => activateTab(btn.getAttribute('data-tab')));
});
}
// ── Overview tab ─────────────────────────────────────────────────────
const overview = (() => {
let timer = null;
let lastInfo = null;
const history = []; // { cpu, mem }
async function tick() {
try {
const [info, snap] = await Promise.all([
lastInfo ? Promise.resolve(lastInfo) : api('/api/server-management/info'),
api('/api/server-management/resources')
]);
if (!lastInfo) lastInfo = info;
render(info, snap.snapshot);
} catch (err) {
console.warn('[server-mgmt] overview tick failed:', err.message);
}
}
function start() {
stop();
tick();
timer = setInterval(tick, 2000);
}
function stop() {
if (timer) { clearInterval(timer); timer = null; }
}
function render(info, snap) {
if (!snap || typeof snap !== 'object') return;
// CPU — backend returns sample.cpu as number (percent)
const cpuPct = Math.max(0, Math.min(100, Math.round(Number(snap.cpu) || 0)));
document.getElementById('sm-cpu-fill').style.width = `${cpuPct}%`;
document.getElementById('sm-cpu-value').textContent = `${cpuPct}%`;
document.getElementById('sm-cpu-meta').textContent =
`${snap.cpuModel || ''} · ${snap.cpuCount || 0} ${t('server_mgmt.cores', 'cores')}`;
// Memory — backend returns sample.mem.{total,free,used,percent}
const mem = snap.mem || {};
const memPct = Math.max(0, Math.min(100, Math.round(Number(mem.percent) || 0)));
document.getElementById('sm-mem-fill').style.width = `${memPct}%`;
document.getElementById('sm-mem-value').textContent = `${memPct}%`;
document.getElementById('sm-mem-meta').textContent =
`${formatBytes(mem.used)} / ${formatBytes(mem.total)}`;
// Load avg / uptime — backend returns sample.load array
const load = Array.isArray(snap.load) ? snap.load : [0, 0, 0];
document.getElementById('sm-load-1').textContent = (Number(load[0]) || 0).toFixed(2);
document.getElementById('sm-load-5').textContent = (Number(load[1]) || 0).toFixed(2);
document.getElementById('sm-load-15').textContent = (Number(load[2]) || 0).toFixed(2);
document.getElementById('sm-uptime-meta').textContent =
`${t('server_mgmt.uptime', 'Uptime')}: ${formatUptime(snap.uptime)}`;
// Disks — backend returns sample.disks[] with {mount, fstype, size, used, avail}
const disksHost = document.getElementById('sm-disks');
disksHost.innerHTML = (snap.disks || []).map((d) => {
const total = Number(d.size || d.total) || 0;
const used = Number(d.used) || 0;
const pct = total > 0 ? Math.max(0, Math.min(100, Math.round((used / total) * 100))) : 0;
return `
<div class="sm-disk-row">
<div class="sm-disk-mount">${escapeHtml(d.mount || d.fs || '?')}</div>
<div class="sm-disk-bar"><div class="sm-disk-bar-fill" style="width:${pct}%"></div></div>
<div class="sm-disk-stat">${formatBytes(used)} / ${formatBytes(total)} (${pct}%)</div>
</div>
`;
}).join('') || `<div class="sm-meta">${t('server_mgmt.no_disks', 'No disk data available')}</div>`;
// Host info
const hi = document.getElementById('sm-host-info');
const rows = [
['hostname', snap.hostname],
['platform', `${snap.platform} (${snap.arch})`],
['kernel', snap.release || snap.osVersion],
['node', info.nodeVersion || snap.nodeVersion],
['process_id', info.pid],
['pty_available', info.ptyAvailable ? '✓' : '✗']
];
hi.innerHTML = rows.map(([k, v]) =>
`<div><span class="sm-info-key">${escapeHtml(t('server_mgmt.info_' + k, k))}:</span><span class="sm-info-val">${escapeHtml(v != null ? String(v) : '')}</span></div>`
).join('');
// History
history.push({ cpu: cpuPct, mem: memPct });
if (history.length > 60) history.shift();
drawHistory();
}
function drawHistory() {
const canvas = document.getElementById('sm-history-chart');
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth || 600;
const h = canvas.clientHeight || 120;
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
canvas.width = w * dpr;
canvas.height = h * dpr;
}
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// Grid
ctx.strokeStyle = 'rgba(139, 148, 158, 0.2)';
ctx.lineWidth = 1;
for (let p = 0; p <= 100; p += 25) {
const y = h - (p / 100) * h;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
if (history.length < 2) return;
const step = w / Math.max(1, history.length - 1);
const drawSeries = (key, color) => {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
history.forEach((pt, i) => {
const x = i * step;
const y = h - (pt[key] / 100) * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
};
drawSeries('cpu', '#58a6ff');
drawSeries('mem', '#d29922');
}
return { start, stop };
})();
// ── Files tab ────────────────────────────────────────────────────────
const filesView = (() => {
let cwd = '/';
let editing = null; // { path, size, mtime, etag }
let loaded = false;
async function refresh(path) {
const target = path || document.getElementById('sm-files-path').value || cwd;
try {
const data = await api(`/api/server-management/files?path=${encodeURIComponent(target)}`);
cwd = data.cwd || target;
loaded = true;
document.getElementById('sm-files-path').value = cwd;
renderEntries(data.entries || [], data.parent);
} catch (err) {
showToast(err.message || t('server_mgmt.files_error', 'Failed to list directory'), 'error');
renderEntries([], null);
}
}
function renderEntries(entries, parent) {
const body = document.getElementById('sm-files-body');
const rows = entries.map((e) => {
const icon = e.type === 'dir'
? '<span class="material-icons sm-file-icon-dir">folder</span>'
: (e.type === 'symlink'
? '<span class="material-icons sm-file-icon-link">link</span>'
: '<span class="material-icons">description</span>');
const sizeText = e.type === 'dir' ? '' : formatBytes(e.size || 0);
const perms = e.mode != null ? (e.mode & 0o7777).toString(8).padStart(4, '0') : '';
const mtime = e.mtime ? new Date(e.mtime).toLocaleString() : '';
const acts = [];
if (e.type === 'file') {
acts.push(`<button class="btn btn-tertiary" data-act="open" data-path="${escapeHtml(e.path)}" title="${t('common.edit', 'Edit')}"><span class="material-icons">edit</span></button>`);
}
acts.push(`<button class="btn btn-tertiary" data-act="rename" data-path="${escapeHtml(e.path)}" data-name="${escapeHtml(e.name)}" title="${t('server_mgmt.files_rename', 'Rename')}"><span class="material-icons">drive_file_rename_outline</span></button>`);
acts.push(`<button class="btn btn-tertiary" data-act="delete" data-path="${escapeHtml(e.path)}" title="${t('common.delete', 'Delete')}"><span class="material-icons">delete</span></button>`);
return `
<tr data-type="${e.type}" data-path="${escapeHtml(e.path)}" data-name="${escapeHtml(e.name)}">
<td>${icon}</td>
<td>${escapeHtml(e.name)}</td>
<td>${sizeText}</td>
<td><code>${perms}</code></td>
<td>${escapeHtml(mtime)}</td>
<td><div class="sm-file-actions">${acts.join('')}</div></td>
</tr>
`;
});
body.innerHTML = rows.join('') ||
`<tr><td colspan="6"><div class="empty-state"><span class="material-icons">folder_off</span><p>${t('server_mgmt.files_empty', 'Empty directory')}</p></div></td></tr>`;
body.querySelectorAll('tr[data-type]').forEach((tr) => {
tr.addEventListener('click', (ev) => {
if (ev.target.closest('button')) return;
if (tr.getAttribute('data-type') === 'dir') refresh(tr.getAttribute('data-path'));
});
});
body.querySelectorAll('button[data-act]').forEach((btn) => {
btn.addEventListener('click', (ev) => {
ev.stopPropagation();
const act = btn.getAttribute('data-act');
const p = btn.getAttribute('data-path');
if (act === 'open') openFile(p);
else if (act === 'rename') renameFile(p, btn.getAttribute('data-name'));
else if (act === 'delete') deleteFile(p);
});
});
}
async function openFile(path) {
try {
const data = await api(`/api/server-management/files/read?path=${encodeURIComponent(path)}`);
editing = { path, size: data.size, mtime: data.mtime };
const ed = document.getElementById('sm-file-editor');
document.getElementById('sm-file-editor-path').textContent = path;
document.getElementById('sm-file-textarea').value = data.content || '';
document.getElementById('sm-file-meta').textContent =
`${formatBytes(data.size || 0)} · ${data.binary ? t('server_mgmt.binary_warn', 'Binary file — preview only') : t('server_mgmt.encoding_utf8', 'UTF-8 text')} · ${data.truncated ? t('server_mgmt.truncated', 'truncated') : t('server_mgmt.full', 'full')}`;
ed.hidden = false;
document.getElementById('sm-file-textarea').focus();
} catch (err) {
showToast(err.message || t('server_mgmt.read_failed', 'Failed to read file'), 'error');
}
}
async function saveFile() {
if (!editing) return;
const content = document.getElementById('sm-file-textarea').value;
try {
await api('/api/server-management/files/write', {
method: 'POST',
body: { path: editing.path, content }
});
showToast(t('server_mgmt.file_saved', 'File saved'), 'success');
refresh();
} catch (err) {
showToast(err.message || t('server_mgmt.save_failed', 'Failed to save file'), 'error');
}
}
async function renameFile(path, name) {
const next = window.prompt(t('server_mgmt.files_rename_prompt', 'New name:'), name || '');
if (!next || next === name) return;
const parent = path.replace(/[/\\][^/\\]*$/, '') || '/';
const sep = path.includes('\\') ? '\\' : '/';
const target = parent.endsWith(sep) ? parent + next : parent + sep + next;
try {
await api('/api/server-management/files/rename', { method: 'POST', body: { from: path, to: target } });
refresh();
} catch (err) {
showToast(err.message || t('server_mgmt.rename_failed', 'Rename failed'), 'error');
}
}
async function deleteFile(path) {
if (!window.confirm(t('server_mgmt.delete_confirm', 'Delete this entry?') + '\n' + path)) return;
try {
await api('/api/server-management/files/delete', { method: 'POST', body: { path } });
refresh();
} catch (err) {
showToast(err.message || t('server_mgmt.delete_failed', 'Delete failed'), 'error');
}
}
async function mkdir() {
const name = window.prompt(t('server_mgmt.mkdir_prompt', 'New folder name:'), 'new-folder');
if (!name) return;
const sep = cwd.includes('\\') ? '\\' : '/';
const target = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
try {
await api('/api/server-management/files/mkdir', { method: 'POST', body: { path: target } });
refresh();
} catch (err) {
showToast(err.message || t('server_mgmt.mkdir_failed', 'mkdir failed'), 'error');
}
}
function up() {
const sep = cwd.includes('\\') ? '\\' : '/';
const trimmed = cwd.replace(/[\\/]+$/, '');
const parent = trimmed.replace(/[/\\][^/\\]*$/, '') || sep;
refresh(parent);
}
function init() {
document.getElementById('sm-files-up').addEventListener('click', up);
document.getElementById('sm-files-refresh').addEventListener('click', () => refresh());
document.getElementById('sm-files-go').addEventListener('click', () => refresh());
document.getElementById('sm-files-mkdir').addEventListener('click', mkdir);
document.getElementById('sm-files-path').addEventListener('keydown', (e) => {
if (e.key === 'Enter') refresh();
});
document.getElementById('sm-file-save').addEventListener('click', saveFile);
document.getElementById('sm-file-close').addEventListener('click', () => {
document.getElementById('sm-file-editor').hidden = true;
editing = null;
});
}
return { init, refresh, get loaded() { return loaded; } };
})();
// ── Services tab ─────────────────────────────────────────────────────
const servicesView = (() => {
let services = [];
let loaded = false;
async function refresh() {
try {
const data = await api('/api/server-management/services');
services = data.services || [];
loaded = true;
render();
} catch (err) {
showToast(err.message || t('server_mgmt.svc_load_failed', 'Failed to load services'), 'error');
}
}
function render() {
const filter = (document.getElementById('sm-services-search').value || '').toLowerCase();
const filtered = filter
? services.filter((s) =>
(s.name || '').toLowerCase().includes(filter) ||
(s.description || '').toLowerCase().includes(filter))
: services;
const body = document.getElementById('sm-services-body');
const isWin = /^win/i.test(navigator.platform);
const rows = filtered.map((s) => {
const stateClass = s.state === 'active' || s.state === 'running' ? 'active'
: (s.state === 'failed' ? 'failed' : '');
const actions = (isWin
? ['start', 'stop', 'restart']
: ['start', 'stop', 'restart', 'reload', 'enable', 'disable']
).map((act) => `<button class="btn btn-tertiary" data-svc="${escapeHtml(s.name)}" data-act="${act}">
<span class="material-icons">${actionIcon(act)}</span>${t('server_mgmt.svc_' + act, act)}
</button>`).join('');
return `
<tr>
<td><span class="sm-svc-name">${escapeHtml(s.name)}</span></td>
<td><span class="sm-svc-state ${stateClass}">${escapeHtml(s.state || s.status || '')}</span></td>
<td>${escapeHtml(s.description || '')}</td>
<td><div class="sm-svc-actions">${actions}</div></td>
</tr>
`;
});
body.innerHTML = rows.join('') ||
`<tr><td colspan="4"><div class="empty-state"><span class="material-icons">miscellaneous_services</span><p>${t('server_mgmt.svc_empty', 'No services found')}</p></div></td></tr>`;
document.getElementById('sm-services-count').textContent =
`${filtered.length} / ${services.length}`;
body.querySelectorAll('button[data-svc]').forEach((btn) => {
btn.addEventListener('click', () => act(btn.getAttribute('data-svc'), btn.getAttribute('data-act')));
});
}
function actionIcon(a) {
return ({
start: 'play_arrow', stop: 'stop', restart: 'restart_alt',
reload: 'cached', enable: 'check_circle', disable: 'block', status: 'info'
}[a]) || 'play_arrow';
}
async function act(name, action) {
const dangerous = ['stop', 'disable'];
if (dangerous.includes(action)) {
if (!window.confirm(`${t('server_mgmt.svc_confirm', 'Run')} ${action} on ${name}?`)) return;
}
try {
const r = await api(`/api/server-management/services/${encodeURIComponent(name)}/${action}`, { method: 'POST' });
showToast(`${name}${action} (exit ${r.exitCode})`, r.exitCode === 0 ? 'success' : 'warning');
refresh();
} catch (err) {
showToast(err.message || t('server_mgmt.svc_action_failed', 'Action failed'), 'error');
}
}
function init() {
document.getElementById('sm-services-refresh').addEventListener('click', refresh);
document.getElementById('sm-services-search').addEventListener('input', () => render());
}
return { init, refresh, get loaded() { return loaded; } };
})();
// ── Terminal tab ─────────────────────────────────────────────────────
const terminalView = (() => {
// xterm core: 5.5.0; addon-fit has its own version (0.10.0)
const XTERM_VERSION = '5.5.0';
const XTERM_FIT_VERSION = '0.10.0';
const XTERM_CSS_URL = `https://cdn.jsdelivr.net/npm/@xterm/xterm@${XTERM_VERSION}/css/xterm.min.css`;
const XTERM_JS_URL = `https://cdn.jsdelivr.net/npm/@xterm/xterm@${XTERM_VERSION}/lib/xterm.min.js`;
const XTERM_FIT_URL = `https://cdn.jsdelivr.net/npm/@xterm/addon-fit@${XTERM_FIT_VERSION}/lib/addon-fit.min.js`;
let xtermLoaded = false;
let xtermLoading = null;
let term = null;
let fitAddon = null;
let ws = null;
let connected = false;
let everConnected = false;
function loadXterm() {
if (xtermLoaded) return Promise.resolve();
if (xtermLoading) return xtermLoading;
xtermLoading = new Promise((resolve, reject) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = XTERM_CSS_URL;
document.head.appendChild(link);
const s1 = document.createElement('script');
s1.src = XTERM_JS_URL;
s1.onload = () => {
const s2 = document.createElement('script');
s2.src = XTERM_FIT_URL;
s2.onload = () => { xtermLoaded = true; resolve(); };
s2.onerror = () => reject(new Error('Failed to load xterm-addon-fit'));
document.head.appendChild(s2);
};
s1.onerror = () => reject(new Error('Failed to load xterm.js'));
document.head.appendChild(s1);
});
return xtermLoading;
}
function setStatus(text, cls) {
const el = document.getElementById('sm-term-status');
el.textContent = text;
el.className = 'sm-term-status' + (cls ? ' ' + cls : '');
}
async function connect() {
try { await loadXterm(); }
catch (err) {
showToast(err.message, 'error');
return;
}
const Terminal = window.Terminal;
const FitAddon = window.FitAddon && window.FitAddon.FitAddon;
if (!Terminal || !FitAddon) {
showToast(t('server_mgmt.term_lib_failed', 'Terminal library failed to load'), 'error');
return;
}
const host = document.getElementById('sm-terminal-host');
host.innerHTML = '';
term = new Terminal({
cursorBlink: true,
fontFamily: 'Cascadia Code, Fira Code, Menlo, monospace',
fontSize: 13,
theme: {
background: '#0d1117',
foreground: '#e6edf3',
cursor: '#58a6ff'
}
});
fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(host);
try { fitAddon.fit(); } catch (_) { /* ignore */ }
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${window.location.host}/ws/server-management/terminal`;
ws = new WebSocket(wsUrl);
setStatus(t('server_mgmt.term_connecting', 'Connecting…'));
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'init', cols: term.cols, rows: term.rows }));
};
ws.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch (_) { return; }
if (msg.type === 'ready') {
connected = true;
everConnected = true;
setStatus(`${t('server_mgmt.term_connected', 'Connected')} · ${msg.user || ''}@${msg.platform || ''} (${msg.kind})`, 'connected');
document.getElementById('sm-term-connect').disabled = true;
document.getElementById('sm-term-disconnect').disabled = false;
document.getElementById('sm-term-clear').disabled = false;
if (!msg.pty_available) {
term.writeln('\x1b[33m[!] node-pty not available — running in pipe-fallback mode (limited interactivity)\x1b[0m');
}
} else if (msg.type === 'output') {
term.write(msg.data);
} else if (msg.type === 'end') {
term.writeln(`\r\n\x1b[90m[shell exited code=${msg.code} signal=${msg.signal || ''}]\x1b[0m`);
} else if (msg.type === 'error') {
term.writeln(`\r\n\x1b[31m[error] ${msg.error}\x1b[0m`);
setStatus(msg.error, 'error');
}
};
ws.onclose = () => {
connected = false;
setStatus(t('server_mgmt.term_disconnected', 'Disconnected'));
document.getElementById('sm-term-connect').disabled = false;
document.getElementById('sm-term-disconnect').disabled = true;
};
ws.onerror = () => setStatus(t('server_mgmt.term_error', 'Connection error'), 'error');
term.onData((d) => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: d }));
});
// Resize handler
const handleResize = () => {
if (!fitAddon || !term) return;
try { fitAddon.fit(); } catch (_) { return; }
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
}
};
window.addEventListener('resize', handleResize);
term._cleanupResize = () => window.removeEventListener('resize', handleResize);
}
function disconnect() {
if (ws) {
try { ws.send(JSON.stringify({ type: 'close' })); } catch (_) { /* ignore */ }
try { ws.close(); } catch (_) { /* ignore */ }
ws = null;
}
if (term) {
try { term._cleanupResize && term._cleanupResize(); } catch (_) { /* ignore */ }
try { term.dispose(); } catch (_) { /* ignore */ }
term = null;
fitAddon = null;
}
document.getElementById('sm-terminal-host').innerHTML = '';
document.getElementById('sm-term-connect').disabled = false;
document.getElementById('sm-term-disconnect').disabled = true;
document.getElementById('sm-term-clear').disabled = true;
setStatus(t('server_mgmt.term_disconnected', 'Disconnected'));
}
function clear() {
if (term) term.clear();
}
function fit() {
if (term && fitAddon) {
try { fitAddon.fit(); } catch (_) { /* ignore */ }
}
}
function init() {
document.getElementById('sm-term-connect').addEventListener('click', connect);
document.getElementById('sm-term-disconnect').addEventListener('click', disconnect);
document.getElementById('sm-term-clear').addEventListener('click', clear);
}
return { init, fit, connect, hasConnected: () => everConnected };
})();
// ── Bootstrap ────────────────────────────────────────────────────────
function bootstrap() {
const page = document.getElementById('server-mgmt-page');
if (!page) return;
initTabs();
filesView.init();
servicesView.init();
terminalView.init();
// Determine initial tab: ?tab= query param > data-initial-tab > 'overview'
let initialTab = 'overview';
try {
const params = new URLSearchParams(window.location.search);
const queryTab = params.get('tab');
if (queryTab) initialTab = queryTab;
else if (page.dataset.initialTab) initialTab = page.dataset.initialTab;
} catch (_) { /* ignore */ }
activateTab(initialTab);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap);
} else {
bootstrap();
}
})();
+2 -21
View File
@@ -7,7 +7,6 @@ const express = require('express');
const router = express.Router();
const authService = require('../services/authService');
const db = require('../services/database');
const { manager } = require('../services/i18nService');
const { guestOnly, requireAuth } = require('../middleware/auth');
const { loginLimiter, passwordChangeLimiter } = require('../middleware/rateLimiter');
@@ -110,21 +109,12 @@ router.post('/api/auth/login', loginLimiter, async (req, res) => {
return res.status(500).json({ success: false, error: 'Server error' });
}
const cookieLang = req.cookies?.betterdesk_lang;
const preferredLanguage = manager.hasLanguage(cookieLang) ? cookieLang : (user.preferred_language || null);
if (preferredLanguage && preferredLanguage !== user.preferred_language && typeof db.updateUserLanguage === 'function') {
db.updateUserLanguage(user.id, preferredLanguage).catch(err => {
console.warn(`[auth] Failed to persist language for ${user.username}:`, err.message);
});
}
// Restore session data
req.session.userId = user.id;
req.session.user = {
id: user.id,
username: user.username,
role: user.role,
preferred_language: preferredLanguage
role: user.role
};
// Log successful login
@@ -286,22 +276,13 @@ router.post('/api/auth/totp/verify', loginLimiter, async (req, res) => {
});
}
const cookieLang = req.cookies?.betterdesk_lang;
const preferredLanguage = manager.hasLanguage(cookieLang) ? cookieLang : (pendingUser.preferred_language || null);
if (preferredLanguage && preferredLanguage !== pendingUser.preferred_language && typeof db.updateUserLanguage === 'function') {
db.updateUserLanguage(pendingUser.id, preferredLanguage).catch(err => {
console.warn(`[auth] Failed to persist language for ${pendingUser.username}:`, err.message);
});
}
// Pending fields belonged to the old session; the regenerated session
// starts empty, so we only need to populate the authenticated user.
req.session.userId = pendingUser.id;
req.session.user = {
id: pendingUser.id,
username: pendingUser.username,
role: pendingUser.role,
preferred_language: preferredLanguage
role: pendingUser.role
};
try {
+2
View File
@@ -59,6 +59,7 @@ const systemRoutes = lazyRoute('./system.routes');
const cdapStudioRoutes = lazyRoute('./cdap-studio.routes');
const permissionsRoutes = lazyRoute('./permissions.routes');
const phase45Routes = lazyRoute('./phase4_5.routes');
const serverManagementRoutes = lazyRoute('./server-management.routes');
/**
* Middleware to require JSON Content-Type for POST/PATCH/PUT requests to API routes.
@@ -145,6 +146,7 @@ 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('/', serverManagementRoutes); // admin-facing: /server-management, /api/server-management/* (BETA)
router.use('/', phase45Routes); // Phase 4/5: /api/users/me/profile, /api/agent-templates, /portal
module.exports = router;
@@ -0,0 +1,197 @@
/**
* BetterDesk Console Server Management Routes (BETA)
*
* Page:
* GET /server-management
*
* REST API (all require server.config permission):
* GET /api/server-management/info
* GET /api/server-management/resources
* GET /api/server-management/files?path=<abs>
* GET /api/server-management/files/read?path=<abs>
* POST /api/server-management/files/write {path, content}
* POST /api/server-management/files/mkdir {path}
* POST /api/server-management/files/rename {from, to}
* POST /api/server-management/files/delete {path}
* GET /api/server-management/services
* POST /api/server-management/services/:name/:action
*
* The terminal endpoint is WebSocket-based and lives on the HTTP upgrade
* path /ws/server-management/terminal see services/serverTerminalProxy.js.
*/
'use strict';
const express = require('express');
const router = express.Router();
const { requireAuth, requirePermission } = require('../middleware/auth');
const sm = require('../services/serverManagement');
const { isPtyAvailable, activeSessionCount } = require('../services/serverTerminalProxy');
const db = require('../services/database');
const REQUIRED_PERMISSION = 'server.config';
// All endpoints below require auth + server.config permission.
const auth = [requireAuth, requirePermission(REQUIRED_PERMISSION)];
function getClientIp(req) {
const fwd = req.headers['x-forwarded-for'];
if (fwd) return String(fwd).split(',')[0].trim();
return req.ip || (req.socket && req.socket.remoteAddress) || '';
}
async function audit(req, action, details) {
try {
if (db && typeof db.logAction === 'function') {
await db.logAction(req.session && req.session.userId, action, details, getClientIp(req));
}
} catch (_) { /* never fail requests due to audit */ }
}
function sendError(res, status, message) {
res.status(status).json({ success: false, error: message });
}
// ─── Page ─────────────────────────────────────────────────────────────────────
router.get('/server-management', requireAuth, requirePermission(REQUIRED_PERMISSION), (req, res) => {
const validTabs = ['overview', 'terminal', 'files', 'services'];
const requested = String(req.query.tab || 'overview').toLowerCase();
const currentTab = validTabs.includes(requested) ? requested : 'overview';
res.render('server-management', {
title: req.t('server_mgmt.title'),
activePage: 'server-management',
currentTab
});
});
// ─── Info / capabilities ──────────────────────────────────────────────────────
router.get('/api/server-management/info', auth, (req, res) => {
res.json({
success: true,
platform: process.platform,
arch: process.arch,
nodeVersion: process.version,
pid: process.pid,
ptyAvailable: isPtyAvailable(),
terminalSessions: activeSessionCount(),
beta: true
});
});
// ─── Resources ────────────────────────────────────────────────────────────────
router.get('/api/server-management/resources', auth, (req, res) => {
try {
res.json({ success: true, snapshot: sm.getResourceSnapshot() });
} catch (err) {
sendError(res, 500, err.message || 'snapshot_failed');
}
});
// ─── Files ────────────────────────────────────────────────────────────────────
router.get('/api/server-management/files', auth, async (req, res) => {
const p = String(req.query.path || '/');
try {
const data = await sm.listDirectory(p);
res.json({ success: true, ...data, cwd: data.path, entries: data.items || [] });
} catch (err) {
sendError(res, 400, err.message || 'list_failed');
}
});
router.get('/api/server-management/files/read', auth, async (req, res) => {
const p = String(req.query.path || '');
try {
const data = await sm.readFilePreview(p);
res.json({ success: true, ...data });
} catch (err) {
sendError(res, 400, err.message || 'read_failed');
}
});
router.post('/api/server-management/files/write', auth, async (req, res) => {
const { path: p, content } = req.body || {};
if (typeof p !== 'string' || typeof content !== 'string') {
return sendError(res, 400, 'path_and_content_required');
}
try {
const result = await sm.writeFile(p, content);
await audit(req, 'server_file_write', `path=${result.path} bytes=${result.size}`);
res.json({ success: true, ...result });
} catch (err) {
sendError(res, 400, err.message || 'write_failed');
}
});
router.post('/api/server-management/files/mkdir', auth, async (req, res) => {
const p = (req.body && req.body.path) || '';
if (typeof p !== 'string' || !p.length) return sendError(res, 400, 'path_required');
try {
const result = await sm.makeDirectory(p);
await audit(req, 'server_file_mkdir', `path=${result.path}`);
res.json({ success: true, ...result });
} catch (err) {
sendError(res, 400, err.message || 'mkdir_failed');
}
});
router.post('/api/server-management/files/rename', auth, async (req, res) => {
const { from, to } = req.body || {};
if (typeof from !== 'string' || typeof to !== 'string') {
return sendError(res, 400, 'from_and_to_required');
}
try {
const result = await sm.renamePath(from, to);
await audit(req, 'server_file_rename', `from=${result.from} to=${result.to}`);
res.json({ success: true, ...result });
} catch (err) {
sendError(res, 400, err.message || 'rename_failed');
}
});
router.post('/api/server-management/files/delete', auth, async (req, res) => {
const p = (req.body && req.body.path) || '';
if (typeof p !== 'string' || !p.length) return sendError(res, 400, 'path_required');
try {
const result = await sm.deletePath(p);
await audit(req, 'server_file_delete', `path=${result.path}`);
res.json({ success: true, ...result });
} catch (err) {
sendError(res, 400, err.message || 'delete_failed');
}
});
// ─── Services ─────────────────────────────────────────────────────────────────
router.get('/api/server-management/services', auth, (req, res) => {
try {
const services = sm.listServices().map((svc) => ({
...svc,
state: svc.state || svc.active || svc.status || 'unknown',
status: svc.status || svc.sub || svc.active || 'unknown'
}));
res.json({ success: true, platform: process.platform, services });
} catch (err) {
sendError(res, 500, err.message || 'list_failed');
}
});
router.post('/api/server-management/services/:name/:action', auth, async (req, res) => {
const { name, action } = req.params;
if (!sm.SERVICE_NAME_RE.test(name)) {
return sendError(res, 400, 'invalid_service_name');
}
try {
const result = await sm.controlService(name, action);
await audit(req, 'server_service_control', `name=${name} action=${action} exit=${result.exitCode}`);
res.json({ success: true, ...result });
} catch (err) {
sendError(res, 400, err.message || 'control_failed');
}
});
module.exports = router;
@@ -0,0 +1,780 @@
#!/usr/bin/env node
/**
* One-shot script: inject Server Management i18n keys into all language files.
* Run from repo root: node web-nodejs/scripts/inject-server-mgmt-i18n.js
*
* Adds:
* nav.server_management
* server_mgmt.* (full namespace)
*
* For languages without an explicit translation block, English is used as
* fallback (standard i18n practice the i18n middleware also falls back to EN).
*/
'use strict';
const fs = require('fs');
const path = require('path');
const LANG_DIR = path.join(__dirname, '..', 'lang');
// English baseline (source of truth)
const EN = {
nav_label: 'Server Management',
sm: {
title: 'Server Management',
subtitle: 'Cockpit-like control plane for the BetterDesk console host (BETA).',
beta_tooltip: 'Beta feature — under active development.',
notice_title: 'Beta feature',
notice_body: 'These tools run on the host where the BetterDesk console process lives. All operations are audited. Feature scope will expand in future releases.',
tab_overview: 'Overview',
tab_terminal: 'Terminal',
tab_files: 'Files',
tab_services: 'Services',
cpu: 'CPU',
memory: 'Memory',
load: 'Load average',
disks: 'Disks',
history: 'History (last 60 samples)',
host_info: 'Host info',
cores: 'cores',
uptime: 'Uptime',
no_disks: 'No disk data available',
info_hostname: 'Hostname',
info_platform: 'Platform',
info_kernel: 'Kernel',
info_node: 'Node.js',
info_process_id: 'Process ID',
info_pty_available: 'PTY available',
term_connect: 'Connect',
term_disconnect: 'Disconnect',
term_clear: 'Clear',
term_status: 'Status',
term_connecting: 'Connecting…',
term_connected: 'Connected',
term_disconnected: 'Disconnected',
term_error: 'Connection error',
term_warning: 'Commands run as the user that owns the console process. Add a sudoers rule to enable elevation.',
term_hint: 'Tip: type `sudo -i` to escalate (requires sudoers configuration).',
term_lib_failed: 'Failed to load terminal library',
files_up: 'Up one level',
files_go: 'Go',
files_mkdir: 'New folder',
files_name: 'Name',
files_size: 'Size',
files_perms: 'Mode',
files_mtime: 'Modified',
files_rename: 'Rename',
files_rename_prompt: 'New name:',
files_empty: 'Empty directory',
files_error: 'Failed to list directory',
read_failed: 'Failed to read file',
save_failed: 'Failed to save file',
rename_failed: 'Rename failed',
delete_failed: 'Delete failed',
delete_confirm: 'Delete this entry?',
mkdir_prompt: 'New folder name:',
mkdir_failed: 'Failed to create folder',
file_saved: 'File saved',
binary_warn: 'Binary file — preview only',
encoding_utf8: 'UTF-8 text',
truncated: 'truncated',
full: 'full',
svc_search_placeholder: 'Search services…',
svc_name: 'Service',
svc_state: 'State',
svc_description: 'Description',
svc_empty: 'No services found',
svc_load_failed: 'Failed to load services',
svc_action_failed: 'Action failed',
svc_confirm: 'Run',
svc_start: 'Start',
svc_stop: 'Stop',
svc_restart: 'Restart',
svc_reload: 'Reload',
svc_enable: 'Enable',
svc_disable: 'Disable',
svc_status: 'Status'
}
};
// Per-language overrides. Languages not listed here keep the English text.
const TRANSLATIONS = {
pl: {
nav_label: 'Zarządzanie serwerem',
sm: {
title: 'Zarządzanie serwerem',
subtitle: 'Panel typu Cockpit dla hosta konsoli BetterDesk (BETA).',
beta_tooltip: 'Funkcja beta — w aktywnym rozwoju.',
notice_title: 'Funkcja w wersji beta',
notice_body: 'Te narzędzia działają na hoście, na którym uruchomiony jest proces konsoli BetterDesk. Wszystkie operacje są audytowane. Zakres funkcji będzie rozszerzany w kolejnych wydaniach.',
tab_overview: 'Przegląd',
tab_terminal: 'Terminal',
tab_files: 'Pliki',
tab_services: 'Usługi',
cpu: 'CPU',
memory: 'Pamięć',
load: 'Średnie obciążenie',
disks: 'Dyski',
history: 'Historia (ostatnie 60 próbek)',
host_info: 'Informacje o hoście',
cores: 'rdzeni',
uptime: 'Czas pracy',
no_disks: 'Brak danych o dyskach',
info_hostname: 'Nazwa hosta',
info_platform: 'Platforma',
info_kernel: 'Jądro',
info_node: 'Node.js',
info_process_id: 'ID procesu',
info_pty_available: 'PTY dostępne',
term_connect: 'Połącz',
term_disconnect: 'Rozłącz',
term_clear: 'Wyczyść',
term_status: 'Status',
term_connecting: 'Łączenie…',
term_connected: 'Połączono',
term_disconnected: 'Rozłączono',
term_error: 'Błąd połączenia',
term_warning: 'Polecenia wykonywane są jako użytkownik procesu konsoli. Dodaj regułę sudoers, aby umożliwić eskalację uprawnień.',
term_hint: 'Wskazówka: wpisz `sudo -i`, aby uzyskać uprawnienia root (wymaga konfiguracji sudoers).',
term_lib_failed: 'Nie udało się załadować biblioteki terminala',
files_up: 'Poziom wyżej',
files_go: 'Idź',
files_mkdir: 'Nowy folder',
files_name: 'Nazwa',
files_size: 'Rozmiar',
files_perms: 'Uprawnienia',
files_mtime: 'Zmodyfikowano',
files_rename: 'Zmień nazwę',
files_rename_prompt: 'Nowa nazwa:',
files_empty: 'Pusty katalog',
files_error: 'Nie udało się odczytać katalogu',
read_failed: 'Nie udało się odczytać pliku',
save_failed: 'Nie udało się zapisać pliku',
rename_failed: 'Nie udało się zmienić nazwy',
delete_failed: 'Nie udało się usunąć',
delete_confirm: 'Usunąć tę pozycję?',
mkdir_prompt: 'Nazwa nowego folderu:',
mkdir_failed: 'Nie udało się utworzyć folderu',
file_saved: 'Plik zapisany',
binary_warn: 'Plik binarny — tylko podgląd',
encoding_utf8: 'Tekst UTF-8',
truncated: 'obcięty',
full: 'pełny',
svc_search_placeholder: 'Szukaj usług…',
svc_name: 'Usługa',
svc_state: 'Stan',
svc_description: 'Opis',
svc_empty: 'Nie znaleziono usług',
svc_load_failed: 'Nie udało się załadować usług',
svc_action_failed: 'Akcja nie powiodła się',
svc_confirm: 'Uruchomić',
svc_start: 'Start',
svc_stop: 'Stop',
svc_restart: 'Restart',
svc_reload: 'Przeładuj',
svc_enable: 'Włącz',
svc_disable: 'Wyłącz',
svc_status: 'Status'
}
},
de: {
nav_label: 'Server-Verwaltung',
sm: {
title: 'Server-Verwaltung',
subtitle: 'Cockpit-ähnliche Steuerung für den BetterDesk-Konsolen-Host (BETA).',
beta_tooltip: 'Beta-Funktion — in aktiver Entwicklung.',
notice_title: 'Beta-Funktion',
notice_body: 'Diese Werkzeuge laufen auf dem Host, auf dem der BetterDesk-Konsolenprozess ausgeführt wird. Alle Operationen werden auditiert.',
tab_overview: 'Übersicht',
tab_terminal: 'Terminal',
tab_files: 'Dateien',
tab_services: 'Dienste',
cpu: 'CPU',
memory: 'Speicher',
load: 'Last (Durchschnitt)',
disks: 'Festplatten',
history: 'Verlauf (letzte 60 Werte)',
host_info: 'Host-Info',
cores: 'Kerne',
uptime: 'Laufzeit',
term_connect: 'Verbinden',
term_disconnect: 'Trennen',
term_clear: 'Löschen',
term_connecting: 'Verbinden…',
term_connected: 'Verbunden',
term_disconnected: 'Getrennt',
term_error: 'Verbindungsfehler',
term_warning: 'Befehle werden als Konsolen-Prozessbenutzer ausgeführt. Sudoers-Regel hinzufügen für Erhöhung.',
files_name: 'Name',
files_size: 'Größe',
files_perms: 'Modus',
files_mtime: 'Geändert',
files_rename: 'Umbenennen',
files_empty: 'Leeres Verzeichnis',
svc_name: 'Dienst',
svc_state: 'Status',
svc_description: 'Beschreibung',
svc_start: 'Start',
svc_stop: 'Stopp',
svc_restart: 'Neustart',
svc_reload: 'Neu laden',
svc_enable: 'Aktivieren',
svc_disable: 'Deaktivieren'
}
},
es: {
nav_label: 'Gestión del servidor',
sm: {
title: 'Gestión del servidor',
subtitle: 'Panel tipo Cockpit para el host de la consola BetterDesk (BETA).',
beta_tooltip: 'Función beta — en desarrollo activo.',
notice_title: 'Función beta',
notice_body: 'Estas herramientas se ejecutan en el host donde corre el proceso de la consola BetterDesk. Todas las operaciones son auditadas.',
tab_overview: 'Resumen',
tab_terminal: 'Terminal',
tab_files: 'Archivos',
tab_services: 'Servicios',
memory: 'Memoria',
load: 'Carga promedio',
disks: 'Discos',
history: 'Historial (últimas 60 muestras)',
host_info: 'Info del host',
cores: 'núcleos',
uptime: 'Tiempo activo',
term_connect: 'Conectar',
term_disconnect: 'Desconectar',
term_clear: 'Limpiar',
term_connecting: 'Conectando…',
term_connected: 'Conectado',
term_disconnected: 'Desconectado',
files_name: 'Nombre',
files_size: 'Tamaño',
files_mtime: 'Modificado',
files_rename: 'Renombrar',
files_empty: 'Directorio vacío',
svc_name: 'Servicio',
svc_state: 'Estado',
svc_description: 'Descripción',
svc_start: 'Iniciar',
svc_stop: 'Detener',
svc_restart: 'Reiniciar',
svc_reload: 'Recargar',
svc_enable: 'Habilitar',
svc_disable: 'Deshabilitar'
}
},
fr: {
nav_label: 'Gestion du serveur',
sm: {
title: 'Gestion du serveur',
subtitle: 'Panneau de type Cockpit pour l\'hôte de la console BetterDesk (BÊTA).',
beta_tooltip: 'Fonction bêta — en développement actif.',
notice_title: 'Fonction bêta',
notice_body: 'Ces outils s\'exécutent sur l\'hôte où tourne le processus de la console BetterDesk. Toutes les opérations sont auditées.',
tab_overview: 'Aperçu',
tab_terminal: 'Terminal',
tab_files: 'Fichiers',
tab_services: 'Services',
memory: 'Mémoire',
load: 'Charge moyenne',
disks: 'Disques',
history: 'Historique (60 derniers échantillons)',
host_info: 'Infos hôte',
cores: 'cœurs',
uptime: 'Temps d\'activité',
term_connect: 'Connecter',
term_disconnect: 'Déconnecter',
term_clear: 'Effacer',
term_connecting: 'Connexion…',
term_connected: 'Connecté',
term_disconnected: 'Déconnecté',
files_name: 'Nom',
files_size: 'Taille',
files_mtime: 'Modifié',
files_rename: 'Renommer',
files_empty: 'Répertoire vide',
svc_name: 'Service',
svc_state: 'État',
svc_description: 'Description',
svc_start: 'Démarrer',
svc_stop: 'Arrêter',
svc_restart: 'Redémarrer',
svc_reload: 'Recharger',
svc_enable: 'Activer',
svc_disable: 'Désactiver'
}
},
it: {
nav_label: 'Gestione server',
sm: {
title: 'Gestione server',
subtitle: 'Pannello in stile Cockpit per l\'host della console BetterDesk (BETA).',
tab_overview: 'Panoramica',
tab_terminal: 'Terminale',
tab_files: 'File',
tab_services: 'Servizi',
memory: 'Memoria',
load: 'Carico medio',
disks: 'Dischi',
uptime: 'Tempo di attività',
term_connect: 'Connetti',
term_disconnect: 'Disconnetti',
term_clear: 'Pulisci',
term_connected: 'Connesso',
term_disconnected: 'Disconnesso',
files_name: 'Nome',
files_size: 'Dimensione',
files_mtime: 'Modificato',
files_rename: 'Rinomina',
svc_name: 'Servizio',
svc_state: 'Stato',
svc_start: 'Avvia',
svc_stop: 'Ferma',
svc_restart: 'Riavvia',
svc_enable: 'Abilita',
svc_disable: 'Disabilita'
}
},
pt: {
nav_label: 'Gestão do servidor',
sm: {
title: 'Gestão do servidor',
subtitle: 'Painel tipo Cockpit para o host da console BetterDesk (BETA).',
tab_overview: 'Visão geral',
tab_terminal: 'Terminal',
tab_files: 'Arquivos',
tab_services: 'Serviços',
memory: 'Memória',
disks: 'Discos',
uptime: 'Tempo ativo',
term_connect: 'Conectar',
term_disconnect: 'Desconectar',
files_name: 'Nome',
files_size: 'Tamanho',
files_mtime: 'Modificado',
svc_name: 'Serviço',
svc_state: 'Estado',
svc_start: 'Iniciar',
svc_stop: 'Parar',
svc_restart: 'Reiniciar'
}
},
nl: {
nav_label: 'Serverbeheer',
sm: {
title: 'Serverbeheer',
subtitle: 'Cockpit-achtig paneel voor de BetterDesk-consolehost (BÈTA).',
tab_overview: 'Overzicht',
tab_terminal: 'Terminal',
tab_files: 'Bestanden',
tab_services: 'Services',
memory: 'Geheugen',
disks: 'Schijven',
uptime: 'Uptime',
term_connect: 'Verbinden',
term_disconnect: 'Verbinding verbreken',
files_name: 'Naam',
files_size: 'Grootte',
svc_name: 'Service',
svc_state: 'Status',
svc_start: 'Starten',
svc_stop: 'Stoppen',
svc_restart: 'Herstarten'
}
},
zh: {
nav_label: '服务器管理',
sm: {
title: '服务器管理',
subtitle: '类似 Cockpit 的 BetterDesk 控制台主机管理面板(测试版)。',
beta_tooltip: '测试版功能 — 正在积极开发中。',
notice_title: '测试版功能',
notice_body: '这些工具在运行 BetterDesk 控制台进程的主机上执行。所有操作均被审计。',
tab_overview: '概览',
tab_terminal: '终端',
tab_files: '文件',
tab_services: '服务',
cpu: 'CPU',
memory: '内存',
load: '负载平均值',
disks: '磁盘',
history: '历史记录(最近 60 个样本)',
host_info: '主机信息',
cores: '核心',
uptime: '运行时间',
term_connect: '连接',
term_disconnect: '断开',
term_clear: '清除',
term_connecting: '连接中…',
term_connected: '已连接',
term_disconnected: '已断开',
files_name: '名称',
files_size: '大小',
files_perms: '权限',
files_mtime: '修改时间',
files_rename: '重命名',
files_empty: '空目录',
svc_name: '服务',
svc_state: '状态',
svc_description: '说明',
svc_start: '启动',
svc_stop: '停止',
svc_restart: '重启',
svc_reload: '重新加载',
svc_enable: '启用',
svc_disable: '禁用'
}
},
'zh-TW': {
nav_label: '伺服器管理',
sm: {
title: '伺服器管理',
subtitle: '類似 Cockpit 的 BetterDesk 主控台主機管理面板(測試版)。',
tab_overview: '概覽',
tab_terminal: '終端機',
tab_files: '檔案',
tab_services: '服務',
memory: '記憶體',
disks: '磁碟',
uptime: '運行時間',
term_connect: '連線',
term_disconnect: '中斷連線',
files_name: '名稱',
files_size: '大小',
svc_name: '服務',
svc_state: '狀態',
svc_start: '啟動',
svc_stop: '停止',
svc_restart: '重新啟動'
}
},
ja: {
nav_label: 'サーバー管理',
sm: {
title: 'サーバー管理',
subtitle: 'BetterDesk コンソールホスト用の Cockpit 風コントロールパネル(ベータ)。',
tab_overview: '概要',
tab_terminal: 'ターミナル',
tab_files: 'ファイル',
tab_services: 'サービス',
memory: 'メモリ',
disks: 'ディスク',
uptime: '稼働時間',
term_connect: '接続',
term_disconnect: '切断',
files_name: '名前',
files_size: 'サイズ',
svc_name: 'サービス',
svc_state: '状態',
svc_start: '開始',
svc_stop: '停止',
svc_restart: '再起動'
}
},
ko: {
nav_label: '서버 관리',
sm: {
title: '서버 관리',
subtitle: 'BetterDesk 콘솔 호스트용 Cockpit 스타일 제어판(베타).',
tab_overview: '개요',
tab_terminal: '터미널',
tab_files: '파일',
tab_services: '서비스',
memory: '메모리',
disks: '디스크',
uptime: '가동 시간',
term_connect: '연결',
term_disconnect: '연결 해제',
files_name: '이름',
files_size: '크기',
svc_name: '서비스',
svc_state: '상태',
svc_start: '시작',
svc_stop: '중지',
svc_restart: '재시작'
}
},
uk: {
nav_label: 'Керування сервером',
sm: {
title: 'Керування сервером',
subtitle: 'Панель типу Cockpit для хоста консолі BetterDesk (БЕТА).',
tab_overview: 'Огляд',
tab_terminal: 'Термінал',
tab_files: 'Файли',
tab_services: 'Служби',
memory: 'Памʼять',
disks: 'Диски',
uptime: 'Час роботи',
term_connect: 'Зʼєднати',
term_disconnect: 'Розʼєднати',
files_name: 'Назва',
files_size: 'Розмір',
svc_name: 'Служба',
svc_state: 'Стан',
svc_start: 'Запустити',
svc_stop: 'Зупинити',
svc_restart: 'Перезапустити'
}
},
cs: {
nav_label: 'Správa serveru',
sm: {
title: 'Správa serveru',
tab_overview: 'Přehled',
tab_terminal: 'Terminál',
tab_files: 'Soubory',
tab_services: 'Služby',
memory: 'Paměť',
disks: 'Disky',
uptime: 'Doba běhu',
term_connect: 'Připojit',
term_disconnect: 'Odpojit',
svc_name: 'Služba',
svc_state: 'Stav',
svc_start: 'Spustit',
svc_stop: 'Zastavit',
svc_restart: 'Restartovat'
}
},
sv: {
nav_label: 'Serverhantering',
sm: {
title: 'Serverhantering',
tab_overview: 'Översikt',
tab_terminal: 'Terminal',
tab_files: 'Filer',
tab_services: 'Tjänster',
memory: 'Minne',
disks: 'Diskar',
uptime: 'Drifttid',
term_connect: 'Anslut',
term_disconnect: 'Koppla från',
svc_start: 'Starta',
svc_stop: 'Stoppa',
svc_restart: 'Starta om'
}
},
da: {
nav_label: 'Serveradministration',
sm: {
title: 'Serveradministration',
tab_overview: 'Oversigt',
tab_terminal: 'Terminal',
tab_files: 'Filer',
tab_services: 'Tjenester',
memory: 'Hukommelse',
disks: 'Diske',
uptime: 'Oppetid',
term_connect: 'Forbind',
term_disconnect: 'Afbryd',
svc_start: 'Start',
svc_stop: 'Stop',
svc_restart: 'Genstart'
}
},
nb: {
nav_label: 'Serveradministrasjon',
sm: {
title: 'Serveradministrasjon',
tab_overview: 'Oversikt',
tab_terminal: 'Terminal',
tab_files: 'Filer',
tab_services: 'Tjenester',
memory: 'Minne',
disks: 'Disker',
uptime: 'Oppetid',
term_connect: 'Koble til',
term_disconnect: 'Koble fra'
}
},
fi: {
nav_label: 'Palvelimen hallinta',
sm: {
title: 'Palvelimen hallinta',
tab_overview: 'Yleiskatsaus',
tab_terminal: 'Pääte',
tab_files: 'Tiedostot',
tab_services: 'Palvelut',
memory: 'Muisti',
disks: 'Levyt',
uptime: 'Käyntiaika',
term_connect: 'Yhdistä',
term_disconnect: 'Katkaise'
}
},
hu: {
nav_label: 'Kiszolgáló-kezelés',
sm: {
title: 'Kiszolgáló-kezelés',
tab_overview: 'Áttekintés',
tab_terminal: 'Terminál',
tab_files: 'Fájlok',
tab_services: 'Szolgáltatások',
memory: 'Memória',
disks: 'Lemezek',
uptime: 'Üzemidő',
term_connect: 'Csatlakozás',
term_disconnect: 'Lecsatlakozás'
}
},
ro: {
nav_label: 'Gestionare server',
sm: {
title: 'Gestionare server',
tab_overview: 'Prezentare',
tab_terminal: 'Terminal',
tab_files: 'Fișiere',
tab_services: 'Servicii',
memory: 'Memorie',
disks: 'Discuri',
uptime: 'Timp de funcționare',
term_connect: 'Conectează',
term_disconnect: 'Deconectează'
}
},
tr: {
nav_label: 'Sunucu Yönetimi',
sm: {
title: 'Sunucu Yönetimi',
tab_overview: 'Genel Bakış',
tab_terminal: 'Terminal',
tab_files: 'Dosyalar',
tab_services: 'Hizmetler',
memory: 'Bellek',
disks: 'Diskler',
uptime: 'Çalışma süresi',
term_connect: 'Bağlan',
term_disconnect: 'Bağlantıyı kes',
svc_start: 'Başlat',
svc_stop: 'Durdur',
svc_restart: 'Yeniden başlat'
}
},
ar: {
nav_label: 'إدارة الخادم',
sm: {
title: 'إدارة الخادم',
tab_overview: 'نظرة عامة',
tab_terminal: 'الطرفية',
tab_files: 'الملفات',
tab_services: 'الخدمات',
memory: 'الذاكرة',
disks: 'الأقراص',
uptime: 'وقت التشغيل',
term_connect: 'اتصال',
term_disconnect: 'قطع الاتصال'
}
},
hi: {
nav_label: 'सर्वर प्रबंधन',
sm: {
title: 'सर्वर प्रबंधन',
tab_overview: 'अवलोकन',
tab_terminal: 'टर्मिनल',
tab_files: 'फ़ाइलें',
tab_services: 'सेवाएं',
memory: 'मेमोरी',
disks: 'डिस्क',
uptime: 'अपटाइम'
}
},
id: {
nav_label: 'Manajemen Server',
sm: {
title: 'Manajemen Server',
tab_overview: 'Ikhtisar',
tab_terminal: 'Terminal',
tab_files: 'Berkas',
tab_services: 'Layanan',
memory: 'Memori',
disks: 'Disk',
uptime: 'Waktu aktif',
term_connect: 'Hubungkan',
term_disconnect: 'Putuskan'
}
},
vi: {
nav_label: 'Quản lý máy chủ',
sm: {
title: 'Quản lý máy chủ',
tab_overview: 'Tổng quan',
tab_terminal: 'Cửa sổ dòng lệnh',
tab_files: 'Tệp',
tab_services: 'Dịch vụ',
memory: 'Bộ nhớ',
disks: 'Ổ đĩa',
uptime: 'Thời gian hoạt động',
term_connect: 'Kết nối',
term_disconnect: 'Ngắt kết nối'
}
},
th: {
nav_label: 'จัดการเซิร์ฟเวอร์',
sm: {
title: 'จัดการเซิร์ฟเวอร์',
tab_overview: 'ภาพรวม',
tab_terminal: 'เทอร์มินัล',
tab_files: 'ไฟล์',
tab_services: 'บริการ',
memory: 'หน่วยความจำ',
disks: 'ดิสก์',
uptime: 'เวลาทำงาน'
}
}
};
function deepMerge(base, override) {
const out = JSON.parse(JSON.stringify(base));
Object.keys(override || {}).forEach((k) => {
out[k] = override[k];
});
return out;
}
function buildBlockForLang(code) {
const tr = TRANSLATIONS[code] || {};
return {
nav_label: tr.nav_label || EN.nav_label,
sm: deepMerge(EN.sm, tr.sm || {})
};
}
function processLang(filePath) {
const code = path.basename(filePath, '.json');
const text = fs.readFileSync(filePath, 'utf8');
const data = JSON.parse(text);
const block = buildBlockForLang(code);
// 1. Add nav.server_management
data.nav = data.nav || {};
data.nav.server_management = block.nav_label;
// 2. Add server_mgmt namespace (do not clobber existing — merge)
data.server_mgmt = Object.assign({}, data.server_mgmt || {}, block.sm);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
return { code, keys: Object.keys(block.sm).length + 1 };
}
function main() {
const files = fs.readdirSync(LANG_DIR)
.filter((f) => f.endsWith('.json'))
.map((f) => path.join(LANG_DIR, f))
.sort();
console.log(`Injecting Server Management i18n keys into ${files.length} language files…`);
files.forEach((fp) => {
try {
const r = processLang(fp);
console.log(`${r.code.padEnd(8)} +${r.keys} keys`);
} catch (err) {
console.error(`${path.basename(fp)}${err.message}`);
}
});
console.log('Done.');
}
main();
-10
View File
@@ -374,16 +374,6 @@ async function startServer() {
// Ensure default admin exists
await authService.ensureDefaultAdmin();
// Mirror Node panel users into the Go users table so they can be
// linked to organizations via "Add User -> Add Existing" (Issue #125).
// Best-effort: failures are logged but never block startup.
try {
const userSync = require('./services/userSync');
await userSync.backfillFromNode();
} catch (err) {
console.warn('[startup] userSync.backfillFromNode failed:', err.message);
}
let server;
let protocol = 'http';
+411
View File
@@ -0,0 +1,411 @@
/**
* BetterDesk Console Server Management Service (BETA)
*
* Provides backend helpers for the Server Management panel:
* Resource snapshots (CPU / memory / disk / load / uptime)
* File browser (list, read, write, delete, rename, mkdir)
* Service control (systemctl on Linux, sc.exe on Windows)
* Audit logging of every privileged operation
*
* SECURITY:
* All operations require RBAC `server.config` permission.
* Service names are validated against a strict regex before being
* passed to spawned commands.
* File operations resolve absolute paths and refuse symlink escapes
* out of allowlisted root directories when restricted mode is on.
* Spawn arguments are passed as argv (never through a shell string)
* unless explicitly using `shell: true` for whitelisted utilities.
*
* STATUS: Beta interface may evolve.
*/
'use strict';
const os = require('os');
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const SERVICE_NAME_RE = /^[A-Za-z0-9_.@:-]{1,128}$/;
const FILE_MAX_BYTES = 8 * 1024 * 1024; // 8 MB read/write cap
const READ_PREVIEW_BYTES = 256 * 1024; // text preview cap for browser
const isLinux = process.platform === 'linux';
const isWindows = process.platform === 'win32';
// ─── Resource snapshots ───────────────────────────────────────────────────────
let lastCpuSample = null;
let metricHistory = []; // ring buffer of {ts, cpu, mem, swap}
const HISTORY_MAX = 120;
function readCpuTimes() {
return os.cpus().map((c) => {
const t = c.times;
const total = t.user + t.nice + t.sys + t.idle + t.irq;
return { idle: t.idle, total };
});
}
function computeCpuPercent() {
const cur = readCpuTimes();
if (!lastCpuSample || lastCpuSample.length !== cur.length) {
lastCpuSample = cur;
return 0;
}
let totalDiff = 0;
let idleDiff = 0;
for (let i = 0; i < cur.length; i++) {
totalDiff += cur[i].total - lastCpuSample[i].total;
idleDiff += cur[i].idle - lastCpuSample[i].idle;
}
lastCpuSample = cur;
if (totalDiff <= 0) return 0;
return Math.max(0, Math.min(100, ((totalDiff - idleDiff) / totalDiff) * 100));
}
function safeExecSync(cmd, args, timeoutMs = 4000) {
try {
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: timeoutMs });
if (r.error || r.status !== 0) return '';
return (r.stdout || '').trim();
} catch (_) {
return '';
}
}
function readSwapInfo() {
if (isLinux) {
try {
const meminfo = fs.readFileSync('/proc/meminfo', 'utf8');
const swapTotalMatch = /SwapTotal:\s+(\d+)/.exec(meminfo);
const swapFreeMatch = /SwapFree:\s+(\d+)/.exec(meminfo);
if (swapTotalMatch) {
const total = parseInt(swapTotalMatch[1], 10) * 1024;
const free = swapFreeMatch ? parseInt(swapFreeMatch[1], 10) * 1024 : total;
return { total, used: total - free, free };
}
} catch (_) { /* ignore */ }
}
return { total: 0, used: 0, free: 0 };
}
function listDisksSync() {
const disks = [];
if (isLinux) {
const raw = safeExecSync('df', ['-B1', '--output=target,fstype,size,used,avail']);
raw.split('\n').slice(1).forEach((line) => {
const parts = line.trim().split(/\s+/);
if (parts.length < 5) return;
const mount = parts[0];
const fstype = parts[1];
const size = parseInt(parts[2], 10);
const used = parseInt(parts[3], 10);
const avail = parseInt(parts[4], 10);
// skip pseudo filesystems
if (['tmpfs', 'devtmpfs', 'squashfs', 'overlay', 'proc', 'sysfs', 'cgroup', 'cgroup2', 'devpts', 'debugfs', 'tracefs', 'pstore', 'autofs', 'fusectl'].includes(fstype)) return;
if (!size || size < 1024 * 1024) return;
disks.push({ mount, fstype, size, used, avail });
});
} else if (isWindows) {
const raw = safeExecSync('wmic', ['logicaldisk', 'where', 'DriveType=3', 'get', 'DeviceID,FileSystem,FreeSpace,Size', '/format:csv'], 8000);
raw.split('\n').forEach((line) => {
const parts = line.trim().split(',');
if (parts.length >= 5 && parts[1]) {
const mount = parts[1];
const fstype = parts[2] || 'NTFS';
const free = parseInt(parts[3], 10) || 0;
const size = parseInt(parts[4], 10) || 0;
if (!size) return;
disks.push({ mount, fstype, size, used: size - free, avail: free });
}
});
}
return disks;
}
function readNetIfaces() {
const ifs = os.networkInterfaces();
const result = [];
Object.keys(ifs).forEach((name) => {
(ifs[name] || []).forEach((addr) => {
if (addr.internal) return;
result.push({
name,
family: addr.family,
address: addr.address,
mac: addr.mac,
cidr: addr.cidr
});
});
});
return result;
}
function getResourceSnapshot() {
const totalMem = os.totalmem();
const freeMem = os.freemem();
const cpuPercent = computeCpuPercent();
const swap = readSwapInfo();
const sample = {
ts: Date.now(),
cpu: Math.round(cpuPercent * 10) / 10,
mem: {
total: totalMem,
free: freeMem,
used: totalMem - freeMem,
percent: Math.round(((totalMem - freeMem) / totalMem) * 1000) / 10
},
swap
};
metricHistory.push({ ts: sample.ts, cpu: sample.cpu, mem: sample.mem.percent });
if (metricHistory.length > HISTORY_MAX) metricHistory = metricHistory.slice(-HISTORY_MAX);
return {
...sample,
load: os.loadavg(),
uptime: os.uptime(),
nodeUptime: process.uptime(),
cpuCount: os.cpus().length,
cpuModel: (os.cpus()[0] && os.cpus()[0].model) || 'unknown',
platform: process.platform,
arch: process.arch,
hostname: os.hostname(),
release: os.release(),
nodeVersion: process.version,
disks: listDisksSync(),
network: readNetIfaces(),
history: metricHistory.slice()
};
}
// ─── File browser ─────────────────────────────────────────────────────────────
/**
* Resolve a user-supplied path to an absolute path.
* Rejects null bytes and empty strings. Does NOT restrict location
* callers must enforce auth + audit on top.
*/
function resolvePath(p) {
if (typeof p !== 'string' || !p.length) throw new Error('Path is required');
if (p.indexOf('\0') !== -1) throw new Error('Invalid path');
let abs = path.resolve(p);
return abs;
}
async function listDirectory(dirPath) {
const abs = resolvePath(dirPath);
const stat = await fsp.stat(abs);
if (!stat.isDirectory()) throw new Error('Not a directory');
const entries = await fsp.readdir(abs, { withFileTypes: true });
const items = [];
for (const e of entries) {
const full = path.join(abs, e.name);
let st = null;
try { st = await fsp.lstat(full); } catch (_) { /* permission denied etc. */ }
items.push({
name: e.name,
path: full,
isDirectory: st ? st.isDirectory() : e.isDirectory(),
isFile: st ? st.isFile() : e.isFile(),
isSymlink: st ? st.isSymbolicLink() : false,
size: st ? st.size : 0,
mtime: st ? st.mtime.toISOString() : null,
mode: st ? (st.mode & 0o777) : null,
uid: st ? st.uid : null,
gid: st ? st.gid : null
});
}
items.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
return { path: abs, parent: path.dirname(abs), items };
}
async function readFilePreview(filePath) {
const abs = resolvePath(filePath);
const st = await fsp.stat(abs);
if (!st.isFile()) throw new Error('Not a regular file');
if (st.size > FILE_MAX_BYTES) throw new Error('File too large for inline edit (>8 MB)');
const fh = await fsp.open(abs, 'r');
try {
const sliceBytes = Math.min(st.size, READ_PREVIEW_BYTES);
const buf = Buffer.alloc(sliceBytes);
await fh.read(buf, 0, sliceBytes, 0);
// crude binary detection: any null byte in slice
const isBinary = buf.includes(0);
let content = '';
if (!isBinary) {
content = buf.toString('utf8');
}
return {
path: abs,
size: st.size,
mtime: st.mtime.toISOString(),
mode: st.mode & 0o777,
isBinary,
truncated: st.size > sliceBytes,
content
};
} finally {
await fh.close();
}
}
async function writeFile(filePath, content) {
const abs = resolvePath(filePath);
if (typeof content !== 'string') throw new Error('Content must be a string');
if (Buffer.byteLength(content, 'utf8') > FILE_MAX_BYTES) {
throw new Error('Content exceeds 8 MB limit');
}
await fsp.writeFile(abs, content, { encoding: 'utf8' });
return { path: abs, size: Buffer.byteLength(content, 'utf8') };
}
async function deletePath(p) {
const abs = resolvePath(p);
if (abs === '/' || /^[A-Za-z]:\\?$/.test(abs)) {
throw new Error('Refusing to delete filesystem root');
}
const st = await fsp.lstat(abs);
if (st.isDirectory()) {
await fsp.rm(abs, { recursive: true, force: false });
} else {
await fsp.unlink(abs);
}
return { path: abs };
}
async function makeDirectory(p) {
const abs = resolvePath(p);
await fsp.mkdir(abs, { recursive: false });
return { path: abs };
}
async function renamePath(oldPath, newPath) {
const a = resolvePath(oldPath);
const b = resolvePath(newPath);
await fsp.rename(a, b);
return { from: a, to: b };
}
// ─── Services ─────────────────────────────────────────────────────────────────
const ALLOWED_SERVICE_ACTIONS_LINUX = new Set(['start', 'stop', 'restart', 'reload', 'enable', 'disable', 'status']);
const ALLOWED_SERVICE_ACTIONS_WINDOWS = new Set(['start', 'stop', 'restart', 'status']);
function listServicesLinux() {
// List loaded units of type service with state info
const raw = safeExecSync('systemctl', ['list-units', '--type=service', '--all', '--no-legend', '--no-pager', '--plain'], 8000);
const services = [];
raw.split('\n').forEach((line) => {
const parts = line.trim().split(/\s+/);
if (parts.length < 4) return;
const unit = parts[0];
const load = parts[1];
const active = parts[2];
const sub = parts[3];
const description = parts.slice(4).join(' ');
if (!unit.endsWith('.service')) return;
services.push({ name: unit, load, active, sub, description });
});
return services;
}
function listServicesWindows() {
const raw = safeExecSync('powershell', ['-NoProfile', '-Command', "Get-Service | Select-Object Name,Status,DisplayName | ConvertTo-Csv -NoTypeInformation"], 10000);
const services = [];
raw.split('\n').slice(1).forEach((line) => {
const m = line.match(/^"([^"]*)","([^"]*)","([^"]*)"$/);
if (m) {
services.push({
name: m[1],
active: m[2].toLowerCase() === 'running' ? 'active' : 'inactive',
sub: m[2].toLowerCase(),
description: m[3]
});
}
});
return services;
}
function listServices() {
if (isLinux) return listServicesLinux();
if (isWindows) return listServicesWindows();
return [];
}
function controlServiceLinux(name, action) {
if (!SERVICE_NAME_RE.test(name)) throw new Error('Invalid service name');
if (!ALLOWED_SERVICE_ACTIONS_LINUX.has(action)) throw new Error('Action not allowed');
return new Promise((resolve) => {
const child = spawn('systemctl', [action, name], { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', (b) => { stdout += b.toString('utf8'); });
child.stderr.on('data', (b) => { stderr += b.toString('utf8'); });
const t = setTimeout(() => {
try { child.kill('SIGKILL'); } catch (_) { /* ignore */ }
}, 15000);
child.on('close', (code) => {
clearTimeout(t);
resolve({
name,
action,
exitCode: code === null ? -1 : code,
stdout: stdout.slice(-4096),
stderr: stderr.slice(-4096)
});
});
});
}
function controlServiceWindows(name, action) {
if (!SERVICE_NAME_RE.test(name)) throw new Error('Invalid service name');
if (!ALLOWED_SERVICE_ACTIONS_WINDOWS.has(action)) throw new Error('Action not allowed');
let psAction = action === 'restart' ? 'Restart-Service' : action === 'start' ? 'Start-Service' : action === 'stop' ? 'Stop-Service' : 'Get-Service';
return new Promise((resolve) => {
const args = ['-NoProfile', '-Command', `${psAction} -Name '${name.replace(/'/g, "''")}' | Out-String`];
const child = spawn('powershell', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', (b) => { stdout += b.toString('utf8'); });
child.stderr.on('data', (b) => { stderr += b.toString('utf8'); });
const t = setTimeout(() => {
try { child.kill(); } catch (_) { /* ignore */ }
}, 20000);
child.on('close', (code) => {
clearTimeout(t);
resolve({
name,
action,
exitCode: code === null ? -1 : code,
stdout: stdout.slice(-4096),
stderr: stderr.slice(-4096)
});
});
});
}
function controlService(name, action) {
if (isLinux) return controlServiceLinux(name, action);
if (isWindows) return controlServiceWindows(name, action);
return Promise.reject(new Error('Service control not supported on this platform'));
}
module.exports = {
SERVICE_NAME_RE,
FILE_MAX_BYTES,
getResourceSnapshot,
listDirectory,
readFilePreview,
writeFile,
deletePath,
makeDirectory,
renamePath,
listServices,
controlService
};
+319
View File
@@ -0,0 +1,319 @@
/**
* BetterDesk Console Server Management Terminal WS proxy (BETA)
*
* Provides a WebSocket-backed PTY for the Server Management page. Connects
* the browser xterm.js client to a real shell on the host running the Node
* console process (e.g. for Cockpit-like server administration).
*
* Browser WS Node.js (:5000) /ws/server-management/terminal
*
* Implementation:
* Prefers `node-pty` when available (full PTY semantics, sudo prompts work).
* Falls back to `child_process.spawn(<shell>, ['-i'])` with pipes adequate
* for non-interactive commands but lacks TTY semantics.
*
* SECURITY:
* Authentication via session cookie (express-session).
* Authorization: only `super_admin` and `server_admin` may open a session.
* The shell runs as the user that owns the Node.js process typically
* `betterdesk-console` (systemd) which has no sudo by default.
* Document this in the install scripts.
* Every session start/end is logged to the audit log.
*/
'use strict';
const WebSocket = require('ws');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
let pty = null;
try {
pty = require('node-pty');
} catch (_) {
pty = null;
}
const isWindows = process.platform === 'win32';
const DEFAULT_SHELL = isWindows ? (process.env.COMSPEC || 'powershell.exe') : '/bin/bash';
const ACTIVE_SESSIONS = new Map(); // sessionId -> session
let nextSessionId = 1;
function makeSessionId() {
const id = nextSessionId++;
return `srv-term-${Date.now().toString(36)}-${id}`;
}
function parsePasswd() {
if (isWindows) return [];
try {
return fs.readFileSync('/etc/passwd', 'utf8')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const p = line.split(':');
return {
username: p[0],
uid: parseInt(p[2], 10),
gid: parseInt(p[3], 10),
homedir: p[5] || '/',
shell: p[6] || DEFAULT_SHELL
};
})
.filter((u) => Number.isInteger(u.uid) && Number.isInteger(u.gid));
} catch (_) {
return [];
}
}
function validLoginShell(shell) {
return shell && !/nologin|false$/i.test(shell);
}
function pickTerminalUser() {
if (isWindows) return { ...os.userInfo(), shell: DEFAULT_SHELL };
const current = os.userInfo();
const passwd = parsePasswd();
const configured = process.env.BETTERDESK_TERMINAL_USER || process.env.SERVER_MANAGEMENT_USER || '';
const isRootProcess = typeof process.getuid === 'function' && process.getuid() === 0;
if (configured) {
const byName = passwd.find((u) => u.username === configured || String(u.uid) === configured);
if (byName && validLoginShell(byName.shell)) return byName;
}
if (isRootProcess) {
const localUser = passwd.find((u) =>
u.uid >= 1000 && u.uid < 60000 &&
u.username !== 'nobody' &&
validLoginShell(u.shell) &&
u.homedir && u.homedir !== '/'
);
if (localUser) return localUser;
}
return {
username: current.username,
uid: current.uid,
gid: current.gid,
homedir: current.homedir || os.homedir(),
shell: validLoginShell(process.env.SHELL) ? process.env.SHELL : DEFAULT_SHELL
};
}
function buildSpawnOptions(userInfo, cols, rows, ptyMode) {
const cwd = userInfo.homedir || os.homedir();
const env = Object.assign({}, process.env, {
HOME: cwd,
USER: userInfo.username,
LOGNAME: userInfo.username,
SHELL: userInfo.shell || DEFAULT_SHELL,
TERM: ptyMode ? 'xterm-256color' : 'dumb',
LANG: process.env.LANG || 'en_US.UTF-8',
COLUMNS: String(cols || 80),
LINES: String(rows || 24)
});
const options = { cwd, env };
if (!isWindows && typeof process.getuid === 'function' && process.getuid() === 0 && userInfo.uid !== 0) {
options.uid = userInfo.uid;
options.gid = userInfo.gid;
}
return options;
}
function spawnPty(cols, rows, userInfo) {
if (!pty) return null;
try {
const options = buildSpawnOptions(userInfo, cols, rows, true);
const child = pty.spawn(userInfo.shell || DEFAULT_SHELL, [], {
name: 'xterm-256color',
cols: cols || 80,
rows: rows || 24,
cwd: options.cwd,
env: options.env,
uid: options.uid,
gid: options.gid
});
return {
kind: 'pty',
child,
write: (data) => child.write(data),
resize: (cols, rows) => {
try { child.resize(cols, rows); } catch (_) { /* ignore */ }
},
onData: (cb) => child.onData(cb),
onExit: (cb) => child.onExit(cb),
kill: () => { try { child.kill(); } catch (_) { /* ignore */ } }
};
} catch (_) {
return null;
}
}
function spawnFallback(cols, rows, userInfo) {
const args = isWindows ? [] : ['-i'];
const options = buildSpawnOptions(userInfo, cols, rows, false);
const child = spawn(userInfo.shell || DEFAULT_SHELL, args, {
cwd: options.cwd,
env: options.env,
uid: options.uid,
gid: options.gid,
stdio: ['pipe', 'pipe', 'pipe']
});
return {
kind: 'pipe',
child,
write: (data) => {
try { child.stdin.write(data); } catch (_) { /* ignore */ }
},
resize: () => { /* no-op for piped shell */ },
onData: (cb) => {
child.stdout.on('data', (b) => cb(b.toString('utf8')));
child.stderr.on('data', (b) => cb(b.toString('utf8')));
},
onExit: (cb) => {
child.on('exit', (code, signal) => cb({ exitCode: code === null ? -1 : code, signal }));
},
kill: () => { try { child.kill(); } catch (_) { /* ignore */ } }
};
}
function startShell(cols, rows, userInfo) {
return spawnPty(cols, rows, userInfo) || spawnFallback(cols, rows, userInfo);
}
/**
* Initialize the Server Management terminal WS proxy.
* @param {import('http').Server} server
* @param {Function} sessionMiddleware - Express session middleware
* @param {{logAction?:Function}} [opts]
*/
function initServerTerminalProxy(server, sessionMiddleware, opts) {
const wss = new WebSocket.Server({ noServer: true });
const audit = opts && typeof opts.logAction === 'function' ? opts.logAction : null;
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname !== '/ws/server-management/terminal') return;
sessionMiddleware(req, {}, () => {
if (!req.session || !req.session.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
return socket.destroy();
}
const sessUser = req.session.user || {};
const role = sessUser.role || req.session.role || '';
const username = sessUser.username || `user#${req.session.userId}`;
// RBAC: only super_admin / admin / server_admin
if (!(role === 'super_admin' || role === 'admin' || role === 'server_admin')) {
console.warn(`[srv-term] 403 upgrade rejected (user=${username} role=${role})`);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
return socket.destroy();
}
req._smUserName = username;
req._smUserRole = role;
req._smUserId = req.session.userId;
req._smIp = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
});
});
wss.on('connection', (ws, req) => {
const sessionId = makeSessionId();
const username = req._smUserName;
const role = req._smUserRole;
const userId = req._smUserId || null;
const ip = req._smIp || '';
let shell = null;
const send = (obj) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
};
if (audit) {
try { audit(userId, 'server_terminal_open', `session=${sessionId} role=${role}`, ip); } catch (_) { /* ignore */ }
}
console.log(`[srv-term] session ${sessionId} opened by ${username}`);
ws.on('message', (raw) => {
let msg;
try { msg = JSON.parse(raw.toString('utf8')); } catch (_) { return; }
if (!msg || typeof msg !== 'object') return;
if (!shell && msg.type !== 'init') return; // must init first
switch (msg.type) {
case 'init': {
const cols = Math.max(20, Math.min(500, parseInt(msg.cols, 10) || 80));
const rows = Math.max(5, Math.min(200, parseInt(msg.rows, 10) || 24));
const terminalUser = pickTerminalUser();
shell = startShell(cols, rows, terminalUser);
if (!shell) {
send({ type: 'error', error: 'failed_to_spawn_shell' });
try { ws.close(); } catch (_) { /* ignore */ }
return;
}
ACTIVE_SESSIONS.set(sessionId, shell);
send({
type: 'ready',
session_id: sessionId,
kind: shell.kind,
shell: terminalUser.shell || DEFAULT_SHELL,
platform: process.platform,
user: terminalUser.username,
cwd: terminalUser.homedir || os.homedir(),
pty_available: !!pty
});
shell.onData((data) => send({ type: 'output', data }));
shell.onExit(({ exitCode, signal }) => {
send({ type: 'end', reason: 'exit', code: exitCode, signal });
try { ws.close(); } catch (_) { /* ignore */ }
});
break;
}
case 'input':
if (typeof msg.data === 'string') shell.write(msg.data);
break;
case 'resize': {
const cols = Math.max(20, Math.min(500, parseInt(msg.cols, 10) || 80));
const rows = Math.max(5, Math.min(200, parseInt(msg.rows, 10) || 24));
shell.resize(cols, rows);
break;
}
case 'close':
try { shell.kill(); } catch (_) { /* ignore */ }
try { ws.close(); } catch (_) { /* ignore */ }
break;
default:
break;
}
});
ws.on('close', () => {
if (shell) {
try { shell.kill(); } catch (_) { /* ignore */ }
}
ACTIVE_SESSIONS.delete(sessionId);
if (audit) {
try { audit(userId, 'server_terminal_close', `session=${sessionId}`, ip); } catch (_) { /* ignore */ }
}
console.log(`[srv-term] session ${sessionId} closed`);
});
ws.on('error', () => { /* no-op */ });
});
wss.on('error', (err) => {
console.error('[srv-term] wss error:', err && err.message);
});
}
module.exports = {
initServerTerminalProxy,
isPtyAvailable: () => !!pty,
activeSessionCount: () => ACTIVE_SESSIONS.size
};
+32 -1
View File
@@ -1,3 +1,4 @@
<% const sidebarServerTab = typeof currentTab !== 'undefined' && currentTab ? currentTab : 'overview'; %>
<!-- Sidebar Navigation — TeamViewer-style icon rail + flyout -->
<aside class="sidebar" id="sidebar" data-collapsed="true">
<!-- Icon rail (always visible) -->
@@ -42,6 +43,12 @@
<span class="material-icons">settings</span>
</button>
<% } %>
<% if (hasPermission('server.config')) { %>
<button class="sidebar-rail-btn <%= currentPage === 'server-management' ? 'active' : '' %>"
data-category="server-mgmt" title="<%= _('nav.server_management') || 'Server Management' %>">
<span class="material-icons">dns</span>
</button>
<% } %>
</nav>
<!-- Bottom rail icons -->
@@ -245,6 +252,29 @@
</a>
</div>
<!-- Server Management category links (BETA) -->
<% if (hasPermission('server.config')) { %>
<div class="sidebar-flyout-panel" data-panel="server-mgmt">
<a href="/server-management?tab=overview" class="sidebar-link <%= currentPage === 'server-management' && sidebarServerTab === 'overview' ? 'active' : '' %>">
<span class="material-icons">monitoring</span>
<span class="sidebar-link-text"><%= _('server_mgmt.tab_overview') || 'Overview' %></span>
</a>
<a href="/server-management?tab=terminal" class="sidebar-link <%= currentPage === 'server-management' && sidebarServerTab === 'terminal' ? 'active' : '' %>">
<span class="material-icons">terminal</span>
<span class="sidebar-link-text"><%= _('server_mgmt.tab_terminal') || 'Terminal' %></span>
</a>
<a href="/server-management?tab=files" class="sidebar-link <%= currentPage === 'server-management' && sidebarServerTab === 'files' ? 'active' : '' %>">
<span class="material-icons">folder_open</span>
<span class="sidebar-link-text"><%= _('server_mgmt.tab_files') || 'Files' %></span>
</a>
<a href="/server-management?tab=services" class="sidebar-link <%= currentPage === 'server-management' && sidebarServerTab === 'services' ? 'active' : '' %>">
<span class="material-icons">miscellaneous_services</span>
<span class="sidebar-link-text"><%= _('server_mgmt.tab_services') || 'Services' %></span>
<span class="badge-sidebar" style="background:linear-gradient(135deg,#ff7b72,#d29922);color:#fff;flex-shrink:0;margin-left:auto;">BETA</span>
</a>
</div>
<% } %>
<!-- Sidebar footer - User -->
<div class="sidebar-footer">
<div class="sidebar-user" id="user-menu-trigger">
@@ -305,7 +335,8 @@
main: '<%= _("nav.main") %>',
management: '<%= _("nav.management") || "Management" %>',
tools: '<%= _("nav.tools") %>',
system: '<%= _("nav.system") %>'
system: '<%= _("nav.system") %>',
'server-mgmt': '<%= _("nav.server_management") || "Server Management" %>'
};
if (flyoutTitle) flyoutTitle.textContent = titles[category] || '';
activeCategory = category;
+214
View File
@@ -0,0 +1,214 @@
<% var initialTab = (typeof currentTab !== 'undefined' && currentTab) ? currentTab : 'overview'; %>
<%- include('layouts/main', {
title: _('server_mgmt.title'),
pageStyles: ['server-management'],
pageScripts: ['server-management'],
currentPage: 'server-management',
currentTab: initialTab,
widePage: true,
breadcrumb: [{ label: _('server_mgmt.title') }],
body: `
<div class="server-mgmt-page" id="server-mgmt-page" data-initial-tab="${initialTab}">
<!-- Header -->
<div class="page-header server-mgmt-header">
<div class="page-title">
<h1>
<span class="material-icons">dns</span>
${_('server_mgmt.title')}
<span class="beta-badge" title="${_('server_mgmt.beta_tooltip')}">BETA</span>
</h1>
</div>
<p class="page-subtitle">${_('server_mgmt.subtitle')}</p>
</div>
<!-- Beta notice -->
<div class="server-mgmt-notice">
<span class="material-icons">info</span>
<div>
<strong>${_('server_mgmt.notice_title')}</strong>
<p>${_('server_mgmt.notice_body')}</p>
</div>
</div>
<div class="server-mgmt-layout">
<div class="server-mgmt-content">
<!-- Overview tab -->
<section class="sm-tab-panel active" id="sm-panel-overview" role="tabpanel">
<div class="sm-overview-grid">
<div class="sm-card">
<div class="sm-card-header">
<span class="material-icons">memory</span>
<h3>${_('server_mgmt.cpu')}</h3>
</div>
<div class="sm-gauge" id="sm-cpu-gauge">
<div class="sm-gauge-bar"><div class="sm-gauge-fill" id="sm-cpu-fill"></div></div>
<div class="sm-gauge-value" id="sm-cpu-value">0%</div>
</div>
<div class="sm-meta" id="sm-cpu-meta"></div>
</div>
<div class="sm-card">
<div class="sm-card-header">
<span class="material-icons">developer_board</span>
<h3>${_('server_mgmt.memory')}</h3>
</div>
<div class="sm-gauge">
<div class="sm-gauge-bar"><div class="sm-gauge-fill" id="sm-mem-fill"></div></div>
<div class="sm-gauge-value" id="sm-mem-value">0%</div>
</div>
<div class="sm-meta" id="sm-mem-meta"></div>
</div>
<div class="sm-card">
<div class="sm-card-header">
<span class="material-icons">speed</span>
<h3>${_('server_mgmt.load')}</h3>
</div>
<div class="sm-load-row">
<div><span class="sm-stat-label">1m</span><span id="sm-load-1"></span></div>
<div><span class="sm-stat-label">5m</span><span id="sm-load-5"></span></div>
<div><span class="sm-stat-label">15m</span><span id="sm-load-15"></span></div>
</div>
<div class="sm-meta" id="sm-uptime-meta"></div>
</div>
<div class="sm-card sm-card-wide">
<div class="sm-card-header">
<span class="material-icons">storage</span>
<h3>${_('server_mgmt.disks')}</h3>
</div>
<div id="sm-disks"></div>
</div>
<div class="sm-card sm-card-wide">
<div class="sm-card-header">
<span class="material-icons">timeline</span>
<h3>${_('server_mgmt.history')}</h3>
</div>
<div class="sm-history-wrap">
<canvas id="sm-history-chart" height="120"></canvas>
<div class="sm-history-legend">
<span><span class="dot dot-cpu"></span>${_('server_mgmt.cpu')}</span>
<span><span class="dot dot-mem"></span>${_('server_mgmt.memory')}</span>
</div>
</div>
</div>
<div class="sm-card sm-card-wide">
<div class="sm-card-header">
<span class="material-icons">badge</span>
<h3>${_('server_mgmt.host_info')}</h3>
</div>
<div class="sm-info-grid" id="sm-host-info"></div>
</div>
</div>
</section>
<!-- Terminal tab -->
<section class="sm-tab-panel" id="sm-panel-terminal" role="tabpanel" hidden>
<div class="sm-terminal-toolbar">
<button class="btn btn-primary" id="sm-term-connect">
<span class="material-icons">play_arrow</span>
${_('server_mgmt.term_connect')}
</button>
<button class="btn btn-secondary" id="sm-term-disconnect" disabled>
<span class="material-icons">stop</span>
${_('server_mgmt.term_disconnect')}
</button>
<button class="btn btn-tertiary" id="sm-term-clear" disabled>
<span class="material-icons">delete_sweep</span>
${_('server_mgmt.term_clear')}
</button>
<span class="sm-term-status" id="sm-term-status">${_('server_mgmt.term_disconnected')}</span>
</div>
<div class="sm-terminal-warning">
<span class="material-icons">warning</span>
<span>${_('server_mgmt.term_warning')}</span>
</div>
<div class="sm-terminal-host" id="sm-terminal-host"></div>
<p class="sm-term-hint">${_('server_mgmt.term_hint')}</p>
</section>
<!-- Files tab -->
<section class="sm-tab-panel" id="sm-panel-files" role="tabpanel" hidden>
<div class="sm-files-toolbar">
<button class="btn btn-tertiary" id="sm-files-up" title="${_('server_mgmt.files_up')}">
<span class="material-icons">arrow_upward</span>
</button>
<button class="btn btn-tertiary" id="sm-files-refresh" title="${_('common.refresh')}">
<span class="material-icons">refresh</span>
</button>
<input type="text" class="form-input sm-files-path" id="sm-files-path" value="/" spellcheck="false">
<button class="btn btn-secondary" id="sm-files-go">${_('server_mgmt.files_go')}</button>
<span class="spacer"></span>
<button class="btn btn-tertiary" id="sm-files-mkdir">
<span class="material-icons">create_new_folder</span>
${_('server_mgmt.files_mkdir')}
</button>
</div>
<div class="sm-files-table-wrapper">
<table class="data-table sm-files-table">
<thead>
<tr>
<th style="width:40px"></th>
<th>${_('server_mgmt.files_name')}</th>
<th style="width:110px">${_('server_mgmt.files_size')}</th>
<th style="width:90px">${_('server_mgmt.files_perms')}</th>
<th style="width:160px">${_('server_mgmt.files_mtime')}</th>
<th style="width:160px">${_('common.actions')}</th>
</tr>
</thead>
<tbody id="sm-files-body">
<tr><td colspan="6"><div class="empty-state"><span class="material-icons">folder_off</span><p>${_('common.loading')}…</p></div></td></tr>
</tbody>
</table>
</div>
<!-- Inline file editor -->
<div class="sm-file-editor" id="sm-file-editor" hidden>
<div class="sm-file-editor-header">
<span class="material-icons">edit_document</span>
<span class="sm-file-editor-path" id="sm-file-editor-path"></span>
<span class="spacer"></span>
<button class="btn btn-primary" id="sm-file-save">
<span class="material-icons">save</span>${_('common.save')}
</button>
<button class="btn btn-tertiary" id="sm-file-close">
<span class="material-icons">close</span>${_('common.cancel')}
</button>
</div>
<textarea class="sm-file-textarea" id="sm-file-textarea" spellcheck="false"></textarea>
<p class="sm-file-meta" id="sm-file-meta"></p>
</div>
</section>
<!-- Services tab -->
<section class="sm-tab-panel" id="sm-panel-services" role="tabpanel" hidden>
<div class="sm-services-toolbar">
<input type="search" class="form-input sm-services-search" id="sm-services-search" placeholder="${_('server_mgmt.svc_search_placeholder')}">
<button class="btn btn-tertiary" id="sm-services-refresh">
<span class="material-icons">refresh</span>${_('common.refresh')}
</button>
<span class="sm-services-count" id="sm-services-count"></span>
</div>
<div class="sm-services-table-wrapper">
<table class="data-table sm-services-table">
<thead>
<tr>
<th>${_('server_mgmt.svc_name')}</th>
<th style="width:120px">${_('server_mgmt.svc_state')}</th>
<th>${_('server_mgmt.svc_description')}</th>
<th style="width:280px">${_('common.actions')}</th>
</tr>
</thead>
<tbody id="sm-services-body">
<tr><td colspan="4"><div class="empty-state"><span class="material-icons">miscellaneous_services</span><p>${_('common.loading')}…</p></div></td></tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
</div>
`
}) %>