mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-10-28 08:20:49 +08:00
feat(system): 添加设备信息及重启关机控制
关于页展示宿主系统、处理器、架构、内核和资源占用,合并同一文件系统上的系统与 MSD 容量。 新增 Linux 宿主机重启和关机接口,在其他设置中提供二次确认、请求互斥及失败提示。 修正 Linux 磁盘已用空间计算,并补充设备信息和电源控制的后端单元测试。 验证:独立暂存快照前端构建通过;6 项后端测试通过;设置页保存、系统信息及模拟电源控制浏览器测试通过。
This commit is contained in:
143
src/diagnostics/host.rs
Normal file
143
src/diagnostics/host.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
151
src/web/handlers/host_power.rs
Normal file
151
src/web/handlers/host_power.rs
Normal 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(_))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
178
web/src/components/AboutSystemCards.vue
Normal file
178
web/src/components/AboutSystemCards.vue
Normal 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>
|
||||||
118
web/src/components/SystemDeviceControls.vue
Normal file
118
web/src/components/SystemDeviceControls.vue
Normal 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>
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')">{{
|
||||||
|
|||||||
Reference in New Issue
Block a user