mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
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:
@@ -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"
|
||||
|
||||
Binary file not shown.
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "伺服器設定",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
}) %>
|
||||
Reference in New Issue
Block a user