refine Semi UI operations health workspace

This commit is contained in:
lingniu
2026-07-18 02:02:40 +08:00
parent f3c1391322
commit da9619617d
4 changed files with 289 additions and 16 deletions

View File

@@ -170,17 +170,23 @@ test('reconciles service identities with bound and identity-required vehicles',
}
expect(screen.getByText('服务身份 / 在线')).toBeInTheDocument();
expect(screen.getByText('已绑定 1024 · 待绑定 11')).toBeInTheDocument();
expect(screen.getByText('服务身份 / 在线').closest('.semi-card')).toHaveClass('v2-ops-kpi-card');
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('全部正常')).toBeInTheDocument();
expect(screen.getByText('运行时安全').closest('.semi-card')).toHaveClass('v2-ops-runtime');
expect(screen.getByText('协议来源就绪度').closest('.semi-card')).toHaveClass('v2-ops-sources');
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.semi-card')).toHaveTextContent('MySQL主库连接正常');
expect(document.querySelector('.v2-ops-link-card')).toHaveTextContent('MySQL主库连接正常');
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');
expect(document.querySelector('.v2-ops-source-list.semi-card-group')).toBeInTheDocument();
expect(screen.getByText('GB32960').closest('.semi-card')).toHaveClass('v2-ops-source-card');
expect(screen.getByLabelText('GB32960 在线覆盖率')).toBeInTheDocument();
expect(screen.getByText('22.9% 在线')).toBeInTheDocument();
expect(screen.getByText('验收标准').closest('.semi-card')).toHaveClass('v2-ops-source-card');
expect(screen.queryByText('单车多来源诊断')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: '单车诊断' }));

View File

