refactor: 全面重构前端 UI 样式
- 重构 theme.css:替换刺眼青色背景为柔和灰蓝(#eef1f6),品牌色改为靛蓝(#5b6ef5) - 统一按钮体系:主按钮36px固定高度,菜单按钮30px,表格按钮28px,尺寸协调 - 登录页:服务器地址与端口并排布局,输入框40px高度统一,卡片阴影加深 - 面板页:导航栏56px高度,内容区居中限宽1400px,滚动条美化 - 概览页:auto-fit网格+340px最小宽度防止卡片不均,进程表全宽跨列 - 进程表格:table-layout:fixed + 列宽分配 + nowrap 防止串行溢出 - 所有文字元素加 white-space:nowrap 防止换行串行 - 更新 Electron 窗口背景色匹配新主题
This commit is contained in:
+44
-1
@@ -46,7 +46,7 @@ function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
backgroundColor: '#d2f8fc',
|
||||
backgroundColor: '#eef1f6',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
@@ -121,6 +121,49 @@ ipcMain.handle('ssh:disconnect', () => {
|
||||
|
||||
ipcMain.handle('ssh:session', () => activeSession)
|
||||
|
||||
// 在活动的 SSH 连接上执行命令
|
||||
function execSsh(cmd, timeoutMs = 10000) {
|
||||
return new Promise((resolve) => {
|
||||
if (!activeConnection) {
|
||||
resolve({ ok: false, message: 'SSH 连接不存在' })
|
||||
return
|
||||
}
|
||||
|
||||
let settled = false
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
resolve({ ok: false, message: '命令执行超时' })
|
||||
}
|
||||
}, timeoutMs)
|
||||
|
||||
activeConnection.exec(cmd, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timer)
|
||||
if (!settled) {
|
||||
settled = true
|
||||
resolve({ ok: false, message: err.message })
|
||||
}
|
||||
return
|
||||
}
|
||||
stream.on('data', (chunk) => (stdout += chunk.toString()))
|
||||
stream.stderr.on('data', (chunk) => (stderr += chunk.toString()))
|
||||
stream.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (!settled) {
|
||||
settled = true
|
||||
resolve({ ok: true, code, stdout, stderr })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
ipcMain.handle('ssh:exec', (event, { cmd, timeout }) => execSsh(cmd, timeout))
|
||||
|
||||
// ---------- IPC:凭据 ----------
|
||||
ipcMain.handle('cred:save', (event, { host, port, username, password }) => {
|
||||
const list = readCredentials()
|
||||
|
||||
@@ -5,6 +5,7 @@ contextBridge.exposeInMainWorld('panel', {
|
||||
connect: (config) => ipcRenderer.invoke('ssh:connect', config),
|
||||
disconnect: () => ipcRenderer.invoke('ssh:disconnect'),
|
||||
getSession: () => ipcRenderer.invoke('ssh:session'),
|
||||
exec: (cmd, timeout) => ipcRenderer.invoke('ssh:exec', { cmd, timeout }),
|
||||
// 凭据(密码加密保存在本机)
|
||||
saveCredential: (cred) => ipcRenderer.invoke('cred:save', cred),
|
||||
listCredentials: () => ipcRenderer.invoke('cred:list'),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>SSH Panel</title>
|
||||
<title>SSH Panel · 服务器管理面板</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import './theme.css';
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -7,10 +9,35 @@
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #d2f8fc;
|
||||
color: #2c3e50;
|
||||
background: var(--page-bg);
|
||||
color: var(--ink);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 全局滚动条美化(WebKit) */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
@@ -1,98 +1,130 @@
|
||||
/* ============================================================
|
||||
登录页
|
||||
============================================================ */
|
||||
.wrap {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 400px;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 30px rgba(0, 40, 60, 0.15);
|
||||
padding: 36px 40px;
|
||||
width: 420px;
|
||||
max-width: 100%;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
padding: 40px 36px 32px;
|
||||
}
|
||||
|
||||
.card h1 {
|
||||
font-size: 24px;
|
||||
color: #123a6b;
|
||||
font-weight: 700;
|
||||
color: var(--brand);
|
||||
text-align: center;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.card .subtitle {
|
||||
text-align: center;
|
||||
color: #7a8a99;
|
||||
font-size: 13px;
|
||||
margin: 6px 0 24px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 14px;
|
||||
margin: 8px 0 28px;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #4a5a6a;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid #cfd9e3;
|
||||
border-radius: 6px;
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--ink-strong);
|
||||
background: var(--card-bg);
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.field input::placeholder {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
border-color: #123a6b;
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px var(--brand-soft);
|
||||
}
|
||||
|
||||
/* 端口字段和服务器地址可以并排 */
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field-row .field {
|
||||
flex: 1;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.field-row .field:nth-child(2) {
|
||||
max-width: 120px;
|
||||
flex: 0 0 120px;
|
||||
}
|
||||
|
||||
.remember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #4a5a6a;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.connect-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #123a6b;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
color: var(--ink-secondary);
|
||||
margin-bottom: 22px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.connect-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
.remember input {
|
||||
accent-color: var(--brand);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status {
|
||||
min-height: 20px;
|
||||
margin-top: 12px;
|
||||
margin-top: 14px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
color: #c0392b;
|
||||
color: var(--status-critical);
|
||||
}
|
||||
|
||||
/* ---- 已保存服务器列表 ---- */
|
||||
.saved {
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid #e6edf4;
|
||||
padding-top: 16px;
|
||||
margin-top: 28px;
|
||||
border-top: 1px solid var(--divider);
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.saved h2 {
|
||||
font-size: 14px;
|
||||
color: #4a5a6a;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-muted);
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.saved ul {
|
||||
@@ -103,25 +135,50 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.saved li:hover {
|
||||
background: #eef4f9;
|
||||
background: var(--brand-soft);
|
||||
}
|
||||
|
||||
.saved li.empty {
|
||||
cursor: default;
|
||||
color: #9aa7b5;
|
||||
color: var(--ink-muted);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.saved li.empty:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.saved li > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.saved .del {
|
||||
color: #c0392b;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--ink-muted);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.saved .del:hover {
|
||||
color: var(--status-critical);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export default function Login({ onConnected }) {
|
||||
<h1>SSH Panel</h1>
|
||||
<p className="subtitle">连接到你的服务器</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label htmlFor="host">服务器地址</label>
|
||||
<input
|
||||
@@ -86,6 +87,7 @@ export default function Login({ onConnected }) {
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="username">用户名</label>
|
||||
<input
|
||||
@@ -115,7 +117,7 @@ export default function Login({ onConnected }) {
|
||||
/>
|
||||
记住密码(加密保存在本机)
|
||||
</label>
|
||||
<button className="connect-btn" type="submit" disabled={connecting}>
|
||||
<button className="btn btn-primary btn-block" type="submit" disabled={connecting}>
|
||||
{connecting ? '正在连接...' : '连接'}
|
||||
</button>
|
||||
<p className="status">{status}</p>
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
/* ============================================================
|
||||
顶部导航栏 + 主内容区
|
||||
============================================================ */
|
||||
.menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: #123a6b;
|
||||
height: 56px;
|
||||
background: var(--brand);
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
padding: 0 24px;
|
||||
gap: 28px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.menu .brand {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu nav {
|
||||
@@ -24,44 +31,50 @@
|
||||
}
|
||||
|
||||
.menu nav a {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu nav a:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.menu nav a.active {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.menu .right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu .right button {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 6px;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
.menu .right span {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 68px 20px 20px;
|
||||
padding: 76px 28px 28px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
main .hint {
|
||||
text-align: center;
|
||||
color: #7a8a99;
|
||||
margin-top: 120px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 15px;
|
||||
margin-top: 140px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import Overview from './overview/Overview'
|
||||
import './Panel.css'
|
||||
|
||||
const NAV_ITEMS = ['概览', '容器', '终端']
|
||||
@@ -25,11 +26,15 @@ export default function Panel({ session, onLogout }) {
|
||||
<span>
|
||||
{session.username}@{session.host}
|
||||
</span>
|
||||
<button onClick={onLogout}>断开连接</button>
|
||||
<button className="btn btn-menu" onClick={onLogout}>
|
||||
断开连接
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<p className="hint">连接成功!这里将展示服务器信息,下一步从这里开始扩展。</p>
|
||||
{active === '概览' && <Overview />}
|
||||
{active === '容器' && <p className="hint">容器功能待开发</p>}
|
||||
{active === '终端' && <p className="hint">终端功能待开发</p>}
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// 卡片外壳:统一处理"检测中 / 正常 / 缺少依赖(置灰+安装按钮) / 安装中 / 出错"五种状态
|
||||
export default function CardShell({
|
||||
title,
|
||||
subtitle,
|
||||
status,
|
||||
message,
|
||||
installable,
|
||||
onInstall,
|
||||
onRetry,
|
||||
children,
|
||||
}) {
|
||||
const dimmed = status === 'missing' || status === 'installing'
|
||||
|
||||
return (
|
||||
<section className={`metric-card ${dimmed ? 'missing' : ''}`}>
|
||||
<header>
|
||||
<h3>{title}</h3>
|
||||
{status === 'ok' && subtitle ? <span className="sub">{subtitle}</span> : null}
|
||||
</header>
|
||||
<div className="body">
|
||||
{status === 'checking' && <p className="placeholder">正在检测服务器环境...</p>}
|
||||
{status === 'ok' && children}
|
||||
{status === 'missing' && (
|
||||
<div className="missing-box">
|
||||
{installable ? (
|
||||
<>
|
||||
<p>此功能需要先在服务器上安装「{message}」</p>
|
||||
<button className="btn btn-primary" onClick={onInstall}>
|
||||
安装
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p>当前服务器不支持此功能(需要 Linux 系统)</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{status === 'installing' && (
|
||||
<p className="placeholder">正在安装「{message}」,可能需要几分钟...</p>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div className="error-box">
|
||||
<p>{message}</p>
|
||||
<button className="btn btn-ghost" onClick={onRetry}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
/* ============================================================
|
||||
概览页 —— 卡片网格 + 各类指标卡片
|
||||
============================================================ */
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* ---- 卡片外壳 ---- */
|
||||
.metric-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 22px 24px;
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
border-color: var(--hairline-strong);
|
||||
}
|
||||
|
||||
.metric-card header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.metric-card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-secondary);
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-card .sub {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-card .body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--ink-muted);
|
||||
font-size: 14px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--status-critical);
|
||||
font-size: 14px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
/* ---- 大数字 ---- */
|
||||
.big-value {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--ink-strong);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.15;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: 13px;
|
||||
color: var(--ink-secondary);
|
||||
margin-top: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---- 仪表条 ---- */
|
||||
.meter {
|
||||
height: 8px;
|
||||
background: var(--track);
|
||||
border-radius: 999px;
|
||||
margin-top: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meter-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.6s ease, background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.meter-fill.good {
|
||||
background: var(--status-good);
|
||||
}
|
||||
|
||||
.meter-fill.warning {
|
||||
background: var(--status-warning);
|
||||
}
|
||||
|
||||
.meter-fill.serious {
|
||||
background: var(--status-serious);
|
||||
}
|
||||
|
||||
.meter-fill.critical {
|
||||
background: var(--status-critical);
|
||||
}
|
||||
|
||||
/* ---- 缺少依赖的置灰卡片 ---- */
|
||||
.metric-card.missing {
|
||||
background: var(--surface-dim);
|
||||
border: 1px dashed var(--hairline-strong);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.metric-card.missing:hover {
|
||||
box-shadow: none;
|
||||
border-color: var(--hairline-strong);
|
||||
}
|
||||
|
||||
.metric-card.missing h3,
|
||||
.metric-card.missing .body {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.missing-box,
|
||||
.error-box {
|
||||
text-align: center;
|
||||
padding: 24px 0 8px;
|
||||
font-size: 14px;
|
||||
color: var(--ink-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.missing-box .btn,
|
||||
.error-box .btn {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
color: var(--status-critical);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
网络速率卡片
|
||||
============================================================ */
|
||||
.net-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.net-row + .net-row {
|
||||
border-top: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.net-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.net-dot.dot-1 {
|
||||
background: var(--series-1);
|
||||
}
|
||||
|
||||
.net-dot.dot-2 {
|
||||
background: var(--series-2);
|
||||
}
|
||||
|
||||
.net-row .label {
|
||||
font-size: 14px;
|
||||
color: var(--ink-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.net-row .rate {
|
||||
margin-left: auto;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
进程表格 —— 全宽跨列
|
||||
============================================================ */
|
||||
.overview-grid > section:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.proc-table {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.proc-table th {
|
||||
text-align: left;
|
||||
color: var(--ink-muted);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 4px 10px 10px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.proc-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 列宽分配 */
|
||||
.proc-table th:nth-child(1),
|
||||
.proc-table td:nth-child(1) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.proc-table th:nth-child(2),
|
||||
.proc-table td:nth-child(2) {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.proc-table th:nth-child(3),
|
||||
.proc-table td:nth-child(3) {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.proc-table th:nth-child(4),
|
||||
.proc-table td:nth-child(4),
|
||||
.proc-table th:nth-child(5),
|
||||
.proc-table td:nth-child(5) {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.proc-table td.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.proc-table tbody tr {
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.proc-table tbody tr:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
/* 杀死进程按钮 */
|
||||
.proc-table .kill-cell {
|
||||
text-align: right;
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.kill-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
background: transparent;
|
||||
color: var(--ink-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.kill-btn:hover {
|
||||
color: var(--status-critical);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
}
|
||||
|
||||
.kill-btn.confirm {
|
||||
background: var(--status-critical);
|
||||
border-color: var(--status-critical);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.kill-btn.confirm:hover {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.kill-feedback {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.kill-feedback.ok {
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.kill-feedback.fail {
|
||||
color: var(--status-critical);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import CpuCard from './cards/CpuCard'
|
||||
import MemoryCard from './cards/MemoryCard'
|
||||
import DiskCard from './cards/DiskCard'
|
||||
import NetworkCard from './cards/NetworkCard'
|
||||
import ProcessCard from './cards/ProcessCard'
|
||||
import './Overview.css'
|
||||
|
||||
export default function Overview() {
|
||||
return (
|
||||
<div className="overview-grid">
|
||||
<CpuCard />
|
||||
<MemoryCard />
|
||||
<DiskCard />
|
||||
<NetworkCard />
|
||||
<ProcessCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import CardShell from '../CardShell'
|
||||
import useMetric from '../useMetric'
|
||||
import { usageStatus } from '../format'
|
||||
|
||||
// 两次采样 /proc/stat 计算 CPU 占用率
|
||||
async function sampleCpu() {
|
||||
const read = async () => {
|
||||
const r = await window.panel.exec('head -n 1 /proc/stat')
|
||||
if (!r.ok) throw new Error(r.message)
|
||||
// 字段:cpu user nice system idle iowait irq softirq steal ...
|
||||
const parts = r.stdout.trim().split(/\s+/).slice(1).map(Number)
|
||||
const idle = (parts[3] || 0) + (parts[4] || 0) // idle + iowait
|
||||
const total = parts.reduce((a, b) => a + b, 0)
|
||||
return { idle, total }
|
||||
}
|
||||
|
||||
const a = await read()
|
||||
await new Promise((resolve) => setTimeout(resolve, 700))
|
||||
const b = await read()
|
||||
|
||||
const dIdle = b.idle - a.idle
|
||||
const dTotal = b.total - a.total
|
||||
if (dTotal <= 0) return null
|
||||
return Math.min(100, Math.max(0, ((dTotal - dIdle) / dTotal) * 100))
|
||||
}
|
||||
|
||||
export default function CpuCard() {
|
||||
const metric = useMetric({ checkCmd: 'test -r /proc/stat' })
|
||||
const [usage, setUsage] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (metric.status !== 'ok') return
|
||||
let alive = true
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const u = await sampleCpu()
|
||||
if (alive) {
|
||||
setUsage(u)
|
||||
setError('')
|
||||
}
|
||||
} catch (e) {
|
||||
if (alive) setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
tick()
|
||||
const timer = setInterval(tick, 2000)
|
||||
return () => {
|
||||
alive = false
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [metric.status])
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
title="CPU 占用"
|
||||
status={metric.status}
|
||||
message={metric.message}
|
||||
installable={metric.installable}
|
||||
onInstall={metric.install}
|
||||
onRetry={metric.check}
|
||||
>
|
||||
{error ? (
|
||||
<p className="error-text">{error}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="big-value">{usage === null ? '—' : `${usage.toFixed(1)}%`}</p>
|
||||
<div className="meter">
|
||||
<div
|
||||
className={`meter-fill ${usageStatus(usage ?? 0)}`}
|
||||
style={{ width: `${usage ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import CardShell from '../CardShell'
|
||||
import useMetric from '../useMetric'
|
||||
import { formatBytes, usageStatus } from '../format'
|
||||
|
||||
// 依赖 df(coreutils),缺失时可自动安装
|
||||
const CHECK_CMD = 'command -v df'
|
||||
const INSTALL_CMD =
|
||||
'SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO=sudo; ' +
|
||||
'if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update >/dev/null 2>&1 && $SUDO apt-get install -y coreutils; ' +
|
||||
'elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y coreutils; ' +
|
||||
'elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y coreutils; ' +
|
||||
'elif command -v apk >/dev/null 2>&1; then $SUDO apk add coreutils; ' +
|
||||
'else echo "无法识别服务器的包管理器"; exit 1; fi'
|
||||
|
||||
async function fetchDisk() {
|
||||
// df -Pk:POSIX 兼容的 1K 块输出,各发行版一致
|
||||
const r = await window.panel.exec('df -Pk / | tail -n 1')
|
||||
if (!r.ok) throw new Error(r.message)
|
||||
|
||||
const f = r.stdout.trim().split(/\s+/)
|
||||
if (f.length < 6) return null
|
||||
return {
|
||||
size: Number(f[1]) * 1024,
|
||||
used: Number(f[2]) * 1024,
|
||||
pct: parseFloat(f[4]),
|
||||
}
|
||||
}
|
||||
|
||||
export default function DiskCard() {
|
||||
const metric = useMetric({
|
||||
checkCmd: CHECK_CMD,
|
||||
installCmd: INSTALL_CMD,
|
||||
installName: 'coreutils(df 命令)',
|
||||
})
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (metric.status !== 'ok') return
|
||||
let alive = true
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const d = await fetchDisk()
|
||||
if (alive) {
|
||||
setData(d)
|
||||
setError('')
|
||||
}
|
||||
} catch (e) {
|
||||
if (alive) setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
tick()
|
||||
const timer = setInterval(tick, 15000)
|
||||
return () => {
|
||||
alive = false
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [metric.status])
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
title="硬盘"
|
||||
subtitle="根分区 /"
|
||||
status={metric.status}
|
||||
message={metric.message}
|
||||
installable={metric.installable}
|
||||
onInstall={metric.install}
|
||||
onRetry={metric.check}
|
||||
>
|
||||
{error ? (
|
||||
<p className="error-text">{error}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="big-value">{data ? `${data.pct.toFixed(1)}%` : '—'}</p>
|
||||
<p className="detail">
|
||||
{data ? `已用 ${formatBytes(data.used)} / 总共 ${formatBytes(data.size)}` : '加载中...'}
|
||||
</p>
|
||||
<div className="meter">
|
||||
<div
|
||||
className={`meter-fill ${usageStatus(data?.pct ?? 0)}`}
|
||||
style={{ width: `${data?.pct ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import CardShell from '../CardShell'
|
||||
import useMetric from '../useMetric'
|
||||
import { formatBytes, usageStatus } from '../format'
|
||||
|
||||
async function fetchMemory() {
|
||||
const r = await window.panel.exec("grep -E '^(MemTotal|MemAvailable):' /proc/meminfo")
|
||||
if (!r.ok) throw new Error(r.message)
|
||||
|
||||
const mem = {}
|
||||
for (const line of r.stdout.split('\n')) {
|
||||
const m = line.match(/^(MemTotal|MemAvailable):\s+(\d+)\s*kB/)
|
||||
if (m) mem[m[1]] = Number(m[2]) * 1024
|
||||
}
|
||||
if (!mem.MemTotal) return null
|
||||
|
||||
const total = mem.MemTotal
|
||||
const available = mem.MemAvailable ?? 0
|
||||
return {
|
||||
total,
|
||||
used: total - available,
|
||||
pct: ((total - available) / total) * 100,
|
||||
}
|
||||
}
|
||||
|
||||
export default function MemoryCard() {
|
||||
const metric = useMetric({ checkCmd: 'test -r /proc/meminfo' })
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (metric.status !== 'ok') return
|
||||
let alive = true
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const d = await fetchMemory()
|
||||
if (alive) {
|
||||
setData(d)
|
||||
setError('')
|
||||
}
|
||||
} catch (e) {
|
||||
if (alive) setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
tick()
|
||||
const timer = setInterval(tick, 3000)
|
||||
return () => {
|
||||
alive = false
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [metric.status])
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
title="内存"
|
||||
status={metric.status}
|
||||
message={metric.message}
|
||||
installable={metric.installable}
|
||||
onInstall={metric.install}
|
||||
onRetry={metric.check}
|
||||
>
|
||||
{error ? (
|
||||
<p className="error-text">{error}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="big-value">{data ? `${data.pct.toFixed(1)}%` : '—'}</p>
|
||||
<p className="detail">
|
||||
{data ? `已用 ${formatBytes(data.used)} / 总共 ${formatBytes(data.total)}` : '加载中...'}
|
||||
</p>
|
||||
<div className="meter">
|
||||
<div
|
||||
className={`meter-fill ${usageStatus(data?.pct ?? 0)}`}
|
||||
style={{ width: `${data?.pct ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import CardShell from '../CardShell'
|
||||
import useMetric from '../useMetric'
|
||||
import { formatRate } from '../format'
|
||||
|
||||
// 读取所有网卡(除 lo)的累计收发字节数
|
||||
async function readNetBytes() {
|
||||
const r = await window.panel.exec('cat /proc/net/dev')
|
||||
if (!r.ok) throw new Error(r.message)
|
||||
|
||||
let rx = 0
|
||||
let tx = 0
|
||||
for (const line of r.stdout.split('\n').slice(2)) {
|
||||
// 字段:iface: rx_bytes rx_packets ... tx_bytes ...
|
||||
const m = line.trim().match(/^([^:]+):\s*(\d+)(?:\s+\d+){7}\s+(\d+)/)
|
||||
if (!m || m[1] === 'lo') continue
|
||||
rx += Number(m[2])
|
||||
tx += Number(m[3])
|
||||
}
|
||||
return { rx, tx }
|
||||
}
|
||||
|
||||
export default function NetworkCard() {
|
||||
const metric = useMetric({ checkCmd: 'test -r /proc/net/dev' })
|
||||
const [rates, setRates] = useState(null) // { rx, tx },单位 bytes/s
|
||||
const [error, setError] = useState('')
|
||||
const lastSample = useRef(null) // { rx, tx, ts }
|
||||
|
||||
useEffect(() => {
|
||||
if (metric.status !== 'ok') return
|
||||
let alive = true
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const cur = await readNetBytes()
|
||||
if (!alive) return
|
||||
const ts = Date.now()
|
||||
|
||||
if (lastSample.current) {
|
||||
const prev = lastSample.current
|
||||
const secs = (ts - prev.ts) / 1000
|
||||
if (secs > 0) {
|
||||
setRates({
|
||||
rx: (cur.rx - prev.rx) / secs,
|
||||
tx: (cur.tx - prev.tx) / secs,
|
||||
})
|
||||
}
|
||||
}
|
||||
lastSample.current = { ...cur, ts }
|
||||
setError('')
|
||||
} catch (e) {
|
||||
if (alive) setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
tick()
|
||||
const timer = setInterval(tick, 2000)
|
||||
return () => {
|
||||
alive = false
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [metric.status])
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
title="网络"
|
||||
subtitle="实时速率"
|
||||
status={metric.status}
|
||||
message={metric.message}
|
||||
installable={metric.installable}
|
||||
onInstall={metric.install}
|
||||
onRetry={metric.check}
|
||||
>
|
||||
{error ? (
|
||||
<p className="error-text">{error}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="net-row">
|
||||
<span className="net-dot dot-1" />
|
||||
<span className="label">下行</span>
|
||||
<span className="rate">{rates ? formatRate(rates.rx) : '—'}</span>
|
||||
</div>
|
||||
<div className="net-row">
|
||||
<span className="net-dot dot-2" />
|
||||
<span className="label">上行</span>
|
||||
<span className="rate">{rates ? formatRate(rates.tx) : '—'}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import CardShell from '../CardShell'
|
||||
import useMetric from '../useMetric'
|
||||
|
||||
// 依赖 ps(procps),缺失时可自动安装
|
||||
const CHECK_CMD = 'command -v ps'
|
||||
const INSTALL_CMD =
|
||||
'SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO=sudo; ' +
|
||||
'if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update >/dev/null 2>&1 && $SUDO apt-get install -y procps; ' +
|
||||
'elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y procps-ng; ' +
|
||||
'elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y procps-ng; ' +
|
||||
'elif command -v apk >/dev/null 2>&1; then $SUDO apk add procps; ' +
|
||||
'else echo "无法识别服务器的包管理器"; exit 1; fi'
|
||||
|
||||
async function fetchProcesses() {
|
||||
const psResult = await window.panel.exec(
|
||||
'ps -eo pid,user,pcpu,pmem,comm --sort=-pcpu --no-headers | head -n 12'
|
||||
)
|
||||
if (!psResult.ok) throw new Error(psResult.message)
|
||||
|
||||
const procs = psResult.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split(/\s+/)
|
||||
return {
|
||||
pid: parts[0],
|
||||
user: parts[1],
|
||||
cpu: parts[2],
|
||||
mem: parts[3],
|
||||
name: parts.slice(4).join(' ') || parts[3],
|
||||
}
|
||||
})
|
||||
|
||||
const countResult = await window.panel.exec('ps -e --no-headers | wc -l')
|
||||
const total = countResult.ok ? Number(countResult.stdout.trim()) : null
|
||||
|
||||
return { procs, total }
|
||||
}
|
||||
|
||||
export default function ProcessCard() {
|
||||
const metric = useMetric({
|
||||
checkCmd: CHECK_CMD,
|
||||
installCmd: INSTALL_CMD,
|
||||
installName: 'procps(ps 命令)',
|
||||
})
|
||||
const [procs, setProcs] = useState([])
|
||||
const [total, setTotal] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [confirmPid, setConfirmPid] = useState(null) // 二次确认中的 PID
|
||||
const [feedback, setFeedback] = useState(null) // { ok, text }
|
||||
const [refreshKey, setRefreshKey] = useState(0) // 杀死进程后立即刷新列表
|
||||
const timers = useRef([])
|
||||
|
||||
// 数据加载
|
||||
useEffect(() => {
|
||||
if (metric.status !== 'ok') return
|
||||
let alive = true
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const d = await fetchProcesses()
|
||||
if (alive) {
|
||||
setProcs(d.procs)
|
||||
setTotal(d.total)
|
||||
setError('')
|
||||
}
|
||||
} catch (e) {
|
||||
if (alive) setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
tick()
|
||||
const timer = setInterval(tick, 5000)
|
||||
return () => {
|
||||
alive = false
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [metric.status, refreshKey])
|
||||
|
||||
// 组件卸载时清理所有未完成的定时器
|
||||
useEffect(() => () => timers.current.forEach(clearTimeout), [])
|
||||
|
||||
const schedule = (fn, ms) => {
|
||||
timers.current.push(setTimeout(fn, ms))
|
||||
}
|
||||
|
||||
const showFeedback = (ok, text) => {
|
||||
setFeedback({ ok, text })
|
||||
schedule(() => setFeedback(null), 4000)
|
||||
}
|
||||
|
||||
const handleKill = async (p) => {
|
||||
// 第一次点击:进入确认状态,3 秒内不确认则自动取消
|
||||
if (confirmPid !== p.pid) {
|
||||
setConfirmPid(p.pid)
|
||||
schedule(() => setConfirmPid(null), 3000)
|
||||
return
|
||||
}
|
||||
|
||||
setConfirmPid(null)
|
||||
const r = await window.panel.exec(`kill -9 ${p.pid}`)
|
||||
if (!r.ok) {
|
||||
showFeedback(false, `杀死失败:${r.message}`)
|
||||
} else if (r.code !== 0) {
|
||||
showFeedback(false, `杀死失败:${(r.stderr || '').trim() || '权限不足'}`)
|
||||
} else {
|
||||
showFeedback(true, `已杀死进程 ${p.pid}(${p.name})`)
|
||||
setRefreshKey((k) => k + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
title="进程"
|
||||
subtitle={total !== null ? `共 ${total} 个 · CPU 占用前 12` : 'CPU 占用前 12'}
|
||||
status={metric.status}
|
||||
message={metric.message}
|
||||
installable={metric.installable}
|
||||
onInstall={metric.install}
|
||||
onRetry={metric.check}
|
||||
>
|
||||
{error ? (
|
||||
<p className="error-text">{error}</p>
|
||||
) : (
|
||||
<>
|
||||
<table className="proc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>进程</th>
|
||||
<th>PID</th>
|
||||
<th>用户</th>
|
||||
<th className="num">CPU%</th>
|
||||
<th className="num">内存%</th>
|
||||
<th className="kill-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{procs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="6">加载中...</td>
|
||||
</tr>
|
||||
) : (
|
||||
procs.map((p) => (
|
||||
<tr key={p.pid}>
|
||||
<td>{p.name}</td>
|
||||
<td>{p.pid}</td>
|
||||
<td>{p.user}</td>
|
||||
<td className="num">{p.cpu}</td>
|
||||
<td className="num">{p.mem}</td>
|
||||
<td className="kill-cell">
|
||||
<button
|
||||
className={`kill-btn ${confirmPid === p.pid ? 'confirm' : ''}`}
|
||||
onClick={() => handleKill(p)}
|
||||
>
|
||||
{confirmPid === p.pid ? '确认杀死' : '杀死'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{feedback && (
|
||||
<p className={`kill-feedback ${feedback.ok ? 'ok' : 'fail'}`}>{feedback.text}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// 字节数格式化:1234 -> "1.2 KB"
|
||||
export function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = bytes
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
||||
}
|
||||
|
||||
// 速率格式化:bytes/s
|
||||
export function formatRate(bytesPerSec) {
|
||||
if (!Number.isFinite(bytesPerSec) || bytesPerSec < 0) return '—'
|
||||
return `${formatBytes(bytesPerSec)}/s`
|
||||
}
|
||||
|
||||
// 占用率分级(阈值 60/80/90),供仪表填充色使用
|
||||
// 颜色始终与数值标签成对出现,绝不单独承载含义
|
||||
export function usageStatus(pct) {
|
||||
if (pct < 60) return 'good'
|
||||
if (pct < 80) return 'warning'
|
||||
if (pct < 90) return 'serious'
|
||||
return 'critical'
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// 卡片环境检测:
|
||||
// - 检测命令执行成功 -> ok,卡片正常展示数据
|
||||
// - 检测命令返回非 0 -> missing,卡片置灰;若有 installCmd 则提供"安装"按钮
|
||||
// - SSH 连接层面失败 -> error,可重试
|
||||
export default function useMetric({ checkCmd, installCmd, installName }) {
|
||||
const [status, setStatus] = useState('checking') // checking | ok | missing | installing | error
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const check = async () => {
|
||||
const r = await window.panel.exec(checkCmd)
|
||||
if (!r.ok) {
|
||||
setStatus('error')
|
||||
setMessage(r.message)
|
||||
} else if (r.code === 0) {
|
||||
setStatus('ok')
|
||||
setMessage('')
|
||||
} else {
|
||||
setStatus('missing')
|
||||
setMessage(installName || '未知依赖')
|
||||
}
|
||||
}
|
||||
|
||||
const install = async () => {
|
||||
setStatus('installing')
|
||||
setMessage(installName || '')
|
||||
const r = await window.panel.exec(installCmd, 600000)
|
||||
if (!r.ok) {
|
||||
setStatus('error')
|
||||
setMessage(`安装失败:${r.message}`)
|
||||
} else if (r.code !== 0) {
|
||||
const detail = (r.stderr || r.stdout).trim()
|
||||
setStatus('error')
|
||||
setMessage(`安装失败:${detail.slice(-200)}`)
|
||||
} else {
|
||||
await check() // 安装成功后重新检测
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
check()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return { status, message, installable: !!installCmd, check, install }
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/* ============================================================
|
||||
设计令牌 —— 全局色彩 / 间距 / 阴影 / 圆角
|
||||
集中管理,换肤或深色模式只需改此文件
|
||||
============================================================ */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
/* ---- 表面层 ---- */
|
||||
--page-bg: #eef1f6;
|
||||
--card-bg: #ffffff;
|
||||
--surface-dim: #f6f7f9;
|
||||
--surface-hover: #f0f4ff;
|
||||
|
||||
/* ---- 文字(墨色令牌:主 / 次 / 弱) ---- */
|
||||
--ink-strong: #1a1d29;
|
||||
--ink: #3d4050;
|
||||
--ink-secondary: #6b7280;
|
||||
--ink-muted: #9ca3af;
|
||||
--success-text: #15803d;
|
||||
|
||||
/* ---- 边框与轨道 ---- */
|
||||
--hairline: #e5e7eb;
|
||||
--hairline-strong: #d1d5db;
|
||||
--divider: #ececef;
|
||||
--track: #e8eaed;
|
||||
|
||||
/* ---- 品牌色:靛蓝(主按钮、导航栏、焦点环) ---- */
|
||||
--brand: #5b6ef5;
|
||||
--brand-hover: #4759f0;
|
||||
--brand-active: #3a48d8;
|
||||
--brand-soft: rgba(91, 110, 245, 0.08);
|
||||
--brand-soft-hover: rgba(91, 110, 245, 0.14);
|
||||
|
||||
/* ---- 类别色:网络下行 / 上行 ---- */
|
||||
--series-1: #5b6ef5;
|
||||
--series-2: #f59e0b;
|
||||
|
||||
/* ---- 状态色 ---- */
|
||||
--status-good: #22c55e;
|
||||
--status-warning: #f59e0b;
|
||||
--status-serious: #f97316;
|
||||
--status-critical: #ef4444;
|
||||
|
||||
/* ---- 形状 ---- */
|
||||
--radius-card: 14px;
|
||||
--radius-btn: 8px;
|
||||
--radius-sm: 6px;
|
||||
|
||||
/* ---- 阴影 ---- */
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
--shadow-card-hover: 0 2px 8px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
--shadow-overlay: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
|
||||
/* ---- 间距基准 ---- */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
按钮体系 —— 统一尺寸,变体只改颜色
|
||||
============================================================ */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 主按钮 */
|
||||
.btn-primary {
|
||||
background: var(--brand);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--brand-hover);
|
||||
}
|
||||
|
||||
.btn-primary:active:not(:disabled) {
|
||||
background: var(--brand-active);
|
||||
}
|
||||
|
||||
/* 幽灵按钮(描边) */
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--ink-secondary);
|
||||
border-color: var(--hairline-strong);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
color: var(--ink-strong);
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-soft);
|
||||
}
|
||||
|
||||
/* 深色导航栏上的按钮变体 */
|
||||
.btn-menu {
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-menu:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
Reference in New Issue
Block a user