feat: add configurable infrastructure alerts
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user