feat(platform): add ops capacity action board

This commit is contained in:
lingniu
2026-07-04 21:54:28 +08:00
parent e6d639572d
commit c73c46ca5d
3 changed files with 190 additions and 0 deletions

View File

@@ -57,6 +57,16 @@ type CapacityMetricRow = {
detail: string;
};
type OpsActionItem = {
key: string;
title: string;
level: 'P0' | 'P1' | 'P2';
owner: string;
evidence: string;
action: string;
acceptance: string;
};
const emptyCapacityMetrics: CapacityMetrics = {
activeConnections: 0,
kafkaLag: 0,
@@ -256,6 +266,124 @@ function opsCapacityHandoffText(health: OpsHealth | null) {
].join('\n');
}
function opsActionItems(health: OpsHealth | null): OpsActionItem[] {
const metrics = getCapacityMetrics(health);
const actions: OpsActionItem[] = [];
if (metrics.bridgeAckPending > 0 || metrics.bridgeConsumerPending > 10000) {
actions.push({
key: 'nats-kafka-bridge',
title: '稳定 NATS 到 Kafka 桥接',
level: metrics.bridgeAckPending > 100 ? 'P0' : 'P1',
owner: '接入网关 / 消息队列',
evidence: `ACK ${formatNumber(metrics.bridgeAckPending)},待消费 ${formatNumber(metrics.bridgeConsumerPending)}`,
action: '检查 Kafka broker、topic 写入、NATS bridge 日志和服务器磁盘;必要时临时扩容桥接消费者。',
acceptance: 'ACK pending 回到 0consumer pending 连续下降Kafka Lag 不再增长。'
});
}
if (metrics.fastWriterAckPending > 0 || metrics.fastWriterConsumerPending > 10000) {
actions.push({
key: 'fast-writer',
title: '保护实时写入通道',
level: metrics.fastWriterAckPending > 10 ? 'P0' : 'P1',
owner: '实时投影 / 存储',
evidence: `ACK ${formatNumber(metrics.fastWriterAckPending)},待消费 ${formatNumber(metrics.fastWriterConsumerPending)}`,
action: '优先确认 Redis 在线投影和 TDengine 快写是否正常,再看字段扁平化写入耗时。',
acceptance: '实时 KV 延迟恢复到秒级内,在线 TTL 正常续期,快速写入 pending 清零。'
});
}
if (metrics.historyBatchPending > 0 || metrics.historyRowsPending > 0 || metrics.kafkaLag > 0) {
actions.push({
key: 'history-consumer',
title: '疏通历史与统计消费',
level: metrics.kafkaLag > 1000 || metrics.historyRowsPending > 10000 ? 'P0' : 'P1',
owner: '历史消费 / 统计',
evidence: `Kafka Lag ${formatNumber(metrics.kafkaLag)},历史待写 ${formatNumber(metrics.historyRowsPending)}`,
action: '查看 Kafka 消费者组、TDengine 批量写入错误和统计任务耗时恢复后补看轨迹、RAW、里程统计。',
acceptance: 'Lag 持续归零,历史待写行归零,按 VIN 的历史查询恢复毫秒到百毫秒级。'
});
}
if (metrics.activeConnections > 100000) {
actions.push({
key: 'gateway-capacity',
title: '扩容接入网关容量',
level: 'P1',
owner: '网关 / ECS',
evidence: `活跃连接 ${formatNumber(metrics.activeConnections)}`,
action: '检查 FD、CPU、网卡、端口 accept backlog 和协议源分布;准备按协议拆分实例。',
acceptance: '连接数增长时 CPU 和内存稳定,登录、注册、位置、实时帧响应无超时。'
});
}
if (health && (!health.tdengineWritable || !health.mysqlWritable)) {
actions.push({
key: 'storage-writable',
title: '恢复核心存储可写',
level: 'P0',
owner: '数据库 / 应用',
evidence: `TDengine ${statusText(health.tdengineWritable)}MySQL ${statusText(health.mysqlWritable)}`,
action: '先确认网络和账号,再看连接池、慢写、磁盘和表结构变更;避免实时链路被关系库写入拖慢。',
acceptance: '健康检查全部恢复正常RAW、location、snapshot 和身份表写入成功。'
});
}
if (health && (health.activeConnections ?? 0) > 0 && (health.redisOnlineKeys ?? 0) === 0) {
actions.push({
key: 'redis-online',
title: '校验 Redis 在线投影',
level: 'P1',
owner: '实时投影',
evidence: `活跃连接 ${formatNumber(health.activeConnections)},在线 Key ${formatNumber(health.redisOnlineKeys)}`,
action: '确认只有实时数据帧进入 Redis检查 online TTL 写入、VIN 解析和协议过滤条件。',
acceptance: '按协议、VIN 的在线状态可查,离线时间统计有连续依据。'
});
}
if (health?.runtime?.amapWebJsConfigured && !health.runtime.amapSecurityProxyEnabled) {
actions.push({
key: 'amap-security',
title: '收敛地图密钥暴露风险',
level: health.runtime.amapSecurityCodeExposed ? 'P1' : 'P2',
owner: '前端 / 平台 API',
evidence: amapSecurityText(health),
action: '启用高德安全代理,前端只保留 Web JS Key服务端负责逆地理和安全密钥。',
acceptance: '轨迹回放和实时地图可用,浏览器端不暴露安全密钥。'
});
}
if (!actions.length) {
actions.push({
key: 'steady-watch',
title: '维持生产容量巡检',
level: 'P2',
owner: '值班 / 平台',
evidence: '当前未发现积压或核心写入异常',
action: '继续观察 32960、808、MQTT 三类实时数据源、在线状态、地图服务和历史查询耗时。',
acceptance: '10W 车辆接入准备项持续无红色风险,实时投影和历史消费无积压。'
});
}
return actions;
}
function opsCapacityActionPlanText(health: OpsHealth | null) {
const actions = opsActionItems(health);
return [
'【车辆数据中台容量处置计划】',
`生成版本:${health?.runtime?.platformRelease || '-'}`,
...actions.map((item, index) => [
`${index + 1}. ${item.level} ${item.title}`,
`负责人:${item.owner}`,
`证据:${item.evidence}`,
`动作:${item.action}`,
`验收:${item.acceptance}`
].join('\n'))
].join('\n\n');
}
async function copyText(value: string, label: string) {
try {
await navigator.clipboard.writeText(value);
@@ -285,6 +413,7 @@ export function OpsQuality() {
const capacityRisks = (health?.capacityFindings ?? []).map(capacityRisk);
const capacityRows = capacityMetricRows(health);
const bridgeRisks = capacityRisks.filter((item) => item.category === 'NATS -> Kafka');
const actionItems = opsActionItems(health);
return (
<div className="vp-page">
@@ -294,6 +423,7 @@ export function OpsQuality() {
actions={(
<Space>
<Button size="small" icon={<IconCopy />} aria-label="复制容量交接" disabled={!health} onClick={() => copyText(opsCapacityHandoffText(health), '运维容量交接')}></Button>
<Button size="small" icon={<IconCopy />} aria-label="复制处置计划" disabled={!health} onClick={() => copyText(opsCapacityActionPlanText(health), '容量处置计划')}></Button>
<Button size="small" loading={loading} onClick={load}></Button>
</Space>
)}
@@ -372,6 +502,23 @@ export function OpsQuality() {
/>
</Card>
<Card bordered title="容量风险处置工作台" loading={loading} style={{ marginTop: 16 }}>
<div className="vp-ops-action-grid">
{actionItems.map((item) => (
<div key={item.key} className="vp-ops-action-item">
<Space wrap>
<Tag color={item.level === 'P0' ? 'red' : item.level === 'P1' ? 'orange' : 'green'}>{item.level}</Tag>
<Tag color="blue">{item.owner}</Tag>
</Space>
<Typography.Title heading={6} style={{ margin: 0 }}>{item.title}</Typography.Title>
<div className="vp-ops-action-evidence">{item.evidence}</div>
<Typography.Text type="secondary">{item.action}</Typography.Text>
<div className="vp-ops-action-acceptance">{item.acceptance}</div>
</div>
))}
</div>
</Card>
{capacityRisks.length ? (
<Card bordered title="容量与风险发现" style={{ marginTop: 16 }}>
<div className="vp-risk-grid">

View File

@@ -881,6 +881,39 @@ button.vp-realtime-command-item:focus-visible {
gap: 10px;
}
.vp-ops-action-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.vp-ops-action-item {
min-height: 196px;
padding: 12px;
border: 1px solid var(--vp-border);
border-radius: var(--vp-radius);
background: #fbfcff;
display: grid;
gap: 10px;
align-content: start;
}
.vp-ops-action-evidence {
color: var(--vp-text);
font-size: 18px;
font-weight: 700;
line-height: 26px;
word-break: break-word;
}
.vp-ops-action-acceptance {
padding-top: 8px;
border-top: 1px solid var(--vp-border);
color: var(--vp-text-muted);
font-size: 13px;
line-height: 20px;
}
.vp-alert-ops-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(360px, 0.6fr);
@@ -1702,6 +1735,7 @@ button.vp-realtime-command-item:focus-visible {
.vp-alert-command-grid,
.vp-sla-summary,
.vp-risk-grid,
.vp-ops-action-grid,
.vp-alert-ops-grid,
.vp-stat-workspace,
.vp-stat-insight-grid,

View File

@@ -3645,6 +3645,10 @@ test('renders ops quality as a standalone runtime health page', async () => {
expect(screen.getByText('历史行待写')).toBeInTheDocument();
expect(screen.getByText('1,482,047')).toBeInTheDocument();
expect(screen.getByText('4,000')).toBeInTheDocument();
expect(screen.getByText('容量风险处置工作台')).toBeInTheDocument();
expect(screen.getByText('稳定 NATS 到 Kafka 桥接')).toBeInTheDocument();
expect(screen.getByText('恢复核心存储可写')).toBeInTheDocument();
expect(screen.getByText(/历史待写 320 行/)).toBeInTheDocument();
expect(screen.getByText('Kafka lag 42')).toBeInTheDocument();
expect(screen.getByText('NATS 桥接 ACK 未确认')).toBeInTheDocument();
expect(screen.getAllByText('NATS 桥接待消费').length).toBeGreaterThanOrEqual(1);
@@ -3658,6 +3662,11 @@ test('renders ops quality as a standalone runtime health page', async () => {
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('NATS桥接consumer pending 1,482,047 / ack pending 4,000'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('存储状态TDengine 正常 / MySQL 异常'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('1. 先看 Kafka Lag 与 NATS ACK pending'));
fireEvent.click(screen.getByRole('button', { name: '复制处置计划' }));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【车辆数据中台容量处置计划】'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('P0 稳定 NATS 到 Kafka 桥接'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('负责人:数据库 / 应用'));
});
test('copies notification text from quality priority queue', async () => {