feat(platform): add notification rules workspace
This commit is contained in:
@@ -7,6 +7,7 @@ import { AppShell, type PageKey } from './layout/AppShell';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { History } from './pages/History';
|
||||
import { Mileage } from './pages/Mileage';
|
||||
import { NotificationRules } from './pages/NotificationRules';
|
||||
import { Quality } from './pages/Quality';
|
||||
import { Realtime } from './pages/Realtime';
|
||||
import { VehicleDetail } from './pages/VehicleDetail';
|
||||
@@ -153,6 +154,10 @@ export default function App() {
|
||||
replaceQualityHash(qualityFilters);
|
||||
return;
|
||||
}
|
||||
if (page === 'notification-rules') {
|
||||
replaceHash(page);
|
||||
return;
|
||||
}
|
||||
if (page === 'vehicles') {
|
||||
replaceVehicleHash(vehicleFilters);
|
||||
return;
|
||||
@@ -406,10 +411,11 @@ export default function App() {
|
||||
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} platformRelease={platformRelease} onOpenRealtime={openRealtimeForVehicle} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onOpenQuality={openQuality} onOpenHistoryEvidence={openHistoryWithFilters} onOpenRawEvidence={openRawWithFilters} onQueryChange={updateVehicleDetailQuery} />,
|
||||
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} initialFilters={historyFilters} onFiltersChange={updateHistoryFilters} onOpenVehicle={openVehicle} onOpenMileage={openMileageWithFilters} onOpenRaw={openRawWithFilters} />,
|
||||
mileage: <Mileage initialVin={analysisVin} initialProtocol={activeProtocol} initialFilters={mileageFilters} onFiltersChange={updateMileageFilters} onOpenVehicle={openVehicle} onOpenHistory={openHistoryWithFilters} onOpenRaw={openRawWithFilters} />,
|
||||
quality: <Quality onOpenVehicle={openVehicle} onOpenRealtime={openRealtime} onOpenHistory={openHistoryWithFilters} onOpenRaw={openRawWithFilters} onOpenMileage={openMileageWithFilters} onHealthLoaded={(health) => {
|
||||
quality: <Quality onOpenVehicle={openVehicle} onOpenRealtime={openRealtime} onOpenHistory={openHistoryWithFilters} onOpenRaw={openRawWithFilters} onOpenMileage={openMileageWithFilters} onOpenNotificationRules={() => navigatePage('notification-rules')} onHealthLoaded={(health) => {
|
||||
setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length);
|
||||
setPlatformRelease(health.runtime?.platformRelease ?? '');
|
||||
}} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />
|
||||
}} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />,
|
||||
'notification-rules': <NotificationRules />
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -48,6 +48,13 @@ describe('parseAppHash', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('parses notification rules page', () => {
|
||||
expect(parseAppHash('#/notification-rules')).toEqual({
|
||||
page: 'notification-rules',
|
||||
filters: {}
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores unknown pages', () => {
|
||||
expect(parseAppHash('#/unknown?keyword=VIN001')).toEqual({});
|
||||
});
|
||||
@@ -70,6 +77,10 @@ describe('buildAppHash', () => {
|
||||
expect(buildAppHash({ page: 'quality', keyword: '粤A', protocol: 'VEHICLE_SERVICE', filters: { issueType: 'NO_SOURCE' } })).toBe('#/quality?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE');
|
||||
});
|
||||
|
||||
test('builds notification rules page hash', () => {
|
||||
expect(buildAppHash({ page: 'notification-rules' })).toBe('#/notification-rules');
|
||||
});
|
||||
|
||||
test('builds page-only hash when keyword is empty', () => {
|
||||
expect(buildAppHash({ page: 'quality', keyword: '' })).toBe('#/quality');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PageKey } from '../layout/AppShell';
|
||||
|
||||
const pageKeys = new Set<PageKey>(['dashboard', 'vehicles', 'realtime', 'detail', 'history', 'mileage', 'quality']);
|
||||
const pageKeys = new Set<PageKey>(['dashboard', 'vehicles', 'realtime', 'detail', 'history', 'mileage', 'quality', 'notification-rules']);
|
||||
|
||||
export type AppRoute = {
|
||||
page?: PageKey;
|
||||
|
||||
@@ -7,14 +7,15 @@ import {
|
||||
IconMapPin,
|
||||
IconSearch,
|
||||
IconServer,
|
||||
IconSetting
|
||||
IconSetting,
|
||||
IconBell
|
||||
} from '@douyinfe/semi-icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { VehicleSourceConsistency, VehicleServiceStatus } from '../api/types';
|
||||
import { isAMapConfigured } from '../config/appConfig';
|
||||
|
||||
export type PageKey = 'dashboard' | 'vehicles' | 'realtime' | 'detail' | 'history' | 'mileage' | 'quality';
|
||||
export type PageKey = 'dashboard' | 'vehicles' | 'realtime' | 'detail' | 'history' | 'mileage' | 'quality' | 'notification-rules';
|
||||
|
||||
const navItems = [
|
||||
{ itemKey: 'dashboard', text: '运营驾驶舱', icon: <IconHome /> },
|
||||
@@ -23,7 +24,8 @@ const navItems = [
|
||||
{ itemKey: 'detail', text: '车辆档案', icon: <IconSetting /> },
|
||||
{ itemKey: 'history', text: '轨迹回放', icon: <IconMapPin /> },
|
||||
{ itemKey: 'mileage', text: '统计分析', icon: <IconBarChartHStroked /> },
|
||||
{ itemKey: 'quality', text: '告警事件', icon: <IconHistogram /> }
|
||||
{ itemKey: 'quality', text: '告警事件', icon: <IconHistogram /> },
|
||||
{ itemKey: 'notification-rules', text: '通知规则', icon: <IconBell /> }
|
||||
];
|
||||
|
||||
function linkHealthClassName(count: number | null) {
|
||||
@@ -116,6 +118,7 @@ export function AppShell({
|
||||
<Button size="small" aria-label="顶部轨迹回放" onClick={() => onChange('history')}>轨迹回放</Button>
|
||||
<Button size="small" aria-label="顶部历史查询" onClick={() => onChange('history')}>历史查询</Button>
|
||||
<Button size="small" aria-label="顶部统计查询" onClick={() => onChange('mileage')}>统计查询</Button>
|
||||
<Button size="small" aria-label="顶部通知规则" onClick={() => onChange('notification-rules')}>通知规则</Button>
|
||||
<Button
|
||||
size="small"
|
||||
aria-label={linkIssueCount == null ? '顶部告警事件' : linkIssueCount > 0 ? `顶部告警事件 ${linkIssueCount}项关注` : '顶部告警事件正常'}
|
||||
|
||||
177
vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx
Normal file
177
vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Button, Card, Space, Table, Tag, Toast } 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 } 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');
|
||||
}
|
||||
|
||||
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.qualityNotificationPlan(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 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 ?? '';
|
||||
|
||||
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">{release || '-'}</div>
|
||||
<div className="vp-kpi-label">运行版本</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
@@ -495,6 +495,7 @@ export function Quality({
|
||||
onOpenHistory,
|
||||
onOpenRaw,
|
||||
onOpenMileage,
|
||||
onOpenNotificationRules,
|
||||
onHealthLoaded,
|
||||
onFiltersChange,
|
||||
initialFilters = {}
|
||||
@@ -504,6 +505,7 @@ export function Quality({
|
||||
onOpenHistory?: (filters: Record<string, string>) => void;
|
||||
onOpenRaw?: (filters: Record<string, string>) => void;
|
||||
onOpenMileage?: (filters: Record<string, string>) => void;
|
||||
onOpenNotificationRules?: () => void;
|
||||
onHealthLoaded?: (health: OpsHealth) => void;
|
||||
onFiltersChange?: (filters: Record<string, string>) => void;
|
||||
initialFilters?: Record<string, string>;
|
||||
@@ -794,7 +796,7 @@ export function Quality({
|
||||
/>
|
||||
</Card>
|
||||
<div className="vp-alert-ops-grid">
|
||||
<Card bordered title="告警触发规则">
|
||||
<Card bordered title={<Space><span>告警触发规则</span>{onOpenNotificationRules ? <Button size="small" onClick={onOpenNotificationRules}>管理通知规则</Button> : null}</Space>}>
|
||||
<Table<AlertRuleRow>
|
||||
pagination={false}
|
||||
dataSource={rules}
|
||||
|
||||
@@ -3183,6 +3183,82 @@ test('uses backend quality notification plan on quality page', async () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/notification-plan?limit=20'), undefined);
|
||||
});
|
||||
|
||||
test('renders notification rules as a standalone operations page', async () => {
|
||||
window.history.replaceState(null, '', '/#/notification-rules');
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText }
|
||||
});
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path.includes('/api/ops/health')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
linkHealth: [],
|
||||
kafkaLag: 0,
|
||||
activeConnections: 1000,
|
||||
capacityFindings: [],
|
||||
redisOnlineKeys: 800,
|
||||
tdengineWritable: true,
|
||||
mysqlWritable: true,
|
||||
runtime: { requestTimeoutMs: 5000, platformRelease: 'platform-rules-test' }
|
||||
},
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/quality/notification-plan')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
summary: { issueVehicleCount: 5, issueRecordCount: 8, errorCount: 2, warningCount: 6, protocols: [], issueTypes: [{ name: 'NO_SOURCE', count: 3 }] },
|
||||
rules: [
|
||||
{ issueType: 'NO_SOURCE', title: '无来源规则', level: 'P0', owner: '平台接入', trigger: '车辆无任何来源证据', notify: '立即通知接入运维', sla: '30 分钟确认', count: 3 },
|
||||
{ issueType: 'FIELD_MISSING', title: '字段缺失规则', level: 'P1', owner: '协议解析', trigger: '核心字段缺失', notify: '进入每日治理清单', sla: '当日闭环', count: 1 }
|
||||
],
|
||||
policies: [
|
||||
{ name: 'P0 实时中断', target: '接入运维 + 业务责任人', channel: '邮件 / 企业微信', condition: '无来源、VIN 缺失、存储不可写', escalationMinutes: 30, acceptanceCriteria: '来源恢复并持续 10 分钟' },
|
||||
{ name: 'P1 数据质量', target: '协议解析 + 数据治理', channel: '每日汇总邮件', condition: '字段缺失、链路间断', escalationMinutes: 120, acceptanceCriteria: '核心字段恢复解析' }
|
||||
],
|
||||
priorityIssues: [],
|
||||
activeRuleCount: 2,
|
||||
p0RuleCount: 1
|
||||
},
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { items: [], total: 0, limit: 20, offset: 0 },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '通知规则' })).toBeInTheDocument();
|
||||
expect(screen.getByText('无来源规则')).toBeInTheDocument();
|
||||
expect(screen.getByText('车辆无任何来源证据')).toBeInTheDocument();
|
||||
expect(screen.getByText('P0 实时中断')).toBeInTheDocument();
|
||||
expect(screen.getByText('接入运维 + 业务责任人')).toBeInTheDocument();
|
||||
expect(screen.getByText('platform-rules-test')).toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/notification-plan?limit=50'), undefined);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制通知规则Runbook' }));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【通知规则Runbook】'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('无来源规则 / P0 / 平台接入'));
|
||||
});
|
||||
|
||||
test('copies notification text from quality priority queue', async () => {
|
||||
window.history.replaceState(null, '', '/#/quality');
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
|
||||
@@ -21,8 +21,9 @@ The platform navigation should use vehicle-service language rather than protocol
|
||||
5. Trajectory Replay: historical location playback with AMap polyline, playback marker, controls, and table evidence.
|
||||
6. History Query: historical locations, raw frames, and flattened parsed fields with pagination and field trimming.
|
||||
7. Alert Events: alert trigger records, affected vehicles, rule evidence, manual state, notification state, and escalation readiness.
|
||||
8. Statistics: daily/range mileage, online rate, offline duration, data completeness, and source consistency.
|
||||
9. Ops Quality: GB32960, JT808, Yutong MQTT, Kafka, NATS, Redis, TDengine, MySQL, AMap, gateway, and runtime health.
|
||||
8. Notification Rules: alert trigger rules, notification targets, channels, escalation windows, acceptance criteria, and copyable runbooks.
|
||||
9. Statistics: daily/range mileage, online rate, offline duration, data completeness, and source consistency.
|
||||
10. Ops Quality: GB32960, JT808, Yutong MQTT, Kafka, NATS, Redis, TDengine, MySQL, AMap, gateway, and runtime health.
|
||||
|
||||
See `docs/vehicle-platform-blueprint.md` for the detailed page-level blueprint and phased implementation plan.
|
||||
|
||||
@@ -83,6 +84,8 @@ Alert events are first-class product records, but notification delivery can be i
|
||||
|
||||
Alerts should cover source offline, no realtime update, location missing, mileage abnormality, raw parse failure, binding missing, and cross-source inconsistency.
|
||||
|
||||
Notification rules are managed separately from alert records. Alert Events answers "what is happening now"; Notification Rules answers "who should be notified, when to escalate, and how to verify recovery." A rule should always expose trigger condition, owner, channel, escalation window, and acceptance criteria.
|
||||
|
||||
## Statistics Scope
|
||||
|
||||
Statistics must stay vehicle-first and evidence-backed:
|
||||
|
||||
Reference in New Issue
Block a user