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

311 lines
15 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 { 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 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);
}
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 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 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];
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="复制待通知汇总" disabled={priorityIssues.length === 0} icon={<IconCopy />} onClick={() => copyText(priorityIssueDigest(plan, 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
title={<Space><span></span><Button size="small" aria-label="复制通知规则Runbook" icon={<IconCopy />} onClick={() => copyText(notificationRulesRunbook(plan, release), '通知规则Runbook')}>Runbook</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>
);
}