feat(platform): add alert dispatch board
This commit is contained in:
@@ -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={(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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('SLA:15 分钟恢复');
|
||||||
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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user