更新首页
This commit is contained in:
@@ -33,4 +33,36 @@ 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()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// 服务健康检查: 探测本应用各关键端点, 写入 ServiceCheck 表
|
||||
const mysql = require('mysql2/promise')
|
||||
|
||||
function parseUrl(url) { const u = new URL(url); return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') } }
|
||||
|
||||
const TARGETS = [
|
||||
{ name: 'api-health', url: 'http://127.0.0.1:' + (process.env.PORT || 3001) + '/api/health' },
|
||||
{ name: 'auth-login', url: 'http://127.0.0.1:' + (process.env.PORT || 3001) + '/api/auth/login' },
|
||||
]
|
||||
|
||||
async function probeOne(target) {
|
||||
const t0 = Date.now()
|
||||
try {
|
||||
const ctrl = new AbortController()
|
||||
setTimeout(() => ctrl.abort(), 8000)
|
||||
const r = await fetch(target.url, { method: 'GET', signal: ctrl.signal })
|
||||
const latency = Date.now() - t0
|
||||
const status = (r.ok || r.status === 400 || r.status === 401) ? 'up' : 'down'
|
||||
const msg = 'HTTP ' + r.status
|
||||
return { name: target.name, url: target.url, status, latencyMs: latency, message: msg }
|
||||
} catch (e) {
|
||||
return { name: target.name, url: target.url, status: 'down', latencyMs: Date.now() - t0, message: e.message || String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDb() {
|
||||
const t0 = Date.now()
|
||||
try {
|
||||
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||
await conn.execute('SELECT 1')
|
||||
await conn.end()
|
||||
return { name: 'database', url: 'mysql://' + (new URL(process.env.DATABASE_URL)).host, status: 'up', latencyMs: Date.now() - t0, message: 'ok' }
|
||||
} catch (e) {
|
||||
return { name: 'database', url: 'mysql', status: 'down', latencyMs: Date.now() - t0, message: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
async function runOnce() {
|
||||
const results = await Promise.all([...TARGETS.map(probeOne), checkDb()])
|
||||
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||
try {
|
||||
for (const r of results) {
|
||||
await conn.execute(
|
||||
'INSERT INTO `ServiceCheck` (`name`,`url`,`status`,`latencyMs`,`message`,`lastCheckAt`,`createdAt`,`updatedAt`) VALUES (?,?,?,?,?,NOW(3),NOW(3),NOW(3)) ' +
|
||||
'ON DUPLICATE KEY UPDATE `url`=VALUES(`url`),`status`=VALUES(`status`),`latencyMs`=VALUES(`latencyMs`),`message`=VALUES(`message`),`lastCheckAt`=NOW(3),`updatedAt`=NOW(3)',
|
||||
[r.name, r.url, r.status, r.latencyMs, r.message.slice(0, 180)],
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await conn.end()
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
module.exports = { runOnce, TARGETS }
|
||||
@@ -0,0 +1,120 @@
|
||||
// komari 服务器监控拉取: 公开 API (无需 key)
|
||||
// GET /api/nodes 节点列表
|
||||
// GET /api/recent/{uuid} 最近实时状态 (数组, 多个时间点)
|
||||
// GET /api/records/load?uuid=... 1 分钟粒度历史 (保留 24h)
|
||||
const mysql = require('mysql2/promise')
|
||||
|
||||
const BASE = process.env.KOMARI_BASE || 'https://komari.honor3.com'
|
||||
const TIMEOUT = 20000
|
||||
|
||||
function parseUrl(url) {
|
||||
const u = new URL(url)
|
||||
return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') }
|
||||
}
|
||||
|
||||
async function jget(path) {
|
||||
const ctrl = new AbortController()
|
||||
setTimeout(() => ctrl.abort(), TIMEOUT)
|
||||
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
|
||||
}
|
||||
|
||||
// 拉一次所有节点 + 最近状态 + 1 分钟历史, 入库
|
||||
async function runOnce() {
|
||||
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||
const stats = { nodes: 0, snapshots: 0, history: 0, failed: 0 }
|
||||
try {
|
||||
const nodes = await jget('/api/nodes')
|
||||
if (!Array.isArray(nodes)) throw new Error('nodes 格式异常')
|
||||
stats.nodes = nodes.length
|
||||
for (const n of nodes) {
|
||||
const uuid = n.uuid
|
||||
if (!uuid) continue
|
||||
try {
|
||||
const recent = await jget('/api/recent/' + uuid)
|
||||
let snap = null
|
||||
if (Array.isArray(recent) && recent.length) snap = recent[recent.length - 1]
|
||||
const recordedAt = snap ? new Date(snap.updated_at) : new Date()
|
||||
const ramTotal = snap && snap.ram ? Number(snap.ram.total) : Number(n.mem_total || 0)
|
||||
const diskTotal = snap && snap.disk ? Number(snap.disk.total) : Number(n.disk_total || 0)
|
||||
await conn.execute(
|
||||
'INSERT INTO `ServerNode` (`uuid`,`name`,`cpuName`,`os`,`region`,`memTotal`,`diskTotal`,`cpuCores`,' +
|
||||
'`cpuUsage`,`ramUsed`,`swapUsed`,`load1`,`load5`,`load15`,`diskUsed`,`netIn`,`netOut`,`uptime`,`process`,`connections`,`status`,`recordedAt`,`createdAt`,`updatedAt`) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(3),NOW(3)) ' +
|
||||
'ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`cpuName`=VALUES(`cpuName`),`os`=VALUES(`os`),`region`=VALUES(`region`),' +
|
||||
'`memTotal`=VALUES(`memTotal`),`diskTotal`=VALUES(`diskTotal`),`cpuCores`=VALUES(`cpuCores`),' +
|
||||
'`cpuUsage`=VALUES(`cpuUsage`),`ramUsed`=VALUES(`ramUsed`),`swapUsed`=VALUES(`swapUsed`),' +
|
||||
'`load1`=VALUES(`load1`),`load5`=VALUES(`load5`),`load15`=VALUES(`load15`),`diskUsed`=VALUES(`diskUsed`),' +
|
||||
'`netIn`=VALUES(`netIn`),`netOut`=VALUES(`netOut`),`uptime`=VALUES(`uptime`),' +
|
||||
'`process`=VALUES(`process`),`connections`=VALUES(`connections`),`status`=VALUES(`status`),' +
|
||||
'`recordedAt`=VALUES(`recordedAt`),`updatedAt`=NOW(3)',
|
||||
[
|
||||
uuid, n.name || '', n.cpu_name || '', n.os || '', n.region || '',
|
||||
ramTotal, diskTotal, Number(n.cpu_cores || 0),
|
||||
snap ? Number(snap.cpu?.usage || 0) : 0,
|
||||
snap ? Number(snap.ram?.used || 0) : 0,
|
||||
snap ? Number(snap.swap?.used || 0) : 0,
|
||||
snap ? Number(snap.load?.load1 || 0) : 0,
|
||||
snap ? Number(snap.load?.load5 || 0) : 0,
|
||||
snap ? Number(snap.load?.load15 || 0) : 0,
|
||||
snap ? Number(snap.disk?.used || 0) : 0,
|
||||
snap ? Number(snap.network?.down || 0) : 0,
|
||||
snap ? Number(snap.network?.up || 0) : 0,
|
||||
snap ? Number(snap.uptime || 0) : 0,
|
||||
snap ? Number(snap.process || 0) : 0,
|
||||
snap ? Number(snap.connections?.tcp || 0) : 0,
|
||||
snap ? 'online' : 'unknown',
|
||||
recordedAt,
|
||||
],
|
||||
)
|
||||
stats.snapshots++
|
||||
|
||||
let rec = null
|
||||
try { rec = await jget('/api/records/load?uuid=' + uuid) } catch (e) { /* 节点未开启 record */ }
|
||||
if (rec && Array.isArray(rec.records)) {
|
||||
for (const r of rec.records) {
|
||||
const period = new Date(r.time)
|
||||
if (isNaN(period.getTime())) continue
|
||||
const [res] = await conn.execute(
|
||||
'INSERT INTO `ServerNodeHistory` (`uuid`,`period`,`cpu`,`ram`,`load`,`disk`,`netIn`,`netOut`,`process`,`connections`,`createdAt`) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,NOW(3)) ' +
|
||||
'ON DUPLICATE KEY UPDATE `cpu`=VALUES(`cpu`),`ram`=VALUES(`ram`),`load`=VALUES(`load`),`disk`=VALUES(`disk`),' +
|
||||
'`netIn`=VALUES(`netIn`),`netOut`=VALUES(`netOut`),`process`=VALUES(`process`),`connections`=VALUES(`connections`)',
|
||||
[
|
||||
uuid, period,
|
||||
Number(r.cpu || 0), Number(r.ram || 0), Number(r.load || 0),
|
||||
Number(r.disk || 0), Number(r.net_in || 0), Number(r.net_out || 0),
|
||||
Number(r.process || 0), Number(r.connections || 0),
|
||||
],
|
||||
)
|
||||
if (res.affectedRows === 1) stats.history++
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
stats.failed++
|
||||
console.error('[komari] 节点 ' + uuid + ' 失败:', e.message)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await conn.end()
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// 清理 24h 之前的历史
|
||||
async function cleanupOld() {
|
||||
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
const [r] = await conn.execute('DELETE FROM `ServerNodeHistory` WHERE `period` < ?', [cutoff])
|
||||
return r.affectedRows
|
||||
} finally {
|
||||
await conn.end()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { runOnce, cleanupOld, BASE }
|
||||
|
||||
+13
-8
@@ -8,16 +8,21 @@ router.use(authRequired)
|
||||
// 报警列表: 按时间最新优先, cursor 分页 (cursor = id)
|
||||
router.get('/alerts', async (req, res, next) => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query.limit) || 10, 50)
|
||||
const cursor = req.query.cursor ? Number(req.query.cursor) : null
|
||||
const rawLimit = Number(req.query.limit)
|
||||
const limit = Number.isFinite(rawLimit) && rawLimit >= 1 ? Math.min(Math.floor(rawLimit), 50) : 10
|
||||
const rawCursor = Number(req.query.cursor)
|
||||
const cursor = Number.isInteger(rawCursor) && rawCursor > 0 ? rawCursor : null
|
||||
const where = cursor ? { id: { lt: cursor } } : {}
|
||||
const items = await prisma.alert.findMany({
|
||||
where,
|
||||
orderBy: { id: 'desc' },
|
||||
take: limit,
|
||||
})
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.alert.findMany({
|
||||
where,
|
||||
orderBy: { id: 'desc' },
|
||||
take: limit,
|
||||
}),
|
||||
prisma.alert.count(),
|
||||
])
|
||||
const nextCursor = items.length === limit ? String(items[items.length - 1].id) : null
|
||||
res.json({ items, nextCursor })
|
||||
res.json({ items, nextCursor, total })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { authRequired } from '../middleware.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(authRequired)
|
||||
|
||||
// 服务器节点列表: 元数据 + 最新快照
|
||||
router.get('/servers', async (_req, res, next) => {
|
||||
try {
|
||||
const rows = await prisma.serverNode.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
res.json({ items: rows })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// 节点时序: ?uuid=...&hours=24
|
||||
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 })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// 服务健康列表
|
||||
router.get('/services', async (_req, res, next) => {
|
||||
try {
|
||||
const rows = await prisma.serviceCheck.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
res.json({ items: rows })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
export default router
|
||||
+32
-6
@@ -5,21 +5,47 @@ import { authRequired } from '../middleware.js'
|
||||
const router = Router()
|
||||
router.use(authRequired)
|
||||
|
||||
// 新闻列表: 按时间最新优先, cursor 分页 (cursor = publishedAt ISO 字符串)
|
||||
// 用 prisma cursor 配合 id 兜底 (同一秒发布的新闻)
|
||||
// 新闻列表: 按时间最新优先, cursor 分页
|
||||
// cursor 格式: "<publishedAt ISO>|<lastId>", 兼容旧版纯时间戳 (不带 |)
|
||||
// 同秒多条时用 id 兜底, 避免翻页时整批漏掉
|
||||
router.get('/news', async (req, res, next) => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query.limit) || 10, 50)
|
||||
const cursor = req.query.cursor || null
|
||||
const where = cursor ? { publishedAt: { lt: new Date(cursor) } } : {}
|
||||
const rawLimit = Number(req.query.limit)
|
||||
const limit = Number.isFinite(rawLimit) && rawLimit >= 1 ? Math.min(Math.floor(rawLimit), 50) : 10
|
||||
let where = {}
|
||||
const cursor = req.query.cursor || ''
|
||||
if (cursor) {
|
||||
const [ts, idRaw] = cursor.split('|')
|
||||
const c = new Date(ts)
|
||||
if (!isNaN(c.getTime())) {
|
||||
const id = Number(idRaw)
|
||||
where = id > 0
|
||||
? { OR: [{ publishedAt: { lt: c } }, { publishedAt: c, id: { lt: id } }] }
|
||||
: { publishedAt: { lt: c } }
|
||||
}
|
||||
}
|
||||
const items = await prisma.news.findMany({
|
||||
select: { id: true, title: true, summary: true, source: true, url: true, publishedAt: true, impactRank: true },
|
||||
where,
|
||||
orderBy: [{ publishedAt: 'desc' }, { id: 'desc' }],
|
||||
take: limit,
|
||||
})
|
||||
const nextCursor = items.length === limit ? items[items.length - 1].publishedAt.toISOString() : null
|
||||
const nextCursor = items.length === limit && items.length > 0
|
||||
? items[items.length - 1].publishedAt.toISOString() + '|' + items[items.length - 1].id
|
||||
: null
|
||||
res.json({ items, nextCursor })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// 新闻详情: 含正文 content
|
||||
router.get('/news/:id', async (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id)
|
||||
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: '无效的新闻 id' })
|
||||
const item = await prisma.news.findUnique({ where: { id } })
|
||||
if (!item) return res.status(404).json({ error: '新闻不存在' })
|
||||
res.json({ item })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
Reference in New Issue
Block a user