feat(platform): add notification reachability checks

This commit is contained in:
lingniu
2026-07-05 14:31:32 +08:00
parent 109ba09934
commit ee257b4aa1
3 changed files with 229 additions and 0 deletions

View File

@@ -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}