diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx index aec51ad9..4393a8ca 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx @@ -53,7 +53,7 @@ function seedSession() { test('renders reconciliation queue, loads evidence on demand and records review conclusion', async () => { seedSession(); mocks.opsHealth.mockResolvedValue({ - linkHealth: [{ name: 'MySQL', status: 'ok', detail: '主库连接正常' }], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5, + linkHealth: [{ name: 'MySQL realtime', status: 'ok', detail: '主库连接正常' }], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5, tdengineWritable: true, mysqlWritable: true, runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false } }); @@ -179,7 +179,7 @@ test('renders only the compact mobile reconciliation surface and defers secondar test('reconciles service identities with bound and identity-required vehicles', async () => { seedSession(); mocks.opsHealth.mockResolvedValue({ - linkHealth: [{ name: 'MySQL', status: 'ok', detail: '主库连接正常' }], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5, + linkHealth: [{ name: 'MySQL realtime', status: 'ok', detail: '主库连接正常' }], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5, tdengineWritable: true, mysqlWritable: true, runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false } }); @@ -205,6 +205,8 @@ test('reconciles service identities with bound and identity-required vehicles', expect(liveOptions.refetchOnWindowFocus).toBe(true); } expect(screen.getByText('车辆总数 / 在线')).toBeInTheDocument(); + expect(screen.getByText('协议就绪 / 总数')).toBeInTheDocument(); + expect(screen.getByText('全部协议达到当前口径')).toBeInTheDocument(); expect(screen.getByText('已绑定 1024 · 待绑定 11')).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '运行概览', level: 5 })).toBeInTheDocument(); expect(screen.getByText('车辆总数 / 在线').closest('.v2-ops-metric')).toBeInTheDocument(); @@ -215,7 +217,7 @@ test('reconciles service identities with bound and identity-required vehicles', expect(screen.getByRole('heading', { name: '数据链路', level: 5 })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '运行时安全', level: 5 })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '协议来源就绪度', level: 5 })).toBeInTheDocument(); - expect(document.querySelector('.v2-ops-link-card')).toHaveTextContent('MySQL主库连接正常'); + expect(document.querySelector('.v2-ops-link-card')).toHaveTextContent('MySQL 实时连接MySQL realtime主库连接正常'); expect(document.querySelector('.v2-ops-link-card.semi-card')).not.toBeInTheDocument(); expect(document.querySelector('.v2-ops-runtime-descriptions.semi-descriptions')).toBeInTheDocument(); expect(screen.getByText('容量检查通过').closest('.semi-empty')).toHaveClass('v2-ops-capacity-empty'); @@ -236,6 +238,42 @@ test('reconciles service identities with bound and identity-required vehicles', expect(screen.queryByText('统一车辆视角')).not.toBeInTheDocument(); }); +test('includes protocol readiness in the global health verdict and prioritizes its evidence', async () => { + seedSession(); + mocks.opsHealth.mockResolvedValue({ + linkHealth: [{ name: 'MySQL realtime', status: 'ok', detail: '主库连接正常' }], 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: 11, onlineVehicles: 234, + sources: [ + { + protocol: 'GB32960', role: '仪表盘里程与整车状态', total: 494, online: 123, severity: 'warning', + status: '覆盖不足', evidence: '在线覆盖偏低', action: '核对接入范围', acceptance: '缺失车辆下降' + }, + { + protocol: 'YUTONG_MQTT', role: '宇通补充来源', total: 50, online: 12, severity: 'error', + status: '链路异常', evidence: '投影停滞', action: '恢复投影与回补', acceptance: '日里程恢复' + } + ] + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + renderOperations(client, ['/operations?view=health']); + + expect(await screen.findByText('需要处理')).toBeInTheDocument(); + expect(screen.getByText('协议覆盖 2 项 · 链路运行 0 项')).toBeInTheDocument(); + expect(screen.getByText('0 / 2')).toBeInTheDocument(); + expect(screen.getByText('2 个协议需要处置')).toBeInTheDocument(); + expect(within(screen.getByRole('navigation', { name: '运维质量视图' })).getByText('2 项关注')).toBeInTheDocument(); + const sourcePanel = screen.getByRole('heading', { name: '协议来源就绪度', level: 5 }).closest('.v2-ops-sources'); + const infrastructureGrid = document.querySelector('.v2-ops-grid'); + expect(sourcePanel).toHaveClass('has-attention'); + expect(sourcePanel).not.toBeNull(); + expect(infrastructureGrid).not.toBeNull(); + expect(Boolean(sourcePanel!.compareDocumentPosition(infrastructureGrid!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); +}); + test('keeps health evidence visible when source readiness fails and retries that section', async () => { seedSession(); mocks.opsHealth.mockResolvedValue({ @@ -252,6 +290,8 @@ test('keeps health evidence visible when source readiness fails and retries that fireEvent.click(screen.getByRole('tab', { name: '全局健康' })); expect(await screen.findByText('来源就绪度暂时不可用')).toBeInTheDocument(); + expect(screen.getByText('协议来源证据不可用')).toBeInTheDocument(); + expect(screen.getByText('证据缺失')).toBeInTheDocument(); expect(screen.getByLabelText('运维质量操作')).toHaveTextContent('上海时间 · 版本 test-release'); expect(screen.getByRole('heading', { name: '数据链路', level: 5 })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /重试/ })); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx index 46e648d4..9e8f5f2f 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx @@ -24,6 +24,20 @@ function statusLabel(status: string) { return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status; } +const opsLinkLabels: Record = { + 'MySQL realtime': 'MySQL 实时连接', + vehicle_realtime_snapshot: '车辆实时快照', + vehicle_realtime_location: '车辆实时位置', + 'TDengine raw_frames': 'TDengine 原始帧', + 'Capacity check': '容量与消费积压', + 'Redis online keys': 'Redis 在线状态', + 'Alert Kafka stream': '告警消费链路' +}; + +function opsLinkLabel(name: string) { + return opsLinkLabels[name] ?? name; +} + function HealthTag({ status, children }: { status: string; children?: string }) { const color = status === 'ok' ? 'green' : status === 'warning' ? 'orange' : status === 'error' ? 'red' : 'grey'; return {children ?? statusLabel(status)}; @@ -345,8 +359,34 @@ export default function OperationsPage() { const linkIssueCount = data?.linkHealth.filter((item) => item.status !== 'ok').length ?? 0; const runtimeIssueCount = data ? Number(!data.mysqlWritable) + Number(!data.tdengineWritable) + Number(data.runtime.dataMode !== 'production') : 0; const capacityIssueCount = data?.capacityFindings.length ?? 0; - const healthIssueCount = linkIssueCount + runtimeIssueCount + capacityIssueCount + Number((data?.kafkaLag ?? 0) > 0); - const overallStatus = !data ? 'unknown' : healthIssueCount > 0 ? 'warning' : 'ok'; + const infrastructureIssueCount = linkIssueCount + runtimeIssueCount + capacityIssueCount + Number((data?.kafkaLag ?? 0) > 0); + const sourceIssueCount = sources?.sources.filter((item) => item.severity !== 'ok').length ?? 0; + const sourceErrorCount = sources?.sources.filter((item) => item.severity === 'error').length ?? 0; + const sourceHealthyCount = sources?.sources.filter((item) => item.severity === 'ok').length ?? 0; + const readinessUnavailable = readiness.isError; + const healthIssueCount = infrastructureIssueCount + sourceIssueCount + Number(readinessUnavailable); + const healthEvidencePending = !data || readiness.isPending; + const overallStatus = healthEvidencePending + ? 'unknown' + : readinessUnavailable || sourceErrorCount > 0 + ? 'error' + : healthIssueCount > 0 + ? 'warning' + : 'ok'; + const overallValue = healthEvidencePending + ? '读取中' + : readinessUnavailable + ? '证据缺失' + : healthIssueCount > 0 + ? '需要处理' + : '运行正常'; + const overallDetail = healthEvidencePending + ? '等待运行与协议覆盖证据' + : readinessUnavailable + ? '协议来源就绪度暂不可用' + : healthIssueCount > 0 + ? `协议覆盖 ${sourceIssueCount} 项 · 链路运行 ${infrastructureIssueCount} 项` + : '链路、写入与协议覆盖均正常'; const sortedLinks = useMemo(() => [...(data?.linkHealth ?? [])].sort((left, right) => { const priority = { error: 0, warning: 1, ok: 2 } as Record; return (priority[left.status] ?? 3) - (priority[right.status] ?? 3); @@ -365,8 +405,8 @@ export default function OperationsPage() { ? { description: '按需读取单车来源', status: '精确诊断', color: 'cyan' as const } : { description: '15 秒刷新运行证据', - status: !data ? '读取中' : healthIssueCount > 0 ? `${healthIssueCount} 项关注` : '运行正常', - color: !data ? 'grey' as const : healthIssueCount > 0 ? 'orange' as const : 'green' as const + status: healthEvidencePending ? '读取中' : readinessUnavailable ? '协议证据缺失' : healthIssueCount > 0 ? `${healthIssueCount} 项关注` : '运行正常', + color: healthEvidencePending ? 'grey' as const : readinessUnavailable || sourceErrorCount > 0 ? 'red' as const : healthIssueCount > 0 ? 'orange' as const : 'green' as const }; return
{!data ? '正在读取' : healthIssueCount > 0 ? `${healthIssueCount} 项需关注` : '全部正常'}15 秒自动刷新} />
- 0 ? '需要处理' : '运行正常'} detail={!data ? '等待服务端健康证据' : healthIssueCount > 0 ? `${healthIssueCount} 项证据需要关注` : '链路与写入探针均正常'} tone={!data ? 'neutral' : healthIssueCount > 0 ? 'warning' : 'success'} /> + 0 ? 'danger' : healthIssueCount > 0 ? 'warning' : 'success'} /> + 0 ? `${sourceIssueCount} 个协议需要处置` : sources?.sources.length ? '全部协议达到当前口径' : '等待协议覆盖证据'} + tone={readinessUnavailable || sourceErrorCount > 0 ? 'danger' : sourceIssueCount > 0 ? 'warning' : sources?.sources.length ? 'success' : 'neutral'} + /> -
-
0 ? 'warning' : 'ok'}>{!data ? '读取中' : linkIssueCount > 0 ? `${linkIssueCount} 条异常` : '链路正常'}} />
{sortedLinks.length ? sortedLinks.map((item) =>
{item.name}

{item.detail || '无补充信息'}

) : }
+ 0 ? 'warning' : sources ? 'ok' : 'unknown'}>{readinessUnavailable ? '证据不可用' : sources ? sourceIssueCount > 0 ? `${sourceIssueCount} 个协议需关注` : `${sources.sources.length} 个协议正常` : '正在读取'}} />{sources ? sortedSources.length ? {sortedSources.map((source) => { + const onlineRate = sourceOnlineRate(source.online, source.total, source.onlineRate); + return {source.protocol}{source.role}} headerExtraContent={{source.status || statusLabel(source.severity)}} headerLine> +
在线覆盖{source.online.toLocaleString('zh-CN')} / {source.total.toLocaleString('zh-CN')}{number(onlineRate)}% 在线{source.missingVehicles ? ` · ${source.missingVehicles.toLocaleString('zh-CN')} 辆待恢复` : ''}
+
当前证据

{source.evidence}

建议动作

{source.action}

验收标准{source.acceptance}
+
; + })}
: : readinessUnavailable ? :
}
+
0 ? 'warning' : 'ok'}>{!data ? '读取中' : linkIssueCount > 0 ? `${linkIssueCount} 条异常` : '链路正常'}} />
{sortedLinks.length ? sortedLinks.map((item) => { + const label = opsLinkLabel(item.name); + return
{label}{label !== item.name ? {item.name} : null}

{item.detail || '无补充信息'}

; + }) : }
0 ? 'warning' : 'ok'}>{!data ? '读取中' : runtimeIssueCount + capacityIssueCount > 0 ? `${runtimeIssueCount + capacityIssueCount} 项关注` : '配置正常'}} />{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'} }, { key: 'MySQL 写探针', value: {data?.mysqlWritable ? '正常' : '异常'} }, @@ -425,13 +480,6 @@ export default function OperationsPage() { { key: '请求超时', value: `${data?.runtime.requestTimeoutMs ?? '—'} ms` }, { key: '高德安全代理', value: {data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'} } ]} />{data?.capacityFindings?.length ?
{data.capacityFindings.map((item) => 待处理

{item}

)}
: } title="容量检查通过" description="当前没有需要处理的容量风险。" />}
- {sources ? `${sources.sources.length} 个协议` : '正在读取'}} />{sources ? sortedSources.length ? {sortedSources.map((source) => { - const onlineRate = sourceOnlineRate(source.online, source.total, source.onlineRate); - return {source.protocol}{source.role}} headerExtraContent={{source.status || statusLabel(source.severity)}} headerLine> -
在线覆盖{source.online.toLocaleString('zh-CN')} / {source.total.toLocaleString('zh-CN')}{number(onlineRate)}% 在线{source.missingVehicles ? ` · ${source.missingVehicles.toLocaleString('zh-CN')} 辆待恢复` : ''}
-
当前证据

{source.evidence}

建议动作

{source.action}

验收标准{source.acceptance}
-
; - })}
: :
}
: null}
; diff --git a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css index a0cdaf21..47ece459 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -15460,3 +15460,55 @@ padding-inline: 6px; } } + +/* + * Semi UI operations health attention hierarchy. + * Protocol readiness is part of the global verdict, so its actionable + * evidence now appears before healthy infrastructure detail. + */ +.v2-ops-sources.has-attention.semi-card { + border-color: #e8d2a6; + box-shadow: 0 9px 28px rgba(127, 88, 23, .075); +} + +.v2-ops-sources.has-attention > .semi-card-body > .v2-workspace-panel-header { + background: linear-gradient(96deg, #fffaf0 0%, #fffdf9 54%, #fff 100%); +} + +.v2-ops-link-status > span { + display: grid; + min-width: 0; + gap: 2px; +} + +.v2-ops-link-status > span > strong, +.v2-ops-link-status > span > small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-ops-link-status > span > small { + color: #91a0b2; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 8px; + font-weight: 500; +} + +.v2-ops-source-empty.is-error.semi-empty { + background: #fff9f8; +} + +.v2-ops-source-empty.is-error .semi-empty-title { + color: #b94a45; +} + +@media (max-width: 680px) { + .v2-ops-sources.has-attention.semi-card { + box-shadow: 0 6px 20px rgba(127, 88, 23, .07); + } + + .v2-ops-link-status > span > small { + font-size: 7px; + } +}