Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/Quality.tsx

540 lines
21 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, Col, Form, Row, Select, Space, Table, Tag, Toast } from '@douyinfe/semi-ui';
import { IconCopy } from '@douyinfe/semi-icons';
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { OpsHealth, QualitySummary, QualityIssueRow } from '../api/types';
import { PageHeader } from '../components/PageHeader';
import { qualityIssueLabel, qualityIssueOptions, qualityProtocolLabel, qualityProtocolOptions } from '../domain/qualityIssue';
import { qualityIssueVehicleLookup } from '../domain/vehicleLookup';
const statusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
ok: 'green',
warning: 'orange',
error: 'red'
};
function formatLag(value?: number | null) {
return value == null ? '未接入' : value.toLocaleString();
}
function storageReadStatus(health: OpsHealth | null) {
if (!health) return 'pending';
return health.tdengineWritable && health.mysqlWritable ? 'ok' : 'error';
}
function formatRequestTimeout(health: OpsHealth | null) {
const value = health?.runtime?.requestTimeoutMs;
return value == null || value <= 0 ? '未限制' : `${value.toLocaleString()} ms`;
}
const emptySummary: QualitySummary = {
issueVehicleCount: 0,
issueRecordCount: 0,
errorCount: 0,
warningCount: 0,
protocols: [],
issueTypes: []
};
const alertRuleTemplates = [
{
issueType: 'NO_SOURCE',
level: 'P0',
owner: '平台接入',
trigger: '绑定车辆连续无 GB32960、JT808、MQTT 来源证据',
notify: '立即通知接入运维30 分钟未恢复升级给业务责任人',
sla: '30 分钟确认'
},
{
issueType: 'VIN_MISSING',
level: 'P0',
owner: '车辆档案',
trigger: '来源有数据但无法归并到 VIN',
notify: '通知档案维护人补齐车牌、手机号和 VIN 映射',
sla: '2 小时修复'
},
{
issueType: 'LINK_GAP',
level: 'P1',
owner: '链路运维',
trigger: '车辆或平台来源上报间断、Redis 在线状态过期',
notify: '通知平台转发和网关值班人,持续异常进入日报',
sla: '1 小时恢复'
},
{
issueType: 'FIELD_MISSING',
level: 'P1',
owner: '协议解析',
trigger: '核心字段缺失导致定位、里程或统计不可用',
notify: '通知解析负责人核对字段映射和 RAW 样本',
sla: '当日闭环'
}
];
const notificationPolicies = [
{
name: 'P0 实时中断',
target: '接入运维 + 业务责任人',
channel: '站内告警 / 邮件 / 企业微信',
condition: '无来源、VIN 缺失、存储不可写'
},
{
name: 'P1 数据质量',
target: '协议解析 + 数据治理',
channel: '站内告警 / 每日汇总邮件',
condition: '字段缺失、链路间断、容量风险'
},
{
name: 'P2 趋势关注',
target: '数智中心',
channel: '周报 / 趋势看板',
condition: '单源车辆、档案缺项、来源覆盖下降'
}
];
type AlertRuleRow = {
issueType: string;
level: string;
owner: string;
trigger: string;
notify: string;
sla: string;
count: number;
};
function qualityParams(values: Record<string, string>) {
const params = new URLSearchParams();
if (values?.keyword) params.set('keyword', values.keyword);
if (values?.protocol) params.set('protocol', values.protocol);
if (values?.issueType) params.set('issueType', values.issueType);
return params;
}
function qualityShareURL() {
return `${window.location.origin}${window.location.pathname}${window.location.hash}`;
}
function qualityActionRecommendation(row: QualityIssueRow) {
if (row.issueType === 'NO_SOURCE') {
return {
label: '确认平台转发',
color: 'orange' as const,
detail: '车辆已绑定但没有任何来源证据,先确认平台转发、端口和订阅。'
};
}
if (row.issueType === 'VIN_MISSING' || !row.vin?.trim()) {
return {
label: '维护身份绑定',
color: 'red' as const,
detail: '数据已有来源但无法归并到 VIN优先用车牌/手机号补齐绑定。'
};
}
if (row.issueType === 'LINK_GAP') {
return {
label: '排查来源链路',
color: 'orange' as const,
detail: '来源存在上报间断,优先检查平台转发、网络和消费延迟。'
};
}
if (row.issueType === 'FIELD_MISSING') {
return {
label: '核对解析字段',
color: 'orange' as const,
detail: '字段缺失会影响统计和展示,优先核对解析映射和原始 RAW。'
};
}
return {
label: '查看车辆服务',
color: 'blue' as const,
detail: '进入车辆服务详情,结合来源证据继续排查。'
};
}
function issueCount(summary: QualitySummary, issueType: string) {
return summary.issueTypes.find((item) => item.name === issueType)?.count ?? 0;
}
function alertRuleRows(summary: QualitySummary, health: OpsHealth | null): AlertRuleRow[] {
const storageWritable = health == null || (health.tdengineWritable && health.mysqlWritable);
const capacityCount = health?.capacityFindings?.length ?? 0;
const rows = alertRuleTemplates.map((item) => ({
...item,
count: issueCount(summary, item.issueType)
}));
rows.push({
issueType: 'CAPACITY_RISK',
level: storageWritable && capacityCount === 0 ? 'P2' : 'P0',
owner: '基础设施',
trigger: 'Kafka Lag、连接容量、Redis 在线 Key 或存储读写异常',
notify: storageWritable ? '容量风险进入运维日报,超过阈值升级' : '立即通知基础设施值班人处理存储不可写',
sla: storageWritable ? '当日评估' : '15 分钟恢复',
count: capacityCount + (storageWritable ? 0 : 1)
});
return rows;
}
function ruleStatusColor(count: number, level: string): 'green' | 'orange' | 'red' | 'grey' {
if (count <= 0) return 'green';
if (level === 'P0') return 'red';
if (level === 'P1') return 'orange';
return 'grey';
}
async function copyText(value: string, label: string) {
const text = value.trim();
if (!text) {
Toast.warning(`${label}为空`);
return;
}
try {
await navigator.clipboard.writeText(text);
Toast.success(`已复制${label}`);
} catch {
Toast.error(`复制${label}失败`);
}
}
async function copyQualityShareURL() {
await copyText(qualityShareURL(), '筛选链接');
}
export function Quality({
onOpenVehicle,
onHealthLoaded,
onFiltersChange,
initialFilters = {}
}: {
onOpenVehicle: (vin: string, protocol?: string) => void;
onHealthLoaded?: (health: OpsHealth) => void;
onFiltersChange?: (filters: Record<string, string>) => void;
initialFilters?: Record<string, string>;
}) {
const [issues, setIssues] = useState<QualityIssueRow[]>([]);
const [summary, setSummary] = useState<QualitySummary>(emptySummary);
const [health, setHealth] = useState<OpsHealth | null>(null);
const [loadingIssues, setLoadingIssues] = useState(true);
const [loadingSummary, setLoadingSummary] = useState(true);
const [loadingHealth, setLoadingHealth] = useState(true);
const [filters, setFilters] = useState<Record<string, string>>(initialFilters);
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
const primaryIssueType = summary.issueTypes[0]?.name;
const rules = alertRuleRows(summary, health);
const activeRuleCount = rules.filter((item) => item.count > 0).length;
const p0RuleCount = rules.filter((item) => item.count > 0 && item.level === 'P0').length;
const loadIssues = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
setLoadingIssues(true);
const params = qualityParams(values);
params.set('limit', String(pageSize));
params.set('offset', String((page - 1) * pageSize));
api.qualityIssues(params)
.then((nextPage) => {
setIssues(nextPage.items);
setPagination({ currentPage: page, pageSize, total: nextPage.total });
})
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoadingIssues(false));
};
const loadSummary = (values: Record<string, string> = filters) => {
setLoadingSummary(true);
api.qualitySummary(qualityParams(values))
.then(setSummary)
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoadingSummary(false));
};
const loadHealth = () => {
setLoadingHealth(true);
api.opsHealth()
.then((nextHealth) => {
setHealth(nextHealth);
onHealthLoaded?.(nextHealth);
})
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoadingHealth(false));
};
useEffect(() => {
setFilters(initialFilters);
loadSummary(initialFilters);
loadIssues(initialFilters, 1, pagination.pageSize);
loadHealth();
}, [JSON.stringify(initialFilters)]);
const applyFilters = (nextFilters: Record<string, string>) => {
setFilters(nextFilters);
onFiltersChange?.(nextFilters);
loadSummary(nextFilters);
loadIssues(nextFilters, 1, pagination.pageSize);
};
const drillPrimaryIssue = () => {
if (!primaryIssueType) {
return;
}
applyFilters({ ...filters, issueType: primaryIssueType });
};
const drillProtocol = (protocol: string) => {
applyFilters({ ...filters, protocol });
};
const drillIssueType = (issueType: string) => {
applyFilters({ ...filters, issueType });
};
const filterSummary = [
filters.keyword ? `关键词:${filters.keyword}` : '',
filters.protocol ? `数据来源:${qualityProtocolLabel(filters.protocol)}` : '',
filters.issueType ? `问题类型:${qualityIssueLabel(filters.issueType)}` : ''
].filter(Boolean);
return (
<div className="vp-page">
<PageHeader title="告警通知" description="围绕车辆服务沉淀断链、VIN 缺失、字段缺失和链路健康告警,并形成通知闭环" />
<div className="vp-kpi-grid">
{[
{ label: '问题车辆', value: summary.issueVehicleCount.toLocaleString() },
{ label: '问题记录', value: summary.issueRecordCount.toLocaleString() },
{ label: '错误 / 警告', value: `${summary.errorCount}/${summary.warningCount}` },
{
label: '主要问题',
value: primaryIssueType ? `${qualityIssueLabel(primaryIssueType)} ${summary.issueTypes[0].count}` : '-',
onClick: primaryIssueType ? drillPrimaryIssue : undefined
}
].map((item) => (
<Card key={item.label} bordered loading={loadingSummary}>
{item.onClick ? (
<button
className="vp-result-summary-button"
type="button"
aria-label={`${item.label} ${item.value}`}
onClick={item.onClick}
>
<div className="vp-kpi-value">{item.value}</div>
<div className="vp-kpi-label">{item.label}</div>
</button>
) : (
<>
<div className="vp-kpi-value">{item.value}</div>
<div className="vp-kpi-label">{item.label}</div>
</>
)}
</Card>
))}
</div>
<Row gutter={16}>
<Col span={6}><Card bordered title="Kafka Lag">{formatLag(health?.kafkaLag)}</Card></Col>
<Col span={6}><Card bordered title="活跃连接">{formatLag(health?.activeConnections)}</Card></Col>
<Col span={6}><Card bordered title="Redis 在线 Key">{formatLag(health?.redisOnlineKeys)}</Card></Col>
<Col span={6}>
<Card bordered title="存储读取">
<Tag color={statusColor[storageReadStatus(health)] ?? 'grey'}>
{storageReadStatus(health) === 'pending' ? '检测中' : storageReadStatus(health) === 'ok' ? '正常' : '异常'}
</Tag>
</Card>
</Col>
</Row>
<Card bordered title="告警通知闭环" style={{ marginTop: 16 }}>
<div className="vp-alert-flow">
{[
{ label: '事件触发', value: `${activeRuleCount} 类活跃`, detail: '断链、无来源、VIN 缺失、字段缺失和容量异常进入告警池。' },
{ label: '分级通知', value: `${p0RuleCount} 类 P0`, detail: '按错误、警告、关注分层推送给平台、运维和业务责任人。' },
{ label: '处置回执', value: `${summary.issueVehicleCount.toLocaleString()} 辆车`, detail: '告警需要关联车辆服务、来源证据和处理结论,形成可追踪闭环。' }
].map((item) => (
<div key={item.label} className="vp-alert-flow-item">
<Tag color="blue">{item.label}</Tag>
<div className="vp-alert-flow-value">{item.value}</div>
<div>{item.detail}</div>
</div>
))}
</div>
</Card>
<div className="vp-alert-ops-grid">
<Card bordered title="告警触发规则">
<Table<AlertRuleRow>
pagination={false}
dataSource={rules}
rowKey="issueType"
columns={[
{ title: '规则', render: (_: unknown, row: AlertRuleRow) => row.issueType === 'CAPACITY_RISK' ? '容量与存储风险' : qualityIssueLabel(row.issueType) },
{ title: '级别', width: 90, render: (_: unknown, row: AlertRuleRow) => <Tag color={ruleStatusColor(row.count, row.level)}>{row.level}</Tag> },
{ title: '当前命中', width: 110, render: (_: unknown, row: AlertRuleRow) => row.count.toLocaleString() },
{ title: '触发条件', dataIndex: 'trigger' },
{ title: 'SLA', dataIndex: 'sla', width: 110 }
]}
/>
</Card>
<Card bordered title="通知策略">
<div className="vp-notification-policy-list">
{notificationPolicies.map((item) => (
<div key={item.name} className="vp-notification-policy">
<div>
<Space>
<Tag color={item.name.startsWith('P0') ? 'red' : item.name.startsWith('P1') ? 'orange' : 'grey'}>{item.name}</Tag>
<strong>{item.target}</strong>
</Space>
<div className="vp-alert-policy-detail">{item.condition}</div>
</div>
<Tag color="blue">{item.channel}</Tag>
</div>
))}
</div>
</Card>
</div>
{health?.capacityFindings?.length ? (
<Card bordered title="容量检查发现" style={{ marginTop: 16 }}>
<Space wrap>
{health.capacityFindings.map((item) => (
<Tag key={item} color="orange">{item}</Tag>
))}
</Space>
</Card>
) : null}
{filterSummary.length > 0 ? (
<Card bordered title="当前筛选" style={{ marginTop: 16 }}>
<Space wrap>
{filterSummary.map((item) => (
<Tag key={item} color="blue">{item}</Tag>
))}
<Button size="small" onClick={() => applyFilters({})}></Button>
</Space>
</Card>
) : null}
<Card bordered title="问题来源分布" loading={loadingSummary} style={{ marginTop: 16 }}>
<Table
pagination={false}
dataSource={summary.protocols}
rowKey="name"
columns={[
{ title: '数据来源', render: (_: unknown, row: { name: string }) => qualityProtocolLabel(row.name) },
{ title: '问题数', dataIndex: 'count', width: 120 },
{
title: '操作',
width: 140,
render: (_: unknown, row: { name: string }) => (
<Button size="small" onClick={() => drillProtocol(row.name)}> {qualityProtocolLabel(row.name)} </Button>
)
}
]}
/>
</Card>
<Card bordered title="问题类型分布" loading={loadingSummary} style={{ marginTop: 16 }}>
<Table
pagination={false}
dataSource={summary.issueTypes}
rowKey="name"
columns={[
{ title: '问题类型', render: (_: unknown, row: { name: string }) => qualityIssueLabel(row.name) },
{ title: '问题数', dataIndex: 'count', width: 120 },
{
title: '操作',
width: 160,
render: (_: unknown, row: { name: string }) => (
<Button size="small" onClick={() => drillIssueType(row.name)}> {qualityIssueLabel(row.name)}</Button>
)
}
]}
/>
</Card>
<Card bordered title="质量问题" style={{ marginTop: 16 }}>
<Form key={JSON.stringify(filters)} initValues={filters} layout="horizontal" onSubmit={(values) => {
const nextFilters = values as Record<string, string>;
applyFilters(nextFilters);
}} style={{ marginBottom: 12 }}>
<Form.Input field="keyword" label="关键词" placeholder="VIN / 车牌 / 手机号 / 来源地址" style={{ width: 260 }} />
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 160 }}>
{qualityProtocolOptions.map((item) => (
<Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
))}
</Form.Select>
<Form.Select field="issueType" label="问题类型" placeholder="全部问题" style={{ width: 160 }}>
{qualityIssueOptions.map((item) => (
<Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
))}
</Form.Select>
<Space>
<Button htmlType="submit" theme="solid" type="primary"></Button>
<Button icon={<IconCopy />} onClick={copyQualityShareURL}></Button>
<Button onClick={() => {
applyFilters({});
}}></Button>
</Space>
</Form>
<Table
loading={loadingIssues}
rowKey={(row?: QualityIssueRow) => `${row?.protocol ?? ''}-${row?.issueType ?? ''}-${row?.lastSeen ?? ''}-${row?.detail ?? ''}`}
dataSource={issues}
pagination={{
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
onPageChange: (page) => loadIssues(filters, page, pagination.pageSize),
onPageSizeChange: (pageSize) => loadIssues(filters, 1, pageSize)
}}
columns={[
{ title: 'VIN', dataIndex: 'vin' },
{ title: '车牌', dataIndex: 'plate' },
{ title: '手机号', dataIndex: 'phone' },
{ title: '来源地址', dataIndex: 'sourceEndpoint' },
{ title: '数据来源', render: (_: unknown, row: QualityIssueRow) => qualityProtocolLabel(row.protocol) },
{ title: '问题', render: (_: unknown, row: QualityIssueRow) => qualityIssueLabel(row.issueType) },
{ title: '级别', render: (_: unknown, row: QualityIssueRow) => <Tag color={row.severity === 'error' ? 'red' : 'orange'}>{row.severity}</Tag> },
{ title: '最后时间', dataIndex: 'lastSeen' },
{ title: '说明', dataIndex: 'detail' },
{
title: '处置建议',
width: 260,
render: (_: unknown, row: QualityIssueRow) => {
const action = qualityActionRecommendation(row);
return (
<div>
<Tag color={action.color}>{action.label}</Tag>
<div style={{ marginTop: 6 }}>{action.detail}</div>
</div>
);
}
},
{
title: '操作',
width: 250,
render: (_: unknown, row: QualityIssueRow) => {
const lookup = qualityIssueVehicleLookup(row);
return (
<Space spacing={4}>
<Button size="small" icon={<IconCopy />} onClick={() => copyText(row.phone, '手机号')}></Button>
<Button size="small" icon={<IconCopy />} onClick={() => copyText(row.sourceEndpoint, '来源')}></Button>
<Button size="small" disabled={!lookup.key} onClick={() => onOpenVehicle(lookup.key, row.protocol)}>
{lookup.label}
</Button>
</Space>
);
}
}
]}
/>
</Card>
<Card
bordered
title={<Space><span></span><Button size="small" loading={loadingHealth} onClick={loadHealth}></Button></Space>}
style={{ marginTop: 16 }}
>
<Table
loading={loadingHealth}
dataSource={health?.linkHealth ?? []}
pagination={false}
columns={[
{ title: '链路', dataIndex: 'name' },
{
title: '状态',
render: (_: unknown, row: { status: string }) => (
<Tag color={statusColor[row.status] ?? 'grey'}>{row.status}</Tag>
)
},
{ title: '说明', dataIndex: 'detail' }
]}
/>
</Card>
</div>
);
}