feat(platform): surface global alert pressure
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Toast } from '@douyinfe/semi-ui';
|
import { Toast } from '@douyinfe/semi-ui';
|
||||||
import { api } from './api/client';
|
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 { buildAppHash, parseAppHash } from './domain/appRoute';
|
||||||
import { AppShell, type PageKey } from './layout/AppShell';
|
import { AppShell, type PageKey } from './layout/AppShell';
|
||||||
import { Dashboard } from './pages/Dashboard';
|
import { Dashboard } from './pages/Dashboard';
|
||||||
@@ -38,6 +38,8 @@ export default function App() {
|
|||||||
initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {}
|
initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {}
|
||||||
);
|
);
|
||||||
const [linkIssueCount, setLinkIssueCount] = useState<number | null>(null);
|
const [linkIssueCount, setLinkIssueCount] = useState<number | null>(null);
|
||||||
|
const [activeAlertRuleCount, setActiveAlertRuleCount] = useState<number | null>(null);
|
||||||
|
const [p0AlertRuleCount, setP0AlertRuleCount] = useState<number | null>(null);
|
||||||
const [platformRelease, setPlatformRelease] = useState('');
|
const [platformRelease, setPlatformRelease] = useState('');
|
||||||
const [currentVehicleStatus, setCurrentVehicleStatus] = useState<VehicleServiceStatus | undefined>();
|
const [currentVehicleStatus, setCurrentVehicleStatus] = useState<VehicleServiceStatus | undefined>();
|
||||||
const [currentVehicleLabel, setCurrentVehicleLabel] = useState('');
|
const [currentVehicleLabel, setCurrentVehicleLabel] = useState('');
|
||||||
@@ -62,11 +64,29 @@ export default function App() {
|
|||||||
return { resolved, nextKey, overview };
|
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) => {
|
const refreshOpsHealth = useCallback((showError = true) => {
|
||||||
return api.opsHealth()
|
return Promise.all([
|
||||||
.then((health) => {
|
api.opsHealth(),
|
||||||
|
api.qualityNotificationPlan(new URLSearchParams({ limit: '5' })).catch(() => null)
|
||||||
|
])
|
||||||
|
.then(([health, notificationPlan]) => {
|
||||||
setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length);
|
setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length);
|
||||||
setPlatformRelease(health.runtime?.platformRelease ?? '');
|
setPlatformRelease(health.runtime?.platformRelease ?? '');
|
||||||
|
applyNotificationPlanSummary(notificationPlan);
|
||||||
return health;
|
return health;
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
@@ -444,13 +464,13 @@ export default function App() {
|
|||||||
quality: <Quality onOpenVehicle={openVehicle} onOpenRealtime={openRealtime} onOpenHistory={openHistoryWithFilters} onOpenRaw={openRawWithFilters} onOpenMileage={openMileageWithFilters} onOpenNotificationRules={() => navigatePage('notification-rules')} 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);
|
setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length);
|
||||||
setPlatformRelease(health.runtime?.platformRelease ?? '');
|
setPlatformRelease(health.runtime?.platformRelease ?? '');
|
||||||
}} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />,
|
}} onNotificationPlanLoaded={applyNotificationPlanSummary} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />,
|
||||||
'notification-rules': <NotificationRules />,
|
'notification-rules': <NotificationRules />,
|
||||||
'ops-quality': <OpsQuality />
|
'ops-quality': <OpsQuality />
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell activePage={activePage} linkIssueCount={linkIssueCount} platformRelease={platformRelease} currentVehicleStatus={currentVehicleStatus} currentVehicleLabel={currentVehicleLabel} currentVehicleConsistency={currentVehicleConsistency} onChange={navigatePage} onVehicleSearch={openVehicle}>
|
<AppShell activePage={activePage} linkIssueCount={linkIssueCount} activeAlertRuleCount={activeAlertRuleCount} p0AlertRuleCount={p0AlertRuleCount} platformRelease={platformRelease} currentVehicleStatus={currentVehicleStatus} currentVehicleLabel={currentVehicleLabel} currentVehicleConsistency={currentVehicleConsistency} onChange={navigatePage} onVehicleSearch={openVehicle}>
|
||||||
{pages[activePage]}
|
{pages[activePage]}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,9 +39,35 @@ function linkHealthClassName(count: number | null) {
|
|||||||
return count > 0 ? 'vp-link-health-button-warning' : 'vp-link-health-button-ok';
|
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({
|
export function AppShell({
|
||||||
activePage,
|
activePage,
|
||||||
linkIssueCount,
|
linkIssueCount,
|
||||||
|
activeAlertRuleCount,
|
||||||
|
p0AlertRuleCount,
|
||||||
platformRelease,
|
platformRelease,
|
||||||
currentVehicleStatus,
|
currentVehicleStatus,
|
||||||
currentVehicleLabel,
|
currentVehicleLabel,
|
||||||
@@ -52,6 +78,8 @@ export function AppShell({
|
|||||||
}: {
|
}: {
|
||||||
activePage: PageKey;
|
activePage: PageKey;
|
||||||
linkIssueCount: number | null;
|
linkIssueCount: number | null;
|
||||||
|
activeAlertRuleCount?: number | null;
|
||||||
|
p0AlertRuleCount?: number | null;
|
||||||
platformRelease?: string;
|
platformRelease?: string;
|
||||||
currentVehicleStatus?: VehicleServiceStatus;
|
currentVehicleStatus?: VehicleServiceStatus;
|
||||||
currentVehicleLabel?: string;
|
currentVehicleLabel?: string;
|
||||||
@@ -63,6 +91,8 @@ export function AppShell({
|
|||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const amapConfigured = isAMapConfigured();
|
const amapConfigured = isAMapConfigured();
|
||||||
|
const alertLabel = alertButtonLabel(linkIssueCount, activeAlertRuleCount, p0AlertRuleCount);
|
||||||
|
const alertClassName = alertButtonClassName(linkIssueCount, activeAlertRuleCount, p0AlertRuleCount);
|
||||||
|
|
||||||
const search = () => {
|
const search = () => {
|
||||||
const value = keyword.trim();
|
const value = keyword.trim();
|
||||||
@@ -127,11 +157,11 @@ export function AppShell({
|
|||||||
<Button size="small" aria-label="顶部运维质量" onClick={() => onChange('ops-quality')}>运维质量</Button>
|
<Button size="small" aria-label="顶部运维质量" onClick={() => onChange('ops-quality')}>运维质量</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
aria-label={linkIssueCount == null ? '顶部告警事件' : linkIssueCount > 0 ? `顶部告警事件 ${linkIssueCount}项关注` : '顶部告警事件正常'}
|
aria-label={`顶部${alertLabel}`}
|
||||||
className={linkHealthClassName(linkIssueCount)}
|
className={alertClassName}
|
||||||
onClick={() => onChange('quality')}
|
onClick={() => onChange('quality')}
|
||||||
>
|
>
|
||||||
{linkIssueCount == null ? '告警事件' : linkIssueCount > 0 ? `告警事件 ${linkIssueCount}项关注` : '告警事件正常'}
|
{alertLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -497,6 +497,7 @@ export function Quality({
|
|||||||
onOpenMileage,
|
onOpenMileage,
|
||||||
onOpenNotificationRules,
|
onOpenNotificationRules,
|
||||||
onHealthLoaded,
|
onHealthLoaded,
|
||||||
|
onNotificationPlanLoaded,
|
||||||
onFiltersChange,
|
onFiltersChange,
|
||||||
initialFilters = {}
|
initialFilters = {}
|
||||||
}: {
|
}: {
|
||||||
@@ -507,6 +508,7 @@ export function Quality({
|
|||||||
onOpenMileage?: (filters: Record<string, string>) => void;
|
onOpenMileage?: (filters: Record<string, string>) => void;
|
||||||
onOpenNotificationRules?: () => void;
|
onOpenNotificationRules?: () => void;
|
||||||
onHealthLoaded?: (health: OpsHealth) => void;
|
onHealthLoaded?: (health: OpsHealth) => void;
|
||||||
|
onNotificationPlanLoaded?: (plan: QualityNotificationPlan) => void;
|
||||||
onFiltersChange?: (filters: Record<string, string>) => void;
|
onFiltersChange?: (filters: Record<string, string>) => void;
|
||||||
initialFilters?: Record<string, string>;
|
initialFilters?: Record<string, string>;
|
||||||
}) {
|
}) {
|
||||||
@@ -519,7 +521,13 @@ export function Quality({
|
|||||||
const [loadingHealth, setLoadingHealth] = useState(true);
|
const [loadingHealth, setLoadingHealth] = useState(true);
|
||||||
const [filters, setFilters] = useState<Record<string, string>>(initialFilters);
|
const [filters, setFilters] = useState<Record<string, string>>(initialFilters);
|
||||||
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
|
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 rules = normalizeAlertRules(notificationPlan?.rules) ?? alertRuleRows(summary, health);
|
||||||
const policies = normalizeNotificationPolicies(notificationPlan?.policies) ?? notificationPolicies;
|
const policies = normalizeNotificationPolicies(notificationPlan?.policies) ?? notificationPolicies;
|
||||||
const activeRuleCount = notificationPlan?.activeRuleCount ?? rules.filter((item) => item.count > 0).length;
|
const activeRuleCount = notificationPlan?.activeRuleCount ?? rules.filter((item) => item.count > 0).length;
|
||||||
@@ -565,6 +573,7 @@ export function Quality({
|
|||||||
.then((plan) => {
|
.then((plan) => {
|
||||||
if (Array.isArray(plan.rules) && Array.isArray(plan.policies) && Array.isArray(plan.priorityIssues)) {
|
if (Array.isArray(plan.rules) && Array.isArray(plan.policies) && Array.isArray(plan.priorityIssues)) {
|
||||||
setNotificationPlan(plan);
|
setNotificationPlan(plan);
|
||||||
|
onNotificationPlanLoaded?.(plan);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -662,12 +671,12 @@ export function Quality({
|
|||||||
<PageHeader title="告警事件" description="围绕车辆服务沉淀断链、VIN 缺失、字段缺失和链路健康事件,并形成通知闭环" />
|
<PageHeader title="告警事件" description="围绕车辆服务沉淀断链、VIN 缺失、字段缺失和链路健康事件,并形成通知闭环" />
|
||||||
<div className="vp-kpi-grid">
|
<div className="vp-kpi-grid">
|
||||||
{[
|
{[
|
||||||
{ label: '问题车辆', value: summary.issueVehicleCount.toLocaleString() },
|
{ label: '问题车辆', value: issueVehicleCount.toLocaleString() },
|
||||||
{ label: '问题记录', value: summary.issueRecordCount.toLocaleString() },
|
{ label: '问题记录', value: issueRecordCount.toLocaleString() },
|
||||||
{ label: '错误 / 警告', value: `${summary.errorCount}/${summary.warningCount}` },
|
{ label: '错误 / 警告', value: `${errorCount}/${warningCount}` },
|
||||||
{
|
{
|
||||||
label: '主要问题',
|
label: '主要问题',
|
||||||
value: primaryIssueType ? `${qualityIssueLabel(primaryIssueType)} ${summary.issueTypes[0].count}` : '-',
|
value: primaryIssueType ? `${qualityIssueLabel(primaryIssueType)} ${summaryIssueTypes[0].count}` : '-',
|
||||||
onClick: primaryIssueType ? drillPrimaryIssue : undefined
|
onClick: primaryIssueType ? drillPrimaryIssue : undefined
|
||||||
}
|
}
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
@@ -709,7 +718,7 @@ export function Quality({
|
|||||||
{[
|
{[
|
||||||
{ label: '事件触发', value: `${activeRuleCount} 类活跃`, detail: '断链、无来源、VIN 缺失、字段缺失和容量异常进入告警池。' },
|
{ label: '事件触发', value: `${activeRuleCount} 类活跃`, detail: '断链、无来源、VIN 缺失、字段缺失和容量异常进入告警池。' },
|
||||||
{ label: '分级通知', value: `${p0RuleCount} 类 P0`, detail: '按错误、警告、关注分层推送给平台、运维和业务责任人。' },
|
{ label: '分级通知', value: `${p0RuleCount} 类 P0`, detail: '按错误、警告、关注分层推送给平台、运维和业务责任人。' },
|
||||||
{ label: '处置回执', value: `${summary.issueVehicleCount.toLocaleString()} 辆车`, detail: '告警需要关联车辆服务、来源证据和处理结论,形成可追踪闭环。' }
|
{ label: '处置回执', value: `${issueVehicleCount.toLocaleString()} 辆车`, detail: '告警需要关联车辆服务、来源证据和处理结论,形成可追踪闭环。' }
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div key={item.label} className="vp-alert-flow-item">
|
<div key={item.label} className="vp-alert-flow-item">
|
||||||
<Tag color="blue">{item.label}</Tag>
|
<Tag color="blue">{item.label}</Tag>
|
||||||
@@ -856,7 +865,7 @@ export function Quality({
|
|||||||
<Card bordered title="问题来源分布" loading={loadingSummary} style={{ marginTop: 16 }}>
|
<Card bordered title="问题来源分布" loading={loadingSummary} style={{ marginTop: 16 }}>
|
||||||
<Table
|
<Table
|
||||||
pagination={false}
|
pagination={false}
|
||||||
dataSource={summary.protocols}
|
dataSource={summaryProtocols}
|
||||||
rowKey="name"
|
rowKey="name"
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '数据来源', render: (_: unknown, row: { name: string }) => qualityProtocolLabel(row.name) },
|
{ title: '数据来源', render: (_: unknown, row: { name: string }) => qualityProtocolLabel(row.name) },
|
||||||
@@ -874,7 +883,7 @@ export function Quality({
|
|||||||
<Card bordered title="问题类型分布" loading={loadingSummary} style={{ marginTop: 16 }}>
|
<Card bordered title="问题类型分布" loading={loadingSummary} style={{ marginTop: 16 }}>
|
||||||
<Table
|
<Table
|
||||||
pagination={false}
|
pagination={false}
|
||||||
dataSource={summary.issueTypes}
|
dataSource={summaryIssueTypes}
|
||||||
rowKey="name"
|
rowKey="name"
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '问题类型', render: (_: unknown, row: { name: string }) => qualityIssueLabel(row.name) },
|
{ title: '问题类型', render: (_: unknown, row: { name: string }) => qualityIssueLabel(row.name) },
|
||||||
|
|||||||
@@ -98,6 +98,62 @@ test('exposes AMap operations shortcuts when map key is configured', async () =>
|
|||||||
expect(window.location.hash).toBe('#/quality');
|
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(<App />);
|
||||||
|
|
||||||
|
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 () => {
|
test('shows vehicle service status distribution on dashboard', async () => {
|
||||||
window.history.replaceState(null, '', '/#/dashboard');
|
window.history.replaceState(null, '', '/#/dashboard');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user