1318 lines
68 KiB
TypeScript
1318 lines
68 KiB
TypeScript
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>
|
||
);
|
||
}
|