180 lines
17 KiB
TypeScript
180 lines
17 KiB
TypeScript
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||
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 { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
|
||
import ReconciliationCenter from './ReconciliationCenter';
|
||
|
||
function statusLabel(status: string) {
|
||
return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status;
|
||
}
|
||
|
||
function fmt(value?: string) {
|
||
if (!value) return '—';
|
||
const parsed = new Date(value.replace(' ', 'T'));
|
||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false });
|
||
}
|
||
|
||
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 }: {
|
||
vin: string;
|
||
source: VehicleLocationSourceEvidence;
|
||
diagnostic: VehicleSourceDiagnostic;
|
||
editable: boolean;
|
||
onSaved: (next: VehicleSourceDiagnostic) => void;
|
||
}) {
|
||
const [enabled, setEnabled] = useState(source.enabled);
|
||
const [priority, setPriority] = useState(source.priority);
|
||
const [providerName, setProviderName] = useState(source.providerOverride || '');
|
||
const [providerEvidence, setProviderEvidence] = useState('');
|
||
const [remark, setRemark] = useState(source.policyRemark || '');
|
||
useEffect(() => {
|
||
setEnabled(source.enabled);
|
||
setPriority(source.priority);
|
||
setProviderName(source.providerOverride || '');
|
||
setProviderEvidence('');
|
||
setRemark(source.policyRemark || '');
|
||
}, [source.enabled, source.priority, source.providerOverride, source.policyRemark, source.sourceRef]);
|
||
const save = useMutation({
|
||
mutationFn: () => api.updateVehicleSourcePolicy(vin, {
|
||
version: diagnostic.policy.version,
|
||
sourceRef: source.sourceRef ?? '',
|
||
providerName,
|
||
providerEvidence,
|
||
enabled,
|
||
priority,
|
||
remark
|
||
}),
|
||
onSuccess: onSaved
|
||
});
|
||
const providerChanged = providerName.trim() !== (source.providerOverride || '');
|
||
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>
|
||
{save.isError ? <em role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</em> : null}
|
||
</td>
|
||
</tr>;
|
||
}
|
||
|
||
function SourceDiagnosticWorkspace() {
|
||
const queryClient = useQueryClient();
|
||
const [keyword, setKeyword] = useState('');
|
||
const deferredKeyword = useDeferredValue(keyword.trim());
|
||
const [selected, setSelected] = useState<VehicleCoverageRow>();
|
||
const [candidateOffset, setCandidateOffset] = useState(0);
|
||
useEffect(() => setCandidateOffset(0), [deferredKeyword]);
|
||
const candidates = useQuery({
|
||
queryKey: ['ops-source-candidates', deferredKeyword, candidateOffset],
|
||
queryFn: ({ signal }) => api.vehicleCoverage(new URLSearchParams({ keyword: deferredKeyword, bindingStatus: 'bound', limit: '20', offset: String(candidateOffset) }), signal),
|
||
enabled: deferredKeyword.length > 0,
|
||
staleTime: 15_000,
|
||
gcTime: QUERY_MEMORY.optionGcTime
|
||
});
|
||
const session = useQuery({ queryKey: ['ops-session'], queryFn: ({ signal }) => api.session(signal), staleTime: 60_000 });
|
||
const diagnostic = useQuery({
|
||
queryKey: ['ops-source-diagnostic', selected?.vin],
|
||
queryFn: ({ signal }) => api.vehicleSourceDiagnostic(selected!.vin, signal),
|
||
enabled: Boolean(selected?.vin),
|
||
staleTime: 5_000,
|
||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||
});
|
||
const choose = (vehicle: VehicleCoverageRow) => {
|
||
setSelected(vehicle);
|
||
setKeyword(vehicle.plate || vehicle.vin);
|
||
};
|
||
const submit = (event: FormEvent) => {
|
||
event.preventDefault();
|
||
const first = candidates.data?.items[0];
|
||
if (first) choose(first);
|
||
};
|
||
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>
|
||
<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}
|
||
</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}
|
||
{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>
|
||
</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>
|
||
{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>
|
||
</> : null}
|
||
</section>;
|
||
}
|
||
|
||
export default function OperationsPage() {
|
||
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>
|
||
</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>;
|
||
}
|