完成基础功能
Docker Build and Push / build-image (push) Successful in 3m1s

This commit is contained in:
cheney
2026-08-06 16:55:03 +08:00
parent 4d02439ecb
commit eddddc56ae
10 changed files with 381 additions and 56 deletions
+2
View File
@@ -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
View File
@@ -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>
)
}
+100
View File
@@ -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>
)
}
+30
View File
@@ -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; }