diff --git a/src/diagnostics/host.rs b/src/diagnostics/host.rs new file mode 100644 index 00000000..b62e7a95 --- /dev/null +++ b/src/diagnostics/host.rs @@ -0,0 +1,143 @@ +//! Read-only host metadata and filesystem identity for the About page. +use super::{get_disk_space, DiskSpaceInfo}; +use serde::Serialize; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Serialize)] +pub struct HostInfo { + pub operating_system: String, + pub architecture: &'static str, + pub kernel: Option, + pub cpu_count: usize, + pub storage: StorageInfo, +} + +#[derive(Debug, Serialize)] +pub struct StorageInfo { + pub system: Option, + pub msd: Option, + /// Unknown identity must never be interpreted as a shared filesystem. + pub shared_partition: Option, +} + +pub fn get_host_info(msd_path: &Path) -> HostInfo { + let operating_system = std::fs::read_to_string("/etc/os-release") + .ok() + .and_then(|text| os_release_name(&text)) + .unwrap_or_else(|| std::env::consts::OS.to_string()); + let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + HostInfo { + operating_system, + architecture: std::env::consts::ARCH, + kernel, + cpu_count: std::thread::available_parallelism() + .map(usize::from) + .unwrap_or(1), + storage: storage_info(system_root().as_path(), msd_path), + } +} + +fn system_root() -> PathBuf { + #[cfg(windows)] + { + PathBuf::from(std::env::var_os("SystemDrive").unwrap_or_else(|| "C:".into())).join("\\") + } + #[cfg(not(windows))] + { + PathBuf::from("/") + } +} + +fn os_release_name(text: &str) -> Option { + text.lines().find_map(|line| { + let value = line + .strip_prefix("PRETTY_NAME=")? + .trim() + .trim_matches(['\'', '"']); + (!value.is_empty()).then(|| value.to_string()) + }) +} + +// MSD directories may not have been created yet. Read their containing filesystem +// without creating directories or changing permissions. +fn existing_ancestor(path: &Path) -> Option { + if path.as_os_str().is_empty() { + return None; + } + let mut candidate = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir().ok()?.join(path) + }; + loop { + match std::fs::metadata(&candidate) { + Ok(_) => return Some(candidate), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if !candidate.pop() { + return None; + } + } + Err(_) => return None, + } + } +} + +fn storage_info(system: &Path, msd: &Path) -> StorageInfo { + let system = existing_ancestor(system); + let msd = existing_ancestor(msd); + StorageInfo { + system: system.as_deref().and_then(|path| get_disk_space(path).ok()), + msd: msd.as_deref().and_then(|path| get_disk_space(path).ok()), + shared_partition: system + .as_deref() + .zip(msd.as_deref()) + .and_then(|(a, b)| same_filesystem(a, b)), + } +} + +#[cfg(unix)] +fn same_filesystem(a: &Path, b: &Path) -> Option { + use std::os::unix::fs::MetadataExt; + Some(std::fs::metadata(a).ok()?.dev() == std::fs::metadata(b).ok()?.dev()) +} + +#[cfg(not(unix))] +fn same_filesystem(_a: &Path, _b: &Path) -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn distribution_name_handles_quotes_and_missing_values() { + assert_eq!( + os_release_name("NAME=Linux\nPRETTY_NAME=\"OpenWrt 24.10\"\n"), + Some("OpenWrt 24.10".into()) + ); + assert_eq!(os_release_name("PRETTY_NAME=\n"), None); + assert_eq!(os_release_name("NAME=Linux\n"), None); + } + + #[cfg(unix)] + #[test] + fn nested_msd_directory_shares_its_existing_parent_filesystem() { + let parent = tempfile::tempdir().unwrap(); + let missing = parent.path().join("not-created/msd"); + let storage = storage_info(parent.path(), &missing); + assert_eq!(storage.shared_partition, Some(true)); + assert!(storage.system.is_some() && storage.msd.is_some()); + assert!(!missing.exists()); + } + + #[test] + fn missing_msd_is_not_a_duplicate_system_partition() { + let storage = storage_info(&system_root(), Path::new("")); + assert!(storage.msd.is_none()); + assert_eq!(storage.shared_partition, None); + } +} diff --git a/src/diagnostics/linux.rs b/src/diagnostics/linux.rs index 2045ffd0..623bf36c 100644 --- a/src/diagnostics/linux.rs +++ b/src/diagnostics/linux.rs @@ -9,7 +9,7 @@ pub fn get_disk_space(path: &std::path::Path) -> Result { let block_size = stat.block_size() as u64; let total = stat.blocks() as u64 * block_size; let available = stat.blocks_available() as u64 * block_size; - let used = total - available; + let used = total.saturating_sub(stat.blocks_free() as u64 * block_size); Ok(DiskSpaceInfo { total, diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 4bca8def..0158376f 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -4,6 +4,9 @@ use serde::Serialize; use crate::error::Result; +mod host; +pub use host::{get_host_info, HostInfo}; + #[derive(Debug, Clone, Serialize)] pub struct DeviceInfo { pub hostname: String, diff --git a/src/web/handlers/host_power.rs b/src/web/handlers/host_power.rs new file mode 100644 index 00000000..d726bf90 --- /dev/null +++ b/src/web/handlers/host_power.rs @@ -0,0 +1,151 @@ +//! Host power controls are separate from restarting the One-KVM process. +use super::*; + +#[derive(Deserialize)] +pub struct HostPowerRequest { + #[serde(default)] + pub confirmed: bool, +} + +#[derive(Clone, Copy)] +enum HostPowerAction { + Reboot, + Poweroff, +} + +impl HostPowerAction { + fn command(self) -> &'static str { + match self { + Self::Reboot => "reboot", + Self::Poweroff => "poweroff", + } + } +} + +fn confirmed_request( + request: HostPowerRequest, + action: HostPowerAction, + execute: impl FnOnce(HostPowerAction) -> Result<()>, +) -> Result> { + if !request.confirmed { + return Err(AppError::BadRequest( + "Explicit confirmation is required for host power control.".into(), + )); + } + execute(action)?; + Ok(Json(LoginResponse { + success: true, + message: Some("Host power request accepted.".into()), + })) +} + +#[cfg(target_os = "linux")] +fn schedule_action(action: HostPowerAction) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + use std::sync::atomic::{AtomicBool, Ordering}; + static PENDING: AtomicBool = AtomicBool::new(false); + + // These are system commands; do not run them through a user-controlled shell. + if unsafe { libc::geteuid() } != 0 { + return Err(AppError::BadRequest( + "Host power control requires the One-KVM service to run as root.".into(), + )); + } + let command = action.command(); + let executable = ["/sbin", "/usr/sbin", "/bin", "/usr/bin"] + .into_iter() + .map(|directory| std::path::Path::new(directory).join(command)) + .find(|path| { + std::fs::metadata(path) + .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + }) + .ok_or_else(|| AppError::NotFound(format!("Host command {command} is unavailable.")))?; + if PENDING.swap(true, Ordering::AcqRel) { + return Err(AppError::Conflict( + "A host power request is already pending.".into(), + )); + } + tokio::spawn(async move { + // Let the accepted response reach the browser before the connection ends. + tokio::time::sleep(std::time::Duration::from_millis(750)).await; + let result = tokio::process::Command::new(executable) + .stdin(std::process::Stdio::null()) + .output() + .await; + match result { + Ok(output) if output.status.success() => info!("Host {command} command accepted"), + Ok(output) => { + PENDING.store(false, Ordering::Release); + warn!( + "Host {command} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Err(error) => { + PENDING.store(false, Ordering::Release); + warn!("Could not execute host {command}: {error}"); + } + } + }); + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn schedule_action(_action: HostPowerAction) -> Result<()> { + Err(AppError::BadRequest( + "Host power control is only available on Linux.".into(), + )) +} + +pub async fn host_reboot(Json(request): Json) -> Result> { + confirmed_request(request, HostPowerAction::Reboot, schedule_action) +} + +pub async fn host_poweroff(Json(request): Json) -> Result> { + confirmed_request(request, HostPowerAction::Poweroff, schedule_action) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_or_false_confirmation_cannot_reach_the_executor() { + for body in ["{}", r#"{"confirmed":false}"#] { + let request = serde_json::from_str(body).unwrap(); + let result = confirmed_request(request, HostPowerAction::Poweroff, |_| { + panic!("Unconfirmed request must never execute a system command") + }); + assert!(matches!(result, Err(AppError::BadRequest(_)))); + } + } + + #[test] + fn confirmation_dispatches_only_the_selected_host_command() { + for (action, expected) in [ + (HostPowerAction::Reboot, "reboot"), + (HostPowerAction::Poweroff, "poweroff"), + ] { + let mut dispatched = None; + let Json(response) = + confirmed_request(HostPowerRequest { confirmed: true }, action, |a| { + dispatched = Some(a.command()); + Ok(()) + }) + .unwrap(); + assert!(response.success); + assert_eq!(dispatched, Some(expected)); + } + } + + #[test] + fn executor_errors_are_not_reported_as_accepted() { + let result = confirmed_request( + HostPowerRequest { confirmed: true }, + HostPowerAction::Reboot, + |_| Err(AppError::Conflict("pending".into())), + ); + assert!(matches!(result, Err(AppError::Conflict(_)))); + } +} diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index 7b6a6d90..4ade59e0 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -8,6 +8,7 @@ mod atx_api; mod audio_api; mod auth; mod hid_api; +mod host_power; mod inventory; mod mcp; #[cfg(unix)] @@ -23,6 +24,7 @@ pub use atx_api::*; pub use audio_api::*; pub use auth::*; pub use hid_api::*; +pub use host_power::*; pub use inventory::*; pub use mcp::*; #[cfg(unix)] diff --git a/src/web/handlers/system.rs b/src/web/handlers/system.rs index d0f93009..8f622a85 100644 --- a/src/web/handlers/system.rs +++ b/src/web/handlers/system.rs @@ -26,6 +26,7 @@ pub struct SystemInfo { pub disk_space: Option, #[serde(skip_serializing_if = "Option::is_none")] pub device_info: Option, + pub host: crate::diagnostics::HostInfo, } #[derive(Serialize)] @@ -112,5 +113,6 @@ pub async fn system_info(State(state): State>) -> Json }, disk_space, device_info, + host: crate::diagnostics::get_host_info(&config.msd.msd_dir_path()), }) } diff --git a/src/web/routes.rs b/src/web/routes.rs index 51bee195..9b4059d8 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -221,6 +221,8 @@ pub fn create_router(state: Arc) -> Router { ) // System control .route("/system/restart", post(handlers::system_restart)) + .route("/system/reboot", post(handlers::host_reboot)) + .route("/system/poweroff", post(handlers::host_poweroff)) .route("/update/overview", get(handlers::update_overview)) .route("/update/upgrade", post(handlers::update_upgrade)) .route("/update/status", get(handlers::update_status)) diff --git a/web/src/api/index.ts b/web/src/api/index.ts index ed7434f9..87858ee2 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -93,6 +93,18 @@ export interface NetworkAddress { ip: string } +export interface HostInfo { + operating_system: string + architecture: string + kernel: string | null + cpu_count: number + storage: { + system: { total: number; used: number; available: number } | null + msd: { total: number; used: number; available: number } | null + shared_partition: boolean | null + } +} + export interface DeviceInfo { hostname: string cpu_model: string @@ -148,6 +160,7 @@ export const systemApi = { available: number used: number } + host?: HostInfo device_info?: DeviceInfo }>('/info'), @@ -186,6 +199,12 @@ export const systemApi = { request<{ success: boolean; message?: string }>('/system/restart', { method: 'POST', }), + + hostPower: (action: 'reboot' | 'poweroff') => + request<{ success: boolean; message?: string }>(`/system/${action}`, { + method: 'POST', + body: JSON.stringify({ confirmed: true }), + }, { toastOnError: false }), } export type UpdateChannel = 'stable' | 'beta' diff --git a/web/src/components/AboutSystemCards.vue b/web/src/components/AboutSystemCards.vue new file mode 100644 index 00000000..65bb98d3 --- /dev/null +++ b/web/src/components/AboutSystemCards.vue @@ -0,0 +1,178 @@ + + diff --git a/web/src/components/SystemDeviceControls.vue b/web/src/components/SystemDeviceControls.vue new file mode 100644 index 00000000..0ae3a758 --- /dev/null +++ b/web/src/components/SystemDeviceControls.vue @@ -0,0 +1,118 @@ + + + diff --git a/web/src/i18n/settings-ui.ts b/web/src/i18n/settings-ui.ts index eb47f080..4967b90c 100644 --- a/web/src/i18n/settings-ui.ts +++ b/web/src/i18n/settings-ui.ts @@ -1,12 +1,49 @@ export const settingsUiZh = { watchdogAutoReset: '启用自动复位', + deviceControls: '设备操作', + deviceControlsHelp: '管理运行 One-KVM 的设备', + reboot: '重启', + poweroff: '关机', + rebootConfirm: '确认重启设备?', + poweroffConfirm: '确认关闭设备?', + rebootDescription: '将重启运行 One-KVM 的设备,当前连接会中断。启动完成后可重新连接。', + poweroffDescription: '将关闭运行 One-KVM 的设备,当前连接会中断。关机后需要手动重新上电。', + rebootSent: '重启请求已发送,请等待设备重新启动后再连接。', + poweroffSent: '关机请求已发送,设备即将关闭。', + deviceActionFailed: '操作请求未能发送,请重试。', + mcpRate: '请求频率(次/秒)', help: '查看提示', back: '返回控制台', saveCapture: '保存采集设置', saveEncoder: '保存编码设置', saveConnection: '保存连接设置', + load: '系统监控', + loadHelp: + '显示 CPU、内存和分区的实际占用比例。系统与 MSD 位于同一文件系统时合并显示,不重复统计容量。', + cpu: 'CPU 占用', + memory: '内存占用', + systemDisk: '系统分区占用', + msdDisk: 'MSD 分区占用', + sharedDisk: '系统 / MSD 分区占用', + cores: '{count} 核处理器', + systemInfo: '版本与系统', + systemInfoHelp: + '报告问题时,可提供软件版本、系统、处理器、架构、内核与主机名,方便定位设备环境。', + softwareVersion: '软件版本', + operatingSystem: '操作系统', + processor: '处理器', + architecture: '系统架构', + kernel: '内核版本', + hostname: '主机名', + copyInfo: '复制信息', + copied: '信息已复制', + copyFailed: '复制失败,请手动选择信息', + unavailable: '暂不可用', project: '项目主页', + docs: '使用文档', + feedback: '反馈问题', + loadFailed: '无法刷新系统信息,暂时保留最近一次读取的数据。', featuresHelp: 'USB 功能会按当前驱动与设备能力提供。修改后只保存本卡片配置,设备可能重新识别 USB 连接。', identifiers: '高级设备标识', @@ -22,13 +59,52 @@ export const settingsUiZh = { } export const settingsUiEn = { watchdogAutoReset: 'Enable automatic recovery', + deviceControls: 'Device controls', + deviceControlsHelp: 'Manage the device running One-KVM', + reboot: 'Restart', + poweroff: 'Shut down', + rebootConfirm: 'Restart this device?', + poweroffConfirm: 'Shut down this device?', + rebootDescription: + 'The device running One-KVM will restart and disconnect this session. Reconnect after startup.', + poweroffDescription: + 'The device running One-KVM will shut down and disconnect this session. You will need to power it on manually.', + rebootSent: 'Restart requested. Reconnect after the device starts up.', + poweroffSent: 'Shutdown requested. The device will turn off shortly.', + deviceActionFailed: 'Could not send the request. Please try again.', + mcpRate: 'Requests per second', help: 'Show help', back: 'Back to console', saveCapture: 'Save capture', saveEncoder: 'Save encoder', saveConnection: 'Save connection', + load: 'System monitoring', + loadHelp: + 'Actual CPU, memory and filesystem usage. System and MSD storage share one indicator when they belong to the same filesystem.', + cpu: 'CPU usage', + memory: 'Memory usage', + systemDisk: 'System partition', + msdDisk: 'MSD partition', + sharedDisk: 'System / MSD partition', + cores: '{count} CPU cores', + systemInfo: 'Version & system', + systemInfoHelp: + 'Include the software version, operating system, processor, architecture, kernel and hostname when reporting an issue.', + softwareVersion: 'Software version', + operatingSystem: 'Operating system', + processor: 'Processor', + architecture: 'Architecture', + kernel: 'Kernel version', + hostname: 'Hostname', + copyInfo: 'Copy information', + copied: 'Information copied', + copyFailed: 'Copy failed. Select and copy the information manually.', + unavailable: 'Unavailable', project: 'Project', + docs: 'Documentation', + feedback: 'Report an issue', + loadFailed: 'Could not refresh system information. Showing the most recent data.', featuresHelp: 'Available USB functions depend on the driver and hardware. Saving applies only this card and may reconnect the USB device.', identifiers: 'Advanced device identity', diff --git a/web/src/stores/system.ts b/web/src/stores/system.ts index 8da24c07..da97a33b 100644 --- a/web/src/stores/system.ts +++ b/web/src/stores/system.ts @@ -1,3 +1,4 @@ +import type { HostInfo } from '@/api' import { defineStore } from 'pinia' import { ref } from 'vue' import { systemApi, streamApi, hidApi, atxApi, msdApi, type DeviceInfo, type DiskMode, type MountedMedia, type PlatformCapabilities } from '@/api' @@ -166,6 +167,7 @@ export const useSystemStore = defineStore('system', () => { const capabilities = ref(null) const diskSpace = ref(null) const deviceInfo = ref(null) + const hostInfo = ref(null) const stream = ref(null) const hid = ref(null) const atx = ref(null) @@ -189,6 +191,7 @@ export const useSystemStore = defineStore('system', () => { capabilities.value = info.capabilities diskSpace.value = info.disk_space ?? null deviceInfo.value = info.device_info ?? null + hostInfo.value = info.host ?? null return info } catch (e) { console.error('Failed to fetch system info:', e) @@ -455,6 +458,7 @@ export const useSystemStore = defineStore('system', () => { capabilities, diskSpace, deviceInfo, + hostInfo, stream, hid, atx, diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index b05f96f1..b1a0633d 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -3,6 +3,8 @@ import { usesCh9329, supportsOtgFunctions } from '@/lib/hidGuide' import '@/styles/settings.css' import CardTitle from '@/components/SettingsCardTitle.vue' import SidebarMenuButton from '@/components/SettingsSidebarButton.vue' +import AboutSystemCards from '@/components/AboutSystemCards.vue' +import SystemDeviceControls from '@/components/SystemDeviceControls.vue' import McpSettingsCard from '@/components/McpSettingsCard.vue' import HidDeviceSettings from '@/components/HidDeviceSettings.vue' @@ -4033,6 +4035,7 @@ watch(isWindows, () => {
+ {{ @@ -6433,6 +6436,7 @@ watch(isWindows, () => {
+ {{