diff --git a/vehicle-data-platform/apps/web/src/App.tsx b/vehicle-data-platform/apps/web/src/App.tsx index fd76653a..f1229e2d 100644 --- a/vehicle-data-platform/apps/web/src/App.tsx +++ b/vehicle-data-platform/apps/web/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Toast } from '@douyinfe/semi-ui'; import { api } from './api/client'; -import type { VehicleSourceConsistency, VehicleServiceOverview, VehicleServiceStatus } from './api/types'; +import type { QualityNotificationPlan, VehicleSourceConsistency, VehicleServiceOverview, VehicleServiceStatus } from './api/types'; import { buildAppHash, parseAppHash } from './domain/appRoute'; import { AppShell, type PageKey } from './layout/AppShell'; import { Dashboard } from './pages/Dashboard'; @@ -38,6 +38,8 @@ export default function App() { initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {} ); const [linkIssueCount, setLinkIssueCount] = useState(null); + const [activeAlertRuleCount, setActiveAlertRuleCount] = useState(null); + const [p0AlertRuleCount, setP0AlertRuleCount] = useState(null); const [platformRelease, setPlatformRelease] = useState(''); const [currentVehicleStatus, setCurrentVehicleStatus] = useState(); const [currentVehicleLabel, setCurrentVehicleLabel] = useState(''); @@ -62,11 +64,29 @@ export default function App() { return { resolved, nextKey, overview }; }, []); + const applyNotificationPlanSummary = (plan?: QualityNotificationPlan | null) => { + if (!plan) { + return; + } + const activeCount = Number(plan.activeRuleCount); + const p0Count = Number(plan.p0RuleCount); + if (Number.isFinite(activeCount)) { + setActiveAlertRuleCount(activeCount); + } + if (Number.isFinite(p0Count)) { + setP0AlertRuleCount(p0Count); + } + }; + const refreshOpsHealth = useCallback((showError = true) => { - return api.opsHealth() - .then((health) => { + return Promise.all([ + api.opsHealth(), + api.qualityNotificationPlan(new URLSearchParams({ limit: '5' })).catch(() => null) + ]) + .then(([health, notificationPlan]) => { setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length); setPlatformRelease(health.runtime?.platformRelease ?? ''); + applyNotificationPlanSummary(notificationPlan); return health; }) .catch((error: Error) => { @@ -444,13 +464,13 @@ export default function App() { quality: navigatePage('notification-rules')} onHealthLoaded={(health) => { setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length); setPlatformRelease(health.runtime?.platformRelease ?? ''); - }} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />, + }} onNotificationPlanLoaded={applyNotificationPlanSummary} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />, 'notification-rules': , 'ops-quality': }; return ( - + {pages[activePage]} ); diff --git a/vehicle-data-platform/apps/web/src/layout/AppShell.tsx b/vehicle-data-platform/apps/web/src/layout/AppShell.tsx index 7212a20c..96b9703b 100644 --- a/vehicle-data-platform/apps/web/src/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/layout/AppShell.tsx @@ -39,9 +39,35 @@ function linkHealthClassName(count: number | null) { return count > 0 ? 'vp-link-health-button-warning' : 'vp-link-health-button-ok'; } +function alertButtonLabel(linkIssueCount: number | null, activeRuleCount?: number | null, p0RuleCount?: number | null) { + const activeCount = Number.isFinite(Number(activeRuleCount)) ? Number(activeRuleCount) : 0; + const p0Count = Number.isFinite(Number(p0RuleCount)) ? Number(p0RuleCount) : 0; + if (p0Count > 0) { + return `告警事件 P0 ${p0Count.toLocaleString()} / 活跃 ${activeCount.toLocaleString()}`; + } + if (activeCount > 0) { + return `告警事件 ${activeCount.toLocaleString()}类规则`; + } + if (linkIssueCount == null) { + return '告警事件'; + } + return linkIssueCount > 0 ? `告警事件 ${linkIssueCount}项关注` : '告警事件正常'; +} + +function alertButtonClassName(linkIssueCount: number | null, activeRuleCount?: number | null, p0RuleCount?: number | null) { + const activeCount = Number.isFinite(Number(activeRuleCount)) ? Number(activeRuleCount) : 0; + const p0Count = Number.isFinite(Number(p0RuleCount)) ? Number(p0RuleCount) : 0; + if (p0Count > 0 || activeCount > 0 || (linkIssueCount ?? 0) > 0) { + return 'vp-link-health-button-warning'; + } + return linkHealthClassName(linkIssueCount); +} + export function AppShell({ activePage, linkIssueCount, + activeAlertRuleCount, + p0AlertRuleCount, platformRelease, currentVehicleStatus, currentVehicleLabel, @@ -52,6 +78,8 @@ export function AppShell({ }: { activePage: PageKey; linkIssueCount: number | null; + activeAlertRuleCount?: number | null; + p0AlertRuleCount?: number | null; platformRelease?: string; currentVehicleStatus?: VehicleServiceStatus; currentVehicleLabel?: string; @@ -63,6 +91,8 @@ export function AppShell({ const [keyword, setKeyword] = useState(''); const [searching, setSearching] = useState(false); const amapConfigured = isAMapConfigured(); + const alertLabel = alertButtonLabel(linkIssueCount, activeAlertRuleCount, p0AlertRuleCount); + const alertClassName = alertButtonClassName(linkIssueCount, activeAlertRuleCount, p0AlertRuleCount); const search = () => { const value = keyword.trim(); @@ -127,11 +157,11 @@ export function AppShell({ diff --git a/vehicle-data-platform/apps/web/src/pages/Quality.tsx b/vehicle-data-platform/apps/web/src/pages/Quality.tsx index 9db4ec2d..cd4201e0 100644 --- a/vehicle-data-platform/apps/web/src/pages/Quality.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Quality.tsx @@ -497,6 +497,7 @@ export function Quality({ onOpenMileage, onOpenNotificationRules, onHealthLoaded, + onNotificationPlanLoaded, onFiltersChange, initialFilters = {} }: { @@ -507,6 +508,7 @@ export function Quality({ onOpenMileage?: (filters: Record) => void; onOpenNotificationRules?: () => void; onHealthLoaded?: (health: OpsHealth) => void; + onNotificationPlanLoaded?: (plan: QualityNotificationPlan) => void; onFiltersChange?: (filters: Record) => void; initialFilters?: Record; }) { @@ -519,7 +521,13 @@ export function Quality({ const [loadingHealth, setLoadingHealth] = useState(true); const [filters, setFilters] = useState>(initialFilters); const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 }); - const primaryIssueType = summary.issueTypes[0]?.name; + const summaryIssueTypes = Array.isArray(summary.issueTypes) ? summary.issueTypes : []; + const summaryProtocols = Array.isArray(summary.protocols) ? summary.protocols : []; + const issueVehicleCount = Number(summary.issueVehicleCount ?? 0); + const issueRecordCount = Number(summary.issueRecordCount ?? 0); + const errorCount = Number(summary.errorCount ?? 0); + const warningCount = Number(summary.warningCount ?? 0); + const primaryIssueType = summaryIssueTypes[0]?.name; const rules = normalizeAlertRules(notificationPlan?.rules) ?? alertRuleRows(summary, health); const policies = normalizeNotificationPolicies(notificationPlan?.policies) ?? notificationPolicies; const activeRuleCount = notificationPlan?.activeRuleCount ?? rules.filter((item) => item.count > 0).length; @@ -565,6 +573,7 @@ export function Quality({ .then((plan) => { if (Array.isArray(plan.rules) && Array.isArray(plan.policies) && Array.isArray(plan.priorityIssues)) { setNotificationPlan(plan); + onNotificationPlanLoaded?.(plan); } }) .catch(() => { @@ -662,12 +671,12 @@ export function Quality({
{[ - { label: '问题车辆', value: summary.issueVehicleCount.toLocaleString() }, - { label: '问题记录', value: summary.issueRecordCount.toLocaleString() }, - { label: '错误 / 警告', value: `${summary.errorCount}/${summary.warningCount}` }, + { label: '问题车辆', value: issueVehicleCount.toLocaleString() }, + { label: '问题记录', value: issueRecordCount.toLocaleString() }, + { label: '错误 / 警告', value: `${errorCount}/${warningCount}` }, { label: '主要问题', - value: primaryIssueType ? `${qualityIssueLabel(primaryIssueType)} ${summary.issueTypes[0].count}` : '-', + value: primaryIssueType ? `${qualityIssueLabel(primaryIssueType)} ${summaryIssueTypes[0].count}` : '-', onClick: primaryIssueType ? drillPrimaryIssue : undefined } ].map((item) => ( @@ -709,7 +718,7 @@ export function Quality({ {[ { label: '事件触发', value: `${activeRuleCount} 类活跃`, detail: '断链、无来源、VIN 缺失、字段缺失和容量异常进入告警池。' }, { label: '分级通知', value: `${p0RuleCount} 类 P0`, detail: '按错误、警告、关注分层推送给平台、运维和业务责任人。' }, - { label: '处置回执', value: `${summary.issueVehicleCount.toLocaleString()} 辆车`, detail: '告警需要关联车辆服务、来源证据和处理结论,形成可追踪闭环。' } + { label: '处置回执', value: `${issueVehicleCount.toLocaleString()} 辆车`, detail: '告警需要关联车辆服务、来源证据和处理结论,形成可追踪闭环。' } ].map((item) => (
{item.label} @@ -856,7 +865,7 @@ export function Quality({ qualityProtocolLabel(row.name) }, @@ -874,7 +883,7 @@ export function Quality({
qualityIssueLabel(row.name) }, diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index f7bb32f5..5f931c4e 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -98,6 +98,62 @@ test('exposes AMap operations shortcuts when map key is configured', async () => expect(window.location.hash).toBe('#/quality'); }); +test('shows global alert pressure from notification plan in topbar', async () => { + window.history.replaceState(null, '', '/#/dashboard'); + 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, + redisOnlineKeys: 0, + tdengineWritable: true, + mysqlWritable: true, + runtime: { requestTimeoutMs: 5000, platformRelease: 'platform-20260704180000' } + }, + 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: [] }, + rules: [], + policies: [], + priorityIssues: [], + activeRuleCount: 4, + p0RuleCount: 2 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 8, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + const alertButton = await screen.findByRole('button', { name: '顶部告警事件 P0 2 / 活跃 4' }); + expect(alertButton).toHaveTextContent('告警事件 P0 2 / 活跃 4'); + fireEvent.click(alertButton); + expect(window.location.hash).toBe('#/quality'); +}); + test('shows vehicle service status distribution on dashboard', async () => { window.history.replaceState(null, '', '/#/dashboard'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {