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());
|
||||
|
||||
Reference in New Issue
Block a user