@@ -1,5 +1,5 @@
import { IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, Progress, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, useDeferredValue, useEffect, useState } from 'react';
import { api } from '../../api/client';
@@ -36,6 +36,24 @@ 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 <article className={`v2-ops-metric is-${tone}`} role="listitem">
<small>{label}</small>
<strong>{value}</strong>
<span>{detail}</span>
</article>;
}
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;
}
function SourcePolicyEditor({ vin, source, diagnostic, editable, onSaved }: {
vin: string;
source: VehicleLocationSourceEvidence;
@@ -316,6 +334,11 @@ export default function OperationsPage() {
const readiness = useQuery({ queryKey: ['ops-source-readiness-v2'], queryFn: ({ signal }) => api.sourceReadiness(signal), refetchInterval: 30_000, staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime, ...LIVE_QUERY_POLICY });
const data = health.data; const sources = readiness.data;
const refresh = () => Promise.all([health.refetch(), readiness.refetch()]);
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';
return <div className="v2-ops-page">
<PageHeader
title="运维质量"
@@ -343,24 +366,35 @@ export default function OperationsPage() {
{workspace === 'health' ? <>
{health.isError ? <InlineError message={health.error.message} onRetry={refresh} /> : null}
{readiness.isError ? <InlineError message={readiness.error instanceof Error ? readiness.error.message : '协议来源就绪度读取失败'} onRetry={() => readiness.refetch()} /> : null}
<section className="v2-ops-kpis">
<Card className="v2-ops-kpi-card" bodyStyle={{ padding: 0 }}><small></small><strong>{data?.runtime.dataMode === 'production' ? '生产数据' : '待确认'}</strong><HealthTag status={data?.runtime.dataMode === 'production' ? 'ok' : 'error'}>{data?.runtime.platformRelease ? '版本已注入' : '缺少版本'}</HealthTag></Card>
<Card className="v2-ops-kpi-card" bodyStyle={{ padding: 0 }}><small></small><strong>{data?.activeConnections?.toLocaleString('zh-CN') ?? '—'}</strong><span></span></Card>
<Card className="v2-ops-kpi-card" bodyStyle={{ padding: 0 }}><small>Kafka Lag</small><strong>{data?.kafkaLag?.toLocaleString('zh-CN') ?? '—'}</strong><HealthTag status={data?.kafkaLag === 0 ? 'ok' : 'warning'}>{data?.kafkaLag === 0 ? '已回零' : '需检查'}</HealthTag></Card>
<Card className="v2-ops-kpi-card" bodyStyle={{ padding: 0 }}><small>Redis 线 Key</small><strong>{data?.redisOnlineKeys?.toLocaleString('zh-CN') ?? '—'}</strong><span>线</span></Card>
<Card className="v2-ops-kpi-card" bodyStyle={{ padding: 0 }}><small> / 线</small><strong>{sources ? `${sources.totalVehicles} / ${sources.onlineVehicles}` : '—'}</strong><span>{sources ? `已绑定 ${sources.boundVehicles} · 待绑定 ${sources.identityRequiredVehicles}` : '档案与快照并集'}</span></Card>
<Card className="v2-ops-overview" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="运行概览"
description="关键链路、基础设施与车辆覆盖的同一健康快照"
meta={<span className="v2-ops-overview-meta"><HealthTag status={overallStatus}>{!data ? '正在读取' : healthIssueCount > 0 ? `${healthIssueCount} 项需关注` : '全部正常'}</HealthTag><Typography.Text type="tertiary">15 </Typography.Text></span>}
/>
<section className="v2-ops-metric-rail" role="list" aria-label="运行健康指标">
<OpsMetric label="整体状态" value={!data ? '读取中' : healthIssueCount > 0 ? '需要处理' : '运行正常'} detail={!data ? '等待服务端健康证据' : healthIssueCount > 0 ? `${healthIssueCount} 项证据需要关注` : '链路与写入探针均正常'} tone={!data ? 'neutral' : healthIssueCount > 0 ? 'warning' : 'success'} />
<OpsMetric label="活跃连接" value={data?.activeConnections?.toLocaleString('zh-CN') ?? '—'} detail="网关实时连接" />
<OpsMetric label="Kafka Lag" value={data?.kafkaLag?.toLocaleString('zh-CN') ?? '—'} detail={data?.kafkaLag === 0 ? '消费积压已回零' : data ? '存在待消费消息' : '等待消费证据'} tone={data?.kafkaLag === 0 ? 'success' : data ? 'warning' : 'neutral'} />
<OpsMetric label="Redis 在线 Key" value={data?.redisOnlineKeys?.toLocaleString('zh-CN') ?? '—'} detail="在线状态快照" />
<OpsMetric label="服务身份 / 在线" value={sources ? `${sources.totalVehicles} / ${sources.onlineVehicles}` : '—'} detail={sources ? `已绑定 ${sources.boundVehicles} · 待绑定 ${sources.identityRequiredVehicles}` : '档案与快照并集'} />
</section>
<div className="v2-ops-grid"><Card className="v2-ops-panel v2-ops-links" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="数据链路" meta="15 秒自动刷新" /><div className="v2-ops-link-list">{data?.linkHealth.length ? data.linkHealth.map((item) => <Card className={`v2-ops-link-card is-${item.status}`} key={item.name} bodyStyle={{ padding: 0 }}><span className="v2-ops-link-status"><i /><strong>{item.name}</strong></span><p>{item.detail || '无补充信息'}</p><HealthTag status={item.status} /></Card>) : <Empty className="v2-ops-link-empty" title={data ? '暂无链路探针' : '正在读取链路状态'} description={data ? '当前响应没有可展示的数据链路证据。' : '全局健康数据返回后将在这里更新。'} />}</div></Card>
<Card className="v2-ops-panel v2-ops-runtime" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="运行时安全" /><Descriptions className="v2-ops-runtime-descriptions" align="left" size="small" data={[
</Card>
<div className="v2-ops-grid"><Card className="v2-ops-panel v2-ops-links" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="数据链路" description="按链路查看当前状态与服务端诊断证据" meta={<HealthTag status={!data ? 'unknown' : linkIssueCount > 0 ? 'warning' : 'ok'}>{!data ? '读取中' : linkIssueCount > 0 ? `${linkIssueCount} 条异常` : '链路正常'}</HealthTag>} /><div className="v2-ops-link-list" role="list">{data?.linkHealth.length ? data.linkHealth.map((item) => <article className={`v2-ops-link-card is-${item.status}`} key={item.name} role="listitem"><span className="v2-ops-link-status"><i /><strong>{item.name}</strong></span><p>{item.detail || '无补充信息'}</p><HealthTag status={item.status} /></article>) : <Empty className="v2-ops-link-empty" title={data ? '暂无链路探针' : '正在读取链路状态'} description={data ? '当前响应没有可展示的数据链路证据。' : '全局健康数据返回后将在这里更新。'} />}</div></Card>
<Card className="v2-ops-panel v2-ops-runtime" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="运行时安全" description="生产模式、写入探针与代理配置" meta={<HealthTag status={!data ? 'unknown' : runtimeIssueCount + capacityIssueCount > 0 ? 'warning' : 'ok'}>{!data ? '读取中' : runtimeIssueCount + capacityIssueCount > 0 ? `${runtimeIssueCount + capacityIssueCount} 项关注` : '配置正常'}</HealthTag>} /><Descriptions className="v2-ops-runtime-descriptions" align="left" size="small" data={[
{ key: '生产数据模式', value: <HealthTag status={data?.runtime.dataMode === 'production' ? 'ok' : 'error'}>{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'}</HealthTag> },
{ key: 'MySQL 写探针', value: <HealthTag status={data?.mysqlWritable ? 'ok' : 'error'}>{data?.mysqlWritable ? '正常' : '异常'}</HealthTag> },
{ key: 'TDengine 写探针', value: <HealthTag status={data?.tdengineWritable ? 'ok' : 'error'}>{data?.tdengineWritable ? '正常' : '异常'}</HealthTag> },
{ key: '请求超时', value: `${data?.runtime.requestTimeoutMs ?? '—'} ms` },
{ key: '高德安全代理', value: <HealthTag status={data?.runtime.amapSecurityProxyEnabled && !data?.runtime.amapSecurityCodeExposed ? 'ok' : 'warning'}>{data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'}</HealthTag> }
]} />{data?.capacityFindings?.length ? <div className="v2-ops-capacity-findings">{data.capacityFindings.map((item) => <Card className="v2-ops-capacity-finding" key={item}><HealthTag status="warning"></HealthTag><p>{item}</p></Card>)}</div> : <Empty className="v2-ops-capacity-empty" image={<IconTickCircle />} title="容量检查通过" description="当前没有需要处理的容量风险。" />}</Card></div>
<Card className="v2-ops-panel v2-ops-sources" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="协议来源就绪度" description="验收口径与处置建议" />{sources ? sources.sources.length ? <CardGroup className="v2-ops-source-list" type="grid" spacing={0}>{sources.sources.map((source) => <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.online} / ${source.total} 在线`}</HealthTag>} headerLine>
<Card className="v2-ops-panel v2-ops-sources" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="协议来源就绪度" description="在线覆盖、当前证据、处置动作与验收口径" meta={<Tag color="blue" type="light" size="small">{sources ? `${sources.sources.length} 个协议` : '正在读取'}</Tag>} />{sources ? sources.sources.length ? <CardGroup className="v2-ops-source-list" type="grid" spacing={0}>{sources.sources.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> : <Empty className="v2-ops-source-empty" title="暂无协议来源" description="当前响应没有可展示的协议来源就绪度。" /> : <div className="v2-ops-source-loading" role="status"><Spin size="middle" tip="正在读取协议来源就绪度" /></div>}</Card>
</Card>;
})}</CardGroup> : <Empty className="v2-ops-source-empty" title="暂无协议来源" description="当前响应没有可展示的协议来源就绪度。" /> : <div className="v2-ops-source-loading" role="status"><Spin size="middle" tip="正在读取协议来源就绪度" /></div>}</Card>
</> : null}
</section>
</div>;

View File

@@ -244,9 +244,11 @@ describe('V2 production entry', () => {
expect(corePageSources.OperationsPage).toContain('<Checkbox');
expect(corePageSources.OperationsPage).toContain('<Input');
expect(corePageSources.OperationsPage).toContain('<Card className="v2-source-diagnostic"');
expect(corePageSources.OperationsPage).toContain('<Card className="v2-ops-kpi-card"');
expect(corePageSources.OperationsPage).toContain('<Card className="v2-ops-overview"');
expect(corePageSources.OperationsPage).toContain('<OpsMetric');
expect(corePageSources.OperationsPage).toContain('<Card className="v2-ops-panel v2-ops-links"');
expect(corePageSources.OperationsPage).toContain('<Card className={`v2-ops-link-card');
expect(corePageSources.OperationsPage).toContain('<article className={`v2-ops-link-card');
expect(corePageSources.OperationsPage).not.toContain('<Card className={`v2-ops-link-card');
expect(corePageSources.OperationsPage).toContain('<Descriptions className="v2-ops-runtime-descriptions"');
expect(corePageSources.OperationsPage).toContain('<Empty className="v2-ops-capacity-empty"');
expect(corePageSources.OperationsPage).toContain('<CardGroup className="v2-ops-source-list"');

View File

@@ -2479,6 +2479,237 @@
}
}
/* Semi UI operations health: one overview rail, evidence-first rows and compact protocol coverage. */
.v2-ops-overview.semi-card {
min-width: 0;
overflow: hidden;
border: 1px solid #dce5ef;
border-radius: 12px;
background: #fff;
box-shadow: 0 8px 28px rgba(31, 53, 80, .055);
}
.v2-ops-overview > .semi-card-body > .v2-workspace-panel-header {
min-height: 64px;
border-bottom: 1px solid #e7edf4;
padding: 11px 16px;
}
.v2-ops-overview-meta {
display: flex;
align-items: center;
gap: 10px;
}
.v2-ops-overview-meta > .semi-typography {
font-size: 11px;
white-space: nowrap;
}
.v2-ops-metric-rail {
display: grid;
grid-template-columns: 1.2fr repeat(4, minmax(0, 1fr));
background: #fff;
}
.v2-ops-metric {
position: relative;
min-width: 0;
min-height: 112px;
padding: 17px 18px 15px;
}
.v2-ops-metric + .v2-ops-metric {
border-left: 1px solid #e7edf4;
}
.v2-ops-metric::after {
position: absolute;
right: 18px;
bottom: 0;
left: 18px;
height: 3px;
border-radius: 999px 999px 0 0;
background: #dce5ef;
content: "";
}
.v2-ops-metric.is-success::after { background: var(--v2-green); }
.v2-ops-metric.is-warning::after { background: var(--v2-orange); }
.v2-ops-metric.is-danger::after { background: var(--v2-red); }
.v2-ops-metric small {
display: block;
overflow: hidden;
color: #6b7c92;
font-size: 11px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-ops-metric strong {
display: block;
margin: 10px 0 8px;
overflow: hidden;
color: #20364f;
font-size: 24px;
font-variant-numeric: tabular-nums;
letter-spacing: -.025em;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-ops-metric.is-success strong { color: #16835d; }
.v2-ops-metric.is-warning strong { color: #a66500; }
.v2-ops-metric.is-danger strong { color: #cf3f3b; }
.v2-ops-metric span {
display: block;
overflow: hidden;
color: #8390a2;
font-size: 10px;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-ops-panel > .semi-card-body > .v2-workspace-panel-header {
min-height: 64px;
border-bottom: 1px solid #e7edf4;
padding: 11px 15px;
}
.v2-ops-link-card {
display: grid;
min-height: 62px;
grid-template-columns: minmax(140px, .7fr) minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
border-bottom: 1px solid #e8edf3;
background: #fff;
padding: 10px 15px;
}
.v2-ops-link-card:last-child { border-bottom: 0; }
.v2-ops-source-list.semi-card-group {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.v2-ops-source-card.semi-card {
box-shadow: inset 0 3px #d6e0ec, 0 5px 16px rgba(31, 53, 80, .045);
}
.v2-ops-source-card.is-ok { box-shadow: inset 0 3px var(--v2-green), 0 5px 16px rgba(31, 53, 80, .045); }
.v2-ops-source-card.is-warning { box-shadow: inset 0 3px var(--v2-orange), 0 5px 16px rgba(31, 53, 80, .045); }
.v2-ops-source-card.is-error { box-shadow: inset 0 3px var(--v2-red), 0 5px 16px rgba(31, 53, 80, .045); }
.v2-ops-source-coverage {
display: grid;
gap: 7px;
border-bottom: 1px solid #e9eef4;
padding: 13px;
background: #fff;
}
.v2-ops-source-coverage > span {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.v2-ops-source-coverage strong {
color: #41556d;
font-size: 11px;
}
.v2-ops-source-coverage b {
color: #263d57;
font-size: 17px;
font-variant-numeric: tabular-nums;
letter-spacing: -.02em;
}
.v2-ops-source-coverage .semi-progress {
width: 100%;
margin: 0;
}
.v2-ops-source-coverage small {
color: #748398;
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;
padding: 9px 12px;
}
.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;
gap: 5px 10px;
padding: 10px 12px;
}
.v2-ops-link-card p {
grid-column: 1 / -1;
white-space: normal;
}
.v2-ops-source-list.semi-card-group {
display: flex;
grid-template-columns: none;
}
.v2-ops-source-coverage b { font-size: 16px; }
}
/* Unified Semi UI state language: page, panel and inline recovery surfaces. */
.v2-state-surface {
box-sizing: border-box;