feat(platform): add realtime source coverage board
This commit is contained in:
@@ -168,6 +168,15 @@ const realtimeExportColumns: CsvColumn<VehicleRealtimeRow>[] = [
|
||||
{ title: '绑定状态', value: (row) => row.bindingStatus }
|
||||
];
|
||||
|
||||
type RealtimeSourceCoverage = {
|
||||
protocol: string;
|
||||
total: number;
|
||||
online: number;
|
||||
located: number;
|
||||
degraded: number;
|
||||
stale: number;
|
||||
};
|
||||
|
||||
function realtimeExportFileName(filters: Record<string, string>) {
|
||||
const keyword = filters.keyword?.trim() || 'all';
|
||||
const protocol = filters.protocol?.trim() || 'all-source';
|
||||
@@ -387,6 +396,26 @@ function realtimeDutyHandoffText({
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildRealtimeSourceCoverage(rows: VehicleRealtimeRow[]) {
|
||||
const sourceMap = new Map<string, RealtimeSourceCoverage>();
|
||||
rows.forEach((row) => {
|
||||
const protocols = row.sourceStatus?.length
|
||||
? row.sourceStatus.map((source) => source.protocol)
|
||||
: row.primaryProtocol ? [row.primaryProtocol] : [];
|
||||
protocols.forEach((protocol) => {
|
||||
const source = row.sourceStatus?.find((item) => item.protocol === protocol);
|
||||
const current = sourceMap.get(protocol) ?? { protocol, total: 0, online: 0, located: 0, degraded: 0, stale: 0 };
|
||||
current.total += 1;
|
||||
current.online += (source ? source.online : row.online) ? 1 : 0;
|
||||
current.located += isValidCoordinate(row) ? 1 : 0;
|
||||
current.degraded += (!source || !source.online || !source.hasRealtime || row.onlineSourceCount < row.sourceCount) ? 1 : 0;
|
||||
current.stale += dataFreshness(row).stale ? 1 : 0;
|
||||
sourceMap.set(protocol, current);
|
||||
});
|
||||
});
|
||||
return [...sourceMap.values()].sort((left, right) => right.total - left.total || left.protocol.localeCompare(right.protocol));
|
||||
}
|
||||
|
||||
async function copyText(value: string, label: string) {
|
||||
const text = value.trim();
|
||||
if (!text) {
|
||||
@@ -499,6 +528,7 @@ export function Realtime({
|
||||
const degradedRate = rows.length > 0 ? (degradedCount / rows.length) * 100 : 0;
|
||||
const pageCoverageRate = pagination.total > 0 ? (rows.length / pagination.total) * 100 : 0;
|
||||
const primaryProtocols = new Set(rows.map((row) => row.primaryProtocol).filter(Boolean));
|
||||
const sourceCoverageRows = buildRealtimeSourceCoverage(rows);
|
||||
const sourceIssueRows = rows
|
||||
.filter((row) => canOpenVehicle(row.vin) && hasSourceIssue(row))
|
||||
.sort((a, b) => {
|
||||
@@ -705,6 +735,33 @@ export function Realtime({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vp-source-coverage-board">
|
||||
<div className="vp-current-service-title">协议地图覆盖</div>
|
||||
<div className="vp-source-coverage-grid">
|
||||
{sourceCoverageRows.length === 0 ? (
|
||||
<Typography.Text type="tertiary">当前页暂无协议来源。</Typography.Text>
|
||||
) : sourceCoverageRows.map((item) => (
|
||||
<button
|
||||
key={item.protocol}
|
||||
type="button"
|
||||
className="vp-source-coverage-item"
|
||||
aria-label={`筛选协议 ${item.protocol}`}
|
||||
onClick={() => applyFilters({ ...filters, protocol: item.protocol })}
|
||||
>
|
||||
<div className="vp-source-coverage-item-head">
|
||||
<Tag color={item.degraded > 0 ? 'orange' : 'green'}>{item.protocol}</Tag>
|
||||
<span>{item.total.toLocaleString()} 辆</span>
|
||||
</div>
|
||||
<div className="vp-source-coverage-bars">
|
||||
<span>在线 {item.online.toLocaleString()}</span>
|
||||
<span>定位 {item.located.toLocaleString()}</span>
|
||||
<span>降级 {item.degraded.toLocaleString()}</span>
|
||||
<span>超时 {item.stale.toLocaleString()}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vp-monitor-layout">
|
||||
<div className="vp-monitor-map">
|
||||
<div className="vp-monitor-map-header">
|
||||
|
||||
@@ -344,6 +344,61 @@ button.vp-realtime-command-item:focus-visible {
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.vp-source-coverage-board {
|
||||
margin-bottom: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: var(--vp-surface);
|
||||
}
|
||||
|
||||
.vp-source-coverage-grid {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vp-source-coverage-item {
|
||||
min-height: 98px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #fbfcff;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vp-source-coverage-item:hover,
|
||||
.vp-source-coverage-item:focus-visible {
|
||||
border-color: rgba(22, 100, 255, 0.42);
|
||||
background: #f5f9ff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vp-source-coverage-item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vp-source-coverage-item-head span {
|
||||
color: var(--vp-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vp-source-coverage-bars {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.vp-result-summary-item {
|
||||
min-height: 72px;
|
||||
padding: 12px;
|
||||
@@ -1402,6 +1457,7 @@ button.vp-realtime-command-item:focus-visible {
|
||||
.vp-current-service-grid,
|
||||
.vp-realtime-command-board,
|
||||
.vp-realtime-command-grid,
|
||||
.vp-source-coverage-grid,
|
||||
.vp-runbook-grid,
|
||||
.vp-workbench-grid,
|
||||
.vp-scenario-grid,
|
||||
|
||||
@@ -8511,6 +8511,10 @@ test('copies realtime operations summary from realtime page', async () => {
|
||||
expect(screen.getByRole('button', { name: '实时筛选 降级服务 2' })).toBeInTheDocument();
|
||||
expect(screen.getByText('建议处置')).toBeInTheDocument();
|
||||
expect(screen.getByText('排查降级服务 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('协议地图覆盖')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '筛选协议 GB32960' })).toHaveTextContent('GB32960');
|
||||
expect(screen.getByRole('button', { name: '筛选协议 JT808' })).toHaveTextContent('2 辆');
|
||||
expect(screen.getByRole('button', { name: '筛选协议 YUTONG_MQTT' })).toHaveTextContent('超时 1');
|
||||
expect(screen.getByText('实时覆盖判读')).toBeInTheDocument();
|
||||
expect(screen.getByText('在线率')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('66.7%').length).toBeGreaterThanOrEqual(3);
|
||||
@@ -8552,6 +8556,8 @@ test('copies realtime operations summary from realtime page', async () => {
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('历史RAW:http://localhost:3000/api/history/raw-frames?protocol=JT808&vin=VIN-RT-SUMMARY-003&limit=20&includeFields=true'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '实时筛选 降级服务 2' }));
|
||||
expect(window.location.hash).toBe('#/realtime?protocol=JT808&serviceStatus=degraded&online=online');
|
||||
fireEvent.click(screen.getByRole('button', { name: '筛选协议 GB32960' }));
|
||||
expect(window.location.hash).toBe('#/realtime?protocol=GB32960&serviceStatus=degraded&online=online');
|
||||
});
|
||||
|
||||
test('shows production AMap integration status on realtime page', async () => {
|
||||
|
||||
Reference in New Issue
Block a user