feat(system): 添加设备信息及重启关机控制

关于页展示宿主系统、处理器、架构、内核和资源占用,合并同一文件系统上的系统与 MSD 容量。
新增 Linux 宿主机重启和关机接口,在其他设置中提供二次确认、请求互斥及失败提示。
修正 Linux 磁盘已用空间计算,并补充设备信息和电源控制的后端单元测试。

验证:独立暂存快照前端构建通过;6 项后端测试通过;设置页保存、系统信息及模拟电源控制浏览器测试通过。
This commit is contained in:
mofeng-git
2026-10-28 07:20:44 +08:00
parent fed753261b
commit 8d58119b77
13 changed files with 703 additions and 1 deletions

143
src/diagnostics/host.rs Normal file
View File

@@ -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<String>,
pub cpu_count: usize,
pub storage: StorageInfo,
}
#[derive(Debug, Serialize)]
pub struct StorageInfo {
pub system: Option<DiskSpaceInfo>,
pub msd: Option<DiskSpaceInfo>,
/// Unknown identity must never be interpreted as a shared filesystem.
pub shared_partition: Option<bool>,
}
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<String> {
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<PathBuf> {
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<bool> {
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<bool> {
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);
}
}

View File

@@ -9,7 +9,7 @@ pub fn get_disk_space(path: &std::path::Path) -> Result<DiskSpaceInfo> {
let block_size = stat.block_size() as u64; let block_size = stat.block_size() as u64;
let total = stat.blocks() as u64 * block_size; let total = stat.blocks() as u64 * block_size;
let available = stat.blocks_available() 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 { Ok(DiskSpaceInfo {
total, total,

View File

@@ -4,6 +4,9 @@ use serde::Serialize;
use crate::error::Result; use crate::error::Result;
mod host;
pub use host::{get_host_info, HostInfo};
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct DeviceInfo { pub struct DeviceInfo {
pub hostname: String, pub hostname: String,

View File

@@ -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<Json<LoginResponse>> {
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<HostPowerRequest>) -> Result<Json<LoginResponse>> {
confirmed_request(request, HostPowerAction::Reboot, schedule_action)
}
pub async fn host_poweroff(Json(request): Json<HostPowerRequest>) -> Result<Json<LoginResponse>> {
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(_))));
}
}

View File

@@ -8,6 +8,7 @@ mod atx_api;
mod audio_api; mod audio_api;
mod auth; mod auth;
mod hid_api; mod hid_api;
mod host_power;
mod inventory; mod inventory;
mod mcp; mod mcp;
#[cfg(unix)] #[cfg(unix)]
@@ -23,6 +24,7 @@ pub use atx_api::*;
pub use audio_api::*; pub use audio_api::*;
pub use auth::*; pub use auth::*;
pub use hid_api::*; pub use hid_api::*;
pub use host_power::*;
pub use inventory::*; pub use inventory::*;
pub use mcp::*; pub use mcp::*;
#[cfg(unix)] #[cfg(unix)]

View File

@@ -26,6 +26,7 @@ pub struct SystemInfo {
pub disk_space: Option<DiskSpaceInfo>, pub disk_space: Option<DiskSpaceInfo>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub device_info: Option<DeviceInfo>, pub device_info: Option<DeviceInfo>,
pub host: crate::diagnostics::HostInfo,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -112,5 +113,6 @@ pub async fn system_info(State(state): State<Arc<AppState>>) -> Json<SystemInfo>
}, },
disk_space, disk_space,
device_info, device_info,
host: crate::diagnostics::get_host_info(&config.msd.msd_dir_path()),
}) })
} }

View File

@@ -221,6 +221,8 @@ pub fn create_router(state: Arc<AppState>) -> Router {
) )
// System control // System control
.route("/system/restart", post(handlers::system_restart)) .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/overview", get(handlers::update_overview))
.route("/update/upgrade", post(handlers::update_upgrade)) .route("/update/upgrade", post(handlers::update_upgrade))
.route("/update/status", get(handlers::update_status)) .route("/update/status", get(handlers::update_status))

