feat(platform): center console on vehicle service
This commit is contained in:
@@ -19,7 +19,7 @@ import { isAMapConfigured } from '../config/appConfig';
|
||||
export type PageKey = 'dashboard' | 'vehicles' | 'map' | 'realtime' | 'detail' | 'history' | 'history-query' | 'mileage' | 'alert-events' | 'quality' | 'notification-rules' | 'ops-quality';
|
||||
|
||||
const navItems = [
|
||||
{ itemKey: 'dashboard', text: '运营驾驶舱', icon: <IconHome /> },
|
||||
{ itemKey: 'dashboard', text: '车辆服务总览', icon: <IconHome /> },
|
||||
{ itemKey: 'vehicles', text: '车辆中心', icon: <IconServer /> },
|
||||
{ itemKey: 'map', text: '地图态势', icon: <IconMapPin /> },
|
||||
{ itemKey: 'realtime', text: '实时监控', icon: <IconActivity /> },
|
||||
@@ -147,7 +147,7 @@ export function AppShell({
|
||||
) : null}
|
||||
<Tag color="blue">生产环境</Tag>
|
||||
{platformRelease ? <Tag color="blue">版本 {platformRelease}</Tag> : null}
|
||||
<Tag color="grey">多源接入 / 一个车辆服务</Tag>
|
||||
<Tag color="grey">三源归一 / 车辆服务</Tag>
|
||||
<Tag color={amapConfigured ? 'green' : 'orange'}>{amapConfigured ? '地图就绪' : '地图待配置'}</Tag>
|
||||
<Button size="small" aria-label="顶部地图态势" onClick={() => onChange('map')}>地图态势</Button>
|
||||
<Button size="small" aria-label="顶部实时监控" onClick={() => onChange('realtime')}>实时监控</Button>
|
||||
|
||||
@@ -413,7 +413,7 @@ export function Dashboard({
|
||||
|
||||
return (
|
||||
<div className="vp-page">
|
||||
<PageHeader title="运营驾驶舱" description="围绕一个车辆服务汇总实时态势、轨迹证据、告警事件、统计查询和链路健康" />
|
||||
<PageHeader title="车辆服务总览" description="三个数据源最终汇总为一个车辆服务,统一承载实时监控、轨迹回放、历史查询、告警通知、统计查询和链路质量" />
|
||||
<Spin spinning={loading}>
|
||||
<div className="vp-kpi-grid">
|
||||
{kpis.map((item) => (
|
||||
@@ -425,7 +425,7 @@ export function Dashboard({
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card bordered title="统一车辆服务" style={{ marginBottom: 16 }}>
|
||||
<Card bordered title="统一车辆服务入口" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Tag color="blue">{vehicleServiceOnlineText(serviceSummary, summary)}</Tag>
|
||||
<Button size="small" theme="light" type="primary" onClick={() => onOpenVehicles({ online: 'online' })}>查看在线车辆</Button>
|
||||
@@ -522,7 +522,7 @@ export function Dashboard({
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
<Card bordered title="车联网能力矩阵" style={{ marginBottom: 16 }}>
|
||||
<Card bordered title="车辆服务能力矩阵" style={{ marginBottom: 16 }}>
|
||||
<div className="vp-capability-grid">
|
||||
{capabilities.map((item) => (
|
||||
<div key={item.title} className="vp-capability-item">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Card, Space, Table, Tag, Toast } from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Space, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { LinkHealth, OpsHealth } from '../api/types';
|
||||
@@ -37,6 +37,89 @@ function amapSecurityColor(health: OpsHealth | null): 'green' | 'orange' | 'red'
|
||||
return 'orange';
|
||||
}
|
||||
|
||||
type CapacityRisk = {
|
||||
key: string;
|
||||
title: string;
|
||||
value: string;
|
||||
category: string;
|
||||
severity: 'warning' | 'error';
|
||||
finding: string;
|
||||
action: string;
|
||||
};
|
||||
|
||||
function firstNumber(value: string) {
|
||||
const match = value.match(/[\d,.]+/);
|
||||
return match ? match[0] : '-';
|
||||
}
|
||||
|
||||
function capacityRisk(finding: string): CapacityRisk {
|
||||
const lower = finding.toLowerCase();
|
||||
if (lower.includes('bridge') && lower.includes('ack pending')) {
|
||||
return {
|
||||
key: finding,
|
||||
title: 'NATS 桥接 ACK 未确认',
|
||||
value: firstNumber(finding),
|
||||
category: 'NATS -> Kafka',
|
||||
severity: 'error',
|
||||
finding,
|
||||
action: '优先检查 Kafka 是否监听 9092、磁盘是否打满,以及桥接服务是否持续写入成功。'
|
||||
};
|
||||
}
|
||||
if (lower.includes('bridge') && lower.includes('consumer pending')) {
|
||||
return {
|
||||
key: finding,
|
||||
title: 'NATS 桥接待消费',
|
||||
value: firstNumber(finding),
|
||||
category: 'NATS -> Kafka',
|
||||
severity: 'warning',
|
||||
finding,
|
||||
action: '确认 Kafka 已恢复后观察 pending 是否持续下降;若不下降,扩容桥接或降低 Kafka topic 保留压力。'
|
||||
};
|
||||
}
|
||||
if (lower.includes('kafka') || lower.includes('lag')) {
|
||||
return {
|
||||
key: finding,
|
||||
title: 'Kafka 消费积压',
|
||||
value: firstNumber(finding),
|
||||
category: 'Kafka',
|
||||
severity: 'warning',
|
||||
finding,
|
||||
action: '检查消费者组 lag、topic retention、broker 磁盘和生产写入错误。'
|
||||
};
|
||||
}
|
||||
if (lower.includes('redis')) {
|
||||
return {
|
||||
key: finding,
|
||||
title: 'Redis 在线投影异常',
|
||||
value: firstNumber(finding),
|
||||
category: 'Redis',
|
||||
severity: 'warning',
|
||||
finding,
|
||||
action: '核对 realtime KV、online TTL 和协议实时帧过滤,确认车辆在线统计来源。'
|
||||
};
|
||||
}
|
||||
if (lower.includes('connection')) {
|
||||
return {
|
||||
key: finding,
|
||||
title: '网关连接压力',
|
||||
value: firstNumber(finding),
|
||||
category: 'Gateway',
|
||||
severity: 'warning',
|
||||
finding,
|
||||
action: '查看 32960、808、MQTT 连接数、系统 FD、CPU 和接入端口队列。'
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: finding,
|
||||
title: '容量风险',
|
||||
value: firstNumber(finding),
|
||||
category: 'General',
|
||||
severity: 'warning',
|
||||
finding,
|
||||
action: '进入对应链路日志和指标继续定位,确认是否影响车辆实时服务。'
|
||||
};
|
||||
}
|
||||
|
||||
export function OpsQuality() {
|
||||
const [health, setHealth] = useState<OpsHealth | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -54,6 +137,8 @@ export function OpsQuality() {
|
||||
}, []);
|
||||
|
||||
const runtime = health?.runtime;
|
||||
const capacityRisks = (health?.capacityFindings ?? []).map(capacityRisk);
|
||||
const bridgeRisks = capacityRisks.filter((item) => item.category === 'NATS -> Kafka');
|
||||
|
||||
return (
|
||||
<div className="vp-page">
|
||||
@@ -111,13 +196,29 @@ export function OpsQuality() {
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{health?.capacityFindings?.length ? (
|
||||
{capacityRisks.length ? (
|
||||
<Card bordered title="容量与风险发现" style={{ marginTop: 16 }}>
|
||||
<Space wrap>
|
||||
{health.capacityFindings.map((item) => (
|
||||
<Tag key={item} color="orange">{item}</Tag>
|
||||
<div className="vp-risk-grid">
|
||||
{capacityRisks.map((item) => (
|
||||
<div key={item.key} className="vp-risk-item">
|
||||
<Space wrap>
|
||||
<Tag color={item.severity === 'error' ? 'red' : 'orange'}>{item.category}</Tag>
|
||||
<Tag color="grey">{item.finding}</Tag>
|
||||
</Space>
|
||||
<div className="vp-risk-value">{item.value}</div>
|
||||
<Typography.Title heading={6} style={{ margin: 0 }}>{item.title}</Typography.Title>
|
||||
<Typography.Text type="secondary">{item.action}</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
{bridgeRisks.length ? (
|
||||
<div className="vp-risk-runbook">
|
||||
<Tag color="blue">桥接处置顺序</Tag>
|
||||
<Typography.Text>
|
||||
先确认 Kafka broker 监听和磁盘空间,再观察 ACK pending 是否回到 0,最后确认 consumer pending 持续下降。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -542,6 +542,41 @@ body {
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.vp-risk-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vp-risk-item {
|
||||
min-height: 158px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #fbfcff;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.vp-risk-value {
|
||||
color: var(--vp-text);
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.vp-risk-runbook {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #f5f9ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vp-alert-ops-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(360px, 0.6fr);
|
||||
@@ -981,6 +1016,7 @@ body {
|
||||
.vp-conclusion-grid,
|
||||
.vp-monitor-layout,
|
||||
.vp-alert-flow,
|
||||
.vp-risk-grid,
|
||||
.vp-alert-ops-grid,
|
||||
.vp-stat-workspace,
|
||||
.vp-stat-insight-grid,
|
||||
|
||||
@@ -12,7 +12,7 @@ afterEach(() => {
|
||||
test('renders vehicle platform shell', () => {
|
||||
render(<App />);
|
||||
expect(screen.getByText('车辆服务中台')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('运营驾驶舱').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('车辆服务总览').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('exposes AMap operations shortcuts when map key is configured', async () => {
|
||||
@@ -345,7 +345,7 @@ test('dashboard presents one vehicle service operating posture', async () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('统一车辆服务')).toBeInTheDocument();
|
||||
expect(await screen.findByText('统一车辆服务入口')).toBeInTheDocument();
|
||||
expect(screen.getByText('208 / 1,033 在线')).toBeInTheDocument();
|
||||
expect(screen.getByText('181 多源覆盖')).toBeInTheDocument();
|
||||
expect(screen.getByText('7 告警事件')).toBeInTheDocument();
|
||||
@@ -448,7 +448,7 @@ test('dashboard exposes vehicle data center capability matrix', async () => {
|
||||
const renderDashboard = async () => {
|
||||
window.history.replaceState(null, '', '/#/dashboard');
|
||||
render(<App />);
|
||||
expect(await screen.findByText('车联网能力矩阵')).toBeInTheDocument();
|
||||
expect(await screen.findByText('车辆服务能力矩阵')).toBeInTheDocument();
|
||||
};
|
||||
|
||||
await renderDashboard();
|
||||
@@ -3374,7 +3374,12 @@ test('renders ops quality as a standalone runtime health page', async () => {
|
||||
],
|
||||
kafkaLag: 42,
|
||||
activeConnections: 1234,
|
||||
capacityFindings: ['Kafka lag 42', 'Redis online key below expected'],
|
||||
capacityFindings: [
|
||||
'Kafka lag 42',
|
||||
'bridge ack pending 4000 exceeds 100',
|
||||
'bridge consumer pending 1482047 exceeds 10000',
|
||||
'Redis online key below expected'
|
||||
],
|
||||
redisOnlineKeys: 368,
|
||||
tdengineWritable: true,
|
||||
mysqlWritable: false,
|
||||
@@ -3407,7 +3412,7 @@ test('renders ops quality as a standalone runtime health page', async () => {
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '运维质量' })).toBeInTheDocument();
|
||||
expect(screen.getByText('platform-ops-test')).toBeInTheDocument();
|
||||
expect(screen.getByText('42')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('42').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('1,234')).toBeInTheDocument();
|
||||
expect(screen.getByText('368')).toBeInTheDocument();
|
||||
expect(screen.getByText('TDengine 写入')).toBeInTheDocument();
|
||||
@@ -3417,6 +3422,9 @@ test('renders ops quality as a standalone runtime health page', async () => {
|
||||
expect(screen.getByText('安全码未暴露')).toBeInTheDocument();
|
||||
expect(screen.getByText('gb32960-gateway')).toBeInTheDocument();
|
||||
expect(screen.getByText('Kafka lag 42')).toBeInTheDocument();
|
||||
expect(screen.getByText('NATS 桥接 ACK 未确认')).toBeInTheDocument();
|
||||
expect(screen.getByText('NATS 桥接待消费')).toBeInTheDocument();
|
||||
expect(screen.getByText('桥接处置顺序')).toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/ops/health'), undefined);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user