This commit is contained in:
parent
4d02439ecb
commit
eddddc56ae
@ -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()
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
// ===== 已停用 =====
|
||||
// 服务自检的定时任务已从 server/cron.js 移除 (服务监控暂留空)。
|
||||
// 如恢复服务监控, 重新在 cron.js 注册 runOnce 并复用本文件。
|
||||
// 服务健康检查: 探测本应用各关键端点, 写入 ServiceCheck 表
|
||||
const mysql = require('mysql2/promise')
|
||||
|
||||
|
||||
@ -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
94
server/komari-client.js
Normal 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)))
|
||||
}
|
||||
@ -1,3 +1,6 @@
|
||||
// ===== 已停用 =====
|
||||
// 监控数据不再落库 (用户要求实时从 komari 获取), 本脚本的定时入库调用已从 server/cron.js 移除。
|
||||
// 如需恢复"数据库记录监控数据"模式, 重新在 cron.js 注册 runOnce/cleanupOld 即可。
|
||||
// komari 服务器监控拉取: 公开 API (无需 key)
|
||||
// GET /api/nodes 节点列表
|
||||
// GET /api/recent/{uuid} 最近实时状态 (数组, 多个时间点)
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
@ -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
src/pages/monitor/ServerDetail.jsx
Normal file
100
src/pages/monitor/ServerDetail.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user