Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/OpsQuality.tsx
2026-07-04 20:37:20 +08:00

416 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Button, Card, Space, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
import { IconCopy } from '@douyinfe/semi-icons';
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { CapacityMetrics, LinkHealth, OpsHealth } from '../api/types';
import { PageHeader } from '../components/PageHeader';
const statusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
ok: 'green',
warning: 'orange',
error: 'red'
};
function formatNumber(value?: number | null) {
return value == null ? '-' : value.toLocaleString();
}
function statusText(ok?: boolean) {
return ok ? '正常' : '异常';
}
function boolColor(ok?: boolean): 'green' | 'red' {
return ok ? 'green' : 'red';
}
function amapSecurityText(health: OpsHealth | null) {
const runtime = health?.runtime;
if (!runtime?.amapWebJsConfigured) return '地图未配置';
if (runtime.amapSecurityCodeExposed) return '安全码已暴露';
if (runtime.amapSecurityProxyEnabled) return '安全码未暴露';
return '安全代理未启用';
}
function amapSecurityColor(health: OpsHealth | null): 'green' | 'orange' | 'red' {
const runtime = health?.runtime;
if (runtime?.amapSecurityCodeExposed) return 'red';
if (runtime?.amapWebJsConfigured && runtime?.amapSecurityProxyEnabled) return 'green';
return 'orange';
}
type CapacityRisk = {
key: string;
title: string;
value: string;
category: string;
severity: 'warning' | 'error';
finding: string;
action: string;
};
type CapacityMetricRow = {
key: keyof CapacityMetrics;
name: string;
value: number;
threshold: string;
status: 'ok' | 'warning' | 'error';
detail: string;
};
const emptyCapacityMetrics: CapacityMetrics = {
activeConnections: 0,
kafkaLag: 0,
bridgeConsumerPending: 0,
bridgeAckPending: 0,
bridgeBatchPendingMessages: 0,
fastWriterConsumerPending: 0,
fastWriterAckPending: 0,
fastWriterBatchPending: 0,
historyBatchPending: 0,
historyRowsPending: 0
};
function getCapacityMetrics(health: OpsHealth | null): CapacityMetrics {
return {
...emptyCapacityMetrics,
...(health?.capacityMetrics ?? {}),
activeConnections: health?.capacityMetrics?.activeConnections ?? health?.activeConnections ?? 0,
kafkaLag: health?.capacityMetrics?.kafkaLag ?? health?.kafkaLag ?? 0
};
}
function metricStatus(value: number, warningAt: number, errorAt?: number): 'ok' | 'warning' | 'error' {
if (errorAt != null && value > errorAt) return 'error';
return value > warningAt ? 'warning' : 'ok';
}
function capacityMetricRows(health: OpsHealth | null): CapacityMetricRow[] {
const metrics = getCapacityMetrics(health);
return [
{
key: 'bridgeConsumerPending',
name: 'NATS 桥接待消费',
value: metrics.bridgeConsumerPending,
threshold: '> 10,000 预警',
status: metricStatus(metrics.bridgeConsumerPending, 10000),
detail: 'RAW/FIELDS 从 NATS 进入 Kafka 前的待消费量。'
},
{
key: 'bridgeAckPending',
name: 'NATS 桥接 ACK',
value: metrics.bridgeAckPending,
threshold: '> 100 严重',
status: metricStatus(metrics.bridgeAckPending, 0, 100),
detail: '桥接消费者已拉取但未确认的消息,持续不为 0 需要优先排查。'
},
{
key: 'fastWriterConsumerPending',
name: '快速写入待消费',
value: metrics.fastWriterConsumerPending,
threshold: '> 10,000 预警',
status: metricStatus(metrics.fastWriterConsumerPending, 10000),
detail: '实时数据写 TDengine、Redis 前的 NATS 待消费量。'
},
{
key: 'fastWriterAckPending',
name: '快速写入 ACK',
value: metrics.fastWriterAckPending,
threshold: '> 10 严重',
status: metricStatus(metrics.fastWriterAckPending, 0, 10),
detail: '快速写入消费者已拉取但未确认的消息。'
},
{
key: 'historyBatchPending',
name: '历史批次待写',
value: metrics.historyBatchPending,
threshold: '> 0 预警',
status: metricStatus(metrics.historyBatchPending, 0),
detail: '历史落库批处理内存队列,正常应快速归零。'
},
{
key: 'historyRowsPending',
name: '历史行待写',
value: metrics.historyRowsPending,
threshold: '> 0 预警',
status: metricStatus(metrics.historyRowsPending, 0),
detail: '历史落库批处理待写行数,用于判断 TDengine/MySQL 写入是否滞后。'
},
{
key: 'kafkaLag',
name: 'Kafka 消费 Lag',
value: metrics.kafkaLag,
threshold: '> 0 预警',
status: metricStatus(metrics.kafkaLag, 0),
detail: '后续统计和历史消费链路的 Kafka 积压。'
},
{
key: 'activeConnections',
name: '网关活跃连接',
value: metrics.activeConnections,
threshold: '> 100,000 预警',
status: metricStatus(metrics.activeConnections, 100000),
detail: '32960、808、MQTT 当前活跃连接总量。'
}
];
}
function firstNumber(value: string) {
const match = value.match(/[\d,.]+/);
return match ? match[0] : '-';
}
function capacityRisk(finding: string): CapacityRisk {
const lower = finding.toLowerCase();
if (lower.includes('bridge') && lower.includes('ack pending')) {
return {
key: finding,
title: 'NATS 桥接 ACK 未确认',
value: firstNumber(finding),
category: 'NATS -> Kafka',
severity: 'error',
finding,
action: '优先检查 Kafka 是否监听 9092、磁盘是否打满以及桥接服务是否持续写入成功。'
};
}
if (lower.includes('bridge') && lower.includes('consumer pending')) {
return {
key: finding,
title: 'NATS 桥接待消费',
value: firstNumber(finding),
category: 'NATS -> Kafka',
severity: 'warning',
finding,
action: '确认 Kafka 已恢复后观察 pending 是否持续下降;若不下降,扩容桥接或降低 Kafka topic 保留压力。'
};
}
if (lower.includes('kafka') || lower.includes('lag')) {
return {
key: finding,
title: 'Kafka 消费积压',
value: firstNumber(finding),
category: 'Kafka',
severity: 'warning',
finding,
action: '检查消费者组 lag、topic retention、broker 磁盘和生产写入错误。'
};
}
if (lower.includes('redis')) {
return {
key: finding,
title: 'Redis 在线投影异常',
value: firstNumber(finding),
category: 'Redis',
severity: 'warning',
finding,
action: '核对 realtime KV、online TTL 和协议实时帧过滤,确认车辆在线统计来源。'
};
}
if (lower.includes('connection')) {
return {
key: finding,
title: '网关连接压力',
value: firstNumber(finding),
category: 'Gateway',
severity: 'warning',
finding,
action: '查看 32960、808、MQTT 连接数、系统 FD、CPU 和接入端口队列。'
};
}
return {
key: finding,
title: '容量风险',
value: firstNumber(finding),
category: 'General',
severity: 'warning',
finding,
action: '进入对应链路日志和指标继续定位,确认是否影响车辆实时服务。'
};
}
function opsCapacityHandoffText(health: OpsHealth | null) {
const metrics = getCapacityMetrics(health);
const risks = (health?.capacityFindings ?? []).map(capacityRisk);
const unhealthyLinks = (health?.linkHealth ?? []).filter((item) => item.status !== 'ok');
const runtime = health?.runtime;
return [
'【车辆数据中台运维容量交接】',
`运行版本:${runtime?.platformRelease || '-'}`,
`Kafka Lag${formatNumber(metrics.kafkaLag)}`,
`活跃连接:${formatNumber(metrics.activeConnections)}`,
`Redis 在线 Key${formatNumber(health?.redisOnlineKeys)}`,
`NATS桥接consumer pending ${formatNumber(metrics.bridgeConsumerPending)} / ack pending ${formatNumber(metrics.bridgeAckPending)} / batch pending ${formatNumber(metrics.bridgeBatchPendingMessages)}`,
`快速写入consumer pending ${formatNumber(metrics.fastWriterConsumerPending)} / ack pending ${formatNumber(metrics.fastWriterAckPending)} / batch pending ${formatNumber(metrics.fastWriterBatchPending)}`,
`历史写入batch pending ${formatNumber(metrics.historyBatchPending)} / rows pending ${formatNumber(metrics.historyRowsPending)}`,
`存储状态TDengine ${statusText(health?.tdengineWritable)} / MySQL ${statusText(health?.mysqlWritable)}`,
`地图配置Web JS ${runtime?.amapWebJsConfigured ? '已配置' : '未配置'} / API ${runtime?.amapApiConfigured ? '已配置' : '未配置'} / ${amapSecurityText(health)}`,
`异常链路:${unhealthyLinks.length > 0 ? unhealthyLinks.map((item) => `${item.name}=${item.status}(${item.detail || '-'})`).join('') : '正常'}`,
'',
'容量发现:',
...(risks.length > 0 ? risks.map((risk, index) => `${index + 1}. [${risk.category}] ${risk.finding} -> ${risk.action}`) : ['暂无容量风险']),
'',
'处置顺序:',
'1. 先看 Kafka Lag 与 NATS ACK pending确认消息是否卡在桥接或下游消费',
'2. 再看快速写入 pending确认 Redis/TDengine 实时链路是否影响在线和实时数据',
'3. 再看历史写入 pending确认轨迹、RAW、统计是否滞后',
'4. 最后核对 MySQL、TDengine、Redis 和地图配置,确认中台查询体验是否受影响'
].join('\n');
}
async function copyText(value: string, label: string) {
try {
await navigator.clipboard.writeText(value);
Toast.success(`已复制${label}`);
} catch {
Toast.error(`复制${label}失败`);
}
}
export function OpsQuality() {
const [health, setHealth] = useState<OpsHealth | null>(null);
const [loading, setLoading] = useState(true);
const load = () => {
setLoading(true);
api.opsHealth()
.then(setHealth)
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoading(false));
};
useEffect(() => {
load();
}, []);
const runtime = health?.runtime;
const capacityRisks = (health?.capacityFindings ?? []).map(capacityRisk);
const capacityRows = capacityMetricRows(health);
const bridgeRisks = capacityRisks.filter((item) => item.category === 'NATS -> Kafka');
return (
<div className="vp-page">
<PageHeader
title="运维质量"
description="集中查看网关、消息队列、缓存、时序库、关系库、地图配置和运行版本状态"
actions={(
<Space>
<Button size="small" icon={<IconCopy />} aria-label="复制容量交接" disabled={!health} onClick={() => copyText(opsCapacityHandoffText(health), '运维容量交接')}></Button>
<Button size="small" loading={loading} onClick={load}></Button>
</Space>
)}
/>
<div className="vp-kpi-grid">
<Card bordered loading={loading}>
<div className="vp-kpi-value">{formatNumber(health?.kafkaLag)}</div>
<div className="vp-kpi-label">Kafka Lag</div>
</Card>
<Card bordered loading={loading}>
<div className="vp-kpi-value">{formatNumber(health?.activeConnections)}</div>
<div className="vp-kpi-label"></div>
</Card>
<Card bordered loading={loading}>
<div className="vp-kpi-value">{formatNumber(health?.redisOnlineKeys)}</div>
<div className="vp-kpi-label">Redis 线 Key</div>
</Card>
<Card bordered loading={loading}>
<div className="vp-kpi-value">{runtime?.platformRelease || '-'}</div>
<div className="vp-kpi-label"></div>
</Card>
</div>
<Card bordered title="存储与运行时" loading={loading} style={{ marginTop: 16 }}>
<div className="vp-alert-flow">
<div className="vp-alert-flow-item">
<Tag color={boolColor(health?.tdengineWritable)}>TDengine </Tag>
<div className="vp-alert-flow-value">{statusText(health?.tdengineWritable)}</div>
<div>RAW TDengine </div>
</div>
<div className="vp-alert-flow-item">
<Tag color={boolColor(health?.mysqlWritable)}>MySQL </Tag>
<div className="vp-alert-flow-value">{statusText(health?.mysqlWritable)}</div>
<div> MySQL</div>
</div>
<div className="vp-alert-flow-item">
<Tag color="blue"></Tag>
<div className="vp-alert-flow-value">{runtime?.requestTimeoutMs ? `${runtime.requestTimeoutMs.toLocaleString()} ms` : '-'}</div>
<div> RedisTDengineMySQL </div>
</div>
</div>
</Card>
<Card bordered title="地图安全配置" loading={loading} style={{ marginTop: 16 }}>
<Space wrap>
<Tag color={runtime?.amapWebJsConfigured ? 'green' : 'orange'}>{runtime?.amapWebJsConfigured ? 'Web JS Key 已配置' : 'Web JS Key 未配置'}</Tag>
<Tag color={runtime?.amapApiConfigured ? 'green' : 'orange'}>{runtime?.amapApiConfigured ? '服务端 API Key 已配置' : '服务端 API Key 未配置'}</Tag>
<Tag color={amapSecurityColor(health)}>{amapSecurityText(health)}</Tag>
<Tag color={runtime?.amapSecurityProxyEnabled ? 'green' : 'grey'}>{runtime?.amapSecurityServiceHost || '代理未启用'}</Tag>
</Space>
</Card>
<Card bordered title="消息链路积压" loading={loading} style={{ marginTop: 16 }}>
<Table<CapacityMetricRow>
pagination={false}
dataSource={capacityRows}
rowKey={(row?: CapacityMetricRow) => row?.key ?? ''}
columns={[
{ title: '指标', dataIndex: 'name' },
{
title: '当前值',
width: 140,
render: (_: unknown, row: CapacityMetricRow) => (
<Typography.Text strong>{formatNumber(row.value)}</Typography.Text>
)
},
{
title: '状态',
width: 120,
render: (_: unknown, row: CapacityMetricRow) => <Tag color={statusColor[row.status]}>{row.status}</Tag>
},
{ title: '阈值', width: 150, dataIndex: 'threshold' },
{ title: '说明', dataIndex: 'detail' }
]}
/>
</Card>
{capacityRisks.length ? (
<Card bordered title="容量与风险发现" style={{ marginTop: 16 }}>
<div className="vp-risk-grid">
{capacityRisks.map((item) => (
<div key={item.key} className="vp-risk-item">
<Space wrap>
<Tag color={item.severity === 'error' ? 'red' : 'orange'}>{item.category}</Tag>
<Tag color="grey">{item.finding}</Tag>
</Space>
<div className="vp-risk-value">{item.value}</div>
<Typography.Title heading={6} style={{ margin: 0 }}>{item.title}</Typography.Title>
<Typography.Text type="secondary">{item.action}</Typography.Text>
</div>
))}
</div>
{bridgeRisks.length ? (
<div className="vp-risk-runbook">
<Tag color="blue"></Tag>
<Typography.Text>
Kafka broker ACK pending 0 consumer pending
</Typography.Text>
</div>
) : null}
</Card>
) : null}
<Card bordered title="链路健康" loading={loading} style={{ marginTop: 16 }}>
<Table<LinkHealth>
pagination={false}
dataSource={health?.linkHealth ?? []}
rowKey={(row?: LinkHealth) => `${row?.name ?? ''}-${row?.status ?? ''}`}
columns={[
{ title: '链路', dataIndex: 'name' },
{ title: '状态', width: 120, render: (_: unknown, row: LinkHealth) => <Tag color={statusColor[row.status] ?? 'grey'}>{row.status}</Tag> },
{ title: '说明', dataIndex: 'detail' }
]}
/>
</Card>
</div>
);
}