View File

@@ -93,6 +93,18 @@ export interface NetworkAddress {
ip: string 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 { export interface DeviceInfo {
hostname: string hostname: string
cpu_model: string cpu_model: string
@@ -148,6 +160,7 @@ export const systemApi = {
available: number available: number
used: number used: number
} }
host?: HostInfo
device_info?: DeviceInfo device_info?: DeviceInfo
}>('/info'), }>('/info'),
@@ -186,6 +199,12 @@ export const systemApi = {
request<{ success: boolean; message?: string }>('/system/restart', { request<{ success: boolean; message?: string }>('/system/restart', {
method: 'POST', 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' export type UpdateChannel = 'stable' | 'beta'

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Copy, ExternalLink } from 'lucide-vue-next'
import { useSystemStore } from '@/stores/system'
import { useClipboard } from '@/composables/useClipboard'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import CardTitle from './SettingsCardTitle.vue'
import { Button } from '@/components/ui/button'
const props = defineProps<{ active: boolean }>()
const { t } = useI18n()
const system = useSystemStore()
const clipboard = useClipboard()
const failed = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
let generation = 0
async function refresh(own = generation) {
try {
await system.fetchSystemInfo()
if (own === generation) failed.value = false
} catch {
if (own === generation) failed.value = true
} finally {
if (own === generation && props.active) timer = setTimeout(() => void refresh(own), 5000)
}
}
watch(
() => props.active,
(active) => {
clearTimeout(timer)
const own = ++generation
if (active) void refresh(own)
},
{ immediate: true },
)
onUnmounted(() => {
++generation
clearTimeout(timer)
})
const host = computed(() => system.hostInfo)
function bytes(value?: number) {
if (value === undefined || !Number.isFinite(value)) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index++
}
return `${value.toLocaleString(undefined, { maximumFractionDigits: index > 2 ? 1 : 0 })} ${units[index]}`
}
function percent(used?: number, total?: number) {
return used !== undefined && total && Number.isFinite(used)
? Math.min(100, Math.max(0, (used / total) * 100))
: null
}
const metrics = computed(() => {
const device = system.deviceInfo
const shared = host.value?.storage.shared_partition === true
const result = [
{
name: t('settingsUi.cpu'),
value: device ? Math.min(100, Math.max(0, device.cpu_usage)) : null,
detail: host.value ? t('settingsUi.cores', { count: host.value.cpu_count }) : '—',
},
{
name: t('settingsUi.memory'),
value: percent(device?.memory_used, device?.memory_total),
detail: device ? `${bytes(device.memory_used)} / ${bytes(device.memory_total)}` : '—',
},
{
name: t(shared ? 'settingsUi.sharedDisk' : 'settingsUi.systemDisk'),
value: percent(host.value?.storage.system?.used, host.value?.storage.system?.total),
detail: host.value?.storage.system
? `${bytes(host.value.storage.system.used)} / ${bytes(host.value.storage.system.total)}`
: '—',
},
]
if (!shared && system.platform?.msd.available !== false) {
const msd = host.value?.storage.msd
result.push({
name: t('settingsUi.msdDisk'),
value: percent(msd?.used, msd?.total),
detail: msd ? `${bytes(msd.used)} / ${bytes(msd.total)}` : '—',
})
}
return result
})
const fields = computed(() => [
[t('settingsUi.softwareVersion'), system.version ? `One-KVM v${system.version}` : '—'],
[t('settingsUi.operatingSystem'), host.value?.operating_system || '—'],
[t('settingsUi.processor'), system.deviceInfo?.cpu_model || '—'],
[t('settingsUi.architecture'), host.value?.architecture || '—'],
[t('settingsUi.kernel'), host.value?.kernel || '—'],
[t('settingsUi.hostname'), system.deviceInfo?.hostname || '—'],
])
async function copyInfo() {
const copied = await clipboard.copy(
fields.value.map(([key, value]) => `${key}: ${value}`).join('\n'),
)
copied ? toast.success(t('settingsUi.copied')) : toast.error(t('settingsUi.copyFailed'))
}
</script>
<template>
<Card class="system-load-card">
<CardHeader
><CardTitle :help="t('settingsUi.loadHelp')">{{
t('settingsUi.load')
}}</CardTitle></CardHeader
>
<CardContent>
<div class="load-metrics" :style="{ '--metric-count': metrics.length }">
<div v-for="metric in metrics" :key="metric.name" class="load-metric">
<div
class="usage-ring"
role="img"
:aria-label="`${metric.name}: ${metric.value === null ? t('settingsUi.unavailable') : Math.round(metric.value) + '%'}`"
>
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle class="ring-track" cx="60" cy="60" r="49" />
<circle
v-if="metric.value !== null"
class="ring-value"
cx="60"
cy="60"
r="49"
pathLength="100"
:stroke-dasharray="`${metric.value} 100`"
/>
</svg>
<span class="usage-value"
>{{ metric.value === null ? '—' : Math.round(metric.value)
}}<small v-if="metric.value !== null">%</small></span
>
</div>
<div class="metric-label">{{ metric.name }}</div>
<div class="metric-detail">{{ metric.detail }}</div>
</div>
</div>
<p
v-if="failed"
role="status"
class="mt-4 flex items-center gap-2 text-sm text-muted-foreground"
>
{{ t('settingsUi.loadFailed') }}
</p>
</CardContent>
</Card>
<Card class="system-info-card">
<CardHeader
><CardTitle :help="t('settingsUi.systemInfoHelp')">{{ t('settingsUi.systemInfo') }}</CardTitle
><Button variant="outline" @click="copyInfo"
><Copy class="size-4" />{{ t('settingsUi.copyInfo') }}</Button
></CardHeader
>
<CardContent>
<dl class="system-info-grid">
<div v-for="[key, value] in fields" :key="key">
<dt>{{ key }}</dt>
<dd>{{ value }}</dd>
</div>
</dl>
<div class="project-links">
<a href="https://github.com/mofeng-git/One-KVM" target="_blank" rel="noopener noreferrer"
>{{ t('settingsUi.project') }}<ExternalLink /></a
><a href="https://docs.one-kvm.cn/" target="_blank" rel="noopener noreferrer"
>{{ t('settingsUi.docs') }}<ExternalLink /></a
><a
href="https://github.com/mofeng-git/One-KVM/issues"
target="_blank"
rel="noopener noreferrer"
>{{ t('settingsUi.feedback') }}<ExternalLink
/></a>
</div>
</CardContent>
</Card>
</template>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Power, RotateCw, Loader2 } from 'lucide-vue-next'
import { systemApi } from '@/api'
import { useSystemStore } from '@/stores/system'
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardContent } from '@/components/ui/card'
import CardTitle from './SettingsCardTitle.vue'
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
} from '@/components/ui/alert-dialog'
type PowerAction = 'reboot' | 'poweroff'
const { t } = useI18n()
const system = useSystemStore()
const selected = ref<PowerAction>('reboot')
const dialogOpen = ref(false)
const accepted = ref<PowerAction | null>(null)
const busy = ref(false)
const error = ref('')
const actionLabel = computed(() =>
t(selected.value === 'poweroff' ? 'settingsUi.poweroff' : 'settingsUi.reboot'),
)
function ask(action: PowerAction) {
if (busy.value || accepted.value) return
error.value = ''
selected.value = action
dialogOpen.value = true
}
function setOpen(open: boolean) {
if (!busy.value) dialogOpen.value = open
}
async function confirm() {
if (!dialogOpen.value || busy.value) return
const action = selected.value
busy.value = true
error.value = ''
try {
await systemApi.hostPower(action)
accepted.value = action
dialogOpen.value = false
} catch (cause) {
error.value = cause instanceof Error ? cause.message : t('settingsUi.deviceActionFailed')
} finally {
busy.value = false
}
}
</script>
<template>
<Card v-if="system.platform?.mode === 'linux'" class="system-device-controls">
<CardHeader class="settings-card-heading">
<CardTitle :help="t('settingsUi.deviceControlsHelp')">{{
t('settingsUi.deviceControls')
}}</CardTitle>
<div class="settings-card-actions">
<Button
data-device-action="reboot"
variant="outline"
:disabled="busy || !!accepted"
@click="ask('reboot')"
>
<RotateCw class="size-4" />{{ t('settingsUi.reboot') }}
</Button>
<Button
data-device-action="poweroff"
variant="outline"
class="text-destructive hover:text-destructive"
:disabled="busy || !!accepted"
@click="ask('poweroff')"
>
<Power class="size-4" />{{ t('settingsUi.poweroff') }}
</Button>
</div>
</CardHeader>
<CardContent>
<p class="text-sm text-muted-foreground">{{ t('settingsUi.deviceControlsHelp') }}</p>
<p v-if="accepted" role="status" class="mt-2 text-sm">
{{ t(accepted === 'reboot' ? 'settingsUi.rebootSent' : 'settingsUi.poweroffSent') }}
</p>
</CardContent>
<AlertDialog :open="dialogOpen" @update:open="setOpen">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{
t(selected === 'poweroff' ? 'settingsUi.poweroffConfirm' : 'settingsUi.rebootConfirm')
}}</AlertDialogTitle>
<AlertDialogDescription>{{
t(
selected === 'poweroff'
? 'settingsUi.poweroffDescription'
: 'settingsUi.rebootDescription',
)
}}</AlertDialogDescription>
</AlertDialogHeader>
<p v-if="error" role="alert" class="text-sm text-destructive">{{ error }}</p>
<AlertDialogFooter>
<AlertDialogCancel :disabled="busy">{{ t('common.cancel') }}</AlertDialogCancel>
<Button
data-confirm-device-action
:variant="selected === 'poweroff' ? 'destructive' : 'default'"
:disabled="busy"
@click="confirm"
>
<Loader2 v-if="busy" class="size-4 animate-spin" />{{ actionLabel }}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
</template>

