feat: add configurable infrastructure alerts

This commit is contained in:
hectorzhao
2026-08-14 17:51:42 +08:00
parent 1ef4380422
commit 6ccc102830
23 changed files with 506 additions and 39 deletions
@@ -1,7 +1,10 @@
import { request, withQuery } from '../core/httpClient';
import type { InfrastructureMonitoringOverview, InfrastructureMonitoringRange } from '../types';
import type { InfrastructureAlertSettings, InfrastructureAlertThresholds, InfrastructureMonitoringOverview, InfrastructureMonitoringRange } from '../types';
export const adminInfrastructureMonitoringApi = {
getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange) =>
request<InfrastructureMonitoringOverview>(withQuery('/admin/infrastructure-monitoring/overview', { range })),
getInfrastructureMonitoringNotificationSummary: () => request<{ count: number; criticalCount: number }>('/admin/infrastructure-monitoring/notification-summary'),
getInfrastructureAlertThresholds: () => request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds'),
updateInfrastructureAlertThresholds: (body: { configVersion: number; thresholds: InfrastructureAlertThresholds }) => request<InfrastructureAlertSettings>('/admin/infrastructure-monitoring/alert-thresholds', { method: 'PUT', body: JSON.stringify(body) }),
};
@@ -76,3 +76,16 @@ export type InfrastructureMonitoringOverview = {
serviceMetrics: InfrastructureServiceMetricGroup[];
alerts: InfrastructureAlert[];
};
export type InfrastructureAlertThresholds = Record<string, { warning: number; critical: number }>;
export type InfrastructureAlertSettings = {
configVersion: number;
effectiveVersion: number;
applyStatus: 'effective' | 'applying' | 'failed';
lastError: string | null;
appliedAt: string | null;
thresholds: InfrastructureAlertThresholds;
effectiveThresholds: InfrastructureAlertThresholds;
definitions: Array<{ key: string; label: string; unit: string; min: number; max: number; step: number }>;
};
@@ -1 +1,2 @@
.admin-security-page{gap:20px}.security-heading{align-items:flex-end}.security-heading h1{margin:10px 0 4px;font-size:26px}.security-heading p{margin:0;color:#64748b}.security-kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.security-kpi{display:grid;grid-template-columns:42px 1fr;grid-template-rows:auto auto;gap:3px 12px;padding:18px}.security-kpi>div{grid-row:1/3;width:42px;height:42px;border-radius:12px;display:grid;place-items:center;background:#e8f0ff;color:#2563eb}.security-kpi>div svg{width:20px}.security-kpi span{font-size:13px;color:#64748b}.security-kpi strong{font-size:24px;line-height:1.1}.security-kpi.is-danger>div{background:#fef2f2;color:#dc2626}.security-overview-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(360px,.8fr);gap:16px}.security-chart,.security-latest,.security-table-card{padding:18px}.security-chart header,.security-latest header{display:flex;justify-content:space-between}.security-chart header span,.security-latest header span{font-size:12px;color:#94a3b8}.security-latest-row{display:grid;grid-template-columns:10px 1fr auto;gap:10px;align-items:center;padding:13px 0;border-bottom:1px solid #eef2f7}.security-latest-row div{display:grid;gap:3px}.security-latest-row small,.security-latest-row time{color:#64748b;font-size:12px}.severity-dot{width:8px;height:8px;border-radius:99px;background:#3b82f6}.severity-dot.is-high{background:#f59e0b}.severity-dot.is-critical{background:#ef4444}.security-cell{display:grid;gap:3px}.security-cell span{font-size:11px;color:#94a3b8}.security-actions{display:flex;gap:6px}.security-note,.security-section-toolbar{display:flex;align-items:center;gap:9px;margin-bottom:16px;padding:12px 14px;border-radius:10px;background:#f8fafc;color:#475569;font-size:13px}.security-section-toolbar{justify-content:space-between}.security-error{display:flex;gap:8px;align-items:center;padding:12px 14px;border:1px solid #fecaca;border-radius:10px;background:#fef2f2;color:#b91c1c}.security-empty{height:265px;display:grid;place-items:center;color:#94a3b8}.security-dialog{display:grid;gap:16px}.security-target{display:grid;gap:4px;padding:14px;border-radius:10px;background:#f8fafc}.security-target span,.security-target small{color:#64748b;font-size:12px}.security-target strong{font-family:ui-monospace,monospace;font-size:18px}.security-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.security-form-grid .security-error{grid-column:1/-1}.admin-security-page code{font-size:12px;color:#334155}@media(max-width:1100px){.security-kpis{grid-template-columns:repeat(2,1fr)}.security-overview-grid{grid-template-columns:1fr}}@media(max-width:640px){.security-kpis{grid-template-columns:1fr}.security-form-grid{grid-template-columns:1fr}}
.security-heading p{margin:10px 0 0}
@@ -76,7 +76,7 @@ export function AdminSecurityDetectionPage() {
{ value: 'protected', label: '保护名单', content: <section className="surface security-table-card"><div className="security-section-toolbar"><span></span><Button onClick={() => setShowNetwork(true)}></Button></div><Table columns={networkColumns} data={networks} emptyText="暂无保护网段" rowKey="id" /></section> },
];
return <section className="page-stack admin-security-page"><div className="page-heading security-heading"><div><Breadcrumb items={['安全控制', '安全检测与封禁']} /><h1></h1><p>Fail2ban </p></div><Button disabled={loading} icon={<RefreshCw className={loading ? 'is-spinning' : ''} size={16} />} onClick={() => void load()} variant="ghost"></Button></div>
return <section className="page-stack admin-security-page"><div className="page-heading security-heading"><div><Breadcrumb items={['安全控制', '安全检测与封禁']} /><p>使 Fail2ban </p></div><Button disabled={loading} icon={<RefreshCw className={loading ? 'is-spinning' : ''} size={16} />} onClick={() => void load()} variant="ghost"></Button></div>
{error ? <div className="security-error" role="alert"><AlertTriangle size={18} />{error}</div> : null}
<div className="security-kpis"><Kpi icon={<BellRing />} label="24小时检测事件" value={overview?.totalEvents ?? 0} /><Kpi icon={<AlertTriangle />} label="待处置告警" value={overview?.activeAlerts ?? 0} danger={Boolean(overview?.criticalAlerts)} /><Kpi icon={<Ban />} label="生效封禁" value={overview?.activeBlocks ?? 0} /><Kpi icon={overview?.health.agent === 'healthy' ? <ShieldCheck /> : <ShieldOff />} label="安全代理" value={overview?.health.agent === 'healthy' ? '在线' : '不可用'} danger={overview?.health.agent !== 'healthy'} /></div>
<Tabs items={tabs} />
@@ -321,3 +321,16 @@
@media (prefers-reduced-motion: reduce) {
.is-spinning { animation: none; }
}
.system-monitoring-service-actions { align-items: center; display: flex; gap: 12px; }
.system-monitoring-threshold-dialog { display: grid; gap: 12px; max-height: 62vh; overflow: auto; padding-right: 4px; }
.system-monitoring-threshold-note { align-items: flex-start; background: #eff6ff; border-radius: 10px; color: #475569; display: flex; font-size: 13px; gap: 9px; padding: 12px 14px; }
.system-monitoring-threshold-row { align-items: end; border-bottom: 1px solid #eef2f7; display: grid; gap: 14px; grid-template-columns: minmax(180px, 1fr) 150px 150px; padding: 12px 0; }
.system-monitoring-threshold-row > div:first-child { align-self: center; display: grid; gap: 4px; }
.system-monitoring-threshold-row small { color: #64748b; }
@media (max-width: 760px) {
.system-monitoring-service-actions { align-items: flex-start; flex-direction: column; }
.system-monitoring-threshold-row { grid-template-columns: 1fr 1fr; }
.system-monitoring-threshold-row > div:first-child { grid-column: 1 / -1; }
}
@@ -12,16 +12,19 @@ import {
Network,
RefreshCw,
Server,
Settings2,
ShieldAlert,
} from 'lucide-react';
import {
adminApi,
type InfrastructureAlert,
type InfrastructureAlertSettings,
type InfrastructureAlertThresholds,
type InfrastructureMetricPoint,
type InfrastructureMonitoringOverview,
type InfrastructureMonitoringRange,
} from '@/api/adminApi';
import { Breadcrumb, Button, Chart, Table, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Chart, Input, Modal, Table, Tag, type TableColumn } from '@/components/ui';
import './AdminSystemMonitoringPage.css';
const RANGE_OPTIONS: Array<{ value: InfrastructureMonitoringRange; label: string }> = [
@@ -166,6 +169,11 @@ export function AdminSystemMonitoringPage() {
const [overview, setOverview] = useState<InfrastructureMonitoringOverview | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [settings, setSettings] = useState<InfrastructureAlertSettings | null>(null);
const [draftThresholds, setDraftThresholds] = useState<InfrastructureAlertThresholds>({});
const [showSettings, setShowSettings] = useState(false);
const [settingsError, setSettingsError] = useState('');
const [savingSettings, setSavingSettings] = useState(false);
const requestSequence = useRef(0);
const pendingRequests = useRef(0);
@@ -189,8 +197,38 @@ export function AdminSystemMonitoringPage() {
}
}, [range]);
const loadSettings = useCallback(async () => {
try {
const result = await adminApi.getInfrastructureAlertThresholds();
setSettings(result);
setDraftThresholds(result.thresholds);
setSettingsError('');
} catch (reason) {
setSettingsError(reason instanceof Error ? reason.message : '告警阈值加载失败');
}
}, []);
const saveSettings = useCallback(async () => {
if (!settings) return;
setSavingSettings(true);
setSettingsError('');
try {
const result = await adminApi.updateInfrastructureAlertThresholds({ configVersion: settings.configVersion, thresholds: draftThresholds });
setSettings(result);
setDraftThresholds(result.thresholds);
setShowSettings(false);
window.dispatchEvent(new Event('cmpp-infrastructure-alert-count-refresh'));
await loadData(true);
} catch (reason) {
setSettingsError(reason instanceof Error ? reason.message : '告警阈值保存失败');
} finally {
setSavingSettings(false);
}
}, [draftThresholds, loadData, settings]);
useEffect(() => {
void loadData(true);
void loadSettings();
const intervalId = window.setInterval(() => {
if (document.visibilityState === 'visible') void loadData();
}, 30_000);
@@ -203,7 +241,7 @@ export function AdminSystemMonitoringPage() {
window.clearInterval(intervalId);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [loadData]);
}, [loadData, loadSettings]);
const status = STATUS_COPY[overview?.summary.overallStatus ?? 'unknown'];
const cpuOption = useMemo(() => makeTrendOption({
@@ -232,7 +270,7 @@ export function AdminSystemMonitoringPage() {
<div>
<Breadcrumb items={['系统管理', '系统监控']} />
<div className="system-monitoring-title-row">
<p></p>
<p> Prometheus </p>
<Tag tone={status.tone}>{status.label}</Tag>
</div>
</div>
@@ -283,29 +321,6 @@ export function AdminSystemMonitoringPage() {
<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>
<section className="surface system-monitoring-service-metrics">
<header>
<div><Database size={18} /><strong></strong></div>
<span>ID或SQL文本</span>
</header>
<div className="system-monitoring-service-metric-grid">
{(overview?.serviceMetrics ?? []).map((group) => (
<article key={group.key}>
<div className="system-monitoring-service-metric-title">
<strong>{group.name}</strong>
<Tag tone={group.available ? 'success' : 'neutral'}>{group.available ? '已采集' : '待采集'}</Tag>
</div>
{group.metrics.length ? group.metrics.map((metric) => (
<div className="system-monitoring-service-metric-row" key={metric.key}>
<span>{metric.label}</span>
<strong>{formatServiceMetric(metric.value, metric.unit)}</strong>
</div>
)) : <div className="system-monitoring-service-metric-empty"></div>}
</article>
))}
</div>
</section>
<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>
@@ -332,10 +347,40 @@ export function AdminSystemMonitoringPage() {
</aside>
</div>
<section className="surface system-monitoring-alerts">
<section className="surface system-monitoring-service-metrics">
<header>
<div><Database size={18} /><strong></strong></div>
<div className="system-monitoring-service-actions"><span>ID或SQL文本</span><Button icon={<Settings2 size={15} />} onClick={() => setShowSettings(true)} variant="ghost"></Button></div>
</header>
<div className="system-monitoring-service-metric-grid">
{(overview?.serviceMetrics ?? []).map((group) => (
<article key={group.key}>
<div className="system-monitoring-service-metric-title"><strong>{group.name}</strong><Tag tone={group.available ? 'success' : 'neutral'}>{group.available ? '已采集' : '待采集'}</Tag></div>
{group.metrics.length ? group.metrics.map((metric) => <div className="system-monitoring-service-metric-row" key={metric.key}><span>{metric.label}</span><strong>{formatServiceMetric(metric.value, metric.unit)}</strong></div>) : <div className="system-monitoring-service-metric-empty"></div>}
</article>
))}
</div>
</section>
<section className="surface system-monitoring-alerts" id="active-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>
<Modal footer={<><Button onClick={() => setShowSettings(false)} variant="ghost"></Button><Button disabled={savingSettings || !settings} onClick={() => void saveSettings()}>{savingSettings ? '验证并应用中' : '保存并应用'}</Button></>} onClose={() => setShowSettings(false)} open={showSettings} title="Prometheus 告警阈值设置">
<div className="system-monitoring-threshold-dialog">
<div className="system-monitoring-threshold-note"><ShieldAlert size={17} /><span> promtool Prometheus</span></div>
{settings?.definitions.map((definition) => (
<div className="system-monitoring-threshold-row" key={definition.key}>
<div><strong>{definition.label}</strong><small>{definition.unit}</small></div>
<Input label="警告阈值" max={definition.max} min={definition.min} onChange={(event) => setDraftThresholds((current) => ({ ...current, [definition.key]: { ...current[definition.key], warning: Number(event.target.value) } }))} step={definition.step} type="number" value={draftThresholds[definition.key]?.warning ?? ''} />
<Input label="严重阈值" max={definition.max} min={definition.min} onChange={(event) => setDraftThresholds((current) => ({ ...current, [definition.key]: { ...current[definition.key], critical: Number(event.target.value) } }))} step={definition.step} type="number" value={draftThresholds[definition.key]?.critical ?? ''} />
</div>
))}
{settings?.applyStatus === 'failed' ? <div className="system-monitoring-unavailable"><AlertTriangle size={18} /><div><strong></strong><span>{settings.lastError}</span></div></div> : null}
{settingsError ? <div className="system-monitoring-unavailable"><AlertTriangle size={18} /><div><strong></strong><span>{settingsError}</span></div></div> : null}
</div>
</Modal>
</section>
);
}
+8 -2
View File
@@ -49,6 +49,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
const [pendingAudits, setPendingAudits] = useState(EMPTY_PENDING_AUDITS);
const [retirementUnreadCount, setRetirementUnreadCount] = useState(0);
const [securityAlertSummary, setSecurityAlertSummary] = useState({ count: 0, criticalCount: 0 });
const [infrastructureAlertSummary, setInfrastructureAlertSummary] = useState({ count: 0, criticalCount: 0 });
const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
const loadPendingAuditCount = useCallback(() => {
const currentSession = readSession('admin');
@@ -57,16 +58,18 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
return;
}
// This runs globally and on a timer, so it must not fan out through the full dashboard aggregation.
Promise.allSettled([adminApi.getPendingAudits(), adminApi.getSignatureRetirementUnreadCount(), adminApi.getSecurityNotificationSummary()])
.then(([audits, retirement, security]) => {
Promise.allSettled([adminApi.getPendingAudits(), adminApi.getSignatureRetirementUnreadCount(), adminApi.getSecurityNotificationSummary(), adminApi.getInfrastructureMonitoringNotificationSummary()])
.then(([audits, retirement, security, infrastructure]) => {
setPendingAudits(audits.status === 'fulfilled' ? audits.value : EMPTY_PENDING_AUDITS);
setRetirementUnreadCount(retirement.status === 'fulfilled' ? retirement.value.count : 0);
setSecurityAlertSummary(security.status === 'fulfilled' ? security.value : { count: 0, criticalCount: 0 });
setInfrastructureAlertSummary(infrastructure.status === 'fulfilled' ? infrastructure.value : { count: 0, criticalCount: 0 });
})
.catch(() => {
setPendingAudits(EMPTY_PENDING_AUDITS);
setRetirementUnreadCount(0);
setSecurityAlertSummary({ count: 0, criticalCount: 0 });
setInfrastructureAlertSummary({ count: 0, criticalCount: 0 });
});
}, []);
@@ -82,12 +85,14 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
window.addEventListener('cmpp-audit-count-refresh', onAuditRefresh);
window.addEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
window.addEventListener('cmpp-security-alert-count-refresh', onAuditRefresh);
window.addEventListener('cmpp-infrastructure-alert-count-refresh', onAuditRefresh);
return () => {
window.clearInterval(timer);
window.removeEventListener('focus', onFocus);
window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh);
window.removeEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
window.removeEventListener('cmpp-security-alert-count-refresh', onAuditRefresh);
window.removeEventListener('cmpp-infrastructure-alert-count-refresh', onAuditRefresh);
};
}, [loadPendingAuditCount, session.portal, sessionLocked]);
@@ -104,6 +109,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
alertNotifications={[
{ label: '签名清退预警', count: retirementUnreadCount, description: '今日未读且未抑制', to: '/admin/signature-retirement' },
{ label: '安全检测与封禁', count: securityAlertSummary.count, description: securityAlertSummary.criticalCount > 0 ? `${securityAlertSummary.criticalCount} 条严重告警待处置` : '待处置安全告警', to: '/admin/security-detection' },
{ label: '系统监控告警', count: infrastructureAlertSummary.count, description: infrastructureAlertSummary.criticalCount > 0 ? `${infrastructureAlertSummary.criticalCount} 条 Prometheus 严重告警` : 'Prometheus 活动告警', to: '/admin/system-monitoring#active-alerts' },
]}
auditNotifications={[
{ label: '企业认证待审', count: pendingAudits.enterpriseCertifications, to: '/admin/enterprise-audit' },