Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx
2026-07-04 22:49:49 +08:00

874 lines
45 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;
};
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}`,
` RAW证据${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}`,
` RAW证据${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 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. 证据不完整的车辆先补齐车辆服务、实时、轨迹、RAW 链接',
'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}`,
` RAW证据${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}`,
`RAW证据${window.location.origin}${window.location.pathname}${nextIssue.rawHash}`
].join('\n') : '暂无待处置告警',
'',
'值班动作:',
'1. 先处理 P0无来源/VIN 缺失/存储不可写优先于普通字段缺失',
'2. 复制单条通知给责任团队并同步车辆服务、实时、轨迹、RAW 证据',
'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. 对第一条待通知告警打开车辆服务、实时、轨迹、RAW 四类证据',
'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}`,
`RAW证据${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 totalActiveHits = ownerRows.reduce((total, row) => total + row.hitCount, 0);
const unhealthyLinkCount = (health?.linkHealth ?? []).filter((item) => item.status !== 'ok').length;
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="集中管理告警触发条件、通知对象、升级时间和验收标准,让断链与数据质量问题可追踪、可通知、可闭环" />
<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="复制待通知汇总" 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="下一步RAW证据" disabled={!nextPriorityIssue.rawHash} onClick={() => navigateHash(nextPriorityIssue.rawHash)}>RAW证据</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)}>RAW证据</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)}>RAW证据</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)}>RAW</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>
);
}