311 lines
15 KiB
TypeScript
311 lines
15 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 { 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>
|
||
);
|
||
}
|