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 862568c6..644f74bc 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 @@ -223,8 +223,9 @@ test('reconciles service identities with bound and identity-required vehicles', 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(); - expect(screen.getByRole('list', { name: '运行健康指标' })).toHaveClass('v2-ops-metric-rail'); + expect(screen.getByText('车辆总数 / 在线').closest('[role="listitem"]')).toHaveClass('is-secondary'); + expect(screen.getByRole('list', { name: '运行健康指标' })).toHaveClass('v2-workspace-metric-list'); + expect(screen.getByText('Kafka Lag').closest('.v2-workspace-metric-context')).toHaveClass('v2-workspace-metric-context'); expect(screen.getByText('全部正常')).toBeInTheDocument(); expect(screen.getByText('运行时安全').closest('.semi-card')).toHaveClass('v2-ops-runtime'); expect(screen.getByText('协议来源就绪度').closest('.semi-card')).toHaveClass('v2-ops-sources'); 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 eea9ff4a..526a8f7f 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx @@ -13,6 +13,7 @@ import { SegmentedTabs } from '../shared/SegmentedTabs'; import { VehicleCandidateList } from '../shared/VehicleCandidateList'; import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar'; import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel'; +import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet'; import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy'; @@ -49,19 +50,6 @@ function number(value?: number, digits = 1) { return value == null || !Number.isFinite(value) ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits }); } -function OpsMetric({ label, value, detail, tone = 'neutral' }: { - label: string; - value: string; - detail: string; - tone?: 'neutral' | 'success' | 'warning' | 'danger'; -}) { - return
- {label} - {value} - {detail} -
; -} - function sourceOnlineRate(online: number, total: number, onlineRate?: number) { if (onlineRate != null && Number.isFinite(onlineRate)) return Math.max(0, Math.min(100, onlineRate)); return total > 0 ? Math.max(0, Math.min(100, (online / total) * 100)) : 0; @@ -457,6 +445,34 @@ export default function OperationsPage() { : healthIssueCount > 0 ? `协议覆盖 ${sourceIssueCount} 项 · 链路运行 ${infrastructureIssueCount} 项` : '链路、写入与协议覆盖均正常'; + const healthMetrics: WorkspaceQueueMetricRailItem[] = [ + { + label: '整体状态', + value: overallValue, + note: overallDetail, + tone: healthEvidencePending ? 'neutral' : readinessUnavailable || sourceErrorCount > 0 ? 'danger' : healthIssueCount > 0 ? 'warning' : 'success', + emphasis: 'primary' + }, + { + label: '协议就绪 / 总数', + value: sources?.sources.length ? `${sourceHealthyCount} / ${sources.sources.length}` : readinessUnavailable ? '不可用' : '—', + note: readinessUnavailable ? '来源证据读取失败' : sourceIssueCount > 0 ? `${sourceIssueCount} 个协议需要处置` : sources?.sources.length ? '全部协议达到当前口径' : '等待协议覆盖证据', + tone: readinessUnavailable || sourceErrorCount > 0 ? 'danger' : sourceIssueCount > 0 ? 'warning' : sources?.sources.length ? 'success' : 'neutral', + emphasis: 'primary' + }, + { + label: '车辆总数 / 在线', + value: sources ? `${sources.totalVehicles} / ${sources.onlineVehicles}` : '—', + note: sources ? `已绑定 ${sources.boundVehicles} · 待绑定 ${sources.identityRequiredVehicles}` : '档案与快照并集', + emphasis: 'secondary' + }, + { + label: '活跃连接', + value: data?.activeConnections?.toLocaleString('zh-CN') ?? '—', + note: '网关实时连接', + emphasis: 'secondary' + } + ]; 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); @@ -519,18 +535,17 @@ export default function OperationsPage() { description="关键链路、基础设施与车辆覆盖的同一健康快照" meta={{!data ? '正在读取' : healthIssueCount > 0 ? `${healthIssueCount} 项需关注` : '全部正常'}15 秒自动刷新} /> -
- 0 ? 'danger' : healthIssueCount > 0 ? 'warning' : 'success'} /> - 0 ? `${sourceIssueCount} 个协议需要处置` : sources?.sources.length ? '全部协议达到当前口径' : '等待协议覆盖证据'} - tone={readinessUnavailable || sourceErrorCount > 0 ? 'danger' : sourceIssueCount > 0 ? 'warning' : sources?.sources.length ? 'success' : 'neutral'} - /> - - - -
+ + Kafka Lag + {data?.kafkaLag?.toLocaleString('zh-CN') ?? '—'} + {data?.kafkaLag === 0 ? '消费积压已回零' : data ? '存在待消费消息' : '等待消费证据'} + } + /> 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); diff --git a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts index cc8e07c9..f97cf367 100644 --- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts @@ -322,15 +322,17 @@ describe('V2 production entry', () => { expect(workspaceStyles).toContain('grid-template-columns: repeat(2, minmax(0, 1fr));'); expect(workspaceStyles).toContain('Short landscape operations cockpit.'); expect(workspaceStyles).toContain('grid-template-columns: minmax(196px, .42fr) minmax(0, 1fr);'); - expect(workspaceStyles).toContain('.v2-ops-workspace.is-health .v2-ops-metric-rail {'); - expect(workspaceStyles).toContain('grid-template-columns: 1.12fr repeat(4, minmax(0, 1fr));'); + expect(workspaceStyles).toContain('.v2-ops-health-metric-rail.is-queue > .semi-card-body {'); + expect(workspaceStyles).toContain('grid-template-columns: minmax(0, 1fr) 196px;'); expect(workspaceStyles).toContain('.v2-ops-workspace.is-diagnostic {'); expect(workspaceStyles).toContain('grid-template-columns: minmax(176px, .34fr) minmax(0, 1fr);'); expect(corePageSources.OperationsPage).toContain(' { expect(corePageSources.AccessPage).toContain(' .semi-card-body > .v2-workspace-panel-header { min-height: 64px; border-bottom: 1px solid #e7edf4; @@ -5430,26 +5315,6 @@ font-size: 10px; } -@media (max-width: 900px) { - .v2-ops-metric-rail { - display: flex; - overflow-x: auto; - scroll-snap-type: x proximity; - scrollbar-width: thin; - } - - .v2-ops-metric { - min-width: 190px; - flex: 0 0 190px; - scroll-snap-align: start; - } - - .v2-ops-metric:first-child { - min-width: 218px; - flex-basis: 218px; - } -} - @media (max-width: 680px) { .v2-ops-overview > .semi-card-body > .v2-workspace-panel-header { min-height: 58px; @@ -5458,25 +5323,6 @@ .v2-ops-overview-meta > .semi-typography { display: none; } - .v2-ops-metric { - min-width: 156px; - min-height: 96px; - flex-basis: 156px; - padding: 14px 13px 12px; - } - - .v2-ops-metric:first-child { - min-width: 184px; - flex-basis: 184px; - } - - .v2-ops-metric strong { - margin: 8px 0 7px; - font-size: 21px; - } - - .v2-ops-metric span { font-size: 10px; } - .v2-ops-link-card { min-height: 72px; grid-template-columns: minmax(0, 1fr) auto; @@ -14595,21 +14441,6 @@ height: auto; } -.v2-ops-metric { - min-height: 100px; - padding: 14px 16px 13px; -} - -.v2-ops-metric::after { - right: 16px; - left: 16px; -} - -.v2-ops-metric strong { - margin: 8px 0 7px; - font-size: 22px; -} - .v2-ops-workspace.is-diagnostic { display: flex; min-height: 0; @@ -14724,49 +14555,6 @@ min-height: 260px; } - .v2-ops-workspace.is-health .v2-ops-metric-rail { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - overflow: visible; - scroll-snap-type: none; - } - - .v2-ops-workspace.is-health .v2-ops-metric { - min-width: 0; - min-height: 78px; - flex: none; - border-top: 1px solid #e7edf4; - border-left: 0; - padding: 11px 12px 10px; - scroll-snap-align: none; - } - - .v2-ops-workspace.is-health .v2-ops-metric:first-child { - min-width: 0; - min-height: 88px; - grid-column: 1 / -1; - border-top: 0; - padding-inline: 13px; - } - - .v2-ops-workspace.is-health .v2-ops-metric:nth-child(even) { - border-right: 1px solid #e7edf4; - } - - .v2-ops-workspace.is-health .v2-ops-metric strong { - margin: 6px 0 5px; - font-size: 19px; - } - - .v2-ops-workspace.is-health .v2-ops-metric:first-child strong { - font-size: 22px; - } - - .v2-ops-workspace.is-health .v2-ops-metric::after { - right: 12px; - left: 12px; - } - .v2-ops-links .v2-ops-link-card { min-height: 58px; grid-template-columns: minmax(0, 1fr) auto; @@ -14809,9 +14597,6 @@ align-items: stretch; } - .v2-ops-metric { - min-height: 94px; - } } /* @@ -16443,6 +16228,35 @@ color: inherit; } +.v2-ops-health-metric-rail.is-queue.semi-card { + border-bottom: 0; +} + +.v2-ops-health-metric-rail.is-queue > .semi-card-body { + grid-template-columns: minmax(0, 1fr) 196px; +} + +.v2-ops-health-metric-rail .v2-workspace-metric-list { + grid-template-columns: 1.25fr 1.05fr 1fr .9fr; +} + +.v2-ops-health-metric-rail .v2-workspace-metric-list > span.is-primary.is-warning { + background: linear-gradient(145deg, #fff8ed 0%, #fffdf8 100%); + box-shadow: inset 0 -3px #e59a2f; +} + +.v2-ops-health-metric-rail .v2-workspace-metric-list > span.is-primary.is-warning > :is(small, strong) { + color: #9d5d0b; +} + +.v2-ops-health-kafka.is-ok strong { + color: #148459; +} + +.v2-ops-health-kafka.is-warning strong { + color: #a76512; +} + .v2-reconcile-metric-rail { flex: 0 0 auto; } @@ -16474,6 +16288,27 @@ } } +@media (max-width: 900px) and (max-height: 480px) and (orientation: landscape) { + .v2-ops-health-metric-rail.is-queue > .semi-card-body { + min-height: 48px; + grid-template-columns: minmax(0, 1fr) 142px; + } + + .v2-ops-health-metric-rail .v2-workspace-metric-list > span { + min-height: 48px; + padding-block: 4px; + } + + .v2-ops-health-metric-rail .v2-workspace-metric-list > span > strong { + font-size: 15px; + line-height: 18px; + } + + .v2-ops-health-metric-rail .v2-workspace-metric-list > span > em { + display: none; + } +} + @media (max-width: 680px) { .v2-workspace-metric-rail.is-queue > .semi-card-body { min-height: 56px; @@ -20418,52 +20253,6 @@ overflow: hidden; } - .v2-ops-workspace.is-health .v2-ops-metric-rail { - display: grid; - grid-template-columns: 1.12fr repeat(4, minmax(0, 1fr)); - overflow: hidden; - } - - .v2-ops-workspace.is-health .v2-ops-metric, - .v2-ops-workspace.is-health .v2-ops-metric:first-child { - min-width: 0; - min-height: 78px; - grid-column: auto; - border-top: 0; - border-right: 0; - border-left: 1px solid #e7edf4; - padding: 8px 9px 7px; - } - - .v2-ops-workspace.is-health .v2-ops-metric:first-child { - border-left: 0; - } - - .v2-ops-workspace.is-health .v2-ops-metric small { - font-size: 8px; - } - - .v2-ops-workspace.is-health .v2-ops-metric strong, - .v2-ops-workspace.is-health .v2-ops-metric:first-child strong { - margin: 6px 0 5px; - font-size: 15px; - } - - .v2-ops-workspace.is-health .v2-ops-metric:first-child strong { - font-size: 16px; - } - - .v2-ops-workspace.is-health .v2-ops-metric span { - font-size: 8px; - line-height: 1.3; - } - - .v2-ops-workspace.is-health .v2-ops-metric::after { - right: 9px; - left: 9px; - height: 2px; - } - .v2-ops-workspace.is-diagnostic { display: grid; min-height: 0;