完成基础功能
All checks were successful
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

View File

@ -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()

View File

@ -1,3 +1,6 @@
// ===== 已停用 =====
// 服务自检的定时任务已从 server/cron.js 移除 (服务监控暂留空)。
// 如恢复服务监控, 重新在 cron.js 注册 runOnce 并复用本文件。
// 服务健康检查: 探测本应用各关键端点, 写入 ServiceCheck 表
const mysql = require('mysql2/promise')

View File

@ -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) {

94
server/komari-client.js Normal file
View File

@ -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)))
}

View File

@ -1,3 +1,6 @@
// ===== 已停用 =====
// 监控数据不再落库 (用户要求实时从 komari 获取), 本脚本的定时入库调用已从 server/cron.js 移除。
// 如需恢复"数据库记录监控数据"模式, 重新在 cron.js 注册 runOnce/cleanupOld 即可。
// komari 服务器监控拉取: 公开 API (无需 key)
// GET /api/nodes 节点列表
// GET /api/recent/{uuid} 最近实时状态 (数组, 多个时间点)

View File

@ -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({

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 />} />

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>
)
}

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>
)
}

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; }