@@ -5,6 +5,7 @@ import HomePage from './pages/HomePage.jsx'
|
||||
import NewsDetail from './pages/news/NewsDetail.jsx'
|
||||
import MacroPage from './pages/MacroPage.jsx'
|
||||
import MonitorPage from './pages/MonitorPage.jsx'
|
||||
import ServerDetail from './pages/monitor/ServerDetail.jsx'
|
||||
import MePage from './pages/MePage.jsx'
|
||||
import { useAuth } from './auth/AuthContext.jsx'
|
||||
|
||||
@@ -34,6 +35,7 @@ export default function App() {
|
||||
<Route path="/news/:id" element={<NewsDetail />} />
|
||||
<Route path="/macro/*" element={<MacroPage />} />
|
||||
<Route path="/monitor" element={<MonitorPage />} />
|
||||
<Route path="/monitor/server/:uuid" element={<ServerDetail />} />
|
||||
<Route path="/me" element={<MePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
+139
-9
@@ -1,16 +1,146 @@
|
||||
import { ErrorBlock, NavBar } from 'antd-mobile'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { NavBar, Card, Empty, DotLoading } from 'antd-mobile'
|
||||
import { api } from '../api/client.js'
|
||||
import SwipeTabs from '../components/SwipeTabs.jsx'
|
||||
|
||||
// 监控页: 两个横向分类
|
||||
// - 主机监控: 实时数据来自 komari (服务端实时代理, 不落库), 点击节点看 24h 时序
|
||||
// - 服务监控: 暂留空占位
|
||||
function fmtTime(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return s
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return pad(d.getHours()) + ':' + pad(d.getMinutes())
|
||||
}
|
||||
|
||||
function fmtBytes(b) {
|
||||
if (!b) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
let v = Number(b)
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||
return v.toFixed(v >= 100 ? 0 : 1) + ' ' + units[i]
|
||||
}
|
||||
|
||||
function fmtUptime(sec) {
|
||||
const s = Number(sec) || 0
|
||||
const d = Math.floor(s / 86400)
|
||||
const h = Math.floor((s % 86400) / 3600)
|
||||
return d > 0 ? d + '天' + h + '小时' : h + '小时'
|
||||
}
|
||||
|
||||
function pct(used, total) {
|
||||
return total > 0 ? Math.min(100, (used / total) * 100) : 0
|
||||
}
|
||||
|
||||
// 监控: 暂留占位
|
||||
// 使用 antd-mobile ErrorBlock 仿原生空状态页
|
||||
export default function MonitorPage() {
|
||||
const [active, setActive] = useState('servers')
|
||||
const tabs = [
|
||||
{ key: 'servers', label: '主机监控', render: () => <ServerView /> },
|
||||
{ key: 'services', label: '服务监控', render: () => <ServiceView /> },
|
||||
]
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
<NavBar back={null}>监控</NavBar>
|
||||
<ErrorBlock
|
||||
status="empty"
|
||||
title="监控"
|
||||
description="后续接入: 指标阈值告警 / 时序趋势"
|
||||
/>
|
||||
<div className="monitor-content">
|
||||
<SwipeTabs tabs={tabs} active={active} onChange={setActive} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 主机监控: komari 实时节点列表 (30s 轮询, 静默刷新)
|
||||
function ServerView() {
|
||||
const [items, setItems] = useState(null) // null = 加载中
|
||||
const [err, setErr] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const load = useCallback((silent) => {
|
||||
if (!silent) { setErr(''); setItems(null) }
|
||||
api('/servers')
|
||||
.then((data) => { setErr(''); setItems(data.items || []) })
|
||||
.catch((e) => { if (!silent) { setErr(e.message); setItems([]) } })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load(false)
|
||||
const timer = setInterval(() => load(true), 30000)
|
||||
return () => clearInterval(timer)
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<div className="server-list">
|
||||
{items === null && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
|
||||
{err && (
|
||||
<div className="error-banner">
|
||||
{err}
|
||||
<span className="retry-link" onClick={load}>重试</span>
|
||||
</div>
|
||||
)}
|
||||
{items !== null && !err && items.length === 0 && <Empty description="暂无主机" />}
|
||||
{items !== null && items.length > 0 && (
|
||||
<>
|
||||
<div className="monitor-tip">实时数据 · 每 30 秒自动刷新</div>
|
||||
<div className="section-list">
|
||||
{items.map((n) => (
|
||||
<Card key={n.uuid} className="server-card" onClick={() => navigate('/monitor/server/' + n.uuid)}>
|
||||
<div className="server-head">
|
||||
<span className="server-name">
|
||||
<span className={'status-dot ' + (n.status === 'online' ? 'on' : 'off')} />
|
||||
{n.name}
|
||||
</span>
|
||||
<span className="server-update">更新于 {fmtTime(n.recordedAt)}</span>
|
||||
</div>
|
||||
<div className="server-metrics">
|
||||
<div className="metric">
|
||||
<div className="metric-label">CPU</div>
|
||||
<Meter percent={n.cpuUsage} />
|
||||
<div className="metric-value">{n.cpuUsage.toFixed ? n.cpuUsage.toFixed(1) : n.cpuUsage}%</div>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<div className="metric-label">内存</div>
|
||||
<Meter percent={pct(n.ramUsed, n.memTotal)} />
|
||||
<div className="metric-value">{fmtBytes(n.ramUsed)} / {fmtBytes(n.memTotal)}</div>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<div className="metric-label">磁盘</div>
|
||||
<Meter percent={pct(n.diskUsed, n.diskTotal)} />
|
||||
<div className="metric-value">{fmtBytes(n.diskUsed)} / {fmtBytes(n.diskTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="server-foot">
|
||||
<span>负载 {n.load1} / {n.load5} / {n.load15}</span>
|
||||
<span>{n.cpuCores} 核</span>
|
||||
<span>进程 {n.process}</span>
|
||||
<span>连接 {n.connections}</span>
|
||||
<span>运行 {fmtUptime(n.uptime)}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 进度条 (超 80% 变红)
|
||||
function Meter({ percent }) {
|
||||
const p = Math.max(0, Math.min(100, Number(percent) || 0))
|
||||
return (
|
||||
<div className="meter">
|
||||
<div className={'meter-fill' + (p >= 80 ? ' warn' : '')} style={{ width: p + '%' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 服务监控: 暂留空
|
||||
function ServiceView() {
|
||||
return (
|
||||
<div className="monitor-empty">
|
||||
<Empty description="服务监控待接入" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||
import { NavBar, Card } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// 节点详情: /monitor/server/:uuid
|
||||
// 时序实时来自 komari (服务端代理, 24h 分钟粒度)
|
||||
function fmtHM(s) {
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return s
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return pad(d.getHours()) + ':' + pad(d.getMinutes())
|
||||
}
|
||||
|
||||
function fmtBytes(b) {
|
||||
if (!b) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
let v = Number(b)
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||
return v.toFixed(v >= 100 ? 0 : 1) + ' ' + units[i]
|
||||
}
|
||||
|
||||
const GB = 1024 * 1024 * 1024
|
||||
|
||||
export default function ServerDetail() {
|
||||
const { uuid } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [rows, setRows] = useState(null)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setRows(null)
|
||||
setErr('')
|
||||
api('/servers/' + encodeURIComponent(uuid) + '/history')
|
||||
.then((data) => { if (!cancelled) setRows(data.items || []) })
|
||||
.catch((e) => !cancelled && setErr(e.message))
|
||||
.finally(() => !cancelled && setRows((r) => r ?? []))
|
||||
return () => { cancelled = true }
|
||||
}, [uuid])
|
||||
|
||||
// 网络吞吐: komari 原始字节口径, 按 KB/s 展示
|
||||
const chart = (rows || []).map((p) => ({
|
||||
time: fmtHM(p.period),
|
||||
cpu: Number(p.cpu) || 0,
|
||||
memGB: (Number(p.ram) || 0) / GB,
|
||||
load: Number(p.load) || 0,
|
||||
netIn: (Number(p.netIn) || 0) / 1024,
|
||||
netOut: (Number(p.netOut) || 0) / 1024,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="article-page">
|
||||
<NavBar onBack={() => navigate(-1)}>主机监控</NavBar>
|
||||
{err && <div className="error-banner">{err}</div>}
|
||||
{!err && rows === null && <div className="loading">加载中...</div>}
|
||||
{!err && rows !== null && chart.length === 0 && <div className="empty">该节点暂无时序数据</div>}
|
||||
{!err && chart.length > 0 && (
|
||||
<div className="server-detail">
|
||||
<ChartCard title="CPU 使用率 (%)" data={chart} lines={[{ key: 'cpu', name: 'CPU', color: '#1677ff' }]} yMax={100} />
|
||||
<ChartCard title="内存使用 (GB)" data={chart} lines={[{ key: 'memGB', name: '内存', color: '#00b578' }]} />
|
||||
<ChartCard title="系统负载 (1分钟)" data={chart} lines={[{ key: 'load', name: 'Load', color: '#ff8f1f' }]} />
|
||||
<ChartCard
|
||||
title="网络吞吐 (KB/s)"
|
||||
data={chart}
|
||||
lines={[
|
||||
{ key: 'netIn', name: '下行', color: '#1677ff' },
|
||||
{ key: 'netOut', name: '上行', color: '#00b578' },
|
||||
]}
|
||||
/>
|
||||
<div className="monitor-tip">近 24 小时 · 1 分钟粒度 · 数据实时来自 komari</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChartCard({ title, data, lines, yMax }) {
|
||||
return (
|
||||
<Card className="chart-card">
|
||||
<div className="chart-title">{title}</div>
|
||||
<div className="chart-wrap">
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<LineChart data={data} margin={{ top: 6, right: 12, left: -8, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} minTickGap={40} />
|
||||
<YAxis tick={{ fontSize: 10 }} domain={yMax ? [0, yMax] : [0, 'auto']} width={44} />
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
{lines.map((l) => (
|
||||
<Line key={l.key} type="monotone" dataKey={l.key} name={l.name} stroke={l.color} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -224,4 +224,34 @@ input, textarea, select { font: inherit; }
|
||||
.news-origin { color: var(--color-primary); margin-left: auto; }
|
||||
.news-content { font-size: 15px; line-height: 1.75; color: #333; white-space: pre-wrap; word-break: break-word; }
|
||||
.news-content-empty { margin-top: 12px; font-size: 12px; color: var(--color-muted); }
|
||||
/* ================== 监控页 ================== */
|
||||
.monitor-content { padding: 0 0 16px; }
|
||||
.monitor-content .adm-tabs-content { --content-padding: 0; }
|
||||
.monitor-tip { font-size: 11px; color: var(--color-muted); padding: 8px 14px 2px; }
|
||||
.monitor-loading { padding: 40px 0; text-align: center; color: var(--color-muted); font-size: 13px; }
|
||||
.monitor-empty { padding: 48px 0; }
|
||||
.retry-link { color: var(--color-primary); margin-left: 8px; }
|
||||
.server-list { padding: 0 12px; }
|
||||
.server-card.adm-card { border-radius: 10px; margin: 0; }
|
||||
.server-card:active .adm-card-body { background: #f5f5f5; }
|
||||
.server-card .adm-card-body { padding: 12px; }
|
||||
.server-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.server-name { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; font-size: 15px; color: var(--color-text); }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.status-dot.on { background: #00b578; box-shadow: 0 0 0 3px rgba(0, 181, 120, 0.15); }
|
||||
.status-dot.off { background: #ff3141; box-shadow: 0 0 0 3px rgba(255, 49, 65, 0.15); }
|
||||
.server-update { font-size: 11px; color: var(--color-muted); }
|
||||
.server-metrics { display: flex; flex-direction: column; gap: 6px; }
|
||||
.metric { display: grid; grid-template-columns: 34px 1fr 96px; align-items: center; gap: 8px; }
|
||||
.metric-label { font-size: 12px; color: var(--color-muted); }
|
||||
.metric-value { font-size: 11px; color: var(--color-text); text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.meter { height: 6px; border-radius: 3px; background: #f0f0f0; overflow: hidden; }
|
||||
.meter-fill { height: 100%; border-radius: 3px; background: var(--color-primary); transition: width 0.4s ease; }
|
||||
.meter-fill.warn { background: #ff3141; }
|
||||
.server-foot { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--color-border); font-size: 11px; color: var(--color-muted); }
|
||||
/* 节点详情图表 */
|
||||
.server-detail { padding: 10px 12px 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.chart-card.adm-card { border-radius: 10px; margin: 0; }
|
||||
.chart-card .adm-card-body { padding: 12px 8px 8px; }
|
||||
.chart-title { font-size: 13px; font-weight: 600; color: var(--color-text); padding: 0 4px 6px; }
|
||||
.scroll-sentinel { text-align: center; padding: 14px 0 4px; color: var(--color-muted); font-size: 12px; }
|
||||
|
||||
Reference in New Issue
Block a user