feat(platform): add ops quality workspace

This commit is contained in:
lingniu
2026-07-04 16:31:51 +08:00
parent c060317fb0
commit 318beccaaf
7 changed files with 221 additions and 8 deletions

View File

@@ -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 (

View File

@@ -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');
});

View File

@@ -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;

View File

@@ -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}项关注` : '顶部告警事件正常'}

View 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> RedisTDengineMySQL </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>
);
}

View File

@@ -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());

View File

@@ -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