feat: 完善服务监控与下游重投

This commit is contained in:
hectorzhao
2026-08-14 17:21:29 +08:00
parent d30d9ea4d0
commit 1ef4380422
50 changed files with 1279 additions and 82 deletions
+1
View File
@@ -3,6 +3,7 @@ import type { SecurityAlert, SecurityBlock, SecurityOverview, SecurityProtectedN
export const adminSecurityDetectionApi = {
getSecurityOverview: (range = '24h') => request<SecurityOverview>(withQuery('/admin/security-detection/overview', { range })),
getSecurityNotificationSummary: () => request<{ count: number; criticalCount: number }>('/admin/security-detection/notification-summary'),
listSecurityAlerts: (query: Record<string, string | number | undefined> = {}) => request<{ items: SecurityAlert[]; total: number }> (withQuery('/admin/security-detection/alerts', query)),
listSecurityRules: () => request<SecurityRule[]>('/admin/security-detection/rules'),
updateSecurityRule: (id: string, body: Partial<SecurityRule>) => request<SecurityRule>(`/admin/security-detection/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
@@ -12,6 +12,18 @@ export type InfrastructureServiceStatus = {
status: 'healthy' | 'unhealthy' | 'unknown';
};
export type InfrastructureServiceMetricGroup = {
key: string;
name: string;
available: boolean;
metrics: Array<{
key: string;
label: string;
value: number | null;
unit: 'percent' | 'seconds' | 'count' | 'per_second' | 'bytes';
}>;
};
export type InfrastructureAlert = {
fingerprint: string;
name: string;
@@ -61,5 +73,6 @@ export type InfrastructureMonitoringOverview = {
networkTransmitBytesPerSecond: InfrastructureMetricPoint[];
};
services: InfrastructureServiceStatus[];
serviceMetrics: InfrastructureServiceMetricGroup[];
alerts: InfrastructureAlert[];
};
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type DownstreamRequeuePreview, type DownstreamRequeueTask, type DownstreamRequeueTaskItem, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, Textarea, type DateRangeValue } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
@@ -677,7 +677,7 @@ export function AdminDownstreamDeliveriesPage() {
/>
</div>
<div className="surface admin-task-table-card report-task-table-card">
<div className="surface admin-task-table-card report-task-table-card downstream-requeue-task-card">
<div className="section-heading downstream-requeue-task-heading">
<div><h2></h2><p className="muted"></p></div>
<div className="downstream-requeue-task-heading__actions">
@@ -741,8 +741,20 @@ export function AdminDownstreamDeliveriesPage() {
</div>
<div className="downstream-requeue-preview__distribution"><span></span><div>{Object.entries(taskPreview.statusCounts).map(([key, value]) => <Tag key={key} tone={statusTone[key] ?? 'neutral'}>{statusLabel[key] ?? key} {value}</Tag>)}</div></div>
<Select label="执行速度" value={String(taskRate)} onChange={(event) => setTaskRate(Number(event.target.value))} options={[{ label: '平稳(每应用10条/秒)', value: '10' }, { label: '快速(每应用20条/秒)', value: '20' }, { label: '低速(每应用5条/秒)', value: '5' }]} />
<label className="field"><span> *</span><textarea value={taskReason} onChange={(event) => setTaskReason(event.target.value)} placeholder="请填写事故原因、工单号或处理说明(至少5个字)" rows={3} /></label>
<div className="downstream-requeue-warning"><AlertTriangle size={20} /><div><strong></strong><p> ACK 线</p></div></div>
<Textarea
className="downstream-requeue-reason"
error={taskReason.length > 0 && taskReason.trim().length < 5 ? '任务原因至少填写 5 个字' : undefined}
hint={`请填写事故原因、工单号或处理说明 · ${taskReason.length}/200`}
id="downstream-requeue-task-reason"
label="任务原因"
maxLength={200}
onChange={(event) => setTaskReason(event.target.value)}
placeholder="例如:工单 INC-20260814,重新投递客户已确认的历史回执"
required
rows={4}
value={taskReason}
/>
<div className="downstream-requeue-warning"><AlertTriangle size={20} /><div><strong></strong><p> ACK 线</p></div></div>
</div>
</Modal>
) : null}
@@ -11,20 +11,13 @@
align-items: center;
display: flex;
gap: 12px;
margin-top: 10px;
}
.system-monitoring-title-row h1 {
color: var(--color-text-strong);
font-size: 24px;
line-height: 1.25;
margin: 0;
margin-top: 8px;
}
.system-monitoring-title-row p {
color: var(--color-text-muted);
font-size: 13px;
margin: 4px 0 0;
margin: 0;
}
.system-monitoring-controls,
@@ -191,6 +184,24 @@
grid-template-columns: minmax(0, 1fr) 300px;
}
.system-monitoring-service-metrics { padding: 18px; }
.system-monitoring-service-metrics > header {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 14px;
}
.system-monitoring-service-metrics > header > div { align-items: center; color: var(--color-text-strong); display: flex; gap: 8px; }
.system-monitoring-service-metrics > header > span { color: var(--color-text-muted); font-size: 12px; }
.system-monitoring-service-metric-grid { display: grid; gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.system-monitoring-service-metric-grid article { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 14px; }
.system-monitoring-service-metric-title { align-items: center; display: flex; justify-content: space-between; margin-bottom: 9px; }
.system-monitoring-service-metric-title > strong { color: var(--color-text-strong); font-size: 14px; }
.system-monitoring-service-metric-row { align-items: center; border-top: 1px solid var(--color-border); display: flex; justify-content: space-between; min-height: 34px; }
.system-monitoring-service-metric-row span { color: var(--color-text-muted); font-size: 12px; }
.system-monitoring-service-metric-row strong { color: var(--color-text); font-size: 13px; }
.system-monitoring-service-metric-empty { color: var(--color-text-subtle); font-size: 12px; line-height: 1.55; padding-top: 8px; }
.system-monitoring-chart-stack {
display: grid;
gap: 16px;
@@ -285,6 +296,7 @@
.system-monitoring-main-grid { grid-template-columns: minmax(0, 1fr); }
.system-monitoring-services { order: -1; }
.system-monitoring-service-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.system-monitoring-service-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.system-monitoring-service { border-bottom: 0; border-right: 1px solid var(--color-border); padding: 11px 12px; }
.system-monitoring-service:nth-child(3n) { border-right: 0; }
}
@@ -299,7 +311,9 @@
.system-monitoring-health__fact { border-left: 0; border-top: 1px solid var(--color-border); padding: 12px 0 0; }
.system-monitoring-metrics,
.system-monitoring-chart-stack,
.system-monitoring-service-list { grid-template-columns: minmax(0, 1fr); }
.system-monitoring-service-list,
.system-monitoring-service-metric-grid { grid-template-columns: minmax(0, 1fr); }
.system-monitoring-service-metrics > header { align-items: flex-start; flex-direction: column; gap: 6px; }
.system-monitoring-service { border-bottom: 1px solid var(--color-border); border-right: 0; padding: 13px 0; }
.system-monitoring-chart-card { padding: 14px 10px; }
}
@@ -69,6 +69,15 @@ function formatUptime(value: number | null) {
return days > 0 ? `${days}${hours}小时` : `${hours}小时`;
}
function formatServiceMetric(value: number | null, unit: 'percent' | 'seconds' | 'count' | 'per_second' | 'bytes') {
if (value === null) return '—';
if (unit === 'percent') return `${value.toFixed(2)}%`;
if (unit === 'seconds') return value < 1 ? `${Math.round(value * 1000)} ms` : `${value.toFixed(1)} s`;
if (unit === 'per_second') return `${value.toFixed(value < 10 ? 2 : 1)}/s`;
if (unit === 'bytes') return formatBytes(value);
return Math.round(value).toLocaleString('zh-CN');
}
function formatTime(value: string | null) {
if (!value) return '暂无采样';
return new Intl.DateTimeFormat('zh-CN', {
@@ -223,10 +232,7 @@ export function AdminSystemMonitoringPage() {
<div>
<Breadcrumb items={['系统管理', '系统监控']} />
<div className="system-monitoring-title-row">
<div>
<h1></h1>
<p></p>
</div>
<p></p>
<Tag tone={status.tone}>{status.label}</Tag>
</div>
</div>
@@ -277,6 +283,29 @@ 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>
+11 -3
View File
@@ -48,6 +48,7 @@ export function AdminLayout() {
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 [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
const loadPendingAuditCount = useCallback(() => {
const currentSession = readSession('admin');
@@ -56,14 +57,16 @@ 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()])
.then(([audits, retirement]) => {
Promise.allSettled([adminApi.getPendingAudits(), adminApi.getSignatureRetirementUnreadCount(), adminApi.getSecurityNotificationSummary()])
.then(([audits, retirement, security]) => {
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 });
})
.catch(() => {
setPendingAudits(EMPTY_PENDING_AUDITS);
setRetirementUnreadCount(0);
setSecurityAlertSummary({ count: 0, criticalCount: 0 });
});
}, []);
@@ -78,11 +81,13 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
window.addEventListener('focus', onFocus);
window.addEventListener('cmpp-audit-count-refresh', onAuditRefresh);
window.addEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
window.addEventListener('cmpp-security-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);
};
}, [loadPendingAuditCount, session.portal, sessionLocked]);
@@ -96,7 +101,10 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
userName={session.user.displayName}
userRole="平台管理员"
onSessionLockedChange={setSessionLocked}
retirementAlert={{ count: retirementUnreadCount, to: '/admin/signature-retirement' }}
alertNotifications={[
{ label: '签名清退预警', count: retirementUnreadCount, description: '今日未读且未抑制', to: '/admin/signature-retirement' },
{ label: '安全检测与封禁', count: securityAlertSummary.count, description: securityAlertSummary.criticalCount > 0 ? `${securityAlertSummary.criticalCount} 条严重告警待处置` : '待处置安全告警', to: '/admin/security-detection' },
]}
auditNotifications={[
{ label: '企业认证待审', count: pendingAudits.enterpriseCertifications, to: '/admin/enterprise-audit' },
{ label: '短信审核待审', count: pendingAudits.smsAudits, to: '/admin/sms-audit' },
+40 -13
View File
@@ -50,6 +50,10 @@ export type AuditNotificationItem = {
to: string;
};
export type AlertNotificationItem = AuditNotificationItem & {
description: string;
};
type AppShellProps = {
title: string;
subtitle: string;
@@ -60,7 +64,7 @@ type AppShellProps = {
userRole: string;
navSections: ShellNavSection[];
auditNotifications?: AuditNotificationItem[];
retirementAlert?: { count: number; to: string };
alertNotifications?: AlertNotificationItem[];
onSessionLockedChange?: (locked: boolean) => void;
};
@@ -73,7 +77,7 @@ export function AppShell({
userRole,
navSections,
auditNotifications = [],
retirementAlert,
alertNotifications = [],
onSessionLockedChange,
}: AppShellProps) {
const [collapsed, setCollapsed] = useState(false);
@@ -81,6 +85,7 @@ export function AppShell({
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [noticeOpen, setNoticeOpen] = useState(false);
const [alertNoticeOpen, setAlertNoticeOpen] = useState(false);
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
@@ -107,6 +112,10 @@ export function AppShell({
() => auditNotifications.reduce((sum, item) => sum + item.count, 0),
[auditNotifications],
);
const alertTotal = useMemo(
() => alertNotifications.reduce((sum, item) => sum + item.count, 0),
[alertNotifications],
);
async function changeOwnPassword() {
if (!currentPassword || newPassword.length < 6) {
@@ -417,23 +426,41 @@ export function AppShell({
<button className="icon-button topbar-help" type="button" aria-label="帮助中心">
<CircleHelp size={18} />
</button>
{retirementAlert ? (
<Link
aria-label={`今日未读且未抑制签名清退预警 ${retirementAlert.count}`}
className={['icon-button', retirementAlert.count > 0 ? 'has-dot' : ''].filter(Boolean).join(' ')}
title="今日未读且未抑制签名清退预警"
to={retirementAlert.to}
>
<Bell size={18} />
{retirementAlert.count > 0 ? <span className="notice-count">{retirementAlert.count}</span> : null}
</Link>
{alertNotifications.length ? (
<div className="notice-menu-wrap">
<button
aria-expanded={alertNoticeOpen}
aria-haspopup="menu"
aria-label="预警通知"
className={['icon-button', alertTotal > 0 ? 'has-dot' : ''].filter(Boolean).join(' ')}
onClick={() => { setAlertNoticeOpen((open) => !open); setNoticeOpen(false); }}
type="button"
>
<Bell size={18} />
{alertTotal > 0 ? <span className="notice-count">{alertTotal}</span> : null}
</button>
{alertNoticeOpen ? (
<div className="notice-popover notice-popover--alerts" role="menu">
<div className="notice-popover__header">
<strong></strong>
<span className={alertTotal === 0 ? 'is-zero' : ''}>{alertTotal} </span>
</div>
{alertNotifications.map((item) => (
<NavLink key={item.to} onClick={() => setAlertNoticeOpen(false)} role="menuitem" to={item.to}>
<span className="notice-popover__copy"><b>{item.label}</b><small>{item.description}</small></span>
<strong className={item.count === 0 ? 'is-zero' : ''}>{item.count}</strong>
</NavLink>
))}
</div>
) : null}
</div>
) : null}
<div className="notice-menu-wrap">
<button
aria-expanded={noticeOpen}
aria-haspopup="menu"
className={['icon-button', auditTotal > 0 ? 'has-dot' : ''].filter(Boolean).join(' ')}
onClick={() => setNoticeOpen((open) => !open)}
onClick={() => { setNoticeOpen((open) => !open); setAlertNoticeOpen(false); }}
type="button"
aria-label="通知"
>
+9 -3
View File
@@ -9424,10 +9424,14 @@
.downstream-page-size { display: inline-flex; align-items: center; gap: 8px; color: var(--text-muted); font-size: 13px; }
.downstream-page-size select { min-width: 76px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); padding: 0 10px; }
.downstream-requeue-task-heading { gap: 20px; }
.downstream-requeue-task-card > .downstream-requeue-task-heading { padding: var(--space-6) var(--space-6) var(--space-5); }
.downstream-requeue-task-heading__actions { display: flex; align-items: center; gap: 8px; }
.downstream-requeue-task-heading__actions .ui-field { min-width: 150px; }
.downstream-requeue-task-list { display: grid; border-top: 1px solid var(--border); }
.downstream-requeue-task-list article { display: grid; grid-template-columns: minmax(190px, .9fr) minmax(260px, 1.35fr) minmax(250px, 1fr) auto; gap: 22px; align-items: center; padding: 18px 4px; border-bottom: 1px solid var(--border); }
.downstream-requeue-task-list { display: grid; margin: 0 var(--space-6); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--color-surface); }
.downstream-requeue-task-list article { display: grid; grid-template-columns: minmax(190px, .9fr) minmax(260px, 1.35fr) minmax(250px, 1fr) auto; gap: 22px; align-items: center; padding: 18px var(--space-5); border-bottom: 1px solid var(--border); }
.downstream-requeue-task-list article:last-of-type { border-bottom: 0; }
.downstream-requeue-task-list > .muted { margin: 0; padding: var(--space-6); text-align: center; }
.downstream-requeue-task-card > .ui-pagination { padding: var(--space-5) var(--space-6) var(--space-6); }
.downstream-requeue-task-list article > div { min-width: 0; }
.downstream-requeue-task-list__identity, .downstream-requeue-task-list__scope, .downstream-requeue-task-list__progress { display: grid; gap: 5px; }
.downstream-requeue-task-list__identity > strong { color: var(--text-strong); font-size: 14px; font-variant-numeric: tabular-nums; }
@@ -9447,6 +9451,8 @@
.downstream-requeue-preview__distribution { display: grid; gap: 8px; }
.downstream-requeue-preview__distribution > span { color: var(--text-muted); font-size: 13px; }
.downstream-requeue-preview__distribution > div { display: flex; flex-wrap: wrap; gap: 8px; }
.downstream-requeue-reason { padding: 16px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface-subtle, #f8fafc); }
.downstream-requeue-reason .ui-textarea { min-height: 124px; background: var(--color-surface); line-height: 1.65; }
.downstream-requeue-warning { display: flex; align-items: flex-start; gap: 10px; padding: 14px; border: 1px solid #fed7aa; border-radius: 10px; background: var(--warning-soft, #fff7ed); color: var(--warning-text, #9a3412); }
.downstream-requeue-warning > div { display: grid; gap: 4px; }
.downstream-requeue-warning p { margin: 0; color: inherit; line-height: 1.6; }
@@ -9460,4 +9466,4 @@
.downstream-requeue-detail-items__head { background: var(--surface-subtle, #f8fafc); color: var(--text-muted); font-size: 12px; font-weight: 600; }
.downstream-requeue-detail-items time { color: var(--text-muted); font-size: 12px; }
@media (max-width: 1100px) { .downstream-requeue-task-list article { grid-template-columns: 1fr 1.4fr; } .downstream-requeue-task-list__actions { justify-content: flex-start; } .downstream-requeue-preview__summary { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 780px) { .downstream-requeue-task-heading, .downstream-requeue-task-heading__actions { align-items: stretch; flex-direction: column; } .downstream-requeue-task-list article, .downstream-requeue-preview__summary, .downstream-requeue-detail-filter { grid-template-columns: 1fr; } .downstream-requeue-detail-items { border: 0; overflow: visible; gap: 10px; } .downstream-requeue-detail-items__head { display: none !important; } .downstream-requeue-detail-items > div { grid-template-columns: 1fr; gap: 6px; padding: 14px; border: 1px solid var(--border); border-radius: 10px; } }
@media (max-width: 780px) { .downstream-requeue-task-card > .downstream-requeue-task-heading { padding: var(--space-5) var(--space-4) var(--space-4); } .downstream-requeue-task-heading, .downstream-requeue-task-heading__actions { align-items: stretch; flex-direction: column; } .downstream-requeue-task-list { margin: 0 var(--space-4); } .downstream-requeue-task-list article, .downstream-requeue-preview__summary, .downstream-requeue-detail-filter { grid-template-columns: 1fr; } .downstream-requeue-task-card > .ui-pagination { padding: var(--space-4); } .downstream-requeue-detail-items { border: 0; overflow: visible; gap: 10px; } .downstream-requeue-detail-items__head { display: none !important; } .downstream-requeue-detail-items > div { grid-template-columns: 1fr; gap: 6px; padding: 14px; border: 1px solid var(--border); border-radius: 10px; } }
+25
View File
@@ -571,6 +571,31 @@
text-align: right;
}
.notice-popover--alerts {
min-width: 292px;
}
.notice-popover--alerts a {
gap: var(--space-4);
min-height: 58px;
}
.notice-popover__copy {
align-items: flex-start;
display: grid;
gap: 2px;
}
.notice-popover__copy b {
color: var(--color-text-strong);
font-weight: var(--font-weight-semibold);
}
.notice-popover__copy small {
color: var(--color-text-muted);
font-size: 11px;
}
.page-heading__actions {
align-items: center;
display: flex;