feat(web): condense mobile protocol health
This commit is contained in:
@@ -312,6 +312,52 @@ test('includes protocol readiness in the global health verdict and prioritizes i
|
||||
expect(Boolean(sourcePanel!.compareDocumentPosition(infrastructureGrid!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps every protocol scannable on mobile and expands evidence on demand', async () => {
|
||||
layout.mobile = true;
|
||||
seedSession();
|
||||
mocks.opsHealth.mockResolvedValue({
|
||||
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
|
||||
tdengineWritable: true, mysqlWritable: true,
|
||||
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
|
||||
});
|
||||
mocks.sourceReadiness.mockResolvedValue({
|
||||
totalVehicles: 1024, boundVehicles: 1024, identityRequiredVehicles: 0, onlineVehicles: 234,
|
||||
sources: [
|
||||
{
|
||||
protocol: 'YUTONG_MQTT', role: '宇通补充来源', total: 50, online: 12, missingVehicles: 38, severity: 'error',
|
||||
status: '链路异常', evidence: '投影停滞', action: '恢复投影与回补', acceptance: '日里程恢复'
|
||||
},
|
||||
{
|
||||
protocol: 'GB32960', role: '仪表盘里程与整车状态', total: 494, online: 123, missingVehicles: 371, severity: 'warning',
|
||||
status: '覆盖不足', evidence: '在线覆盖偏低', action: '核对接入范围', acceptance: '缺失车辆下降'
|
||||
},
|
||||
{
|
||||
protocol: 'JT808', role: 'GPS 里程与位置', total: 480, online: 99, missingVehicles: 381, severity: 'ok',
|
||||
status: '生产可用', evidence: '实时来源稳定', action: '保持巡检', acceptance: '消费积压持续为零'
|
||||
}
|
||||
]
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderOperations(client, ['/operations?view=health']);
|
||||
|
||||
expect(await screen.findByText('先看覆盖与状态,点击协议展开证据和处置口径')).toBeInTheDocument();
|
||||
const yutong = await screen.findByRole('button', { name: /YUTONG_MQTT/ });
|
||||
expect(document.querySelector('.v2-ops-source-collapse.semi-collapse')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-ops-source-list.semi-card-group')).not.toBeInTheDocument();
|
||||
for (const protocol of ['YUTONG_MQTT', 'GB32960', 'JT808']) {
|
||||
expect(screen.getByRole('button', { name: new RegExp(protocol) })).toHaveAttribute('aria-expanded', 'false');
|
||||
}
|
||||
expect(screen.queryByText('投影停滞')).not.toBeInTheDocument();
|
||||
fireEvent.click(yutong);
|
||||
expect(yutong).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(await screen.findByText('投影停滞')).toBeInTheDocument();
|
||||
expect(screen.getByText('恢复投影与回补')).toBeInTheDocument();
|
||||
expect(screen.getByText('日里程恢复')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /GB32960/ }));
|
||||
expect(yutong).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.getByRole('button', { name: /GB32960/ })).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
test('keeps health evidence visible when source readiness fails and retries that section', async () => {
|
||||
seedSession();
|
||||
mocks.opsHealth.mockResolvedValue({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IconAlertTriangle, IconPulse, IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons';
|
||||
import { Button, Card, CardGroup, Checkbox, Descriptions, Input, Progress, Table, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { Button, Card, CardGroup, Checkbox, Collapse, Descriptions, Input, Progress, Table, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
@@ -58,6 +58,49 @@ function sourceOnlineRate(online: number, total: number, onlineRate?: number) {
|
||||
return total > 0 ? Math.max(0, Math.min(100, (online / total) * 100)) : 0;
|
||||
}
|
||||
|
||||
function SourceReadinessDetails({ source, showCoverage = true }: { source: SourceReadinessRow; showCoverage?: boolean }) {
|
||||
const onlineRate = sourceOnlineRate(source.online, source.total, source.onlineRate);
|
||||
return <>
|
||||
{showCoverage ? <div className="v2-ops-source-coverage"><span><strong>在线覆盖</strong><b>{source.online.toLocaleString('zh-CN')} / {source.total.toLocaleString('zh-CN')}</b></span><Progress aria-label={`${source.protocol} 在线覆盖率`} percent={onlineRate} showInfo={false} stroke="var(--v2-blue)" /><small>{number(onlineRate)}% 在线{source.missingVehicles ? ` · ${source.missingVehicles.toLocaleString('zh-CN')} 辆待恢复` : ''}</small></div> : null}
|
||||
<div className="v2-ops-source-evidence"><strong>当前证据</strong><p>{source.evidence}</p></div>
|
||||
<div className="v2-ops-source-action"><strong>建议动作</strong><p>{source.action}</p></div>
|
||||
<footer><Tag color="green" type="light" size="small">验收标准</Tag><span>{source.acceptance}</span></footer>
|
||||
</>;
|
||||
}
|
||||
|
||||
function SourceReadinessCard({ source }: { source: SourceReadinessRow }) {
|
||||
return <Card
|
||||
className={`v2-ops-source-card is-${source.severity}`}
|
||||
title={<span className="v2-ops-source-title"><Tag color="blue" type="light" size="small">{source.protocol}</Tag><small>{source.role}</small></span>}
|
||||
headerExtraContent={<HealthTag status={source.severity}>{source.status || statusLabel(source.severity)}</HealthTag>}
|
||||
headerLine
|
||||
>
|
||||
<SourceReadinessDetails source={source} />
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function SourceReadinessCollapseHeader({ source }: { source: SourceReadinessRow }) {
|
||||
const onlineRate = sourceOnlineRate(source.online, source.total, source.onlineRate);
|
||||
return <span className="v2-ops-source-collapse-header">
|
||||
<span className="v2-ops-source-collapse-identity"><Tag color="blue" type="light" size="small">{source.protocol}</Tag><small>{source.role}</small></span>
|
||||
<HealthTag status={source.severity}>{source.status || statusLabel(source.severity)}</HealthTag>
|
||||
<span className="v2-ops-source-collapse-coverage"><strong>{source.online.toLocaleString('zh-CN')} / {source.total.toLocaleString('zh-CN')}</strong><small>{number(onlineRate)}% 在线{source.missingVehicles ? ` · ${source.missingVehicles.toLocaleString('zh-CN')} 辆待恢复` : ''}</small></span>
|
||||
</span>;
|
||||
}
|
||||
|
||||
function SourceReadinessCollapse({ sources }: { sources: SourceReadinessRow[] }) {
|
||||
return <Collapse className="v2-ops-source-collapse" accordion lazyRender>
|
||||
{sources.map((source) => <Collapse.Panel
|
||||
className={`is-${source.severity}`}
|
||||
itemKey={source.protocol}
|
||||
key={source.protocol}
|
||||
header={<SourceReadinessCollapseHeader source={source} />}
|
||||
>
|
||||
<div className="v2-ops-source-collapse-detail"><SourceReadinessDetails source={source} showCoverage={false} /></div>
|
||||
</Collapse.Panel>)}
|
||||
</Collapse>;
|
||||
}
|
||||
|
||||
function SourcePolicyEditor({ vin, source, diagnostic, editable, onSaved }: {
|
||||
vin: string;
|
||||
source: VehicleLocationSourceEvidence;
|
||||
@@ -433,6 +476,7 @@ function SourceDiagnosticWorkspace() {
|
||||
}
|
||||
|
||||
export default function OperationsPage() {
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const requestedWorkspace = searchParams.get('view') as OperationsWorkspace | null;
|
||||
const workspace = requestedWorkspace && operationsWorkspaces.has(requestedWorkspace) ? requestedWorkspace : 'reconciliation';
|
||||
@@ -588,13 +632,10 @@ export default function OperationsPage() {
|
||||
</span>}
|
||||
/>
|
||||
</Card>
|
||||
<Card className={`v2-ops-panel v2-ops-sources${sourceIssueCount || readinessUnavailable ? ' has-attention' : ''}`} bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="协议来源就绪度" description="优先展示覆盖不足的协议,并给出处置动作与验收口径" meta={<HealthTag status={readinessUnavailable ? 'error' : sourceIssueCount > 0 ? 'warning' : sourceRows.length ? 'ok' : 'unknown'}>{readinessUnavailable ? '证据不可用' : sources ? sourceIssueCount > 0 ? `${sourceIssueCount} 个协议需关注` : sourceRows.length ? `${sourceRows.length} 个协议正常` : '暂无协议来源' : '正在读取'}</HealthTag>} />{sources ? sortedSources.length ? <CardGroup className="v2-ops-source-list" type="grid" spacing={0}>{sortedSources.map((source) => {
|
||||
const onlineRate = sourceOnlineRate(source.online, source.total, source.onlineRate);
|
||||
return <Card className={`v2-ops-source-card is-${source.severity}`} key={source.protocol} title={<span className="v2-ops-source-title"><Tag color="blue" type="light" size="small">{source.protocol}</Tag><small>{source.role}</small></span>} headerExtraContent={<HealthTag status={source.severity}>{source.status || statusLabel(source.severity)}</HealthTag>} headerLine>
|
||||
<div className="v2-ops-source-coverage"><span><strong>在线覆盖</strong><b>{source.online.toLocaleString('zh-CN')} / {source.total.toLocaleString('zh-CN')}</b></span><Progress aria-label={`${source.protocol} 在线覆盖率`} percent={onlineRate} showInfo={false} stroke="var(--v2-blue)" /><small>{number(onlineRate)}% 在线{source.missingVehicles ? ` · ${source.missingVehicles.toLocaleString('zh-CN')} 辆待恢复` : ''}</small></div>
|
||||
<div className="v2-ops-source-evidence"><strong>当前证据</strong><p>{source.evidence}</p></div><div className="v2-ops-source-action"><strong>建议动作</strong><p>{source.action}</p></div><footer><Tag color="green" type="light" size="small">验收标准</Tag><span>{source.acceptance}</span></footer>
|
||||
</Card>;
|
||||
})}</CardGroup> : <PanelEmpty className="v2-ops-source-empty" icon={<IconPulse />} title="暂无协议来源" description="当前响应没有可展示的协议来源就绪度。" /> : readinessUnavailable ? <PanelEmpty
|
||||
<Card className={`v2-ops-panel v2-ops-sources${sourceIssueCount || readinessUnavailable ? ' has-attention' : ''}`} bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="协议来源就绪度" description={mobileLayout ? '先看覆盖与状态,点击协议展开证据和处置口径' : '优先展示覆盖不足的协议,并给出处置动作与验收口径'} meta={<HealthTag status={readinessUnavailable ? 'error' : sourceIssueCount > 0 ? 'warning' : sourceRows.length ? 'ok' : 'unknown'}>{readinessUnavailable ? '证据不可用' : sources ? sourceIssueCount > 0 ? `${sourceIssueCount} 个协议需关注` : sourceRows.length ? `${sourceRows.length} 个协议正常` : '暂无协议来源' : '正在读取'}</HealthTag>} />{sources ? sortedSources.length ? mobileLayout
|
||||
? <SourceReadinessCollapse sources={sortedSources} />
|
||||
: <CardGroup className="v2-ops-source-list" type="grid" spacing={0}>{sortedSources.map((source) => <SourceReadinessCard source={source} key={source.protocol} />)}</CardGroup>
|
||||
: <PanelEmpty className="v2-ops-source-empty" icon={<IconPulse />} title="暂无协议来源" description="当前响应没有可展示的协议来源就绪度。" /> : readinessUnavailable ? <PanelEmpty
|
||||
className="v2-ops-source-empty is-error"
|
||||
tone="danger"
|
||||
icon={<IconAlertTriangle />}
|
||||
|
||||
@@ -396,7 +396,8 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.OperationsPage).toContain('<Descriptions className="v2-ops-runtime-descriptions"');
|
||||
expect(corePageSources.OperationsPage).toContain('<PanelEmpty className="v2-ops-capacity-empty"');
|
||||
expect(corePageSources.OperationsPage).toContain('<CardGroup className="v2-ops-source-list"');
|
||||
expect(corePageSources.OperationsPage).toContain('<Card className={`v2-ops-source-card');
|
||||
expect(corePageSources.OperationsPage).toContain('<SourceReadinessCard source={source}');
|
||||
expect(corePageSources.OperationsPage).toContain('<Collapse className="v2-ops-source-collapse"');
|
||||
expect(corePageSources.OperationsPage).not.toContain('<article key={item.name}');
|
||||
expect(corePageSources.OperationsPage).not.toContain('<article key={source.protocol}');
|
||||
expect(corePageSources.OperationsPage).not.toContain('<dl><div><dt>生产数据模式</dt>');
|
||||
|
||||
@@ -26385,7 +26385,159 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.v2-ops-sources > .semi-card-body > .v2-ops-source-collapse.semi-collapse {
|
||||
display: grid;
|
||||
border: 0;
|
||||
background: #f7f9fc;
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse > .semi-collapse-item {
|
||||
overflow: hidden;
|
||||
border: 1px solid #dfe6ef;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 3px 12px rgba(31, 53, 80, .035);
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse > .semi-collapse-item + .semi-collapse-item {
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse > .semi-collapse-item.is-warning {
|
||||
border-color: #ecd7ad;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse > .semi-collapse-item.is-error {
|
||||
border-color: #efc9c7;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse > .semi-collapse-item.is-ok {
|
||||
border-color: #cce8dd;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse > .semi-collapse-item > .semi-collapse-header {
|
||||
min-height: 72px;
|
||||
gap: 8px;
|
||||
padding: 9px 11px;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse > .semi-collapse-item > .semi-collapse-header:focus-visible {
|
||||
outline: 2px solid rgba(18, 104, 243, .32);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse .semi-collapse-header-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 28px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5fa;
|
||||
color: #5f7289;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse-header {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 6px 8px;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse-identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse-identity > .semi-tag {
|
||||
min-height: 22px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 6px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse-identity > small {
|
||||
overflow: hidden;
|
||||
color: #6f8094;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse-header > .v2-ops-health-tag.semi-tag {
|
||||
min-height: 22px;
|
||||
border-radius: 999px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse-coverage {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
grid-column: 1 / -1;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse-coverage > strong {
|
||||
color: #283f59;
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse-coverage > small {
|
||||
overflow: hidden;
|
||||
color: #79889b;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse .semi-collapse-content-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse-detail {
|
||||
display: grid;
|
||||
border-top: 1px solid #e7edf4;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse-detail > :is(.v2-ops-source-evidence, .v2-ops-source-action) {
|
||||
min-height: 0;
|
||||
padding: 11px 12px;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse-detail > footer {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
border-top: 1px solid #e9eef4;
|
||||
background: #f5faf8;
|
||||
padding: 11px 12px;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse-detail > footer > .semi-tag {
|
||||
min-height: 22px;
|
||||
border-radius: 999px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.v2-ops-source-collapse-detail > footer > span {
|
||||
color: #557567;
|
||||
font-size: 10px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.v2-ops-sources > .semi-card-body > .v2-workspace-panel-header {
|
||||
min-height: 56px;
|
||||
padding: 8px 11px;
|
||||
}
|
||||
|
||||
.v2-ops-health-metric-rail.is-queue.has-context > .semi-card-body {
|
||||
min-height: 0;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
||||
Reference in New Issue
Block a user