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

View File

@@ -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'

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 = {
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',

View File

@@ -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<SystemCapabilities | null>(null)
const diskSpace = ref<DiskSpaceInfo | null>(null)
const deviceInfo = ref<DeviceInfo | null>(null)
const hostInfo = ref<HostInfo | null>(null)
const stream = ref<StreamState | null>(null)
const hid = ref<HidState | null>(null)
const atx = ref<AtxState | null>(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,

View File

@@ -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, () => {
</div>
<div v-show="activeSection === 'other'" class="space-y-4">
<SystemDeviceControls />
<Card>
<CardHeader class="settings-card-heading"
><CardTitle :help="t('settings.watchdog.description')">{{
@@ -6433,6 +6436,7 @@ watch(isWindows, () => {
<!-- About: usage, host identity and one upgrade card. -->
<div v-show="activeSection === 'about'" class="space-y-4">
<AboutSystemCards :active="activeSection === 'about'" />
<Card class="update-card">
<CardHeader
><CardTitle :help="t('settings.onlineUpgradeDesc')">{{