feat(platform): harden telemetry pipeline and unify Semi UI workspaces
This commit is contained in:
@@ -1,16 +1,30 @@
|
||||
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FormEvent, useDeferredValue, useEffect, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { PageHeader } from '../shared/PageHeader';
|
||||
import { SegmentedTabs } from '../shared/SegmentedTabs';
|
||||
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||||
import ReconciliationCenter from './ReconciliationCenter';
|
||||
|
||||
type OperationsWorkspace = 'reconciliation' | 'diagnostic' | 'health';
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status;
|
||||
}
|
||||
|
||||
function HealthTag({ status, children }: { status: string; children?: string }) {
|
||||
const color = status === 'ok' ? 'green' : status === 'warning' ? 'orange' : status === 'error' ? 'red' : 'grey';
|
||||
return <Tag className={`v2-ops-health-tag is-${status}`} color={color} type="light" size="small"><i />{children ?? statusLabel(status)}</Tag>;
|
||||
}
|
||||
|
||||
function fmt(value?: string) {
|
||||
if (!value) return '—';
|
||||
const parsed = new Date(value.replace(' ', 'T'));
|
||||
@@ -21,7 +35,7 @@ function number(value?: number, digits = 1) {
|
||||
return value == null || !Number.isFinite(value) ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: {
|
||||
function SourcePolicyEditor({ vin, source, diagnostic, editable, onSaved }: {
|
||||
vin: string;
|
||||
source: VehicleLocationSourceEvidence;
|
||||
diagnostic: VehicleSourceDiagnostic;
|
||||
@@ -56,28 +70,140 @@ function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: {
|
||||
const policyEditable = source.sourceKind !== 'CANONICAL';
|
||||
const policyChanged = enabled !== source.enabled || priority !== source.priority || remark.trim() !== (source.policyRemark || '');
|
||||
const changed = (policyEditable && policyChanged) || providerChanged;
|
||||
return <tr className={source.recommended ? 'is-recommended' : ''}>
|
||||
<td><strong>{source.sourceLabel}</strong><span>{source.terminalLabel || source.sourceKind || '未维护终端'}</span></td>
|
||||
<td><b>{source.protocol}</b><span>{source.selectedWithinProtocol ? '协议内已选' : '协议内候选'}</span></td>
|
||||
<td><i className={source.online ? 'is-online' : 'is-offline'} />{source.online ? '在线' : '离线'}<span>{source.qualityStatus || '未知'}{source.qualityReason ? ` · ${source.qualityReason}` : ''}</span></td>
|
||||
<td><strong>{fmt(source.firstSeenAt)}</strong><span>最近 {fmt(source.receivedAt || source.eventTime)}</span></td>
|
||||
<td><strong>{source.reportIntervalSec == null ? '—' : `${source.reportIntervalSec}s`}</strong><span>{source.reportSampleCount ? `${source.reportSampleCount.toLocaleString('zh-CN')} 个里程样本` : '暂无累计样本'}</span></td>
|
||||
<td><strong>{source.longitude == null || source.latitude == null ? '—' : `${source.longitude.toFixed(6)}, ${source.latitude.toFixed(6)}`}</strong><span>{number(source.speedKmh)} km/h · {number(source.totalMileageKm)} km</span></td>
|
||||
<td className="v2-source-reason"><strong>{source.recommended ? '当前推荐' : source.selectedWithinProtocol ? '协议首选' : '备用来源'}</strong><span>{source.selectionReason || '等待选举说明'}</span></td>
|
||||
<td className="v2-source-policy-cell">
|
||||
<label><input type="checkbox" checked={enabled} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setEnabled(event.target.checked)} />启用</label>
|
||||
<input aria-label={`${source.sourceLabel} 优先级`} type="number" min="1" max="1000" value={priority} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setPriority(Number(event.target.value))} />
|
||||
<input className="v2-source-provider-input" aria-label={`${source.sourceLabel} 提供方`} value={providerName} maxLength={128} disabled={!editable || save.isPending} onChange={(event) => setProviderName(event.target.value)} placeholder="提供方,如 G7s" />
|
||||
<input className="v2-source-provider-evidence-input" aria-label={`${source.sourceLabel} 提供方核验依据`} value={providerEvidence} maxLength={255} disabled={!editable || save.isPending || !providerChanged} onChange={(event) => setProviderEvidence(event.target.value)} placeholder={providerChanged ? '权威终端清单、厂商确认记录等(必填)' : '修改提供方后填写核验依据'} />
|
||||
<input className="v2-source-policy-remark-input" aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setRemark(event.target.value)} placeholder={policyEditable ? '启停或优先级调整原因(可选)' : '协议融合快照不可调整策略'} />
|
||||
<button type="button" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000 || (providerChanged && !providerEvidence.trim())} onClick={() => save.mutate()}>{save.isPending ? '保存中' : policyEditable ? '保存策略' : '保存提供方'}</button>
|
||||
return <div className="v2-source-policy-cell">
|
||||
<Checkbox checked={enabled} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setEnabled(Boolean(event.target.checked))} aria-label="启用">启用</Checkbox>
|
||||
<Input aria-label={`${source.sourceLabel} 优先级`} type="number" min="1" max="1000" value={String(priority)} disabled={!editable || !policyEditable || save.isPending} onChange={(value) => setPriority(Number(value))} />
|
||||
<Input className="v2-source-provider-input" aria-label={`${source.sourceLabel} 提供方`} value={providerName} maxLength={128} disabled={!editable || save.isPending} onChange={setProviderName} placeholder="提供方,如 G7s" />
|
||||
<Input className="v2-source-provider-evidence-input" aria-label={`${source.sourceLabel} 提供方核验依据`} value={providerEvidence} maxLength={255} disabled={!editable || save.isPending || !providerChanged} onChange={setProviderEvidence} placeholder={providerChanged ? '权威终端清单、厂商确认记录等(必填)' : '修改提供方后填写核验依据'} />
|
||||
<Input className="v2-source-policy-remark-input" aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || !policyEditable || save.isPending} onChange={setRemark} placeholder={policyEditable ? '启停或优先级调整原因(可选)' : '协议融合快照不可调整策略'} />
|
||||
<Button theme="solid" size="small" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000 || (providerChanged && !providerEvidence.trim())} onClick={() => save.mutate()}>{save.isPending ? '保存中' : policyEditable ? '保存策略' : '保存提供方'}</Button>
|
||||
{save.isError ? <em role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</em> : null}
|
||||
</td>
|
||||
</tr>;
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SourceIdentity({ source }: { source: VehicleLocationSourceEvidence }) {
|
||||
return <div className="v2-source-cell v2-source-identity-cell"><span><strong>{source.sourceLabel}</strong>{source.recommended ? <Tag color="green" type="light" size="small">推荐</Tag> : null}</span><small>{source.terminalLabel || source.sourceKind || '未维护终端'}</small></div>;
|
||||
}
|
||||
|
||||
function SourceProtocol({ source }: { source: VehicleLocationSourceEvidence }) {
|
||||
return <div className="v2-source-cell"><Tag color="blue" type="light" size="small">{source.protocol}</Tag><small>{source.selectedWithinProtocol ? '协议内已选' : '协议内候选'}</small></div>;
|
||||
}
|
||||
|
||||
function SourceHealth({ source }: { source: VehicleLocationSourceEvidence }) {
|
||||
return <div className="v2-source-cell v2-source-health-cell"><span><i className={source.online ? 'is-online' : 'is-offline'} /><strong>{source.online ? '在线' : '离线'}</strong></span><small>{source.qualityStatus || '未知'}{source.qualityReason ? ` · ${source.qualityReason}` : ''}</small></div>;
|
||||
}
|
||||
|
||||
function SourceTiming({ source }: { source: VehicleLocationSourceEvidence }) {
|
||||
return <div className="v2-source-cell"><strong>{fmt(source.firstSeenAt)}</strong><small>最近 {fmt(source.receivedAt || source.eventTime)}</small></div>;
|
||||
}
|
||||
|
||||
function SourceInterval({ source }: { source: VehicleLocationSourceEvidence }) {
|
||||
return <div className="v2-source-cell"><strong>{source.reportIntervalSec == null ? '—' : `${source.reportIntervalSec}s`}</strong><small>{source.reportSampleCount ? `${source.reportSampleCount.toLocaleString('zh-CN')} 个里程样本` : '暂无累计样本'}</small></div>;
|
||||
}
|
||||
|
||||
function SourcePosition({ source }: { source: VehicleLocationSourceEvidence }) {
|
||||
return <div className="v2-source-cell"><strong>{source.longitude == null || source.latitude == null ? '—' : `${source.longitude.toFixed(6)}, ${source.latitude.toFixed(6)}`}</strong><small>{number(source.speedKmh)} km/h · {number(source.totalMileageKm)} km</small></div>;
|
||||
}
|
||||
|
||||
function SourceDecision({ source }: { source: VehicleLocationSourceEvidence }) {
|
||||
return <div className="v2-source-cell v2-source-reason"><strong>{source.recommended ? '当前推荐' : source.selectedWithinProtocol ? '协议首选' : '备用来源'}</strong><small>{source.selectionReason || '等待选举说明'}</small></div>;
|
||||
}
|
||||
|
||||
function sourceRowKey(source?: VehicleLocationSourceEvidence) {
|
||||
return source?.sourceRef || `${source?.protocol ?? ''}-${source?.sourceLabel ?? ''}-${source?.terminalLabel ?? ''}`;
|
||||
}
|
||||
|
||||
function SourcePolicySummary({ source, onEdit }: {
|
||||
source: VehicleLocationSourceEvidence;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
return <div className="v2-source-policy-summary">
|
||||
<span><Tag color={source.enabled ? 'green' : 'grey'} type="light" size="small">{source.enabled ? '已启用' : '已禁用'}</Tag><b>优先级 {source.priority}</b></span>
|
||||
<small>{source.providerOverride || '提供方未维护'}</small>
|
||||
<Button theme="light" size="small" onClick={onEdit}>{source.sourceKind === 'CANONICAL' ? '维护提供方' : '维护策略'}</Button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SourcePolicySheet({ vin, source, diagnostic, editable, onClose, onSaved }: {
|
||||
vin: string;
|
||||
source?: VehicleLocationSourceEvidence;
|
||||
diagnostic: VehicleSourceDiagnostic;
|
||||
editable: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (next: VehicleSourceDiagnostic) => void;
|
||||
}) {
|
||||
useSideSheetA11y(Boolean(source), '.v2-source-policy-sidesheet', 'v2-source-policy-sheet', '车辆来源策略', '关闭车辆来源策略');
|
||||
return <SideSheet
|
||||
className="v2-source-policy-sidesheet"
|
||||
visible={Boolean(source)}
|
||||
width={440}
|
||||
aria-label="车辆来源策略"
|
||||
title={source ? <div className="v2-source-policy-sheet-title"><strong>{source.sourceLabel}</strong><span>{source.terminalLabel || source.protocol} · {source.sourceKind === 'CANONICAL' ? '仅维护提供方' : '来源策略'}</span></div> : '来源策略'}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
>
|
||||
{source ? <div className="v2-source-policy-sheet-content"><div className="v2-source-policy-sheet-summary"><SourceHealth source={source} /><SourceDecision source={source} /></div><SourcePolicyEditor vin={vin} source={source} diagnostic={diagnostic} editable={editable && Boolean(source.sourceRef)} onSaved={(next) => { onSaved(next); onClose(); }} /></div> : null}
|
||||
</SideSheet>;
|
||||
}
|
||||
|
||||
function SourceDiagnosticTable({ vin, sources, diagnostic, editable, onSaved }: {
|
||||
vin: string;
|
||||
sources: VehicleLocationSourceEvidence[];
|
||||
diagnostic: VehicleSourceDiagnostic;
|
||||
editable: boolean;
|
||||
onSaved: (next: VehicleSourceDiagnostic) => void;
|
||||
}) {
|
||||
const [editingSource, setEditingSource] = useState<VehicleLocationSourceEvidence>();
|
||||
const columns = [
|
||||
{ title: '来源 / 终端', dataIndex: 'sourceLabel', width: 180, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceIdentity source={source} /> },
|
||||
{ title: '协议', dataIndex: 'protocol', width: 120, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceProtocol source={source} /> },
|
||||
{ title: '在线 / 质量', dataIndex: 'online', width: 190, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceHealth source={source} /> },
|
||||
{ title: '首次 / 最近上报', dataIndex: 'firstSeenAt', width: 220, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceTiming source={source} /> },
|
||||
{ title: '上报周期', dataIndex: 'reportIntervalSec', width: 145, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceInterval source={source} /> },
|
||||
{ title: '位置 / 里程', dataIndex: 'longitude', width: 230, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourcePosition source={source} /> },
|
||||
{ title: '选举结论', dataIndex: 'recommended', width: 220, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceDecision source={source} /> },
|
||||
{ title: '运维策略', dataIndex: 'policy', width: 200, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourcePolicySummary source={source} onEdit={() => setEditingSource(source)} /> }
|
||||
];
|
||||
return <><div className="v2-source-table-wrap"><Table
|
||||
className="v2-source-table"
|
||||
columns={columns}
|
||||
dataSource={sources}
|
||||
rowKey={sourceRowKey}
|
||||
pagination={false}
|
||||
scroll={{ x: 1505 }}
|
||||
empty={null}
|
||||
onRow={(source) => source ? ({ className: source.recommended ? 'is-recommended' : '' }) : ({})}
|
||||
/></div>
|
||||
<SourcePolicySheet vin={vin} source={editingSource} diagnostic={diagnostic} editable={editable} onClose={() => setEditingSource(undefined)} onSaved={onSaved} />
|
||||
</>;
|
||||
}
|
||||
|
||||
function SourceDiagnosticCards({ vin, sources, diagnostic, editable, onSaved }: {
|
||||
vin: string;
|
||||
sources: VehicleLocationSourceEvidence[];
|
||||
diagnostic: VehicleSourceDiagnostic;
|
||||
editable: boolean;
|
||||
onSaved: (next: VehicleSourceDiagnostic) => void;
|
||||
}) {
|
||||
const [editingSource, setEditingSource] = useState<VehicleLocationSourceEvidence>();
|
||||
return <><div className="v2-source-mobile-list">{sources.map((source) => <Card className={`v2-source-mobile-card${source.recommended ? ' is-recommended' : ''}`} bodyStyle={{ padding: 0 }} key={sourceRowKey(source)}>
|
||||
<header><SourceIdentity source={source} /><SourceHealth source={source} /></header>
|
||||
<Descriptions className="v2-source-mobile-descriptions" align="left" size="small" data={[
|
||||
{ key: '协议状态', value: <SourceProtocol source={source} /> },
|
||||
{ key: '首次 / 最近上报', value: <SourceTiming source={source} /> },
|
||||
{ key: '上报周期', value: <SourceInterval source={source} /> },
|
||||
{ key: '位置 / 里程', value: <SourcePosition source={source} /> },
|
||||
{ key: '选举结论', value: <SourceDecision source={source} /> }
|
||||
]} />
|
||||
<section><strong>运维策略</strong><SourcePolicySummary source={source} onEdit={() => setEditingSource(source)} /></section>
|
||||
</Card>)}</div>
|
||||
<SourcePolicySheet vin={vin} source={editingSource} diagnostic={diagnostic} editable={editable} onClose={() => setEditingSource(undefined)} onSaved={onSaved} />
|
||||
</>;
|
||||
}
|
||||
|
||||
function SourceDiagnosticWorkspace() {
|
||||
const queryClient = useQueryClient();
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const deferredKeyword = useDeferredValue(keyword.trim());
|
||||
const [selected, setSelected] = useState<VehicleCoverageRow>();
|
||||
@@ -109,71 +235,109 @@ function SourceDiagnosticWorkspace() {
|
||||
};
|
||||
const data = diagnostic.data;
|
||||
const editable = session.data?.role === 'admin';
|
||||
return <section className="v2-source-diagnostic">
|
||||
<header>
|
||||
<div><small>专业运维工作台</small><strong>单车多来源诊断</strong><span>按需查询,不加载全量 RAW;普通客户无权访问。</span></div>
|
||||
{selected ? <button type="button" onClick={() => diagnostic.refetch()} disabled={diagnostic.isFetching}><IconRefresh />刷新该车</button> : null}
|
||||
</header>
|
||||
const onSourceSaved = (next: VehicleSourceDiagnostic) => {
|
||||
queryClient.setQueryData(['ops-source-diagnostic', next.evidence.vin], next);
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['access-summary'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['access-vehicles'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['ops-source-readiness-v2'] })
|
||||
]);
|
||||
};
|
||||
return <Card className="v2-source-diagnostic" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="单车多来源诊断"
|
||||
description="按需查询,不加载全量 RAW;普通客户无权访问。"
|
||||
actions={selected ? <Button theme="light" icon={<IconRefresh />} onClick={() => diagnostic.refetch()} disabled={diagnostic.isFetching}>刷新该车</Button> : null}
|
||||
/>
|
||||
<form className="v2-source-search" onSubmit={submit}>
|
||||
<label><IconSearch /><input aria-label="按车牌或 VIN 搜索诊断车辆" value={keyword} onChange={(event) => { setKeyword(event.target.value); setSelected(undefined); }} placeholder="输入车牌或 VIN,支持模糊搜索" /></label>
|
||||
<button type="submit" disabled={!candidates.data?.items.length}>诊断首个结果</button>
|
||||
{deferredKeyword && !selected ? <div className="v2-source-candidates">
|
||||
{candidates.isFetching ? <p>正在查询车辆…</p> : candidates.data?.items.map((vehicle) => <button type="button" key={vehicle.vin} onMouseDown={(event) => event.preventDefault()} onClick={() => choose(vehicle)}>
|
||||
<strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span><em>{vehicle.protocols.join(' / ') || '暂无来源'}</em>
|
||||
</button>)}
|
||||
{!candidates.isFetching && !candidates.data?.items.length ? <p>没有匹配的已绑定车辆</p> : null}
|
||||
{(candidates.data?.total ?? 0) > 20 ? <footer><span>第 {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} 页</span><div><button type="button" disabled={candidateOffset === 0} onClick={() => setCandidateOffset(Math.max(0, candidateOffset - 20))}>上一页</button><button type="button" disabled={candidateOffset + 20 >= (candidates.data?.total ?? 0)} onClick={() => setCandidateOffset(candidateOffset + 20)}>下一页</button></div></footer> : null}
|
||||
</div> : null}
|
||||
<Input aria-label="按车牌或 VIN 搜索诊断车辆" prefix={<IconSearch />} value={keyword} onChange={(value) => { setKeyword(value); setSelected(undefined); }} placeholder="输入车牌或 VIN,支持模糊搜索" />
|
||||
<Button htmlType="submit" theme="solid" disabled={!candidates.data?.items.length}>诊断首个结果</Button>
|
||||
{deferredKeyword && !selected ? <VehicleCandidateList
|
||||
className="v2-source-candidates"
|
||||
items={candidates.data?.items ?? []}
|
||||
loading={candidates.isFetching}
|
||||
loadingText="正在查询车辆…"
|
||||
emptyText="没有匹配的已绑定车辆"
|
||||
actionLabel="诊断"
|
||||
showProtocols
|
||||
onSelect={choose}
|
||||
footer={(candidates.data?.total ?? 0) > 20 ? <><span>第 {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} 页</span><div><Button theme="light" size="small" disabled={candidateOffset === 0} onClick={() => setCandidateOffset(Math.max(0, candidateOffset - 20))}>上一页</Button><Button theme="light" size="small" disabled={candidateOffset + 20 >= (candidates.data?.total ?? 0)} onClick={() => setCandidateOffset(candidateOffset + 20)}>下一页</Button></div></> : undefined}
|
||||
/> : null}
|
||||
</form>
|
||||
{diagnostic.isError ? <InlineError message={diagnostic.error.message} onRetry={() => diagnostic.refetch()} /> : null}
|
||||
{!selected ? <div className="v2-source-empty"><strong>先选择一辆车</strong><span>将展示所有协议和同协议多终端、推荐原因、上报周期与可审计策略。</span></div> : null}
|
||||
{selected && diagnostic.isPending ? <div className="v2-source-empty"><strong>正在读取来源证据</strong><span>只查询当前车辆,不会扫描整车队。</span></div> : null}
|
||||
{!selected ? <Empty className="v2-source-empty" title="先选择一辆车" description="将展示所有协议和同协议多终端、推荐原因、上报周期与可审计策略。" /> : null}
|
||||
{selected && diagnostic.isPending ? <div className="v2-source-loading" role="status"><Spin size="large" /><strong>正在读取来源证据</strong><span>只查询当前车辆,不会扫描整车队。</span></div> : null}
|
||||
{data ? <>
|
||||
<div className="v2-source-summary">
|
||||
<article><small>车辆</small><strong>{data.evidence.plate || selected?.plate || '未绑定车牌'}</strong><span>{data.evidence.vin}</span></article>
|
||||
<article><small>当前推荐</small><strong>{data.evidence.recommendedLocationLabel || '暂无推荐'}</strong><span>{data.evidence.recommendedLocationProtocol || '—'}</span></article>
|
||||
<article><small>来源状态</small><strong>{data.evidence.locationSources.filter((item) => item.online).length} / {data.evidence.locationSources.length} 在线</strong><span>{data.evidence.locationConflict ? `位置冲突 ${number(data.evidence.conflictDistanceM)}m` : '未发现实时位置冲突'}</span></article>
|
||||
<article><small>策略版本</small><strong>v{data.policy.version}</strong><span>{data.policy.updatedAt ? `${data.policy.updatedBy} · ${fmt(data.policy.updatedAt)}` : '尚无人工调整'}</span></article>
|
||||
<Card className="v2-source-summary-card"><small>车辆</small><strong>{data.evidence.plate || selected?.plate || '未绑定车牌'}</strong><span>{data.evidence.vin}</span></Card>
|
||||
<Card className="v2-source-summary-card"><small>当前推荐</small><strong>{data.evidence.recommendedLocationLabel || '暂无推荐'}</strong><Tag color="blue" type="light" size="small">{data.evidence.recommendedLocationProtocol || '—'}</Tag></Card>
|
||||
<Card className={`v2-source-summary-card${data.evidence.locationConflict ? ' is-warning' : ' is-ok'}`}><small>来源状态</small><strong>{data.evidence.locationSources.filter((item) => item.online).length} / {data.evidence.locationSources.length} 在线</strong><span>{data.evidence.locationConflict ? `位置冲突 ${number(data.evidence.conflictDistanceM)}m` : '未发现实时位置冲突'}</span></Card>
|
||||
<Card className="v2-source-summary-card"><small>策略版本</small><strong>v{data.policy.version}</strong><span>{data.policy.updatedAt ? `${data.policy.updatedBy} · ${fmt(data.policy.updatedAt)}` : '尚无人工调整'}</span></Card>
|
||||
</div>
|
||||
<div className="v2-source-recommendation"><strong>推荐说明</strong><p>{data.recommendationReason}</p><span>{data.refreshHint}</span></div>
|
||||
{!editable ? <p className="v2-source-readonly">当前账号可查看诊断证据;只有管理员可以调整启停和优先级。</p> : null}
|
||||
<div className="v2-source-table-wrap"><table className="v2-source-table"><thead><tr><th>来源 / 终端</th><th>协议</th><th>在线 / 质量</th><th>首次 / 最近上报</th><th>上报周期</th><th>位置 / 里程</th><th>选举结论</th><th>运维策略</th></tr></thead><tbody>
|
||||
{data.evidence.locationSources.map((source) => <SourcePolicyRow key={source.sourceRef || `${source.protocol}-${source.sourceLabel}-${source.terminalLabel}`} vin={data.evidence.vin} source={source} diagnostic={data} editable={editable && Boolean(source.sourceRef)} onSaved={(next) => {
|
||||
queryClient.setQueryData(['ops-source-diagnostic', data.evidence.vin], next);
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['access-summary'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['access-vehicles'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['ops-source-readiness-v2'] })
|
||||
]);
|
||||
}} />)}
|
||||
</tbody></table></div>
|
||||
<section className="v2-source-audit"><header><strong>最近策略审计</strong><span>仅记录实际变更,原始 source_key 不对前端暴露</span></header>
|
||||
<Card className="v2-source-recommendation"><header><Tag color="blue" type="light" size="small">推荐说明</Tag><span>{data.refreshHint}</span></header><p>{data.recommendationReason}</p></Card>
|
||||
{!editable ? <div className="v2-source-readonly"><Tag color="orange" type="light" size="small">只读权限</Tag><span>当前账号可查看诊断证据;只有管理员可以调整启停和优先级。</span></div> : null}
|
||||
{mobileLayout
|
||||
? <SourceDiagnosticCards vin={data.evidence.vin} sources={data.evidence.locationSources} diagnostic={data} editable={editable} onSaved={onSourceSaved} />
|
||||
: <SourceDiagnosticTable vin={data.evidence.vin} sources={data.evidence.locationSources} diagnostic={data} editable={editable} onSaved={onSourceSaved} />}
|
||||
<Card className="v2-source-audit" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="最近策略审计" description="仅记录实际变更,原始 source_key 不对前端暴露" />
|
||||
{data.policy.audit.length ? <ol>{data.policy.audit.map((item) => <li key={`${item.changeType}-${item.version}-${item.sourceRef}`}><b>v{item.version}</b><span>{item.summary}</span><em>{item.changeType === 'provider' ? '提供方' : '策略'} · {item.actor} · {fmt(item.changedAt)}</em></li>)}</ol> : <p>该车辆尚无人工来源策略或提供方变更。</p>}
|
||||
</section>
|
||||
</Card>
|
||||
</> : null}
|
||||
</section>;
|
||||
</Card>;
|
||||
}
|
||||
|
||||
export default function OperationsPage() {
|
||||
const [workspace, setWorkspace] = useState<OperationsWorkspace>('reconciliation');
|
||||
const health = useQuery({ queryKey: ['ops-health-v2'], queryFn: ({ signal }) => api.opsHealth(signal), refetchInterval: 15_000, staleTime: 8_000, gcTime: QUERY_MEMORY.summaryGcTime, ...LIVE_QUERY_POLICY });
|
||||
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()]);
|
||||
return <div className="v2-ops-page">
|
||||
<header className="v2-ops-heading"><div><h2>运维质量</h2><p>先定位单车多来源问题,再核对服务和协议全局健康;所有结论来自服务端证据。</p></div><button onClick={refresh} disabled={health.isFetching || readiness.isFetching}><IconRefresh />刷新全局证据</button></header>
|
||||
<ReconciliationCenter />
|
||||
<SourceDiagnosticWorkspace />
|
||||
{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">
|
||||
<article><small>运行版本</small><strong>{data?.runtime.platformRelease || '未注入'}</strong><span className={data?.runtime.dataMode === 'production' ? 'is-ok' : 'is-error'}>{data?.runtime.dataMode || 'unknown'}</span></article>
|
||||
<article><small>活跃连接</small><strong>{data?.activeConnections?.toLocaleString('zh-CN') ?? '—'}</strong><span>capacity-check</span></article>
|
||||
<article><small>Kafka Lag</small><strong>{data?.kafkaLag?.toLocaleString('zh-CN') ?? '—'}</strong><span className={data?.kafkaLag === 0 ? 'is-ok' : 'is-warning'}>{data?.kafkaLag === 0 ? '已回零' : '需检查'}</span></article>
|
||||
<article><small>Redis 在线 Key</small><strong>{data?.redisOnlineKeys?.toLocaleString('zh-CN') ?? '—'}</strong><span>实时探针</span></article>
|
||||
<article><small>服务身份 / 在线</small><strong>{sources ? `${sources.totalVehicles} / ${sources.onlineVehicles}` : '—'}</strong><span>{sources ? `已绑定 ${sources.boundVehicles} · 待绑定 ${sources.identityRequiredVehicles}` : '档案与快照并集'}</span></article>
|
||||
<PageHeader
|
||||
title="运维质量"
|
||||
description="先定位单车多来源问题,再核对服务和协议全局健康;所有结论来自服务端证据。"
|
||||
status={data?.runtime.dataMode === 'production' ? '生产模式' : '状态待确认'}
|
||||
statusColor={data?.runtime.dataMode === 'production' ? 'green' : 'orange'}
|
||||
meta={<Typography.Text type="tertiary">{data?.runtime.platformRelease ? `版本 ${data.runtime.platformRelease}` : '正在读取运行版本'}</Typography.Text>}
|
||||
actions={<Button theme="light" icon={<IconRefresh />} loading={health.isFetching || readiness.isFetching} onClick={refresh}>刷新全局证据</Button>}
|
||||
/>
|
||||
<SegmentedTabs
|
||||
className="v2-ops-tabs"
|
||||
variant="filled"
|
||||
ariaLabel="运维质量工作区"
|
||||
value={workspace}
|
||||
onChange={setWorkspace}
|
||||
items={[
|
||||
{ key: 'reconciliation', label: '差异处置' },
|
||||
{ key: 'diagnostic', label: '单车诊断' },
|
||||
{ key: 'health', label: '全局健康' }
|
||||
]}
|
||||
/>
|
||||
<section className={`v2-ops-workspace is-${workspace}`} role="tabpanel" aria-label={workspace === 'reconciliation' ? '差异处置' : workspace === 'diagnostic' ? '单车诊断' : '全局健康'}>
|
||||
{workspace === 'reconciliation' ? <ReconciliationCenter /> : null}
|
||||
{workspace === 'diagnostic' ? <SourceDiagnosticWorkspace /> : null}
|
||||
{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>
|
||||
</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={[
|
||||
{ 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>
|
||||
<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>
|
||||
</> : null}
|
||||
</section>
|
||||
<div className="v2-ops-grid"><section className="v2-ops-links"><header><strong>数据链路</strong><span>15 秒自动刷新</span></header><div>{data?.linkHealth.map((item) => <article key={item.name}><i className={`is-${item.status}`} /><div><strong>{item.name}</strong><p>{item.detail || '无补充信息'}</p></div><span className={`is-${item.status}`}>{statusLabel(item.status)}</span></article>)}</div></section>
|
||||
<section className="v2-ops-runtime"><header><strong>运行时安全</strong></header><dl><div><dt>生产数据模式</dt><dd>{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'}</dd></div><div><dt>MySQL 写探针</dt><dd className={data?.mysqlWritable ? 'is-ok' : 'is-error'}>{data?.mysqlWritable ? '正常' : '异常'}</dd></div><div><dt>TDengine 写探针</dt><dd className={data?.tdengineWritable ? 'is-ok' : 'is-error'}>{data?.tdengineWritable ? '正常' : '异常'}</dd></div><div><dt>请求超时</dt><dd>{data?.runtime.requestTimeoutMs ?? '—'} ms</dd></div><div><dt>高德安全代理</dt><dd className={data?.runtime.amapSecurityProxyEnabled && !data?.runtime.amapSecurityCodeExposed ? 'is-ok' : 'is-warning'}>{data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'}</dd></div></dl>{data?.capacityFindings?.length ? <div className="v2-ops-findings">{data.capacityFindings.map((item) => <p key={item}>{item}</p>)}</div> : <p className="v2-ops-clear">容量检查无待处理项</p>}</section></div>
|
||||
<section className="v2-ops-sources"><header><strong>协议来源就绪度</strong><span>验收口径与处置建议</span></header><div>{sources?.sources.map((source) => <article key={source.protocol}><div><i className={`is-${source.severity}`} /><strong>{source.protocol}</strong><span>{source.role}</span></div><b>{source.online} / {source.total} 在线</b><p>{source.evidence}</p><p>{source.action}</p><em>{source.acceptance}</em></article>)}</div></section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user