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 }
|
{ 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>) {
|
function realtimeExportFileName(filters: Record<string, string>) {
|
||||||
const keyword = filters.keyword?.trim() || 'all';
|
const keyword = filters.keyword?.trim() || 'all';
|
||||||
const protocol = filters.protocol?.trim() || 'all-source';
|
const protocol = filters.protocol?.trim() || 'all-source';
|
||||||
@@ -387,6 +396,26 @@ function realtimeDutyHandoffText({
|
|||||||
return lines.join('\n');
|
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) {
|
async function copyText(value: string, label: string) {
|
||||||
const text = value.trim();
|
const text = value.trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -499,6 +528,7 @@ export function Realtime({
|
|||||||
const degradedRate = rows.length > 0 ? (degradedCount / rows.length) * 100 : 0;
|
const degradedRate = rows.length > 0 ? (degradedCount / rows.length) * 100 : 0;
|
||||||
const pageCoverageRate = pagination.total > 0 ? (rows.length / pagination.total) * 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 primaryProtocols = new Set(rows.map((row) => row.primaryProtocol).filter(Boolean));
|
||||||
|
const sourceCoverageRows = buildRealtimeSourceCoverage(rows);
|
||||||
const sourceIssueRows = rows
|
const sourceIssueRows = rows
|
||||||
.filter((row) => canOpenVehicle(row.vin) && hasSourceIssue(row))
|
.filter((row) => canOpenVehicle(row.vin) && hasSourceIssue(row))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -705,6 +735,33 @@ export function Realtime({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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-layout">
|
||||||
<div className="vp-monitor-map">
|
<div className="vp-monitor-map">
|
||||||
<div className="vp-monitor-map-header">
|
<div className="vp-monitor-map-header">
|
||||||
|
|||||||
@@ -344,6 +344,61 @@ button.vp-realtime-command-item:focus-visible {
|
|||||||
line-height: 28px;
|
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 {
|
.vp-result-summary-item {
|
||||||
min-height: 72px;
|
min-height: 72px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -1402,6 +1457,7 @@ button.vp-realtime-command-item:focus-visible {
|
|||||||
.vp-current-service-grid,
|
.vp-current-service-grid,
|
||||||
.vp-realtime-command-board,
|
.vp-realtime-command-board,
|
||||||
.vp-realtime-command-grid,
|
.vp-realtime-command-grid,
|
||||||
|
.vp-source-coverage-grid,
|
||||||
.vp-runbook-grid,
|
.vp-runbook-grid,
|
||||||
.vp-workbench-grid,
|
.vp-workbench-grid,
|
||||||
.vp-scenario-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.getByRole('button', { name: '实时筛选 降级服务 2' })).toBeInTheDocument();
|
||||||
expect(screen.getByText('建议处置')).toBeInTheDocument();
|
expect(screen.getByText('建议处置')).toBeInTheDocument();
|
||||||
expect(screen.getByText('排查降级服务 2')).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.getByText('在线率')).toBeInTheDocument();
|
expect(screen.getByText('在线率')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('66.7%').length).toBeGreaterThanOrEqual(3);
|
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'));
|
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' }));
|
fireEvent.click(screen.getByRole('button', { name: '实时筛选 降级服务 2' }));
|
||||||
expect(window.location.hash).toBe('#/realtime?protocol=JT808&serviceStatus=degraded&online=online');
|
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 () => {
|
test('shows production AMap integration status on realtime page', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user