feat: add Prometheus system monitoring

This commit is contained in:
hectorzhao
2026-08-14 10:10:28 +08:00
parent 96e475d60d
commit b78faa1aa2
25 changed files with 1675 additions and 0 deletions
@@ -0,0 +1,316 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { EChartsOption } from 'echarts';
import {
Activity,
AlertTriangle,
CheckCircle2,
Clock3,
Cpu,
Database,
HardDrive,
MemoryStick,
Network,
RefreshCw,
Server,
ShieldAlert,
} from 'lucide-react';
import {
adminApi,
type InfrastructureAlert,
type InfrastructureMetricPoint,
type InfrastructureMonitoringOverview,
type InfrastructureMonitoringRange,
} from '@/api/adminApi';
import { Breadcrumb, Button, Chart, Table, Tag, type TableColumn } from '@/components/ui';
import './AdminSystemMonitoringPage.css';
const RANGE_OPTIONS: Array<{ value: InfrastructureMonitoringRange; label: string }> = [
{ value: '1h', label: '近1小时' },
{ value: '24h', label: '近24小时' },
{ value: '7d', label: '近7天' },
];
const STATUS_COPY = {
healthy: { label: '运行正常', tone: 'success' as const },
warning: { label: '需要关注', tone: 'warning' as const },
critical: { label: '严重告警', tone: 'danger' as const },
unknown: { label: '状态未知', tone: 'neutral' as const },
};
function formatPercent(value: number | null) {
return value === null ? '—' : `${value.toFixed(1)}%`;
}
function formatBytes(value: number | null) {
if (value === null) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let amount = value;
let index = 0;
while (amount >= 1024 && index < units.length - 1) {
amount /= 1024;
index += 1;
}
return `${amount.toFixed(index >= 3 ? 1 : 0)} ${units[index]}`;
}
function formatRate(value: number | null) {
return value === null ? '—' : `${formatBytes(value)}/s`;
}
function totalNetworkRate(receive: number | null | undefined, transmit: number | null | undefined) {
if (receive === null || receive === undefined || transmit === null || transmit === undefined) return null;
return receive + transmit;
}
function formatUptime(value: number | null) {
if (value === null) return '—';
const days = Math.floor(value / 86400);
const hours = Math.floor((value % 86400) / 3600);
return days > 0 ? `${days}${hours}小时` : `${hours}小时`;
}
function formatTime(value: string | null) {
if (!value) return '暂无采样';
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
}).format(new Date(value));
}
function formatDuration(startedAt: string) {
const milliseconds = Date.now() - Date.parse(startedAt);
if (!Number.isFinite(milliseconds) || milliseconds < 0) return '—';
const minutes = Math.floor(milliseconds / 60_000);
if (minutes < 60) return `${Math.max(minutes, 1)}分钟`;
const hours = Math.floor(minutes / 60);
return hours < 24 ? `${hours}小时 ${minutes % 60}分钟` : `${Math.floor(hours / 24)}${hours % 24}小时`;
}
function timeLabels(points: InfrastructureMetricPoint[], range: InfrastructureMonitoringRange) {
return points.map((point) => new Intl.DateTimeFormat('zh-CN', range === '7d'
? { month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false }
: { hour: '2-digit', minute: '2-digit', hour12: false })
.format(new Date(point.timestamp)));
}
function makeTrendOption(params: {
range: InfrastructureMonitoringRange;
series: Array<{ name: string; points: InfrastructureMetricPoint[]; color: string }>;
suffix: string;
maximum?: number;
}): EChartsOption {
const first = params.series[0]?.points ?? [];
return {
animationDuration: 280,
color: params.series.map((item) => item.color),
grid: { left: 8, right: 16, top: 34, bottom: 4, containLabel: true },
legend: params.series.length > 1 ? { top: 0, right: 0, textStyle: { color: '#6b7280', fontSize: 12 } } : undefined,
tooltip: {
trigger: 'axis',
valueFormatter: (value) => `${Number(value).toFixed(1)}${params.suffix}`,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: timeLabels(first, params.range),
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisTick: { show: false },
axisLabel: { color: '#9ca3af', hideOverlap: true, margin: 12 },
},
yAxis: {
type: 'value', min: 0, max: params.maximum,
axisLabel: { color: '#9ca3af', formatter: `{value}${params.suffix}` },
splitLine: { lineStyle: { color: '#eef0f3' } },
},
series: params.series.map((item) => ({
name: item.name,
data: item.points.map((point) => point.value),
type: 'line',
smooth: true,
showSymbol: false,
lineStyle: { width: 2.5 },
areaStyle: { opacity: 0.07 },
})),
};
}
function severityTag(severity: InfrastructureAlert['severity']) {
if (severity === 'critical') return <Tag tone="danger"></Tag>;
if (severity === 'warning') return <Tag tone="warning"></Tag>;
return <Tag tone="info"></Tag>;
}
const alertColumns: Array<TableColumn<InfrastructureAlert>> = [
{ key: 'severity', title: '级别', width: '82px', render: (record) => severityTag(record.severity) },
{
key: 'alert', title: '告警', width: '280px', render: (record) => (
<div className="system-monitoring-alert-copy"><strong>{record.name}</strong><span>{record.summary}</span></div>
),
},
{ key: 'service', title: '服务 / 实例', width: '190px', render: (record) => record.service || record.instance || '主机资源' },
{ key: 'value', title: '当前值 / 阈值', width: '150px', render: (record) => `${record.currentValue || '—'} / ${record.threshold || '—'}` },
{ key: 'startedAt', title: '开始时间', width: '150px', render: (record) => formatTime(record.startedAt) },
{ key: 'duration', title: '持续时间', width: '120px', render: (record) => formatDuration(record.startedAt) },
];
export function AdminSystemMonitoringPage() {
const [range, setRange] = useState<InfrastructureMonitoringRange>('24h');
const [overview, setOverview] = useState<InfrastructureMonitoringOverview | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const requestSequence = useRef(0);
const pendingRequests = useRef(0);
const loadData = useCallback(async (supersede = false) => {
if (!supersede && pendingRequests.current > 0) return;
pendingRequests.current += 1;
const sequence = ++requestSequence.current;
setLoading(true);
try {
const result = await adminApi.getInfrastructureMonitoringOverview(range);
if (sequence !== requestSequence.current) return;
setOverview(result);
setError(result.available ? '' : result.error || '监控数据当前不可用');
} catch (reason) {
if (sequence !== requestSequence.current) return;
setOverview(null);
setError(reason instanceof Error ? reason.message : '监控数据加载失败');
} finally {
if (sequence === requestSequence.current) setLoading(false);
pendingRequests.current -= 1;
}
}, [range]);
useEffect(() => {
void loadData(true);
const intervalId = window.setInterval(() => {
if (document.visibilityState === 'visible') void loadData();
}, 30_000);
const handleVisibility = () => {
if (document.visibilityState === 'visible') void loadData();
};
document.addEventListener('visibilitychange', handleVisibility);
return () => {
requestSequence.current += 1;
window.clearInterval(intervalId);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [loadData]);
const status = STATUS_COPY[overview?.summary.overallStatus ?? 'unknown'];
const cpuOption = useMemo(() => makeTrendOption({
range, maximum: 100, suffix: '%', series: [{ name: 'CPU', points: overview?.trends.cpuUsagePercent ?? [], color: '#2563eb' }],
}), [overview?.trends.cpuUsagePercent, range]);
const memoryOption = useMemo(() => makeTrendOption({
range, maximum: 100, suffix: '%', series: [{ name: '内存', points: overview?.trends.memoryUsagePercent ?? [], color: '#7c3aed' }],
}), [overview?.trends.memoryUsagePercent, range]);
const diskOption = useMemo(() => makeTrendOption({
range, maximum: 100, suffix: '%', series: [{ name: '根磁盘', points: overview?.trends.diskUsagePercent ?? [], color: '#d97706' }],
}), [overview?.trends.diskUsagePercent, range]);
const networkOption = useMemo(() => makeTrendOption({
range, suffix: ' B/s', series: [
{ name: '接收', points: overview?.trends.networkReceiveBytesPerSecond ?? [], color: '#0f766e' },
{ name: '发送', points: overview?.trends.networkTransmitBytesPerSecond ?? [], color: '#2563eb' },
],
}), [overview?.trends.networkReceiveBytesPerSecond, overview?.trends.networkTransmitBytesPerSecond, range]);
const metrics = overview?.metrics;
const serviceHealthy = overview?.summary.serviceHealthy ?? 0;
const serviceTotal = overview?.summary.serviceTotal ?? 6;
return (
<section className="page-stack admin-system-monitoring-page">
<div className="page-heading system-monitoring-heading">
<div>
<Breadcrumb items={['系统管理', '系统监控']} />
<div className="system-monitoring-title-row">
<div>
<h1></h1>
<p></p>
</div>
<Tag tone={status.tone}>{status.label}</Tag>
</div>
</div>
<div className="system-monitoring-controls">
<div className="system-monitoring-range" aria-label="监控时间范围" role="group">
{RANGE_OPTIONS.map((option) => (
<button
aria-pressed={range === option.value}
className={range === option.value ? 'is-active' : ''}
key={option.value}
onClick={() => setRange(option.value)}
type="button"
>{option.label}</button>
))}
</div>
<Button disabled={loading} icon={<RefreshCw className={loading ? 'is-spinning' : ''} size={16} />} onClick={() => void loadData()} variant="ghost">
{loading ? '刷新中' : '刷新'}
</Button>
</div>
</div>
{error ? (
<div className="system-monitoring-unavailable" role="alert">
<ShieldAlert size={20} />
<div><strong></strong><span>{error}</span></div>
</div>
) : null}
<div className="system-monitoring-health surface">
<div className="system-monitoring-health__copy">
<div className={`system-monitoring-health__mark is-${overview?.summary.overallStatus ?? 'unknown'}`}>
{overview?.summary.overallStatus === 'healthy' ? <CheckCircle2 size={24} /> : <AlertTriangle size={24} />}
</div>
<span></span>
<strong>{status.label}</strong>
<small> {formatTime(overview?.lastSampleAt ?? null)}</small>
</div>
<div className="system-monitoring-health__fact"><span></span><strong>{serviceHealthy}/{serviceTotal}</strong><small></small></div>
<div className="system-monitoring-health__fact"><span></span><strong>{overview?.summary.activeAlerts ?? 0}</strong><small>{overview?.summary.criticalAlerts ?? 0} · {overview?.summary.warningAlerts ?? 0} </small></div>
<div className="system-monitoring-health__fact"><span></span><strong>{metrics?.load1 === null || metrics?.load1 === undefined ? '—' : metrics.load1.toFixed(2)}</strong><small>1</small></div>
<div className="system-monitoring-health__fact"><span></span><strong>{formatUptime(metrics?.uptimeSeconds ?? null)}</strong><small></small></div>
</div>
<div className="system-monitoring-metrics">
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-blue"><Cpu size={19} /></div><div><span>CPU 使</span><strong>{formatPercent(metrics?.cpuUsagePercent ?? null)}</strong><small>5</small></div></article>
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-violet"><MemoryStick size={19} /></div><div><span>使</span><strong>{formatPercent(metrics?.memoryUsagePercent ?? null)}</strong><small>{formatBytes(metrics?.memoryAvailableBytes ?? null)} / {formatBytes(metrics?.memoryTotalBytes ?? null)}</small></div></article>
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-amber"><HardDrive size={19} /></div><div><span>使</span><strong>{formatPercent(metrics?.diskUsagePercent ?? null)}</strong><small>{formatBytes(metrics?.diskAvailableBytes ?? null)} / {formatBytes(metrics?.diskTotalBytes ?? null)}</small></div></article>
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-green"><Network size={19} /></div><div><span></span><strong>{formatRate(totalNetworkRate(metrics?.networkReceiveBytesPerSecond, metrics?.networkTransmitBytesPerSecond))}</strong><small> {formatRate(metrics?.networkReceiveBytesPerSecond ?? null)} · {formatRate(metrics?.networkTransmitBytesPerSecond ?? null)}</small></div></article>
</div>
<div className="system-monitoring-main-grid">
<div className="system-monitoring-chart-stack">
<article className="surface system-monitoring-chart-card"><header><div><Cpu size={17} /><strong>CPU </strong></div><span>{formatPercent(metrics?.cpuUsagePercent ?? null)}</span></header>{overview?.trends.cpuUsagePercent.length ? <Chart height={230} option={cpuOption} /> : <EmptyChart />}</article>
<article className="surface system-monitoring-chart-card"><header><div><MemoryStick size={17} /><strong></strong></div><span>{formatPercent(metrics?.memoryUsagePercent ?? null)}</span></header>{overview?.trends.memoryUsagePercent.length ? <Chart height={230} option={memoryOption} /> : <EmptyChart />}</article>
<article className="surface system-monitoring-chart-card"><header><div><HardDrive size={17} /><strong></strong></div><span>{formatPercent(metrics?.diskUsagePercent ?? null)}</span></header>{overview?.trends.diskUsagePercent.length ? <Chart height={230} option={diskOption} /> : <EmptyChart />}</article>
<article className="surface system-monitoring-chart-card"><header><div><Activity size={17} /><strong></strong></div><span>{formatRate(totalNetworkRate(metrics?.networkReceiveBytesPerSecond, metrics?.networkTransmitBytesPerSecond))}</span></header>{overview?.trends.networkReceiveBytesPerSecond.length ? <Chart height={230} option={networkOption} /> : <EmptyChart />}</article>
</div>
<aside className="surface system-monitoring-services">
<header><div><Server size={18} /><strong></strong></div><Tag tone={serviceHealthy === serviceTotal && overview?.available ? 'success' : 'neutral'}>{serviceHealthy}/{serviceTotal} </Tag></header>
<div className="system-monitoring-service-list">
{(overview?.services ?? []).map((service) => (
<div className="system-monitoring-service" key={service.key}>
<span className={`system-monitoring-service__dot is-${service.status}`} />
<div><strong>{service.name}</strong><small>{service.unit}</small></div>
<span>{service.status === 'healthy' ? '正常' : service.status === 'unhealthy' ? '异常' : '未知'}</span>
</div>
))}
{!overview?.services.length ? [
['api', 'API服务'], ['gateway', 'Gateway服务'], ['postgresql', 'PostgreSQL'], ['redis', 'Redis'], ['minio', 'MinIO'], ['nginx', 'Nginx'],
].map(([key, name]) => <div className="system-monitoring-service" key={key}><span className="system-monitoring-service__dot is-unknown" /><div><strong>{name}</strong><small></small></div><span></span></div>) : null}
</div>
<div className="system-monitoring-collector-note"><Database size={16} /><span> Prometheus </span></div>
</aside>
</div>
<section className="surface system-monitoring-alerts">
<header><div><AlertTriangle size={18} /><strong></strong><Tag tone={overview?.summary.activeAlerts ? 'warning' : 'success'}>{overview?.summary.activeAlerts ?? 0}</Tag></div><span><Clock3 size={14} /> {formatTime(overview?.collectedAt ?? null)}</span></header>
<Table columns={alertColumns} data={overview?.alerts ?? []} emptyText={overview?.available ? '当前没有活动告警' : '监控不可用,无法读取活动告警'} pagination={false} rowKey="fingerprint" />
</section>
</section>
);
}
function EmptyChart() {
return <div className="system-monitoring-chart-empty"><Activity size={22} /><span></span></div>;
}