Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx

1318 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Button, Card, Space, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
import { IconCopy } from '@douyinfe/semi-icons';
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { OpsHealth, QualityAlertRule, QualityNotificationPlan, QualityNotificationPolicy, QualityPriorityIssue } from '../api/types';
import { PageHeader } from '../components/PageHeader';
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
import { qualityIssueLabel } from '../domain/qualityIssue';
function formatEscalationMinutes(minutes?: number) {
if (!Number.isFinite(minutes) || Number(minutes) <= 0) return '-';
const value = Number(minutes);
if (value < 60) return `${value} 分钟升级`;
const hours = Math.floor(value / 60);
const rest = value % 60;
return rest > 0 ? `${hours} 小时 ${rest} 分钟升级` : `${hours} 小时升级`;
}
function formatDurationFromMinutes(minutes?: number | null) {
if (minutes == null || !Number.isFinite(minutes)) return '-';
const value = Math.max(0, Math.round(minutes));
if (value < 60) return `${value.toLocaleString()} 分钟`;
const hours = Math.floor(value / 60);
const rest = value % 60;
return rest > 0 ? `${hours.toLocaleString()} 小时 ${rest} 分钟` : `${hours.toLocaleString()} 小时`;
}
function ruleColor(rule: QualityAlertRule): 'green' | 'orange' | 'red' | 'grey' {
if ((rule.count ?? 0) <= 0) return 'green';
if (rule.level === 'P0') return 'red';
if (rule.level === 'P1') return 'orange';
return 'grey';
}
function ruleTitle(rule: QualityAlertRule) {
return rule.title || qualityIssueLabel(rule.issueType);
}
type NotificationExportRow = {
category: string;
name: string;
level: string;
ownerOrTarget: string;
triggerOrCondition: string;
notifyOrChannel: string;
slaOrEscalation: string;
acceptance: string;
count: number | string;
release: string;
};
type SlaEscalationRow = {
issue: QualityPriorityIssue;
policy?: QualityNotificationPolicy;
elapsedMinutes?: number;
remainingMinutes?: number;
status: 'overdue' | 'due_soon' | 'tracking' | 'unknown';
statusLabel: string;
statusColor: 'red' | 'orange' | 'green' | 'grey';
};
type NotificationExecutionRow = {
key: string;
issue: QualityPriorityIssue;
rule?: QualityAlertRule;
policy?: QualityNotificationPolicy;
slaRow: SlaEscalationRow;
evidenceText: string;
evidenceColor: 'green' | 'orange';
};
type NotificationCoverageOwnerRow = {
owner: string;
activeRules: number;
p0Rules: number;
hitCount: number;
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 },
{ title: '级别', value: (row) => row.level },
{ title: '责任方', value: (row) => row.ownerOrTarget },
{ title: '触发条件', value: (row) => row.triggerOrCondition },
{ title: '通知方式', value: (row) => row.notifyOrChannel },
{ title: 'SLA/升级', value: (row) => row.slaOrEscalation },
{ title: '验收口径', value: (row) => row.acceptance },
{ title: '当前命中', value: (row) => row.count },
{ title: '运行版本', value: (row) => row.release }
];
function parseIssueTime(value?: string) {
const normalized = String(value ?? '').trim().replace(' ', 'T');
if (!normalized) return undefined;
const ms = Date.parse(normalized);
return Number.isFinite(ms) ? ms : undefined;
}
function policyForPriority(issue: QualityPriorityIssue, policies: QualityNotificationPolicy[]) {
const prefix = issue.priority === 'P0' ? 'P0' : 'P1';
return policies.find((policy) => policy.name.startsWith(prefix)) ?? policies[0];
}
function slaEscalationRows(issues: QualityPriorityIssue[], policies: QualityNotificationPolicy[], nowMs = Date.now()): SlaEscalationRow[] {
return issues.map<SlaEscalationRow>((issue) => {
const policy = policyForPriority(issue, policies);
const lastSeenMs = parseIssueTime(issue.lastSeen);
if (lastSeenMs == null || !policy?.escalationMinutes) {
return {
issue,
policy,
status: 'unknown',
statusLabel: '待确认时间',
statusColor: 'grey'
};
}
const elapsedMinutes = Math.max(0, (nowMs - lastSeenMs) / 60000);
const remainingMinutes = policy.escalationMinutes - elapsedMinutes;
if (remainingMinutes <= 0) {
return {
issue,
policy,
elapsedMinutes,
remainingMinutes,
status: 'overdue',
statusLabel: `已超时 ${formatDurationFromMinutes(Math.abs(remainingMinutes))}`,
statusColor: 'red'
};
}
if (remainingMinutes <= 15) {
return {
issue,
policy,
elapsedMinutes,
remainingMinutes,
status: 'due_soon',
statusLabel: `${formatDurationFromMinutes(remainingMinutes)} 后升级`,
statusColor: 'orange'
};
}
return {
issue,
policy,
elapsedMinutes,
remainingMinutes,
status: 'tracking',
statusLabel: `${formatDurationFromMinutes(remainingMinutes)} 后升级`,
statusColor: 'green'
};
}).sort((left, right) => {
const statusWeight: Record<SlaEscalationRow['status'], number> = { overdue: 3, due_soon: 2, tracking: 1, unknown: 0 };
return statusWeight[right.status] - statusWeight[left.status]
|| (left.remainingMinutes ?? Number.POSITIVE_INFINITY) - (right.remainingMinutes ?? Number.POSITIVE_INFINITY)
|| left.issue.vehicleLabel.localeCompare(right.issue.vehicleLabel);
});
}
function slaEscalationReport(rows: SlaEscalationRow[], release?: string) {
const overdue = rows.filter((row) => row.status === 'overdue').length;
const dueSoon = rows.filter((row) => row.status === 'due_soon').length;
const tracking = rows.filter((row) => row.status === 'tracking').length;
const next = rows[0];
return [
'【告警SLA升级报告】',
`运行版本:${release?.trim() || '-'}`,
`升级态势:已超时 ${overdue.toLocaleString()} / 即将升级 ${dueSoon.toLocaleString()} / 正常跟进 ${tracking.toLocaleString()}`,
`下一优先:${next ? `[${next.issue.priority}] ${next.issue.vehicleLabel} / ${next.statusLabel}` : '暂无待通知告警'}`,
'',
'升级队列:',
...(rows.length > 0 ? rows.slice(0, 10).map((row, index) => [
`${index + 1}. [${row.issue.priority}] ${row.issue.vehicleLabel}`,
` 状态:${row.statusLabel}`,
` 策略:${row.policy ? `${row.policy.name} / ${row.policy.target} / ${formatEscalationMinutes(row.policy.escalationMinutes)}` : '-'}`,
` 已等待:${formatDurationFromMinutes(row.elapsedMinutes)}`,
` 问题:${qualityIssueLabel(row.issue.issueType)} / ${row.issue.protocol}`,
` 建议动作:${row.issue.actionLabel}`,
` 车辆服务:${window.location.origin}${window.location.pathname}${row.issue.vehicleHash}`,
` 原始记录:${window.location.origin}${window.location.pathname}${row.issue.rawHash}`
].join('\n')) : ['暂无待升级告警'])
].join('\n');
}
function notificationExecutionRows(
issues: QualityPriorityIssue[],
rules: QualityAlertRule[],
policies: QualityNotificationPolicy[],
nowMs = Date.now()
): NotificationExecutionRow[] {
const slaByIssue = new Map(slaEscalationRows(issues, policies, nowMs).map((row) => [
`${row.issue.priority}-${row.issue.protocol}-${row.issue.issueType}-${row.issue.vehicleLabel}-${row.issue.lastSeen}`,
row
]));
return issues.map((issue) => {
const key = `${issue.priority}-${issue.protocol}-${issue.issueType}-${issue.vehicleLabel}-${issue.lastSeen}`;
const rule = rules.find((item) => item.issueType === issue.issueType);
const policy = policyForPriority(issue, policies);
const slaRow = slaByIssue.get(key) ?? {
issue,
policy,
status: 'unknown',
statusLabel: '待确认时间',
statusColor: 'grey'
} as SlaEscalationRow;
return {
key,
issue,
rule,
policy,
slaRow,
evidenceText: evidenceSummary(issue),
evidenceColor: evidenceColor(issue)
};
}).sort((left, right) => {
const priorityWeight = (value: string) => value === 'P0' ? 2 : value === 'P1' ? 1 : 0;
const statusWeight: Record<SlaEscalationRow['status'], number> = { overdue: 3, due_soon: 2, tracking: 1, unknown: 0 };
return priorityWeight(right.issue.priority) - priorityWeight(left.issue.priority)
|| statusWeight[right.slaRow.status] - statusWeight[left.slaRow.status]
|| left.issue.vehicleLabel.localeCompare(right.issue.vehicleLabel);
});
}
function notificationExecutionMatrixReport(rows: NotificationExecutionRow[], release?: string) {
const p0Count = rows.filter((row) => row.issue.priority === 'P0').length;
const incompleteEvidence = rows.filter((row) => countEvidenceLinks(row.issue) < 4).length;
const overdueCount = rows.filter((row) => row.slaRow.status === 'overdue').length;
return [
'【通知执行矩阵】',
`运行版本:${release?.trim() || '-'}`,
`执行态势P0 ${p0Count.toLocaleString()} / 已超时 ${overdueCount.toLocaleString()} / 证据不完整 ${incompleteEvidence.toLocaleString()}`,
'',
...(rows.length > 0 ? rows.slice(0, 12).map((row, index) => [
`${index + 1}. [${row.issue.priority}] ${row.issue.vehicleLabel}`,
` 问题:${qualityIssueLabel(row.issue.issueType)} / ${row.issue.protocol}`,
` 规则:${row.rule ? `${ruleTitle(row.rule)} / ${row.rule.owner}` : '-'}`,
` 通知:${row.policy ? `${row.policy.target} / ${row.policy.channel}` : '-'}`,
` 升级:${row.policy ? formatEscalationMinutes(row.policy.escalationMinutes) : '-'}`,
` 状态:${row.slaRow.statusLabel}`,
` 证据:${row.evidenceText}`,
` 动作:${row.issue.actionLabel} - ${row.issue.actionDetail || '-'}`,
` 车辆服务:${window.location.origin}${window.location.pathname}${row.issue.vehicleHash}`,
` 原始记录:${window.location.origin}${window.location.pathname}${row.issue.rawHash}`
].join('\n')) : ['暂无待执行通知'])
].join('\n');
}
function policyLevel(policy: QualityNotificationPolicy) {
if (policy.name.startsWith('P0')) return 'P0';
if (policy.name.startsWith('P1')) return 'P1';
return '-';
}
function coverageOwnerRows(rules: QualityAlertRule[], policies: QualityNotificationPolicy[]): NotificationCoverageOwnerRow[] {
const rows = new Map<string, QualityAlertRule[]>();
for (const rule of rules) {
if (Number(rule.count ?? 0) <= 0) continue;
const owner = rule.owner?.trim() || '未分配责任方';
rows.set(owner, [...(rows.get(owner) ?? []), rule]);
}
return Array.from(rows.entries()).map(([owner, ownerRules]) => {
const p0Rules = ownerRules.filter((rule) => rule.level === 'P0').length;
const primaryPolicy = policies.find((policy) => policyLevel(policy) === (p0Rules > 0 ? 'P0' : 'P1')) ?? policies[0];
return {
owner,
activeRules: ownerRules.length,
p0Rules,
hitCount: ownerRules.reduce((total, rule) => total + Number(rule.count ?? 0), 0),
primaryPolicy
};
}).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,
ownerRows,
overdueCount,
dueSoonCount,
health
}: {
plan: QualityNotificationPlan | null;
release?: string;
ownerRows: NotificationCoverageOwnerRow[];
overdueCount: number;
dueSoonCount: number;
health: OpsHealth | null;
}) {
const rules = plan?.rules ?? [];
const policies = plan?.policies ?? [];
const issues = plan?.priorityIssues ?? [];
const activeRules = rules.filter((rule) => Number(rule.count ?? 0) > 0);
const p0Issues = issues.filter((issue) => issue.priority === 'P0').length;
const missingEvidence = issues.filter((issue) => countEvidenceLinks(issue) < 4).length;
const unhealthyLinks = (health?.linkHealth ?? []).filter((item) => item.status !== 'ok');
const policyChannels = Array.from(new Set(policies.map((policy) => policy.channel).filter(Boolean)));
return [
'【通知覆盖与升级风险】',
`运行版本:${release?.trim() || '-'}`,
`责任覆盖:${ownerRows.length.toLocaleString()} 个责任方,${activeRules.length.toLocaleString()} 类活跃规则`,
`告警压力:待通知 ${issues.length.toLocaleString()} / P0 ${p0Issues.toLocaleString()} / 证据不完整 ${missingEvidence.toLocaleString()}`,
`升级风险:已超时 ${overdueCount.toLocaleString()} / 即将升级 ${dueSoonCount.toLocaleString()}`,
`通知渠道:${policyChannels.length > 0 ? policyChannels.join('') : '-'}`,
`链路前置:${unhealthyLinks.length > 0 ? unhealthyLinks.map((item) => `${item.name}=${item.status}`).join('') : '正常'}`,
'',
'责任方覆盖:',
...(ownerRows.length > 0 ? ownerRows.slice(0, 8).map((row, index) => [
`${index + 1}. ${row.owner}`,
` 活跃规则:${row.activeRules.toLocaleString()}P0规则${row.p0Rules.toLocaleString()},命中:${row.hitCount.toLocaleString()}`,
` 主策略:${row.primaryPolicy ? `${row.primaryPolicy.name} / ${row.primaryPolicy.target} / ${row.primaryPolicy.channel}` : '-'}`,
` 升级窗口:${row.primaryPolicy ? formatEscalationMinutes(row.primaryPolicy.escalationMinutes) : '-'}`,
` 验收:${row.primaryPolicy?.acceptanceCriteria || '-'}`
].join('\n')) : ['暂无活跃责任方']),
'',
'管理动作:',
'1. P0 命中先确认责任方、渠道和升级窗口是否明确',
'2. 证据不完整的车辆先补齐车辆服务、实时、轨迹和原始记录链接',
'3. 超时或即将升级的告警必须同步业务责任人',
'4. 链路异常时优先恢复采集、MySQL、TDEngine、Redis、Kafka 等前置能力'
].join('\n');
}
function notificationRulesRunbook(plan: QualityNotificationPlan | null, release?: string) {
const rules = plan?.rules ?? [];
const policies = plan?.policies ?? [];
const lines = [
'【通知规则Runbook】',
`运行版本:${release?.trim() || '-'}`,
`活跃规则:${(plan?.activeRuleCount ?? rules.filter((rule) => rule.count > 0).length).toLocaleString()}`,
`P0规则${(plan?.p0RuleCount ?? rules.filter((rule) => rule.count > 0 && rule.level === 'P0').length).toLocaleString()}`,
'',
'触发规则:',
...rules.map((rule, index) => [
`${index + 1}. ${ruleTitle(rule)} / ${rule.level} / ${rule.owner}`,
` 触发:${rule.trigger}`,
` 通知:${rule.notify}`,
` SLA${rule.sla}`,
` 当前命中:${Number(rule.count ?? 0).toLocaleString()}`
].join('\n')),
'',
'通知策略:',
...policies.map((policy, index) => [
`${index + 1}. ${policy.name} / ${policy.target}`,
` 条件:${policy.condition}`,
` 渠道:${policy.channel}`,
` 升级:${formatEscalationMinutes(policy.escalationMinutes)}`,
` 验收:${policy.acceptanceCriteria || '-'}`
].join('\n'))
];
return lines.join('\n');
}
function priorityIssueDigest(plan: QualityNotificationPlan | null, release?: string) {
const issues = plan?.priorityIssues ?? [];
return [
'【当前待通知告警】',
`运行版本:${release?.trim() || '-'}`,
`待通知:${issues.length.toLocaleString()}`,
...issues.map((issue, index) => [
`${index + 1}. [${issue.priority}] ${issue.vehicleLabel}`,
` 问题:${qualityIssueLabel(issue.issueType)} / ${issue.protocol}`,
` SLA${issue.sla}`,
` 建议动作:${issue.actionLabel}`,
` 详情:${issue.detail || '-'}`,
` 车辆服务:${window.location.origin}${window.location.pathname}${issue.vehicleHash}`,
` 实时定位:${window.location.origin}${window.location.pathname}${issue.realtimeHash}`,
` 轨迹证据:${window.location.origin}${window.location.pathname}${issue.historyHash}`,
` 原始记录:${window.location.origin}${window.location.pathname}${issue.rawHash}`
].join('\n'))
].join('\n');
}
function alertEscalationChecklist(plan: QualityNotificationPlan | null, release?: string) {
const rules = plan?.rules ?? [];
const policies = plan?.policies ?? [];
const issues = plan?.priorityIssues ?? [];
const p0Issues = issues.filter((issue) => issue.priority === 'P0');
const nextIssue = issues[0];
const primaryPolicy = policies.find((policy) => policy.name.startsWith('P0')) ?? policies[0];
const activeRules = rules.filter((rule) => Number(rule.count ?? 0) > 0);
return [
'【告警升级值班清单】',
`运行版本:${release?.trim() || '-'}`,
`值班结论:${p0Issues.length > 0 ? `P0 ${p0Issues.length.toLocaleString()} 条,需要立即确认` : issues.length > 0 ? `待通知 ${issues.length.toLocaleString()} 条,按策略跟进` : '暂无待通知告警'}`,
`主通知策略:${primaryPolicy ? `${primaryPolicy.name} / ${primaryPolicy.target} / ${primaryPolicy.channel}` : '-'}`,
`升级窗口:${primaryPolicy ? formatEscalationMinutes(primaryPolicy.escalationMinutes) : '-'}`,
`验收口径:${primaryPolicy?.acceptanceCriteria || '-'}`,
'',
'活跃规则:',
...(activeRules.length > 0 ? activeRules.map((rule, index) => `${index + 1}. [${rule.level}] ${ruleTitle(rule)} / ${rule.owner} / 命中 ${Number(rule.count ?? 0).toLocaleString()} / SLA ${rule.sla}`) : ['暂无活跃规则']),
'',
'下一条处置:',
nextIssue ? [
`[${nextIssue.priority}] ${nextIssue.vehicleLabel}`,
`问题:${qualityIssueLabel(nextIssue.issueType)} / ${nextIssue.protocol}`,
`建议动作:${nextIssue.actionLabel} - ${nextIssue.actionDetail || '-'}`,
`SLA${nextIssue.sla}`,
`证据:${evidenceSummary(nextIssue)}`,
`车辆服务:${window.location.origin}${window.location.pathname}${nextIssue.vehicleHash}`,
`实时证据:${window.location.origin}${window.location.pathname}${nextIssue.realtimeHash}`,
`轨迹证据:${window.location.origin}${window.location.pathname}${nextIssue.historyHash}`,
`原始记录:${window.location.origin}${window.location.pathname}${nextIssue.rawHash}`
].join('\n') : '暂无待处置告警',
'',
'值班动作:',
'1. 先处理 P0无来源/VIN 缺失/存储不可写优先于普通字段缺失',
'2. 复制单条通知给责任团队,并同步车辆服务、实时、轨迹和原始记录',
'3. 超过升级窗口未恢复时,按主通知策略升级到业务责任人',
'4. 恢复后检查验收口径,并在告警事件页确认不再命中'
].join('\n');
}
function alertNotificationDrillPackage(plan: QualityNotificationPlan | null, health: OpsHealth | null, release?: string) {
const rules = plan?.rules ?? [];
const policies = plan?.policies ?? [];
const issues = plan?.priorityIssues ?? [];
const p0Rules = rules.filter((rule) => rule.level === 'P0');
const p1Rules = rules.filter((rule) => rule.level === 'P1');
const p0Issues = issues.filter((issue) => issue.priority === 'P0');
const firstP0Policy = policies.find((policy) => policy.name.startsWith('P0')) ?? policies[0];
const firstP1Policy = policies.find((policy) => policy.name.startsWith('P1')) ?? policies[1] ?? policies[0];
const unhealthyLinks = (health?.linkHealth ?? []).filter((item) => item.status !== 'ok');
const nextIssue = issues[0];
return [
'【告警通知演练包】',
`运行版本:${release?.trim() || '-'}`,
`链路状态:${unhealthyLinks.length > 0 ? unhealthyLinks.map((item) => `${item.name}=${item.status}`).join('') : '正常'}`,
`规则规模P0 ${p0Rules.length.toLocaleString()}P1 ${p1Rules.length.toLocaleString()} 类,待通知 ${issues.length.toLocaleString()}`,
`当前优先级:${p0Issues.length > 0 ? `P0 ${p0Issues.length.toLocaleString()}` : issues.length > 0 ? 'P1/普通告警待跟进' : '暂无待通知告警'}`,
'',
'通知策略核对:',
`P0策略${firstP0Policy ? `${firstP0Policy.name} / ${firstP0Policy.target} / ${firstP0Policy.channel} / ${formatEscalationMinutes(firstP0Policy.escalationMinutes)}` : '-'}`,
`P0验收${firstP0Policy?.acceptanceCriteria || '-'}`,
`P1策略${firstP1Policy ? `${firstP1Policy.name} / ${firstP1Policy.target} / ${firstP1Policy.channel} / ${formatEscalationMinutes(firstP1Policy.escalationMinutes)}` : '-'}`,
`P1验收${firstP1Policy?.acceptanceCriteria || '-'}`,
'',
'演练步骤:',
'1. 在告警事件页确认 P0/P1 命中数量与本页一致',
'2. 对第一条待通知告警打开车辆服务、实时、轨迹、原始记录四类证据',
'3. 复制单条告警通知给责任团队,记录发送时间',
'4. 超过升级窗口仍未恢复时,按策略升级到业务责任人',
'5. 恢复后按验收口径复核,并确认告警不再命中',
'',
'演练样例:',
nextIssue ? [
`[${nextIssue.priority}] ${nextIssue.vehicleLabel}`,
`问题:${qualityIssueLabel(nextIssue.issueType)} / ${nextIssue.protocol}`,
`SLA${nextIssue.sla}`,
`通知文本:${nextIssue.notificationText}`,
`车辆服务:${window.location.origin}${window.location.pathname}${nextIssue.vehicleHash}`,
`实时证据:${window.location.origin}${window.location.pathname}${nextIssue.realtimeHash}`,
`轨迹证据:${window.location.origin}${window.location.pathname}${nextIssue.historyHash}`,
`原始记录:${window.location.origin}${window.location.pathname}${nextIssue.rawHash}`
].join('\n') : '暂无待通知样例'
].join('\n');
}
function countEvidenceLinks(issue: QualityPriorityIssue) {
return [issue.vehicleHash, issue.realtimeHash, issue.historyHash, issue.rawHash].filter(Boolean).length;
}
function evidenceSummary(issue: QualityPriorityIssue) {
const count = countEvidenceLinks(issue);
return count >= 4 ? '证据完整' : `${4 - count} 类证据`;
}
function evidenceColor(issue: QualityPriorityIssue): 'green' | 'orange' {
return countEvidenceLinks(issue) >= 4 ? 'green' : 'orange';
}
function notificationExportRows(plan: QualityNotificationPlan | null, release = ''): NotificationExportRow[] {
const version = release.trim() || '-';
const ruleRows = (plan?.rules ?? []).map((rule) => ({
category: '触发规则',
name: ruleTitle(rule),
level: rule.level,
ownerOrTarget: rule.owner,
triggerOrCondition: rule.trigger,
notifyOrChannel: rule.notify,
slaOrEscalation: rule.sla,
acceptance: '-',
count: Number(rule.count ?? 0),
release: version
}));
const policyRows = (plan?.policies ?? []).map((policy) => ({
category: '通知策略',
name: policy.name,
level: policy.name.startsWith('P0') ? 'P0' : policy.name.startsWith('P1') ? 'P1' : '-',
ownerOrTarget: policy.target,
triggerOrCondition: policy.condition,
notifyOrChannel: policy.channel,
slaOrEscalation: formatEscalationMinutes(policy.escalationMinutes),
acceptance: policy.acceptanceCriteria || '-',
count: '-',
release: version
}));
return [...ruleRows, ...policyRows];
}
function navigateHash(hash: string) {
if (!hash) return;
window.location.hash = hash;
}
async function copyText(value: string, label: string) {
try {
await navigator.clipboard.writeText(value);
Toast.success(`已复制${label}`);
} catch {
Toast.error(`复制${label}失败`);
}
}
export function NotificationRules() {
const [plan, setPlan] = useState<QualityNotificationPlan | null>(null);
const [health, setHealth] = useState<OpsHealth | null>(null);
const [loading, setLoading] = useState(true);
const load = () => {
setLoading(true);
Promise.all([
api.alertEventNotificationPlan(new URLSearchParams({ limit: '50' })),
api.opsHealth().catch(() => null)
])
.then(([nextPlan, nextHealth]) => {
setPlan(nextPlan);
setHealth(nextHealth);
})
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoading(false));
};
useEffect(() => {
load();
}, []);
const rules = plan?.rules ?? [];
const policies = plan?.policies ?? [];
const priorityIssues = plan?.priorityIssues ?? [];
const activeRuleCount = plan?.activeRuleCount ?? rules.filter((rule) => rule.count > 0).length;
const p0RuleCount = plan?.p0RuleCount ?? rules.filter((rule) => rule.count > 0 && rule.level === 'P0').length;
const release = health?.runtime?.platformRelease ?? '';
const p0IssueCount = priorityIssues.filter((issue) => issue.priority === 'P0').length;
const evidenceCompleteCount = priorityIssues.filter((issue) => countEvidenceLinks(issue) >= 4).length;
const primaryOwner = rules.find((rule) => rule.count > 0)?.owner || policies[0]?.target || '-';
const nextPriorityIssue = priorityIssues[0];
const slaRows = slaEscalationRows(priorityIssues, policies);
const overdueCount = slaRows.filter((row) => row.status === 'overdue').length;
const dueSoonCount = slaRows.filter((row) => row.status === 'due_soon').length;
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 = [
{
step: '01',
title: '发现问题',
value: `${priorityIssues.length.toLocaleString()} 待通知`,
detail: '断链、无来源、VIN缺失、字段缺失等问题先进入车辆服务告警队列。',
action: '告警队列',
color: priorityIssues.length > 0 ? 'orange' as const : 'green' as const,
onClick: () => navigateHash('#/alert-events')
},
{
step: '02',
title: '补齐证据',
value: `${evidenceCompleteCount}/${priorityIssues.length || 0} 完整`,
detail: '通知前必须带上车辆服务、实时监控、轨迹回放和原始记录链接。',
action: '证据矩阵',
color: evidenceCompleteCount === priorityIssues.length ? 'green' as const : 'orange' as const,
onClick: () => nextPriorityIssue?.rawHash ? navigateHash(nextPriorityIssue.rawHash) : undefined
},
{
step: '03',
title: '通知责任人',
value: `${ownerRows.length.toLocaleString()} 责任方`,
detail: '规则命中后按责任团队、目标人群和通知渠道形成可复制通知。',
action: '复制通知',
color: ownerRows.length > 0 ? 'blue' as const : 'orange' as const,
onClick: () => copyText(priorityIssueDigest(plan, release), '待通知告警')
},
{
step: '04',
title: '超时升级',
value: `${overdueCount}/${dueSoonCount}`,
detail: '按P0/P1策略监控已超时和即将升级的告警避免断链无人跟进。',
action: 'SLA报告',
color: overdueCount > 0 ? 'red' as const : dueSoonCount > 0 ? 'orange' as const : 'green' as const,
onClick: () => copyText(slaEscalationReport(slaRows, release), 'SLA升级报告')
},
{
step: '05',
title: '验收恢复',
value: release || '-',
detail: '恢复后按验收口径复核,不再命中规则后关闭本次告警闭环。',
action: '运行手册',
color: 'blue' as const,
onClick: () => copyText(notificationRulesRunbook(plan, release), '通知规则Runbook')
}
];
const notificationCustomerQuestions = [
{
question: '哪些车受影响?',
value: priorityIssues.length > 0 ? `${priorityIssues.length.toLocaleString()} 辆/次` : '暂无影响',
evidence: p0IssueCount > 0 ? `P0 ${p0IssueCount.toLocaleString()}` : `${evidenceCompleteCount}/${priorityIssues.length || 0} 证据完整`,
detail: '先给客户受影响车辆、告警级别和可跳转证据,不让客户从规则配置里反推影响范围。',
action: '影响摘要',
color: p0IssueCount > 0 ? 'red' as const : priorityIssues.length > 0 ? 'orange' as const : 'green' as const,
onClick: () => copyText(priorityIssueDigest(plan, release), '待通知告警')
},
{
question: '应该通知谁?',
value: primaryOwner,
evidence: policies[0]?.channel || '待配置渠道',
detail: '按责任团队和通知策略生成可复制文本,包含车辆、原因、证据链接和下一步动作。',
action: '复制通知',
color: ownerRows.length > 0 ? 'blue' as const : 'orange' as const,
onClick: () => copyText(priorityIssueDigest(plan, release), '待通知告警')
},
{
question: '多久没有恢复会升级?',
value: nextSlaRow?.policy?.escalationMinutes ? formatEscalationMinutes(nextSlaRow.policy.escalationMinutes) : `${overdueCount}/${dueSoonCount}`,
evidence: `${overdueCount.toLocaleString()} 超时 / ${dueSoonCount.toLocaleString()} 临近`,
detail: '客户看到的是升级时限和当前风险后台再按P0/P1策略做超时升级和责任追踪。',
action: 'SLA报告',
color: overdueCount > 0 ? 'red' as const : dueSoonCount > 0 ? 'orange' as const : 'green' as const,
onClick: () => copyText(slaEscalationReport(slaRows, release), 'SLA升级报告')
},
{
question: '恢复后怎么验收?',
value: policies[0]?.acceptanceCriteria || '按车辆服务证据验收',
evidence: release || '当前版本',
detail: '恢复不是只看服务在线,而是看车辆证据、实时数据、轨迹和规则命中是否一起恢复。',
action: '验收口径',
color: 'blue' as const,
onClick: () => copyText(notificationRulesRunbook(plan, release), '通知规则Runbook')
}
];
const primaryPolicy = policies.find((policy) => policy.name.startsWith('P0')) ?? policies[0];
const notificationOrchestrationItems = [
{
label: '告警触发',
value: `${activeRuleCount.toLocaleString()} 类活跃规则`,
detail: `${p0RuleCount.toLocaleString()} 类 P0当前命中 ${totalActiveHits.toLocaleString()} 次。`,
color: p0RuleCount > 0 ? 'red' as const : activeRuleCount > 0 ? 'orange' as const : 'green' as const,
onClick: () => navigateHash('#/alert-events')
},
{
label: '受影响车辆',
value: `${priorityIssues.length.toLocaleString()} 待通知`,
detail: p0IssueCount > 0 ? `P0 ${p0IssueCount.toLocaleString()} 条,优先通知业务责任人。` : `${evidenceCompleteCount}/${priorityIssues.length || 0} 证据完整。`,
color: p0IssueCount > 0 ? 'red' as const : priorityIssues.length > 0 ? 'orange' as const : 'green' as const,
onClick: () => copyText(priorityIssueDigest(plan, release), '待通知告警')
},
{
label: '通知对象',
value: primaryPolicy?.target || primaryOwner,
detail: `渠道:${primaryPolicy?.channel || policies[0]?.channel || '待配置渠道'}`,
color: primaryPolicy?.target ? 'blue' as const : 'orange' as const,
onClick: () => copyText(priorityIssueDigest(plan, release), '待通知告警')
},
{
label: '升级时钟',
value: overdueCount > 0 ? `已超时 ${overdueCount.toLocaleString()}` : dueSoonCount > 0 ? `临近 ${dueSoonCount.toLocaleString()}` : '正常跟进',
detail: primaryPolicy ? formatEscalationMinutes(primaryPolicy.escalationMinutes) : '缺少升级策略。',
color: overdueCount > 0 ? 'red' as const : dueSoonCount > 0 ? 'orange' as const : 'green' as const,
onClick: () => copyText(slaEscalationReport(slaRows, release), 'SLA升级报告')
},
{
label: '恢复验收',
value: primaryPolicy?.acceptanceCriteria || '按车辆服务证据验收',
detail: '恢复后必须复核实时、轨迹、原始记录和规则命中状态。',
color: primaryPolicy?.acceptanceCriteria ? 'green' as const : 'orange' as const,
onClick: () => copyText(notificationRulesRunbook(plan, release), '通知规则Runbook')
}
];
const notificationOrchestrationActions = [
{
label: '查看告警',
action: '告警队列',
color: priorityIssues.length > 0 ? 'orange' as const : 'green' as const,
disabled: false,
onClick: () => navigateHash('#/alert-events')
},
{
label: '复制通知',
action: '待通知',
color: priorityIssues.length > 0 ? 'blue' as const : 'grey' as const,
disabled: priorityIssues.length === 0,
onClick: () => copyText(priorityIssueDigest(plan, release), '待通知告警')
},
{
label: '复制SLA',
action: 'SLA报告',
color: overdueCount > 0 ? 'red' as const : dueSoonCount > 0 ? 'orange' as const : 'green' as const,
disabled: slaRows.length === 0,
onClick: () => copyText(slaEscalationReport(slaRows, release), 'SLA升级报告')
},
{
label: '导出演练',
action: '演练包',
color: 'blue' as const,
disabled: false,
onClick: () => copyText(alertNotificationDrillPackage(plan, health, release), '通知演练包')
}
];
const exportNotificationRules = () => {
const rows = notificationExportRows(plan, release);
if (rows.length === 0) {
Toast.warning('当前没有可导出的通知规则');
return;
}
downloadCsv(`notification-rules-${release || 'current'}.csv`, buildCsv(notificationExportColumns, rows));
Toast.success(`已导出 ${rows.length.toLocaleString()} 条通知规则`);
};
return (
<div className="vp-page">
<PageHeader title="通知闭环" description="面向客户交付告警影响、通知对象、升级时限和恢复验收;规则配置只作为闭环背后的证据。" />
<Card bordered loading={loading} className="vp-notification-customer-board" bodyStyle={{ padding: 0 }}>
<div className="vp-notification-customer-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={p0IssueCount > 0 ? 'red' : priorityIssues.length > 0 ? 'orange' : 'green'}>
{p0IssueCount > 0 ? `P0 ${p0IssueCount.toLocaleString()}` : priorityIssues.length > 0 ? `${priorityIssues.length.toLocaleString()} 待通知` : '暂无待通知'}
</Tag>
<Tag color={overdueCount > 0 ? 'red' : dueSoonCount > 0 ? 'orange' : 'green'}>{overdueCount.toLocaleString()} / {dueSoonCount.toLocaleString()} </Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
<Space wrap>
<Button size="small" theme="solid" type="primary" onClick={() => navigateHash('#/alert-events')}></Button>
<Button size="small" theme="light" type="primary" disabled={!nextPriorityIssue?.vehicleHash} onClick={() => nextPriorityIssue?.vehicleHash && navigateHash(nextPriorityIssue.vehicleHash)}></Button>
<Button size="small" theme="light" type="warning" onClick={() => copyText(alertEscalationChecklist(plan, release), '升级值班清单')}></Button>
<Button size="small" theme="light" onClick={() => copyText(notificationCoverageReport({ plan, release, ownerRows, overdueCount, dueSoonCount, health }), '通知覆盖报告')}></Button>
</Space>
</div>
<div className="vp-notification-customer-steps">
{notificationCustomerSteps.map((item) => (
<button
key={item.step}
type="button"
className="vp-notification-customer-step"
onClick={item.onClick}
aria-label={`客户告警闭环 ${item.title} ${item.action}`}
>
<span>{item.step}</span>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<small>{item.detail}</small>
<em>{item.action}</em>
</button>
))}
</div>
</Card>
<section className="vp-notification-orchestration-strip" aria-label="客户通知编排条">
<div className="vp-notification-orchestration-copy">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={p0IssueCount > 0 || overdueCount > 0 ? 'red' : priorityIssues.length > 0 ? 'orange' : 'green'}>
{priorityIssues.length > 0 ? `${priorityIssues.length.toLocaleString()} 待通知` : '当前稳定'}
</Tag>
<Tag color={primaryPolicy?.target ? 'blue' : 'orange'}>{primaryPolicy?.channel || '待配置渠道'}</Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}>
SLA
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</div>
<div className="vp-notification-orchestration-grid">
{notificationOrchestrationItems.map((item) => (
<button
key={item.label}
type="button"
className="vp-notification-orchestration-item"
onClick={item.onClick}
aria-label={`客户通知编排 ${item.label} ${item.value}`}
>
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
</button>
))}
</div>
<div className="vp-notification-orchestration-actions">
{notificationOrchestrationActions.map((item) => (
<button
key={item.label}
type="button"
className="vp-notification-orchestration-action"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`客户通知编排动作 ${item.label} ${item.action}`}
>
<Tag color={item.color}>{item.label}</Tag>
<span>{item.action}</span>
</button>
))}
</div>
</section>
<Card bordered loading={loading} title="客户通知常问" style={{ marginBottom: 16 }}>
<div className="vp-notification-question-board">
<div className="vp-notification-question-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={priorityIssues.length > 0 ? 'orange' : 'green'}>
{priorityIssues.length > 0 ? `${priorityIssues.length.toLocaleString()} 待解释` : '当前稳定'}
</Tag>
</Space>
<Typography.Title heading={5} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</div>
<div className="vp-notification-question-grid">
{notificationCustomerQuestions.map((item) => (
<button
key={item.question}
type="button"
className="vp-notification-question-item"
onClick={item.onClick}
aria-label={`客户通知常问 ${item.question} ${item.action}`}
>
<Space wrap>
<Tag color={item.color}>{item.question}</Tag>
<Tag color="grey">{item.evidence}</Tag>
</Space>
<strong>{item.value}</strong>
<small>{item.detail}</small>
<em>{item.action}</em>
</button>
))}
</div>
</div>
</Card>
<div className="vp-kpi-grid">
<Card bordered loading={loading}>
<div className="vp-kpi-value">{activeRuleCount.toLocaleString()}</div>
<div className="vp-kpi-label"></div>
</Card>
<Card bordered loading={loading}>
<div className="vp-kpi-value">{p0RuleCount.toLocaleString()}</div>
<div className="vp-kpi-label">P0规则</div>
</Card>
<Card bordered loading={loading}>
<div className="vp-kpi-value">{policies.length.toLocaleString()}</div>
<div className="vp-kpi-label"></div>
</Card>
<Card bordered loading={loading}>
<div className="vp-kpi-value">{priorityIssues.length.toLocaleString()}</div>
<div className="vp-kpi-label"></div>
</Card>
<Card bordered loading={loading}>
<div className="vp-kpi-value">{release || '-'}</div>
<div className="vp-kpi-label"></div>
</Card>
</div>
<Card
bordered
loading={loading}
title={<Space><span></span><Button size="small" aria-label="复制通知覆盖报告" icon={<IconCopy />} onClick={() => copyText(notificationCoverageReport({ plan, release, ownerRows, overdueCount, dueSoonCount, health }), '通知覆盖报告')}></Button></Space>}
style={{ marginTop: 16 }}
>
<div className="vp-notification-coverage-board">
<div className="vp-notification-coverage-summary">
<Space wrap>
<Tag color={p0IssueCount > 0 || overdueCount > 0 ? 'red' : dueSoonCount > 0 ? 'orange' : 'green'}>
{p0IssueCount > 0 || overdueCount > 0 ? '高优先级跟进' : dueSoonCount > 0 ? '临近升级' : '覆盖稳定'}
</Tag>
<Tag color={unhealthyLinkCount > 0 ? 'orange' : 'green'}>{unhealthyLinkCount > 0 ? '链路需关注' : '链路正常'}</Tag>
</Space>
<Typography.Text strong>{ownerRows.length.toLocaleString()} </Typography.Text>
<Typography.Text type="secondary">
SLA 便
</Typography.Text>
</div>
<div className="vp-notification-coverage-grid">
{[
{ label: '责任覆盖', value: ownerRows.length.toLocaleString(), detail: `${activeRuleCount.toLocaleString()} 类活跃规则已映射责任方。`, color: ownerRows.length > 0 ? 'green' as const : 'orange' as const },
{ label: '活跃命中', value: totalActiveHits.toLocaleString(), detail: '来自当前告警规则命中数量,用于判断通知压力。', color: totalActiveHits > 0 ? 'orange' as const : 'green' as const },
{ label: 'P0压力', value: p0IssueCount.toLocaleString(), detail: `${p0RuleCount.toLocaleString()} 类 P0 规则,优先进入升级队列。`, color: p0IssueCount > 0 ? 'red' as const : 'green' as const },
{ label: '升级风险', value: `${overdueCount}/${dueSoonCount}`, detail: '已超时 / 即将升级,直接影响通知闭环时效。', color: overdueCount > 0 ? 'red' as const : dueSoonCount > 0 ? 'orange' as const : 'green' as const },
{ label: '链路前置', value: unhealthyLinkCount.toLocaleString(), detail: '采集、存储、缓存等前置链路异常会影响告警判定。', color: unhealthyLinkCount > 0 ? 'orange' as const : 'green' as const }
].map((item) => (
<div key={item.label} className="vp-notification-coverage-item">
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<Typography.Text type="secondary">{item.detail}</Typography.Text>
</div>
))}
</div>
</div>
<div className="vp-notification-owner-list">
{ownerRows.length > 0 ? ownerRows.slice(0, 6).map((row) => (
<div key={row.owner} className="vp-notification-owner-item">
<div>
<Space wrap>
<Tag color={row.p0Rules > 0 ? 'red' : 'orange'}>{row.owner}</Tag>
<Typography.Text strong> {row.hitCount.toLocaleString()}</Typography.Text>
</Space>
<Typography.Text type="secondary">
{row.activeRules.toLocaleString()} {row.p0Rules.toLocaleString()} P0
</Typography.Text>
</div>
<Space wrap>
<Tag color={row.primaryPolicy?.name.startsWith('P0') ? 'red' : 'blue'}>{row.primaryPolicy?.name || '未匹配策略'}</Tag>
<Tag color="blue">{row.primaryPolicy?.channel || '-'}</Tag>
<Tag color="grey">{row.primaryPolicy ? formatEscalationMinutes(row.primaryPolicy.escalationMinutes) : '-'}</Tag>
</Space>
</div>
)) : (
<Tag color="green"></Tag>
)}
</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}
title={<Space><span></span><Button size="small" aria-label="复制待通知汇总" disabled={priorityIssues.length === 0} icon={<IconCopy />} onClick={() => copyText(priorityIssueDigest(plan, release), '待通知告警')}></Button><Button size="small" aria-label="复制升级值班清单" icon={<IconCopy />} onClick={() => copyText(alertEscalationChecklist(plan, release), '升级值班清单')}></Button><Button size="small" aria-label="复制通知演练包" icon={<IconCopy />} onClick={() => copyText(alertNotificationDrillPackage(plan, health, release), '通知演练包')}></Button></Space>}
style={{ marginTop: 16 }}
>
<div className="vp-alert-command-board">
<div className="vp-alert-command-grid">
{[
{ label: '待通知告警', value: priorityIssues.length.toLocaleString(), color: priorityIssues.length > 0 ? 'orange' as const : 'green' as const },
{ label: 'P0优先', value: p0IssueCount.toLocaleString(), color: p0IssueCount > 0 ? 'red' as const : 'green' as const },
{ label: '证据完整', value: `${evidenceCompleteCount}/${priorityIssues.length || 0}`, color: evidenceCompleteCount === priorityIssues.length ? 'green' as const : 'orange' as const },
{ label: '主责任方', value: primaryOwner, color: 'blue' as const }
].map((item) => (
<div key={item.label} className="vp-alert-command-item">
<Tag color={item.color}>{item.label}</Tag>
<div>{item.value}</div>
</div>
))}
</div>
<div className="vp-alert-next-action">
<div className="vp-current-service-title"></div>
{nextPriorityIssue ? (
<>
<Typography.Text strong>{nextPriorityIssue.vehicleLabel}</Typography.Text>
<Typography.Text type="secondary">{nextPriorityIssue.actionDetail || nextPriorityIssue.actionLabel}</Typography.Text>
<Space wrap>
<Tag color={nextPriorityIssue.priority === 'P0' ? 'red' : 'orange'}>{nextPriorityIssue.priority}</Tag>
<Tag color={evidenceColor(nextPriorityIssue)}>{evidenceSummary(nextPriorityIssue)}</Tag>
<Tag color="blue">{nextPriorityIssue.sla}</Tag>
</Space>
<Space wrap>
<Button size="small" aria-label="下一步车辆服务" disabled={!nextPriorityIssue.vehicleHash} onClick={() => navigateHash(nextPriorityIssue.vehicleHash)}></Button>
<Button size="small" aria-label="下一步实时证据" disabled={!nextPriorityIssue.realtimeHash} onClick={() => navigateHash(nextPriorityIssue.realtimeHash)}></Button>
<Button size="small" aria-label="下一步轨迹证据" disabled={!nextPriorityIssue.historyHash} onClick={() => navigateHash(nextPriorityIssue.historyHash)}></Button>
<Button size="small" aria-label="下一步原始记录" disabled={!nextPriorityIssue.rawHash} onClick={() => navigateHash(nextPriorityIssue.rawHash)}></Button>
</Space>
</>
) : (
<Tag color="green"></Tag>
)}
</div>
</div>
</Card>
<Card
bordered
loading={loading}
title={<Space><span></span><Button size="small" aria-label="复制通知执行矩阵" icon={<IconCopy />} disabled={executionRows.length === 0} onClick={() => copyText(notificationExecutionMatrixReport(executionRows, release), '通知执行矩阵')}></Button></Space>}
style={{ marginTop: 16 }}
>
<Table<NotificationExecutionRow>
pagination={false}
dataSource={executionRows.slice(0, 10)}
rowKey={(row?: NotificationExecutionRow) => row?.key ?? ''}
columns={[
{ title: '优先级', width: 90, render: (_: unknown, row: NotificationExecutionRow) => <Tag color={row.issue.priority === 'P0' ? 'red' : 'orange'}>{row.issue.priority}</Tag> },
{ title: '车辆', width: 220, render: (_: unknown, row: NotificationExecutionRow) => row.issue.vehicleLabel },
{ title: '问题', width: 130, render: (_: unknown, row: NotificationExecutionRow) => qualityIssueLabel(row.issue.issueType) },
{ title: '触发规则', width: 180, render: (_: unknown, row: NotificationExecutionRow) => row.rule ? `${ruleTitle(row.rule)} / ${row.rule.owner}` : '-' },
{ title: '通知对象', width: 210, render: (_: unknown, row: NotificationExecutionRow) => row.policy?.target || '-' },
{ title: '渠道', width: 150, render: (_: unknown, row: NotificationExecutionRow) => row.policy?.channel || '-' },
{ title: '升级窗口', width: 120, render: (_: unknown, row: NotificationExecutionRow) => row.policy ? formatEscalationMinutes(row.policy.escalationMinutes) : '-' },
{ title: 'SLA状态', width: 150, render: (_: unknown, row: NotificationExecutionRow) => <Tag color={row.slaRow.statusColor}>{row.slaRow.statusLabel}</Tag> },
{ title: '证据', width: 110, render: (_: unknown, row: NotificationExecutionRow) => <Tag color={row.evidenceColor}>{row.evidenceText}</Tag> },
{
title: '操作',
render: (_: unknown, row: NotificationExecutionRow) => (
<Space wrap>
<Button size="small" disabled={!row.issue.vehicleHash} onClick={() => navigateHash(row.issue.vehicleHash)}></Button>
<Button size="small" disabled={!row.issue.rawHash} onClick={() => navigateHash(row.issue.rawHash)}></Button>
<Button size="small" icon={<IconCopy />} onClick={() => copyText(row.issue.notificationText, '告警通知')}></Button>
</Space>
)
}
]}
/>
</Card>
<Card
bordered
loading={loading}
title={<Space><span>SLA升级态势</span><Button size="small" aria-label="复制SLA升级报告" icon={<IconCopy />} disabled={slaRows.length === 0} onClick={() => copyText(slaEscalationReport(slaRows, release), 'SLA升级报告')}>SLA升级报告</Button></Space>}
style={{ marginTop: 16 }}
>
<div className="vp-sla-board">
<div className="vp-sla-summary">
{[
{ label: '已超时', value: overdueCount.toLocaleString(), color: overdueCount > 0 ? 'red' as const : 'green' as const },
{ label: '即将升级', value: dueSoonCount.toLocaleString(), color: dueSoonCount > 0 ? 'orange' as const : 'green' as const },
{ label: '待通知', value: slaRows.length.toLocaleString(), color: slaRows.length > 0 ? 'blue' as const : 'green' as const },
{ label: '下一优先', value: nextSlaRow?.statusLabel || '-', color: nextSlaRow?.statusColor ?? 'grey' as const }
].map((item) => (
<div key={item.label} className="vp-sla-summary-item">
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
</div>
))}
</div>
<Table<SlaEscalationRow>
pagination={false}
dataSource={slaRows.slice(0, 8)}
rowKey={(row?: SlaEscalationRow) => `${row?.issue.priority ?? ''}-${row?.issue.vehicleLabel ?? ''}-${row?.issue.lastSeen ?? ''}`}
columns={[
{ title: '状态', width: 150, render: (_: unknown, row: SlaEscalationRow) => <Tag color={row.statusColor}>{row.statusLabel}</Tag> },
{ title: '车辆', width: 220, render: (_: unknown, row: SlaEscalationRow) => row.issue.vehicleLabel },
{ title: '优先级', width: 90, render: (_: unknown, row: SlaEscalationRow) => <Tag color={row.issue.priority === 'P0' ? 'red' : 'orange'}>{row.issue.priority}</Tag> },
{ title: '策略', width: 210, render: (_: unknown, row: SlaEscalationRow) => row.policy?.name || '-' },
{ title: '已等待', width: 120, render: (_: unknown, row: SlaEscalationRow) => formatDurationFromMinutes(row.elapsedMinutes) },
{ title: '责任方', width: 190, render: (_: unknown, row: SlaEscalationRow) => row.policy?.target || '-' },
{
title: '处置',
render: (_: unknown, row: SlaEscalationRow) => (
<Space wrap>
<Button size="small" disabled={!row.issue.vehicleHash} onClick={() => navigateHash(row.issue.vehicleHash)}></Button>
<Button size="small" disabled={!row.issue.rawHash} onClick={() => navigateHash(row.issue.rawHash)}></Button>
<Button size="small" icon={<IconCopy />} onClick={() => copyText(row.issue.notificationText, '告警通知')}></Button>
</Space>
)
}
]}
/>
</div>
</Card>
<Card
bordered
title={<Space><span></span><Button size="small" aria-label="复制通知规则Runbook" icon={<IconCopy />} onClick={() => copyText(notificationRulesRunbook(plan, release), '通知规则Runbook')}>Runbook</Button><Button size="small" aria-label="导出通知规则CSV" onClick={exportNotificationRules}> CSV</Button><Button size="small" loading={loading} onClick={load}></Button></Space>}
>
<div className="vp-alert-flow">
{[
{ label: '触发', value: `${activeRuleCount} 类规则`, detail: '规则从车辆服务质量、来源断链、字段缺失和容量风险中生成。' },
{ label: '通知', value: `${policies.length} 套策略`, detail: '策略定义目标人群、渠道、升级窗口和验收口径。' },
{ label: '闭环', value: `${p0RuleCount} 类 P0`, detail: 'P0 问题必须有证据链接、恢复时间和验收结果。' }
].map((item) => (
<div key={item.label} className="vp-alert-flow-item">
<Tag color="blue">{item.label}</Tag>
<div className="vp-alert-flow-value">{item.value}</div>
<div>{item.detail}</div>
</div>
))}
</div>
</Card>
<Card bordered title="触发规则" loading={loading} style={{ marginTop: 16 }}>
<Table<QualityAlertRule>
pagination={false}
dataSource={rules}
rowKey="issueType"
columns={[
{ title: '规则', width: 180, render: (_: unknown, row: QualityAlertRule) => ruleTitle(row) },
{ title: '级别', width: 90, render: (_: unknown, row: QualityAlertRule) => <Tag color={ruleColor(row)}>{row.level}</Tag> },
{ title: '责任团队', width: 130, dataIndex: 'owner' },
{ title: '当前命中', width: 110, render: (_: unknown, row: QualityAlertRule) => Number(row.count ?? 0).toLocaleString() },
{ title: '触发条件', dataIndex: 'trigger' },
{ title: '通知动作', dataIndex: 'notify' },
{ title: 'SLA', width: 120, dataIndex: 'sla' }
]}
/>
</Card>
<Card
bordered
loading={loading}
title="当前待通知告警"
style={{ marginTop: 16 }}
>
<Table<QualityPriorityIssue>
pagination={false}
dataSource={priorityIssues}
rowKey={(row?: QualityPriorityIssue) => `${row?.priority ?? ''}-${row?.protocol ?? ''}-${row?.issueType ?? ''}-${row?.vehicleLabel ?? ''}-${row?.lastSeen ?? ''}`}
columns={[
{ title: '优先级', width: 90, render: (_: unknown, row: QualityPriorityIssue) => <Tag color={row.priority === 'P0' ? 'red' : 'orange'}>{row.priority}</Tag> },
{ title: '车辆', width: 210, dataIndex: 'vehicleLabel' },
{ title: '问题', width: 130, render: (_: unknown, row: QualityPriorityIssue) => qualityIssueLabel(row.issueType) },
{ title: 'SLA', width: 120, dataIndex: 'sla' },
{ title: '证据完整度', width: 120, render: (_: unknown, row: QualityPriorityIssue) => <Tag color={evidenceColor(row)}>{evidenceSummary(row)}</Tag> },
{ title: '建议动作', width: 160, dataIndex: 'actionLabel' },
{ title: '最后时间', width: 170, dataIndex: 'lastSeen' },
{
title: '说明',
render: (_: unknown, row: QualityPriorityIssue) => (
<Typography.Text ellipsis={{ showTooltip: true }}>{row.detail || row.actionDetail || '-'}</Typography.Text>
)
},
{
title: '证据',
width: 360,
render: (_: unknown, row: QualityPriorityIssue) => (
<Space spacing={4} wrap>
<Button size="small" disabled={!row.vehicleHash} onClick={() => navigateHash(row.vehicleHash)}></Button>
<Button size="small" disabled={!row.realtimeHash} onClick={() => navigateHash(row.realtimeHash)}></Button>
<Button size="small" disabled={!row.historyHash} onClick={() => navigateHash(row.historyHash)}></Button>
<Button size="small" disabled={!row.rawHash} onClick={() => navigateHash(row.rawHash)}></Button>
<Button size="small" aria-label="复制单条告警通知" icon={<IconCopy />} onClick={() => copyText(row.notificationText, '告警通知')}></Button>
</Space>
)
}
]}
/>
</Card>
<Card bordered title="通知策略" loading={loading} style={{ marginTop: 16 }}>
<div className="vp-notification-policy-list">
{policies.map((policy: QualityNotificationPolicy) => (
<div key={policy.name} className="vp-notification-policy">
<div>
<Space>
<Tag color={policy.name.startsWith('P0') ? 'red' : policy.name.startsWith('P1') ? 'orange' : 'grey'}>{policy.name}</Tag>
<strong>{policy.target}</strong>
</Space>
<div className="vp-alert-policy-detail">{policy.condition}</div>
<div className="vp-alert-policy-detail">{policy.acceptanceCriteria || '-'}</div>
</div>
<Space wrap>
<Tag color="red">{formatEscalationMinutes(policy.escalationMinutes)}</Tag>
<Tag color="blue">{policy.channel}</Tag>
</Space>
</div>
))}
</div>
</Card>
</div>
);
}