feat(platform): add notification reachability checks
This commit is contained in:
@@ -77,6 +77,22 @@ type NotificationCoverageOwnerRow = {
|
||||
primaryPolicy?: QualityNotificationPolicy;
|
||||
};
|
||||
|
||||
type NotificationReachabilityRow = {
|
||||
owner: string;
|
||||
issueCount: number;
|
||||
p0IssueCount: number;
|
||||
ruleCount: number;
|
||||
channel: string;
|
||||
target: string;
|
||||
escalation: string;
|
||||
acceptance: string;
|
||||
evidenceComplete: number;
|
||||
status: 'ready' | 'risk' | 'blocked';
|
||||
statusLabel: string;
|
||||
statusColor: 'green' | 'orange' | 'red';
|
||||
nextAction: string;
|
||||
};
|
||||
|
||||
const notificationExportColumns: CsvColumn<NotificationExportRow>[] = [
|
||||
{ title: '类型', value: (row) => row.category },
|
||||
{ title: '名称', value: (row) => row.name },
|
||||
@@ -270,6 +286,112 @@ function coverageOwnerRows(rules: QualityAlertRule[], policies: QualityNotificat
|
||||
}).sort((left, right) => right.hitCount - left.hitCount || right.p0Rules - left.p0Rules || left.owner.localeCompare(right.owner));
|
||||
}
|
||||
|
||||
function notificationReachabilityRows(
|
||||
issues: QualityPriorityIssue[],
|
||||
rules: QualityAlertRule[],
|
||||
policies: QualityNotificationPolicy[],
|
||||
slaRows: SlaEscalationRow[]
|
||||
): NotificationReachabilityRow[] {
|
||||
const slaByIssue = new Map(slaRows.map((row) => [
|
||||
`${row.issue.priority}-${row.issue.protocol}-${row.issue.issueType}-${row.issue.vehicleLabel}-${row.issue.lastSeen}`,
|
||||
row
|
||||
]));
|
||||
const ownerMap = new Map<string, { issues: QualityPriorityIssue[]; rules: QualityAlertRule[]; policy?: QualityNotificationPolicy; overdue: number; dueSoon: number }>();
|
||||
|
||||
for (const rule of rules) {
|
||||
if (Number(rule.count ?? 0) <= 0) continue;
|
||||
const owner = rule.owner?.trim() || '未分配责任方';
|
||||
const current = ownerMap.get(owner) ?? { issues: [], rules: [], overdue: 0, dueSoon: 0 };
|
||||
current.rules.push(rule);
|
||||
ownerMap.set(owner, current);
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
const rule = rules.find((item) => item.issueType === issue.issueType);
|
||||
const owner = rule?.owner?.trim() || policyForPriority(issue, policies)?.target || '未分配责任方';
|
||||
const current = ownerMap.get(owner) ?? { issues: [], rules: [], overdue: 0, dueSoon: 0 };
|
||||
current.issues.push(issue);
|
||||
const sla = slaByIssue.get(`${issue.priority}-${issue.protocol}-${issue.issueType}-${issue.vehicleLabel}-${issue.lastSeen}`);
|
||||
if (sla?.status === 'overdue') current.overdue += 1;
|
||||
if (sla?.status === 'due_soon') current.dueSoon += 1;
|
||||
if (!current.policy) current.policy = policyForPriority(issue, policies);
|
||||
ownerMap.set(owner, current);
|
||||
}
|
||||
|
||||
return Array.from(ownerMap.entries()).map(([owner, value]) => {
|
||||
const p0IssueCount = value.issues.filter((issue) => issue.priority === 'P0').length;
|
||||
const evidenceComplete = value.issues.filter((issue) => countEvidenceLinks(issue) >= 4).length;
|
||||
const p0Rules = value.rules.filter((rule) => rule.level === 'P0').length;
|
||||
const policy = value.policy
|
||||
?? policies.find((item) => policyLevel(item) === (p0IssueCount > 0 || p0Rules > 0 ? 'P0' : 'P1'))
|
||||
?? policies[0];
|
||||
const hasPolicy = Boolean(policy?.target && policy.channel);
|
||||
const hasAcceptance = Boolean(policy?.acceptanceCriteria);
|
||||
const evidenceMissing = value.issues.length > 0 && evidenceComplete < value.issues.length;
|
||||
const blocked = !hasPolicy || value.overdue > 0;
|
||||
const risk = !blocked && (value.dueSoon > 0 || evidenceMissing || !hasAcceptance || p0IssueCount > 0);
|
||||
const status: NotificationReachabilityRow['status'] = blocked ? 'blocked' : risk ? 'risk' : 'ready';
|
||||
const statusColor: NotificationReachabilityRow['statusColor'] = blocked ? 'red' : risk ? 'orange' : 'green';
|
||||
return {
|
||||
owner,
|
||||
issueCount: value.issues.length,
|
||||
p0IssueCount,
|
||||
ruleCount: value.rules.length,
|
||||
channel: policy?.channel || '-',
|
||||
target: policy?.target || '-',
|
||||
escalation: policy ? formatEscalationMinutes(policy.escalationMinutes) : '-',
|
||||
acceptance: policy?.acceptanceCriteria || '-',
|
||||
evidenceComplete,
|
||||
status,
|
||||
statusLabel: blocked ? (value.overdue > 0 ? '已超时' : '策略缺失') : risk ? '需跟进' : '可触达',
|
||||
statusColor,
|
||||
nextAction: blocked
|
||||
? (value.overdue > 0 ? '立即升级责任人并同步业务侧' : '补齐通知对象与渠道')
|
||||
: evidenceMissing
|
||||
? '补齐车辆服务、实时、轨迹、原始记录证据'
|
||||
: value.dueSoon > 0
|
||||
? '临近升级,先确认恢复进度'
|
||||
: p0IssueCount > 0
|
||||
? '先发送P0通知并记录回执'
|
||||
: '按策略持续观察并复核验收'
|
||||
};
|
||||
}).sort((left, right) => {
|
||||
const statusWeight: Record<NotificationReachabilityRow['status'], number> = { blocked: 3, risk: 2, ready: 1 };
|
||||
return statusWeight[right.status] - statusWeight[left.status]
|
||||
|| right.p0IssueCount - left.p0IssueCount
|
||||
|| right.issueCount - left.issueCount
|
||||
|| left.owner.localeCompare(right.owner);
|
||||
});
|
||||
}
|
||||
|
||||
function notificationReachabilityReport(rows: NotificationReachabilityRow[], release?: string) {
|
||||
const blocked = rows.filter((row) => row.status === 'blocked').length;
|
||||
const risk = rows.filter((row) => row.status === 'risk').length;
|
||||
const ready = rows.filter((row) => row.status === 'ready').length;
|
||||
return [
|
||||
'【通知可达性检查】',
|
||||
`运行版本:${release?.trim() || '-'}`,
|
||||
`可达结论:阻断 ${blocked.toLocaleString()} / 需跟进 ${risk.toLocaleString()} / 可触达 ${ready.toLocaleString()}`,
|
||||
'',
|
||||
...(rows.length > 0 ? rows.slice(0, 10).map((row, index) => [
|
||||
`${index + 1}. ${row.owner} / ${row.statusLabel}`,
|
||||
` 待通知:${row.issueCount.toLocaleString()} 条,P0 ${row.p0IssueCount.toLocaleString()} 条,活跃规则 ${row.ruleCount.toLocaleString()} 类`,
|
||||
` 通知对象:${row.target}`,
|
||||
` 渠道:${row.channel}`,
|
||||
` 升级:${row.escalation}`,
|
||||
` 证据:${row.evidenceComplete.toLocaleString()}/${row.issueCount.toLocaleString()} 完整`,
|
||||
` 验收:${row.acceptance}`,
|
||||
` 下一步:${row.nextAction}`
|
||||
].join('\n')) : ['暂无待通知责任方']),
|
||||
'',
|
||||
'检查口径:',
|
||||
'1. 必须有责任方、通知对象和渠道',
|
||||
'2. P0 和超时告警优先进入升级队列',
|
||||
'3. 通知前至少带上车辆服务、实时、轨迹和原始记录证据',
|
||||
'4. 恢复后按验收口径复核,确认告警不再命中'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function notificationCoverageReport({
|
||||
plan,
|
||||
release,
|
||||
@@ -549,6 +671,9 @@ export function NotificationRules() {
|
||||
const nextSlaRow = slaRows[0];
|
||||
const executionRows = notificationExecutionRows(priorityIssues, rules, policies);
|
||||
const ownerRows = coverageOwnerRows(rules, policies);
|
||||
const reachabilityRows = notificationReachabilityRows(priorityIssues, rules, policies, slaRows);
|
||||
const reachabilityBlockedCount = reachabilityRows.filter((row) => row.status === 'blocked').length;
|
||||
const reachabilityRiskCount = reachabilityRows.filter((row) => row.status === 'risk').length;
|
||||
const totalActiveHits = ownerRows.reduce((total, row) => total + row.hitCount, 0);
|
||||
const unhealthyLinkCount = (health?.linkHealth ?? []).filter((item) => item.status !== 'ok').length;
|
||||
const notificationCustomerSteps = [
|
||||
@@ -731,6 +856,52 @@ export function NotificationRules() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
bordered
|
||||
loading={loading}
|
||||
title={<Space><span>通知可达性检查</span><Button size="small" aria-label="复制通知可达性检查" icon={<IconCopy />} onClick={() => copyText(notificationReachabilityReport(reachabilityRows, release), '通知可达性检查')}>复制可达性检查</Button></Space>}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<div className="vp-notification-reachability-board">
|
||||
<div className="vp-notification-reachability-summary">
|
||||
<Space wrap>
|
||||
<Tag color={reachabilityBlockedCount > 0 ? 'red' : reachabilityRiskCount > 0 ? 'orange' : 'green'}>
|
||||
{reachabilityBlockedCount > 0 ? '存在阻断' : reachabilityRiskCount > 0 ? '需要跟进' : '可触达'}
|
||||
</Tag>
|
||||
<Tag color="blue">{reachabilityRows.length.toLocaleString()} 个责任方</Tag>
|
||||
</Space>
|
||||
<Typography.Text strong>
|
||||
阻断 {reachabilityBlockedCount.toLocaleString()} / 风险 {reachabilityRiskCount.toLocaleString()}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
按责任方检查通知对象、渠道、升级窗口、证据完整度和验收口径,避免告警已经触发但无人收到或无法闭环。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="vp-notification-reachability-grid">
|
||||
{reachabilityRows.length > 0 ? reachabilityRows.slice(0, 6).map((row) => (
|
||||
<div key={row.owner} className="vp-notification-reachability-item">
|
||||
<Space wrap>
|
||||
<Tag color={row.statusColor}>{row.statusLabel}</Tag>
|
||||
<Tag color={row.p0IssueCount > 0 ? 'red' : 'blue'}>{row.owner}</Tag>
|
||||
</Space>
|
||||
<strong>{row.issueCount.toLocaleString()} 条待通知</strong>
|
||||
<Typography.Text type="secondary">
|
||||
对象:{row.target};渠道:{row.channel};升级:{row.escalation}
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
<Tag color={row.evidenceComplete === row.issueCount ? 'green' : 'orange'}>证据 {row.evidenceComplete}/{row.issueCount}</Tag>
|
||||
<Tag color={row.acceptance === '-' ? 'orange' : 'green'}>{row.acceptance === '-' ? '缺验收' : '有验收'}</Tag>
|
||||
<Tag color={row.p0IssueCount > 0 ? 'red' : 'grey'}>P0 {row.p0IssueCount}</Tag>
|
||||
</Space>
|
||||
<Typography.Text>{row.nextAction}</Typography.Text>
|
||||
</div>
|
||||
)) : (
|
||||
<Tag color="green">暂无待检查通知责任方</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
bordered
|
||||
loading={loading}
|
||||
|
||||
Reference in New Issue
Block a user