View File

@@ -1,12 +1,49 @@
export const settingsUiZh = { export const settingsUiZh = {
watchdogAutoReset: '启用自动复位', watchdogAutoReset: '启用自动复位',
deviceControls: '设备操作',
deviceControlsHelp: '管理运行 One-KVM 的设备',
reboot: '重启',
poweroff: '关机',
rebootConfirm: '确认重启设备?',
poweroffConfirm: '确认关闭设备?',
rebootDescription: '将重启运行 One-KVM 的设备,当前连接会中断。启动完成后可重新连接。',
poweroffDescription: '将关闭运行 One-KVM 的设备,当前连接会中断。关机后需要手动重新上电。',
rebootSent: '重启请求已发送,请等待设备重新启动后再连接。',
poweroffSent: '关机请求已发送,设备即将关闭。',
deviceActionFailed: '操作请求未能发送,请重试。',
mcpRate: '请求频率(次/秒)', mcpRate: '请求频率(次/秒)',
help: '查看提示', help: '查看提示',
back: '返回控制台', back: '返回控制台',
saveCapture: '保存采集设置', saveCapture: '保存采集设置',
saveEncoder: '保存编码设置', saveEncoder: '保存编码设置',
saveConnection: '保存连接设置', 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: '项目主页', project: '项目主页',
docs: '使用文档',
feedback: '反馈问题',
loadFailed: '无法刷新系统信息,暂时保留最近一次读取的数据。',
featuresHelp: featuresHelp:
'USB 功能会按当前驱动与设备能力提供。修改后只保存本卡片配置,设备可能重新识别 USB 连接。', 'USB 功能会按当前驱动与设备能力提供。修改后只保存本卡片配置,设备可能重新识别 USB 连接。',
identifiers: '高级设备标识', identifiers: '高级设备标识',
@@ -22,13 +59,52 @@ export const settingsUiZh = {
} }
export const settingsUiEn = { export const settingsUiEn = {
watchdogAutoReset: 'Enable automatic recovery', 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', mcpRate: 'Requests per second',
help: 'Show help', help: 'Show help',
back: 'Back to console', back: 'Back to console',
saveCapture: 'Save capture', saveCapture: 'Save capture',
saveEncoder: 'Save encoder', saveEncoder: 'Save encoder',
saveConnection: 'Save connection', 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', project: 'Project',
docs: 'Documentation',
feedback: 'Report an issue',
loadFailed: 'Could not refresh system information. Showing the most recent data.',
featuresHelp: featuresHelp:
'Available USB functions depend on the driver and hardware. Saving applies only this card and may reconnect the USB device.', 'Available USB functions depend on the driver and hardware. Saving applies only this card and may reconnect the USB device.',
identifiers: 'Advanced device identity', identifiers: 'Advanced device identity',

View File

@@ -1,3 +1,4 @@
import type { HostInfo } from '@/api'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { systemApi, streamApi, hidApi, atxApi, msdApi, type DeviceInfo, type DiskMode, type MountedMedia, type PlatformCapabilities } from '@/api' 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<SystemCapabilities | null>(null) const capabilities = ref<SystemCapabilities | null>(null)
const diskSpace = ref<DiskSpaceInfo | null>(null) const diskSpace = ref<DiskSpaceInfo | null>(null)
const deviceInfo = ref<DeviceInfo | null>(null) const deviceInfo = ref<DeviceInfo | null>(null)
const hostInfo = ref<HostInfo | null>(null)
const stream = ref<StreamState | null>(null) const stream = ref<StreamState | null>(null)
const hid = ref<HidState | null>(null) const hid = ref<HidState | null>(null)
const atx = ref<AtxState | null>(null) const atx = ref<AtxState | null>(null)
@@ -189,6 +191,7 @@ export const useSystemStore = defineStore('system', () => {
capabilities.value = info.capabilities capabilities.value = info.capabilities
diskSpace.value = info.disk_space ?? null diskSpace.value = info.disk_space ?? null
deviceInfo.value = info.device_info ?? null deviceInfo.value = info.device_info ?? null
hostInfo.value = info.host ?? null
return info return info
} catch (e) { } catch (e) {
console.error('Failed to fetch system info:', e) console.error('Failed to fetch system info:', e)
@@ -455,6 +458,7 @@ export const useSystemStore = defineStore('system', () => {
capabilities, capabilities,
diskSpace, diskSpace,
deviceInfo, deviceInfo,
hostInfo,
stream, stream,
hid, hid,
atx, atx,

View File

@@ -3,6 +3,8 @@ import { usesCh9329, supportsOtgFunctions } from '@/lib/hidGuide'
import '@/styles/settings.css' import '@/styles/settings.css'
import CardTitle from '@/components/SettingsCardTitle.vue' import CardTitle from '@/components/SettingsCardTitle.vue'
import SidebarMenuButton from '@/components/SettingsSidebarButton.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 McpSettingsCard from '@/components/McpSettingsCard.vue'
import HidDeviceSettings from '@/components/HidDeviceSettings.vue' import HidDeviceSettings from '@/components/HidDeviceSettings.vue'
@@ -4033,6 +4035,7 @@ watch(isWindows, () => {
</div> </div>
<div v-show="activeSection === 'other'" class="space-y-4"> <div v-show="activeSection === 'other'" class="space-y-4">
<SystemDeviceControls />
<Card> <Card>
<CardHeader class="settings-card-heading" <CardHeader class="settings-card-heading"
><CardTitle :help="t('settings.watchdog.description')">{{ ><CardTitle :help="t('settings.watchdog.description')">{{
@@ -6433,6 +6436,7 @@ watch(isWindows, () => {
<!-- About: usage, host identity and one upgrade card. --> <!-- About: usage, host identity and one upgrade card. -->
<div v-show="activeSection === 'about'" class="space-y-4"> <div v-show="activeSection === 'about'" class="space-y-4">
<AboutSystemCards :active="activeSection === 'about'" />
<Card class="update-card"> <Card class="update-card">
<CardHeader <CardHeader
><CardTitle :help="t('settings.onlineUpgradeDesc')">{{ ><CardTitle :help="t('settings.onlineUpgradeDesc')">{{