feat(platform): add ops quality workspace
This commit is contained in:
@@ -8,6 +8,7 @@ import { Dashboard } from './pages/Dashboard';
|
||||
import { History } from './pages/History';
|
||||
import { Mileage } from './pages/Mileage';
|
||||
import { NotificationRules } from './pages/NotificationRules';
|
||||
import { OpsQuality } from './pages/OpsQuality';
|
||||
import { Quality } from './pages/Quality';
|
||||
import { Realtime } from './pages/Realtime';
|
||||
import { VehicleDetail } from './pages/VehicleDetail';
|
||||
@@ -154,7 +155,7 @@ export default function App() {
|
||||
replaceQualityHash(qualityFilters);
|
||||
return;
|
||||
}
|
||||
if (page === 'notification-rules') {
|
||||
if (page === 'notification-rules' || page === 'ops-quality') {
|
||||
replaceHash(page);
|
||||
return;
|
||||
}
|
||||
@@ -415,7 +416,8 @@ export default function App() {
|
||||
setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length);
|
||||
setPlatformRelease(health.runtime?.platformRelease ?? '');
|
||||
}} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />,
|
||||
'notification-rules': <NotificationRules />
|
||||
'notification-rules': <NotificationRules />,
|
||||
'ops-quality': <OpsQuality />
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -55,6 +55,13 @@ describe('parseAppHash', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('parses ops quality page', () => {
|
||||
expect(parseAppHash('#/ops-quality')).toEqual({
|
||||
page: 'ops-quality',
|
||||
filters: {}
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores unknown pages', () => {
|
||||
expect(parseAppHash('#/unknown?keyword=VIN001')).toEqual({});
|
||||
});
|
||||
@@ -81,6 +88,10 @@ describe('buildAppHash', () => {
|
||||
expect(buildAppHash({ page: 'notification-rules' })).toBe('#/notification-rules');
|
||||
});
|
||||
|
||||
test('builds ops quality page hash', () => {
|
||||
expect(buildAppHash({ page: 'ops-quality' })).toBe('#/ops-quality');
|
||||
});
|
||||
|
||||
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', 'notification-rules']);
|
||||
const pageKeys = new Set<PageKey>(['dashboard', 'vehicles', 'realtime', 'detail', 'history', 'mileage', 'quality', 'notification-rules', 'ops-quality']);
|
||||
|
||||
export type AppRoute = {
|
||||
page?: PageKey;
|
||||
|
||||
@@ -8,14 +8,15 @@ import {
|
||||
IconSearch,
|
||||
IconServer,
|
||||
IconSetting,
|
||||
IconBell
|
||||
IconBell,
|
||||
IconPulse
|
||||
} 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' | 'notification-rules';
|
||||
export type PageKey = 'dashboard' | 'vehicles' | 'realtime' | 'detail' | 'history' | 'mileage' | 'quality' | 'notification-rules' | 'ops-quality';
|
||||
|
||||
const navItems = [
|
||||
{ itemKey: 'dashboard', text: '运营驾驶舱', icon: <IconHome /> },
|
||||
@@ -25,7 +26,8 @@ const navItems = [
|
||||
{ itemKey: 'history', text: '轨迹回放', icon: <IconMapPin /> },
|
||||
{ itemKey: 'mileage', text: '统计分析', icon: <IconBarChartHStroked /> },
|
||||
{ itemKey: 'quality', text: '告警事件', icon: <IconHistogram /> },
|
||||
{ itemKey: 'notification-rules', text: '通知规则', icon: <IconBell /> }
|
||||
{ itemKey: 'notification-rules', text: '通知规则', icon: <IconBell /> },
|
||||
{ itemKey: 'ops-quality', text: '运维质量', icon: <IconPulse /> }
|
||||
];
|
||||
|
||||
function linkHealthClassName(count: number | null) {
|
||||
@@ -119,6 +121,7 @@ export function AppShell({
|
||||
<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="顶部运维质量" onClick={() => onChange('ops-quality')}>运维质量</Button>
|
||||
<Button
|
||||
size="small"
|
||||
aria-label={linkIssueCount == null ? '顶部告警事件' : linkIssueCount > 0 ? `顶部告警事件 ${linkIssueCount}项关注` : '顶部告警事件正常'}
|
||||
|
||||
137
vehicle-data-platform/apps/web/src/pages/OpsQuality.tsx
Normal file
137
vehicle-data-platform/apps/web/src/pages/OpsQuality.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Button, Card, Space, Table, Tag, Toast } from '@douyinfe/semi-ui';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { LinkHealth, OpsHealth } from '../api/types';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
|
||||
const statusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
|
||||
ok: 'green',
|
||||
warning: 'orange',
|
||||
error: 'red'
|
||||
};
|
||||
|
||||
function formatNumber(value?: number | null) {
|
||||
return value == null ? '-' : value.toLocaleString();
|
||||
}
|
||||
|
||||
function statusText(ok?: boolean) {
|
||||
return ok ? '正常' : '异常';
|
||||
}
|
||||
|
||||
function boolColor(ok?: boolean): 'green' | 'red' {
|
||||
return ok ? 'green' : 'red';
|
||||
}
|
||||
|
||||
function amapSecurityText(health: OpsHealth | null) {
|
||||
const runtime = health?.runtime;
|
||||
if (!runtime?.amapWebJsConfigured) return '地图未配置';
|
||||
if (runtime.amapSecurityCodeExposed) return '安全码已暴露';
|
||||
if (runtime.amapSecurityProxyEnabled) return '安全码未暴露';
|
||||
return '安全代理未启用';
|
||||
}
|
||||
|
||||
function amapSecurityColor(health: OpsHealth | null): 'green' | 'orange' | 'red' {
|
||||
const runtime = health?.runtime;
|
||||
if (runtime?.amapSecurityCodeExposed) return 'red';
|
||||
if (runtime?.amapWebJsConfigured && runtime?.amapSecurityProxyEnabled) return 'green';
|
||||
return 'orange';
|
||||
}
|
||||
|
||||
export function OpsQuality() {
|
||||
const [health, setHealth] = useState<OpsHealth | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
api.opsHealth()
|
||||
.then(setHealth)
|
||||
.catch((error: Error) => Toast.error(error.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const runtime = health?.runtime;
|
||||
|
||||
return (
|
||||
<div className="vp-page">
|
||||
<PageHeader
|
||||
title="运维质量"
|
||||
description="集中查看网关、消息队列、缓存、时序库、关系库、地图配置和运行版本状态"
|
||||
actions={<Button size="small" loading={loading} onClick={load}>刷新</Button>}
|
||||
/>
|
||||
|
||||
<div className="vp-kpi-grid">
|
||||
<Card bordered loading={loading}>
|
||||
<div className="vp-kpi-value">{formatNumber(health?.kafkaLag)}</div>
|
||||
<div className="vp-kpi-label">Kafka Lag</div>
|
||||
</Card>
|
||||
<Card bordered loading={loading}>
|
||||
<div className="vp-kpi-value">{formatNumber(health?.activeConnections)}</div>
|
||||
<div className="vp-kpi-label">活跃连接</div>
|
||||
</Card>
|
||||
<Card bordered loading={loading}>
|
||||
<div className="vp-kpi-value">{formatNumber(health?.redisOnlineKeys)}</div>
|
||||
<div className="vp-kpi-label">Redis 在线 Key</div>
|
||||
</Card>
|
||||
<Card bordered loading={loading}>
|
||||
<div className="vp-kpi-value">{runtime?.platformRelease || '-'}</div>
|
||||
<div className="vp-kpi-label">运行版本</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card bordered title="存储与运行时" loading={loading} style={{ marginTop: 16 }}>
|
||||
<div className="vp-alert-flow">
|
||||
<div className="vp-alert-flow-item">
|
||||
<Tag color={boolColor(health?.tdengineWritable)}>TDengine 写入</Tag>
|
||||
<div className="vp-alert-flow-value">{statusText(health?.tdengineWritable)}</div>
|
||||
<div>时序历史、RAW 和统计依赖 TDengine 可写状态。</div>
|
||||
</div>
|
||||
<div className="vp-alert-flow-item">
|
||||
<Tag color={boolColor(health?.mysqlWritable)}>MySQL 写入</Tag>
|
||||
<div className="vp-alert-flow-value">{statusText(health?.mysqlWritable)}</div>
|
||||
<div>车辆身份、绑定、服务配置和中台查询依赖 MySQL。</div>
|
||||
</div>
|
||||
<div className="vp-alert-flow-item">
|
||||
<Tag color="blue">请求超时</Tag>
|
||||
<div className="vp-alert-flow-value">{runtime?.requestTimeoutMs ? `${runtime.requestTimeoutMs.toLocaleString()} ms` : '-'}</div>
|
||||
<div>接口层读取 Redis、TDengine、MySQL 的统一超时保护。</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card bordered title="地图安全配置" loading={loading} style={{ marginTop: 16 }}>
|
||||
<Space wrap>
|
||||
<Tag color={runtime?.amapWebJsConfigured ? 'green' : 'orange'}>{runtime?.amapWebJsConfigured ? 'Web JS Key 已配置' : 'Web JS Key 未配置'}</Tag>
|
||||
<Tag color={amapSecurityColor(health)}>{amapSecurityText(health)}</Tag>
|
||||
<Tag color={runtime?.amapSecurityProxyEnabled ? 'green' : 'grey'}>{runtime?.amapSecurityServiceHost || '代理未启用'}</Tag>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{health?.capacityFindings?.length ? (
|
||||
<Card bordered title="容量与风险发现" style={{ marginTop: 16 }}>
|
||||
<Space wrap>
|
||||
{health.capacityFindings.map((item) => (
|
||||
<Tag key={item} color="orange">{item}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card bordered title="链路健康" loading={loading} style={{ marginTop: 16 }}>
|
||||
<Table<LinkHealth>
|
||||
pagination={false}
|
||||
dataSource={health?.linkHealth ?? []}
|
||||
rowKey={(row?: LinkHealth) => `${row?.name ?? ''}-${row?.status ?? ''}`}
|
||||
columns={[
|
||||
{ title: '链路', dataIndex: 'name' },
|
||||
{ title: '状态', width: 120, render: (_: unknown, row: LinkHealth) => <Tag color={statusColor[row.status] ?? 'grey'}>{row.status}</Tag> },
|
||||
{ title: '说明', dataIndex: 'detail' }
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3259,6 +3259,65 @@ test('renders notification rules as a standalone operations page', async () => {
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('无来源规则 / P0 / 平台接入'));
|
||||
});
|
||||
|
||||
test('renders ops quality as a standalone runtime health page', async () => {
|
||||
window.history.replaceState(null, '', '/#/ops-quality');
|
||||
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: [
|
||||
{ name: 'gb32960-gateway', status: 'ok', detail: 'listening :32960' },
|
||||
{ name: 'kafka', status: 'warning', detail: 'lag 42' }
|
||||
],
|
||||
kafkaLag: 42,
|
||||
activeConnections: 1234,
|
||||
capacityFindings: ['Kafka lag 42', 'Redis online key below expected'],
|
||||
redisOnlineKeys: 368,
|
||||
tdengineWritable: true,
|
||||
mysqlWritable: false,
|
||||
runtime: {
|
||||
requestTimeoutMs: 5000,
|
||||
platformRelease: 'platform-ops-test',
|
||||
amapWebJsConfigured: true,
|
||||
amapSecurityProxyEnabled: true,
|
||||
amapSecurityCodeExposed: false,
|
||||
amapSecurityServiceHost: '/_AMapService'
|
||||
}
|
||||
},
|
||||
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('platform-ops-test')).toBeInTheDocument();
|
||||
expect(screen.getByText('42')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,234')).toBeInTheDocument();
|
||||
expect(screen.getByText('368')).toBeInTheDocument();
|
||||
expect(screen.getByText('TDengine 写入')).toBeInTheDocument();
|
||||
expect(screen.getByText('MySQL 写入')).toBeInTheDocument();
|
||||
expect(screen.getByText('/_AMapService')).toBeInTheDocument();
|
||||
expect(screen.getByText('安全码未暴露')).toBeInTheDocument();
|
||||
expect(screen.getByText('gb32960-gateway')).toBeInTheDocument();
|
||||
expect(screen.getByText('Kafka lag 42')).toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/ops/health'), undefined);
|
||||
});
|
||||
|
||||
test('copies notification text from quality priority queue', async () => {
|
||||
window.history.replaceState(null, '', '/#/quality');
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
|
||||
@@ -41,8 +41,8 @@ The left navigation should be vehicle-service oriented:
|
||||
6. History Query
|
||||
7. Alert Events
|
||||
8. Statistics
|
||||
9. Ops Quality
|
||||
10. Notification Rules
|
||||
9. Notification Rules
|
||||
10. Ops Quality
|
||||
|
||||
The global search stays persistent in the top bar. It accepts VIN, plate, or JT808 phone and resolves to one vehicle context. The selected vehicle context should travel across all pages.
|
||||
|
||||
@@ -265,6 +265,7 @@ Protocol-specific data appears as source evidence fields in these responses. New
|
||||
- Expand `VehicleMap` for selected marker, fit controls, polyline playback, and large-point fallback.
|
||||
- Add replay controls on History.
|
||||
- Add AMap readiness diagnostics to Ops Quality and Dashboard.
|
||||
- Keep Ops Quality as a standalone runtime page for gateway, queue, Redis, TDengine, MySQL, AMap, and release checks.
|
||||
|
||||
### Phase 3: Alert Events
|
||||
|
||||
|
||||
Reference in New Issue
Block a user