feat(platform): add customer alert decision panel

This commit is contained in:
lingniu
2026-07-05 04:02:43 +08:00
parent 06d80f8147
commit 348c51398e
3 changed files with 253 additions and 0 deletions

View File

@@ -658,6 +658,66 @@ function alertBusinessImpactText({
].join('\n'); ].join('\n');
} }
function alertCustomerDecisionText({
summary,
rules,
priorityRows,
health,
filters,
platformRelease
}: {
summary: QualitySummary;
rules: AlertRuleRow[];
priorityRows: PriorityIssueRow[];
health: OpsHealth | null;
filters: Record<string, string>;
platformRelease?: string;
}) {
const release = platformRelease?.trim();
const activeRules = rules.filter((row) => row.count > 0);
const p0Rows = priorityRows.filter((row) => row.priority === 'P0');
const p1Rows = priorityRows.filter((row) => row.priority === 'P1');
const primaryProtocol = summary.protocols?.[0];
const primaryIssue = summary.issueTypes?.[0];
const storageOk = !health || (health.tdengineWritable && health.mysqlWritable);
const capacityCount = Number(health?.capacityFindings?.length ?? 0);
const decision = !storageOk || p0Rows.length > 0 || activeRules.some((row) => row.level === 'P0') || Number(summary.errorCount ?? 0) > 0
? '立即处置'
: Number(summary.issueVehicleCount ?? 0) > 0 || capacityCount > 0
? '当日跟进'
: '持续观察';
const focusIssue = priorityRows[0];
const focusLookup = focusIssue ? qualityIssueVehicleLookup(focusIssue) : undefined;
const filterLines = [
filters.keyword ? `关键词=${filters.keyword}` : '',
filters.protocol ? `数据来源=${qualityProtocolLabel(filters.protocol)}` : '',
filters.issueType ? `问题类型=${qualityIssueLabel(filters.issueType)}` : ''
].filter(Boolean);
return [
'【客户告警决策说明】',
...(release ? [`运行版本:${release}`] : []),
`决策结论:${decision}`,
`筛选范围:${filterLines.length > 0 ? filterLines.join('') : '全部告警'}`,
`影响车辆:${Number(summary.issueVehicleCount ?? 0).toLocaleString()} 辆;问题记录:${Number(summary.issueRecordCount ?? 0).toLocaleString()}`,
`优先队列P0 ${p0Rows.length.toLocaleString()} / P1 ${p1Rows.length.toLocaleString()}`,
`主要来源:${primaryProtocol ? `${qualityProtocolLabel(primaryProtocol.name)} ${primaryProtocol.count.toLocaleString()}` : '-'}`,
`主要问题:${primaryIssue ? `${qualityIssueLabel(primaryIssue.name)} ${primaryIssue.count.toLocaleString()}` : '-'}`,
`链路状态Kafka Lag ${formatLag(health?.kafkaLag)}Redis 在线 ${formatLag(health?.redisOnlineKeys)};存储${storageOk ? '可写' : '异常'};容量发现 ${capacityCount.toLocaleString()}`,
`焦点车辆:${focusIssue ? `${focusIssue.vehicleLabel} / ${qualityProtocolLabel(focusIssue.protocol)} / ${qualityIssueLabel(focusIssue.issueType)}` : '-'}`,
'',
'客户处置路径:',
'1. 先确认影响车辆和 P0/P1 数量。',
'2. P0 立即通知责任团队,超过 SLA 自动升级。',
'3. 每辆焦点车都要打开实时、轨迹、RAW、里程证据闭环。',
'4. 恢复后确认告警不再命中,并保留交接包。',
'',
`告警事件:${qualityShareURL()}`,
`实时监控:${appURL(buildAppHash({ page: 'realtime', keyword: focusLookup?.key || filters.keyword || '', protocol: focusIssue?.protocol || filters.protocol }))}`,
`轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: focusLookup?.key || filters.keyword || '', protocol: focusIssue?.protocol || filters.protocol }))}`,
`通知规则:${appURL(buildAppHash({ page: 'notification-rules' }))}`
].join('\n');
}
async function copyText(value: string, label: string) { async function copyText(value: string, label: string) {
const text = value.trim(); const text = value.trim();
if (!text) { if (!text) {
@@ -733,6 +793,11 @@ export function Quality({
: issueVehicleCount > 0 || capacityFindingCount > 0 : issueVehicleCount > 0 || capacityFindingCount > 0
? { label: '需关注', color: 'orange' as const, detail: '纳入当日治理,持续观察来源覆盖和字段完整性。' } ? { label: '需关注', color: 'orange' as const, detail: '纳入当日治理,持续观察来源覆盖和字段完整性。' }
: { label: '正常', color: 'green' as const, detail: '当前没有明显业务影响,保持监控。' }; : { label: '正常', color: 'green' as const, detail: '当前没有明显业务影响,保持监控。' };
const customerDecision = impactSeverity.label === '高风险'
? { label: '立即处置', color: 'red' as const, detail: '优先确认客户车辆服务是否受影响,并立即触发通知闭环。' }
: impactSeverity.label === '需关注'
? { label: '当日跟进', color: 'orange' as const, detail: '纳入当日数据治理,持续观察来源恢复和车辆证据。' }
: { label: '持续观察', color: 'green' as const, detail: '当前没有客户侧高优先级影响,保持常规监控。' };
const primaryProtocolImpact = summaryProtocols[0]; const primaryProtocolImpact = summaryProtocols[0];
const primaryIssueImpact = summaryIssueTypes[0]; const primaryIssueImpact = summaryIssueTypes[0];
const impactCards = [ const impactCards = [
@@ -894,10 +959,104 @@ export function Quality({
platformRelease: health?.runtime?.platformRelease platformRelease: health?.runtime?.platformRelease
}), '业务影响报告'); }), '业务影响报告');
}; };
const copyAlertCustomerDecision = () => {
copyText(alertCustomerDecisionText({
summary: notificationPlan?.summary ?? summary,
rules,
priorityRows,
health,
filters,
platformRelease: health?.runtime?.platformRelease
}), '客户告警决策说明');
};
return ( return (
<div className="vp-page"> <div className="vp-page">
<PageHeader title="告警事件" description="围绕车辆服务沉淀断链、VIN 缺失、字段缺失和链路健康事件,并形成通知闭环" /> <PageHeader title="告警事件" description="围绕车辆服务沉淀断链、VIN 缺失、字段缺失和链路健康事件,并形成通知闭环" />
<Card bordered title="客户告警决策台">
<div className="vp-alert-decision-board">
<div className="vp-alert-decision-summary">
<Space wrap>
<Tag color={customerDecision.color}>{customerDecision.label}</Tag>
<Tag color={priorityP0Count > 0 ? 'red' : 'green'}>P0 {priorityP0Count.toLocaleString()}</Tag>
<Tag color={storageWritable ? 'green' : 'red'}>{storageWritable ? '存储可写' : '存储异常'}</Tag>
</Space>
<strong></strong>
<span>
//
</span>
<Space wrap>
<Button size="small" theme="solid" type="primary" onClick={copyAlertCustomerDecision}></Button>
<Button size="small" disabled={priorityRows.length === 0} onClick={copyPriorityDigest}></Button>
{onOpenNotificationRules ? <Button size="small" onClick={onOpenNotificationRules}></Button> : null}
</Space>
</div>
<div className="vp-alert-decision-grid">
{[
{
label: '影响结论',
value: customerDecision.label,
detail: customerDecision.detail,
color: customerDecision.color,
action: '复制决策',
disabled: false,
onClick: copyAlertCustomerDecision
},
{
label: '待通知车辆',
value: `P0 ${priorityP0Count.toLocaleString()} / P1 ${priorityP1Count.toLocaleString()}`,
detail: priorityRows.length > 0 ? '已按车辆生成通知、证据和交接包。' : '当前没有进入优先队列的车辆。',
color: priorityP0Count > 0 ? 'red' as const : priorityP1Count > 0 ? 'orange' as const : 'green' as const,
action: '通知汇总',
disabled: priorityRows.length === 0,
onClick: copyPriorityDigest
},
{
label: '焦点车辆',
value: focusIssue?.vehicleLabel || '暂无',
detail: focusIssue ? `${qualityProtocolLabel(focusIssue.protocol)} / ${qualityIssueLabel(focusIssue.issueType)} / ${focusIssue.sla}` : '没有待处置车辆时保持监控。',
color: focusIssue ? 'blue' as const : 'green' as const,
action: '车辆服务',
disabled: !focusIssue || !focusLookup?.key,
onClick: () => focusIssue && onOpenVehicle(focusLookup?.key ?? '', focusIssue.protocol)
},
{
label: '主要问题',
value: primaryIssueImpact ? qualityIssueLabel(primaryIssueImpact.name) : '-',
detail: primaryIssueImpact ? `${primaryIssueImpact.count.toLocaleString()} 条命中,点击筛选该问题。` : '暂无客户侧问题类型。',
color: primaryIssueImpact ? 'orange' as const : 'green' as const,
action: '筛选问题',
disabled: !primaryIssueImpact,
onClick: () => primaryIssueImpact && drillIssueType(primaryIssueImpact.name)
},
{
label: '证据闭环',
value: '实时/轨迹/RAW/里程',
detail: '所有通知都必须回到车辆证据,不只停留在来源或链路指标。',
color: 'blue' as const,
action: '交接包',
disabled: false,
onClick: copyNotificationHandoff
}
].map((item) => (
<button
key={item.label}
className="vp-alert-decision-item"
type="button"
disabled={item.disabled}
onClick={item.onClick}
>
<div>
<Tag color={item.color}>{item.label}</Tag>
<Tag color="grey">{item.action}</Tag>
</div>
<strong>{item.value}</strong>
<span>{item.detail}</span>
</button>
))}
</div>
</div>
</Card>
<Card bordered title="告警事件与通知中心"> <Card bordered title="告警事件与通知中心">
<div className="vp-alert-center-board"> <div className="vp-alert-center-board">
<div className="vp-alert-center-summary"> <div className="vp-alert-center-summary">

View File

@@ -1925,6 +1925,87 @@ button.vp-realtime-command-item:focus-visible {
gap: 12px; gap: 12px;
} }
.vp-alert-decision-board {
display: grid;
grid-template-columns: minmax(280px, 0.74fr) minmax(0, 1.26fr);
gap: 16px;
}
.vp-alert-decision-summary {
min-height: 176px;
padding: 12px;
border: 1px solid rgba(22, 100, 255, 0.28);
border-radius: var(--vp-radius);
background: #f5f9ff;
display: grid;
gap: 10px;
align-content: start;
}
.vp-alert-decision-summary strong {
color: var(--vp-text);
font-size: 20px;
line-height: 26px;
word-break: break-word;
}
.vp-alert-decision-summary span {
color: var(--vp-text-muted);
font-size: 13px;
line-height: 20px;
}
.vp-alert-decision-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.vp-alert-decision-item {
min-height: 176px;
padding: 12px;
border: 1px solid var(--vp-border);
border-radius: var(--vp-radius);
background: #fbfcff;
display: grid;
gap: 10px;
align-content: start;
text-align: left;
font: inherit;
cursor: pointer;
}
.vp-alert-decision-item:disabled {
cursor: not-allowed;
opacity: 0.68;
}
.vp-alert-decision-item:not(:disabled):hover,
.vp-alert-decision-item:not(:disabled):focus-visible {
border-color: rgba(22, 100, 255, 0.42);
background: #f5f9ff;
outline: none;
}
.vp-alert-decision-item > div {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.vp-alert-decision-item strong {
color: var(--vp-text);
font-size: 20px;
line-height: 26px;
word-break: break-word;
}
.vp-alert-decision-item span {
color: var(--vp-text-muted);
font-size: 13px;
line-height: 20px;
}
.vp-alert-center-board { .vp-alert-center-board {
display: grid; display: grid;
grid-template-columns: minmax(280px, 0.7fr) minmax(0, 1.3fr); grid-template-columns: minmax(280px, 0.7fr) minmax(0, 1.3fr);
@@ -4414,6 +4495,8 @@ button.vp-realtime-command-item:focus-visible {
.vp-conclusion-grid, .vp-conclusion-grid,
.vp-monitor-layout, .vp-monitor-layout,
.vp-alert-flow, .vp-alert-flow,
.vp-alert-decision-board,
.vp-alert-decision-grid,
.vp-alert-impact-board, .vp-alert-impact-board,
.vp-alert-impact-grid, .vp-alert-impact-grid,
.vp-alert-dispatch-grid, .vp-alert-dispatch-grid,

View File

@@ -3639,6 +3639,17 @@ test('shows alert rule and notification policy workspace on quality page', async
render(<App />); render(<App />);
expect(await screen.findByText('客户告警决策台')).toBeInTheDocument();
expect(screen.getByText('先判断影响车辆,再通知责任团队并回到车辆证据闭环')).toBeInTheDocument();
expect(screen.getByText('客户关心的是哪些车辆受影响、是否影响实时/轨迹/里程、是否需要通知、何时恢复;协议来源只作为证据辅助判断。')).toBeInTheDocument();
expect(screen.getAllByText('立即处置').length).toBeGreaterThan(0);
expect(screen.getByText('待通知车辆')).toBeInTheDocument();
expect(screen.getByText('证据闭环')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '复制告警决策' }));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【客户告警决策说明】'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('决策结论:立即处置'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('影响车辆3 辆问题记录5 条'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('客户处置路径:'));
expect(await screen.findByText('告警事件与通知中心')).toBeInTheDocument(); expect(await screen.findByText('告警事件与通知中心')).toBeInTheDocument();
expect(screen.getByText('客户处置先判断影响车辆和优先级再通知责任团队并用实时、轨迹、RAW、里程证据闭环。', { exact: false })).toBeInTheDocument(); expect(screen.getByText('客户处置先判断影响车辆和优先级再通知责任团队并用实时、轨迹、RAW、里程证据闭环。', { exact: false })).toBeInTheDocument();
expect(screen.getByText('链路状态')).toBeInTheDocument(); expect(screen.getByText('链路状态')).toBeInTheDocument();