feat(platform): add alert dispatch board

This commit is contained in:
lingniu
2026-07-04 21:10:58 +08:00
parent 09c86022b2
commit 0fff9e3890
3 changed files with 114 additions and 0 deletions

View File

@@ -123,6 +123,16 @@ type PriorityIssueRow = QualityIssueRow & {
notificationText?: string; notificationText?: string;
}; };
type AlertDispatchOwnerRow = {
owner: string;
activeRuleCount: number;
hitCount: number;
p0Count: number;
primaryIssueType: string;
primarySla: string;
primaryHitCount: number;
};
function qualityParams(values: Record<string, string>) { function qualityParams(values: Record<string, string>) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (values?.keyword) params.set('keyword', values.keyword); if (values?.keyword) params.set('keyword', values.keyword);
@@ -217,6 +227,35 @@ function alertRuleRows(summary: QualitySummary, health: OpsHealth | null): Alert
return rows; return rows;
} }
function alertDispatchOwnerRows(rules: AlertRuleRow[]): AlertDispatchOwnerRow[] {
const ownerMap = new Map<string, AlertDispatchOwnerRow>();
rules.forEach((rule) => {
const owner = rule.owner || '未分配';
const current = ownerMap.get(owner) ?? {
owner,
activeRuleCount: 0,
hitCount: 0,
p0Count: 0,
primaryIssueType: rule.issueType,
primarySla: rule.sla,
primaryHitCount: 0
};
current.activeRuleCount += rule.count > 0 ? 1 : 0;
current.hitCount += rule.count;
current.p0Count += rule.count > 0 && rule.level === 'P0' ? 1 : 0;
const shouldReplacePrimary = rule.count > current.primaryHitCount || (rule.count === current.primaryHitCount && rule.level === 'P0');
if (shouldReplacePrimary) {
current.primaryIssueType = rule.issueType;
current.primarySla = rule.sla;
current.primaryHitCount = rule.count;
}
ownerMap.set(owner, current);
});
return [...ownerMap.values()]
.filter((row) => row.hitCount > 0 || row.activeRuleCount > 0)
.sort((left, right) => right.p0Count - left.p0Count || right.hitCount - left.hitCount || left.owner.localeCompare(right.owner));
}
function issueSla(issueType: string) { function issueSla(issueType: string) {
return alertRuleTemplates.find((item) => item.issueType === issueType)?.sla ?? '当日闭环'; return alertRuleTemplates.find((item) => item.issueType === issueType)?.sla ?? '当日闭环';
} }
@@ -627,6 +666,7 @@ export function Quality({
const activeRuleCount = notificationPlan?.activeRuleCount ?? rules.filter((item) => item.count > 0).length; const activeRuleCount = notificationPlan?.activeRuleCount ?? rules.filter((item) => item.count > 0).length;
const p0RuleCount = notificationPlan?.p0RuleCount ?? rules.filter((item) => item.count > 0 && item.level === 'P0').length; const p0RuleCount = notificationPlan?.p0RuleCount ?? rules.filter((item) => item.count > 0 && item.level === 'P0').length;
const priorityRows = normalizePriorityIssues(notificationPlan?.priorityIssues) ?? priorityIssueRows(issues); const priorityRows = normalizePriorityIssues(notificationPlan?.priorityIssues) ?? priorityIssueRows(issues);
const dispatchOwnerRows = alertDispatchOwnerRows(rules);
const loadIssues = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { const loadIssues = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
setLoadingIssues(true); setLoadingIssues(true);
@@ -836,6 +876,33 @@ export function Quality({
))} ))}
</div> </div>
</Card> </Card>
<Card bordered title="告警分派看板" style={{ marginTop: 16 }}>
<div className="vp-alert-dispatch-grid">
{dispatchOwnerRows.length === 0 ? (
<Tag color="green"></Tag>
) : dispatchOwnerRows.map((item) => (
<button
key={item.owner}
type="button"
className="vp-alert-dispatch-item"
aria-label={`分派团队 ${item.owner}`}
onClick={() => drillIssueType(item.primaryIssueType)}
>
<div className="vp-alert-dispatch-head">
<Tag color={item.p0Count > 0 ? 'red' : 'orange'}>{item.owner}</Tag>
<strong>{item.hitCount.toLocaleString()} </strong>
</div>
<div className="vp-alert-policy-detail">
{item.activeRuleCount.toLocaleString()} / P0 {item.p0Count.toLocaleString()}
</div>
<div className="vp-alert-policy-detail">
{item.primaryIssueType === 'CAPACITY_RISK' ? '容量与存储风险' : qualityIssueLabel(item.primaryIssueType)}
</div>
<div className="vp-alert-policy-detail">SLA{item.primarySla}</div>
</button>
))}
</div>
</Card>
<Card <Card
bordered bordered
title={( title={(

View File

@@ -736,6 +736,46 @@ button.vp-realtime-command-item:focus-visible {
line-height: 28px; line-height: 28px;
} }
.vp-alert-dispatch-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.vp-alert-dispatch-item {
min-height: 132px;
padding: 12px;
border: 1px solid var(--vp-border);
border-radius: var(--vp-radius);
background: #fbfcff;
display: grid;
align-content: start;
gap: 8px;
text-align: left;
font: inherit;
cursor: pointer;
}
.vp-alert-dispatch-item:hover,
.vp-alert-dispatch-item:focus-visible {
border-color: rgba(22, 100, 255, 0.42);
background: #f5f9ff;
outline: none;
}
.vp-alert-dispatch-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.vp-alert-dispatch-head strong {
color: var(--vp-text);
font-size: 22px;
line-height: 28px;
}
.vp-alert-command-board { .vp-alert-command-board {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 360px; grid-template-columns: minmax(0, 1fr) 360px;
@@ -1467,6 +1507,7 @@ button.vp-realtime-command-item:focus-visible {
.vp-conclusion-grid, .vp-conclusion-grid,
.vp-monitor-layout, .vp-monitor-layout,
.vp-alert-flow, .vp-alert-flow,
.vp-alert-dispatch-grid,
.vp-alert-command-board, .vp-alert-command-board,
.vp-alert-command-grid, .vp-alert-command-grid,
.vp-risk-grid, .vp-risk-grid,

View File

@@ -3172,6 +3172,10 @@ test('shows alert rule and notification policy workspace on quality page', async
expect(screen.getByText('3 类 P0')).toBeInTheDocument(); expect(screen.getByText('3 类 P0')).toBeInTheDocument();
expect(screen.getByText('容量与存储风险')).toBeInTheDocument(); expect(screen.getByText('容量与存储风险')).toBeInTheDocument();
expect(screen.getByText('15 分钟恢复')).toBeInTheDocument(); expect(screen.getByText('15 分钟恢复')).toBeInTheDocument();
expect(screen.getByText('告警分派看板')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '分派团队 平台接入' })).toHaveTextContent('2 条');
expect(screen.getByRole('button', { name: '分派团队 车辆档案' })).toHaveTextContent('主问题VIN 缺失');
expect(screen.getByRole('button', { name: '分派团队 基础设施' })).toHaveTextContent('SLA15 分钟恢复');
expect(screen.getByText('通知策略')).toBeInTheDocument(); expect(screen.getByText('通知策略')).toBeInTheDocument();
expect(screen.getByText('P0 实时中断')).toBeInTheDocument(); expect(screen.getByText('P0 实时中断')).toBeInTheDocument();
expect(screen.getByText('接入运维 + 业务责任人')).toBeInTheDocument(); expect(screen.getByText('接入运维 + 业务责任人')).toBeInTheDocument();
@@ -3185,6 +3189,8 @@ test('shows alert rule and notification policy workspace on quality page', async
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('P0 实时中断 / 接入运维 + 业务责任人')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('P0 实时中断 / 接入运维 + 业务责任人'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('升级30 分钟升级')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('升级30 分钟升级'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('验收:来源恢复并持续 10 分钟,车辆服务可查到实时与历史证据')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('验收:来源恢复并持续 10 分钟,车辆服务可查到实时与历史证据'));
fireEvent.click(screen.getByRole('button', { name: '分派团队 车辆档案' }));
expect(window.location.hash).toBe('#/alert-events?issueType=VIN_MISSING');
}); });
test('shows actionable priority queue on quality page', async () => { test('shows actionable priority queue on quality page', async () => {