From eddddc56aefbe0ef8950b43b1a848898841ca499 Mon Sep 17 00:00:00 2001 From: cheney Date: Thu, 6 Aug 2026 16:55:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=9F=BA=E7=A1=80=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/cron.js | 32 ------- server/health-check.cjs | 3 + server/index.js | 2 + server/komari-client.js | 94 ++++++++++++++++++ server/komari-monitor.cjs | 3 + server/routes/monitors.js | 23 ++--- src/App.jsx | 2 + src/pages/MonitorPage.jsx | 148 +++++++++++++++++++++++++++-- src/pages/monitor/ServerDetail.jsx | 100 +++++++++++++++++++ src/styles/globals.css | 30 ++++++ 10 files changed, 381 insertions(+), 56 deletions(-) create mode 100644 server/komari-client.js create mode 100644 src/pages/monitor/ServerDetail.jsx diff --git a/server/cron.js b/server/cron.js index e80b707..5614e8c 100644 --- a/server/cron.js +++ b/server/cron.js @@ -33,36 +33,4 @@ function scheduleNews() { console.log('[cron] 已注册: 每天 03:00 拉取新闻 + 跨源报警检测') } -// 每 2 分钟: 拉取 komari 节点 + 服务自检 -function scheduleMonitors() { - cron.schedule('*/2 * * * *', async () => { - try { - const k = require('./komari-monitor.cjs') - const r = await k.runOnce() - console.log('[cron] komari: nodes=' + r.nodes + ' snapshots=' + r.snapshots + ' history=' + r.history + ' failed=' + r.failed) - } catch (e) { console.error('[cron] komari 任务失败:', e.message) } - try { - const h = require('./health-check.cjs') - const arr = await h.runOnce() - const down = arr.filter(x => x.status !== 'up').map(x => x.name) - console.log('[cron] health: ' + arr.length + ' 项, down=' + (down.length ? down.join(',') : '无')) - } catch (e) { console.error('[cron] health 任务失败:', e.message) } - }) - console.log('[cron] 已注册: 每 2 分钟 komari + 服务自检') -} - -// 每小时: 清理 24h 前的 komari 历史 -function scheduleMonitorCleanup() { - cron.schedule('0 * * * *', async () => { - try { - const k = require('./komari-monitor.cjs') - const n = await k.cleanupOld() - console.log('[cron] 监控历史清理: 删除 ' + n + ' 条') - } catch (e) { console.error('[cron] 监控清理失败:', e.message) } - }) - console.log('[cron] 已注册: 每小时清理 24h 前监控历史') -} - scheduleNews() -scheduleMonitors() -scheduleMonitorCleanup() diff --git a/server/health-check.cjs b/server/health-check.cjs index dbd636d..f4923d9 100644 --- a/server/health-check.cjs +++ b/server/health-check.cjs @@ -1,3 +1,6 @@ +// ===== 已停用 ===== +// 服务自检的定时任务已从 server/cron.js 移除 (服务监控暂留空)。 +// 如恢复服务监控, 重新在 cron.js 注册 runOnce 并复用本文件。 // 服务健康检查: 探测本应用各关键端点, 写入 ServiceCheck 表 const mysql = require('mysql2/promise') diff --git a/server/index.js b/server/index.js index 473c653..1eba465 100644 --- a/server/index.js +++ b/server/index.js @@ -8,6 +8,7 @@ import dataRouter from './routes/data.js' import newsRouter from './routes/news.js' import alertsRouter from './routes/alerts.js' import articlesRouter from './routes/articles.js' +import monitorsRouter from './routes/monitors.js' const PORT = Number(process.env.PORT) || 3001 const DIST_DIR = path.join(process.cwd(), 'dist') @@ -26,6 +27,7 @@ app.use('/api', dataRouter) app.use('/api', newsRouter) app.use('/api', alertsRouter) app.use('/api', articlesRouter) +app.use('/api', monitorsRouter) // 静态资源 (Vite build 产物) if (HAS_DIST) { diff --git a/server/komari-client.js b/server/komari-client.js new file mode 100644 index 0000000..62bd27a --- /dev/null +++ b/server/komari-client.js @@ -0,0 +1,94 @@ +// komari 实时监控客户端: 公开 API, 无鉴权, 请求时实时获取, 不落库 +// GET /api/nodes 节点列表 +// GET /api/recent/{uuid} 最近实时状态 (数组, 多个时间点) +// GET /api/records/load?uuid=... 1 分钟粒度历史 (komari 保留 24h) + +const BASE = process.env.KOMARI_BASE || 'https://komari.honor3.com' +const TIMEOUT = 15000 + +async function jget(path) { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), TIMEOUT) + try { + const r = await fetch(BASE + path, { headers: { 'User-Agent': 'iboard-monitor/1.0' }, signal: ctrl.signal }) + if (!r.ok) throw new Error('komari ' + path + ' HTTP ' + r.status) + const j = await r.json() + if (j.status && j.status !== 'success') throw new Error('komari ' + path + ' status: ' + j.status + ' ' + (j.message || '')) + return j.data + } finally { + clearTimeout(timer) + } +} + +// 节点列表 + 每节点最新实时快照 (并行拉取, 单节点失败不拖垮整体) +export async function fetchServers() { + const nodes = await jget('/api/nodes') + if (!Array.isArray(nodes)) return [] + const snaps = await Promise.all( + nodes + .filter((n) => n.uuid) + .map(async (n) => { + try { + const recent = await jget('/api/recent/' + n.uuid) + return { node: n, snap: Array.isArray(recent) && recent.length ? recent[recent.length - 1] : null } + } catch (e) { + console.error('[komari] 节点 ' + n.uuid + ' 快照失败:', e.message) + return { node: n, snap: null } + } + }), + ) + return snaps.map(({ node: n, snap }) => { + const uuid = n.uuid + return { + uuid, + name: n.name || uuid, + cpuName: n.cpu_name || '', + os: n.os || '', + region: n.region || '', + memTotal: snap ? Number(snap.ram?.total || 0) : Number(n.mem_total || 0), + diskTotal: snap ? Number(snap.disk?.total || 0) : Number(n.disk_total || 0), + cpuCores: Number(n.cpu_cores || 0), + cpuUsage: snap ? Number(snap.cpu?.usage || 0) : 0, + ramUsed: snap ? Number(snap.ram?.used || 0) : 0, + swapUsed: snap ? Number(snap.swap?.used || 0) : 0, + load1: snap ? Number(snap.load?.load1 || 0) : 0, + load5: snap ? Number(snap.load?.load5 || 0) : 0, + load15: snap ? Number(snap.load?.load15 || 0) : 0, + diskUsed: snap ? Number(snap.disk?.used || 0) : 0, + netIn: snap ? Number(snap.network?.down || 0) : 0, + netOut: snap ? Number(snap.network?.up || 0) : 0, + uptime: snap ? Number(snap.uptime || 0) : 0, + process: snap ? Number(snap.process || 0) : 0, + connections: snap ? Number(snap.connections?.tcp || 0) : 0, + status: snap ? 'online' : 'unknown', + recordedAt: snap && snap.updated_at ? new Date(snap.updated_at).toISOString() : new Date().toISOString(), + } + }) +} + +// 节点时序: komari 保留最近 24h, 1 分钟粒度 +// 节点未开启 record 或已下线 (komari 404) 时返回空列表, 不向上抛 500 +export async function fetchHistory(uuid) { + let data + try { + data = await jget('/api/records/load?uuid=' + encodeURIComponent(uuid)) + } catch (e) { + console.error('[komari] ' + uuid + ' history 获取失败:', e.message) + return [] + } + const records = data && Array.isArray(data.records) ? data.records : [] + return records + .map((r) => ({ + period: new Date(r.time).toISOString(), + cpu: Number(r.cpu || 0), + ram: Number(r.ram || 0), + load: Number(r.load || 0), + disk: Number(r.disk || 0), + // net_in/net_out 为 komari 原始值 (字节口径), 前端展示时再按需换算 + netIn: Number(r.net_in || 0), + netOut: Number(r.net_out || 0), + process: Number(r.process || 0), + connections: Number(r.connections || 0), + })) + .filter((r) => !isNaN(Date.parse(r.period))) +} diff --git a/server/komari-monitor.cjs b/server/komari-monitor.cjs index 991d933..cb69551 100644 --- a/server/komari-monitor.cjs +++ b/server/komari-monitor.cjs @@ -1,3 +1,6 @@ +// ===== 已停用 ===== +// 监控数据不再落库 (用户要求实时从 komari 获取), 本脚本的定时入库调用已从 server/cron.js 移除。 +// 如需恢复"数据库记录监控数据"模式, 重新在 cron.js 注册 runOnce/cleanupOld 即可。 // komari 服务器监控拉取: 公开 API (无需 key) // GET /api/nodes 节点列表 // GET /api/recent/{uuid} 最近实时状态 (数组, 多个时间点) diff --git a/server/routes/monitors.js b/server/routes/monitors.js index 090ffca..7033cea 100644 --- a/server/routes/monitors.js +++ b/server/routes/monitors.js @@ -1,35 +1,28 @@ import { Router } from 'express' import { prisma } from '../db.js' import { authRequired } from '../middleware.js' +import { fetchServers, fetchHistory } from '../komari-client.js' const router = Router() router.use(authRequired) -// 服务器节点列表: 元数据 + 最新快照 +// 服务器节点列表: 实时从 komari 拉取 (不落库) router.get('/servers', async (_req, res, next) => { try { - const rows = await prisma.serverNode.findMany({ - orderBy: { name: 'asc' }, - }) - res.json({ items: rows }) + const items = await fetchServers() + res.json({ items }) } catch (e) { next(e) } }) -// 节点时序: ?uuid=...&hours=24 +// 节点时序: komari 保留最近 24h, 1 分钟粒度 (不落库) router.get('/servers/:uuid/history', async (req, res, next) => { try { - const hours = Math.min(Math.max(Number(req.query.hours) || 24, 1), 168) - const since = new Date(Date.now() - hours * 3600 * 1000) - const rows = await prisma.serverNodeHistory.findMany({ - where: { uuid: req.params.uuid, period: { gte: since } }, - orderBy: { period: 'asc' }, - take: 2000, - }) - res.json({ items: rows }) + const items = await fetchHistory(req.params.uuid) + res.json({ items }) } catch (e) { next(e) } }) -// 服务健康列表 +// 服务健康列表 (服务监控暂留空, 接口保留) router.get('/services', async (_req, res, next) => { try { const rows = await prisma.serviceCheck.findMany({ diff --git a/src/App.jsx b/src/App.jsx index 7f60351..b76aede 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/src/pages/MonitorPage.jsx b/src/pages/MonitorPage.jsx index 4b46a28..78b32c1 100644 --- a/src/pages/MonitorPage.jsx +++ b/src/pages/MonitorPage.jsx @@ -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: () => }, + { key: 'services', label: '服务监控', render: () => }, + ] return (
监控 - +
+ +
) -} +} + +// 主机监控: 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 ( +
+ {items === null &&
加载中...
} + {err && ( +
+ {err} + 重试 +
+ )} + {items !== null && !err && items.length === 0 && } + {items !== null && items.length > 0 && ( + <> +
实时数据 · 每 30 秒自动刷新
+
+ {items.map((n) => ( + navigate('/monitor/server/' + n.uuid)}> +
+ + + {n.name} + + 更新于 {fmtTime(n.recordedAt)} +
+
+
+
CPU
+ +
{n.cpuUsage.toFixed ? n.cpuUsage.toFixed(1) : n.cpuUsage}%
+
+
+
内存
+ +
{fmtBytes(n.ramUsed)} / {fmtBytes(n.memTotal)}
+
+
+
磁盘
+ +
{fmtBytes(n.diskUsed)} / {fmtBytes(n.diskTotal)}
+
+
+
+ 负载 {n.load1} / {n.load5} / {n.load15} + {n.cpuCores} 核 + 进程 {n.process} + 连接 {n.connections} + 运行 {fmtUptime(n.uptime)} +
+
+ ))} +
+ + )} +
+ ) +} + +// 进度条 (超 80% 变红) +function Meter({ percent }) { + const p = Math.max(0, Math.min(100, Number(percent) || 0)) + return ( +
+
= 80 ? ' warn' : '')} style={{ width: p + '%' }} /> +
+ ) +} + +// 服务监控: 暂留空 +function ServiceView() { + return ( +
+ +
+ ) +} diff --git a/src/pages/monitor/ServerDetail.jsx b/src/pages/monitor/ServerDetail.jsx new file mode 100644 index 0000000..4845713 --- /dev/null +++ b/src/pages/monitor/ServerDetail.jsx @@ -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 ( +
+ navigate(-1)}>主机监控 + {err &&
{err}
} + {!err && rows === null &&
加载中...
} + {!err && rows !== null && chart.length === 0 &&
该节点暂无时序数据
} + {!err && chart.length > 0 && ( +
+ + + + +
近 24 小时 · 1 分钟粒度 · 数据实时来自 komari
+
+ )} +
+ ) +} + +function ChartCard({ title, data, lines, yMax }) { + return ( + +
{title}
+
+ + + + + + + + {lines.map((l) => ( + + ))} + + +
+
+ ) +} diff --git a/src/styles/globals.css b/src/styles/globals.css index 81919af..9d61c1b 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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; }