fix(web): expose secondary query failures
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import type { AccessVehicleRow, Page } from '../../api/types';
|
||||
@@ -55,3 +55,24 @@ test('removes old access rows immediately when the vehicle filter scope changes'
|
||||
expect(await screen.findByText('新接入车牌')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByText('正在更新车辆接入状态…')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('reports and independently retries unresolved identity and threshold failures', async () => {
|
||||
prepareBaseData();
|
||||
mocks.accessVehicles.mockResolvedValue({ items: [accessRow('VIN001', '粤A00001')], total: 1, limit: 50, offset: 0 });
|
||||
mocks.accessUnresolvedIdentities.mockRejectedValueOnce(new Error('待绑定身份服务超时')).mockResolvedValueOnce({ items: [{ id: 'identity-1', identifierMasked: '138****0001', protocol: 'JT808', plate: '粤A待核', latestSeenAt: '2026-07-16T04:00:00Z', recommendedAction: '核对 VIN' }], total: 1, limit: 20, offset: 0 });
|
||||
mocks.accessThresholds.mockRejectedValueOnce(new Error('阈值配置读取失败')).mockResolvedValueOnce({ version: 1, defaultThresholdSec: 300, delayThresholdSec: 60, longOfflineSec: 86_400, protocols: [], updatedBy: 'test', updatedAt: '2026-07-16T04:00:00Z', audit: [] });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access']}><AccessPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
const identityError = await screen.findByText('待绑定身份服务超时');
|
||||
const thresholdError = await screen.findByText('阈值配置读取失败');
|
||||
fireEvent.click(within(identityError.closest('[role="alert"]')!).getByRole('button', { name: /重试/ }));
|
||||
fireEvent.click(within(thresholdError.closest('[role="alert"]')!).getByRole('button', { name: /重试/ }));
|
||||
|
||||
expect(await screen.findByText('另有 1 条来源身份待绑定')).toBeInTheDocument();
|
||||
expect(await screen.findByText('在线判定阈值 · v1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('待绑定身份服务超时')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('阈值配置读取失败')).not.toBeInTheDocument();
|
||||
expect(mocks.accessUnresolvedIdentities).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.accessThresholds).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -112,9 +112,10 @@ export default function AccessPage() {
|
||||
const apply = (next: Filters) => { setDraft(next); setCriteria(next); setOffset(0); setSelectedVIN(''); syncURL(next); };
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); apply(draft); };
|
||||
const page = Math.floor(offset / limit) + 1; const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit)); const summary = summaryQuery.data;
|
||||
const refresh = () => Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch(), unresolvedQuery.refetch(), thresholdQuery.refetch()]);
|
||||
|
||||
return <div className="v2-access-page v2-access-page-v3">
|
||||
<header className="v2-access-heading"><div><h2>车辆接入管理</h2><p>以主车辆为对象,对照应接协议、实际接入和各协议最新上报时间</p></div><div><span>数据时间 {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}</span><button type="button" onClick={() => void Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch()])}><IconRefresh />刷新</button></div></header>
|
||||
<header className="v2-access-heading"><div><h2>车辆接入管理</h2><p>以主车辆为对象,对照应接协议、实际接入和各协议最新上报时间</p></div><div><span>数据时间 {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}</span><button type="button" onClick={() => void refresh()}><IconRefresh />刷新</button></div></header>
|
||||
<form className="v2-access-filter-v3" onSubmit={submit}><label className="is-search"><span>车辆</span><div><IconSearch /><input aria-label="车辆" value={draft.keyword} onChange={(event) => setDraft({ ...draft, keyword: event.target.value })} placeholder="车牌 / VIN" /></div></label><label><span>接入状态</span><select aria-label="接入状态" value={draft.connectionState} onChange={(event) => setDraft({ ...draft, connectionState: event.target.value })}><option value="">全部状态</option><option value="attention">有接入差异</option><option value="healthy">三协议正常</option><option value="incomplete">接入不完整</option><option value="degraded">协议离线</option><option value="offline">全部离线</option><option value="not_connected">尚未接入</option></select></label><label><span>关注协议</span><select aria-label="关注协议" value={draft.protocol} onChange={(event) => setDraft({ ...draft, protocol: event.target.value })}><option value="">全部协议</option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span>车辆品牌</span><select aria-label="车辆品牌" value={draft.oem} onChange={(event) => setDraft({ ...draft, oem: event.target.value })}><option value="">全部品牌</option>{summary?.oems.filter((item) => item.name !== '未维护').map((item) => <option key={item.name}>{item.name}</option>)}</select></label><button className="v2-primary-button" type="submit">查询</button><button className="v2-secondary-button" type="button" onClick={() => apply(EMPTY_FILTERS)}>重置</button></form>
|
||||
{summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
|
||||
<section className="v2-access-kpis-v3">{[
|
||||
@@ -122,7 +123,9 @@ export default function AccessPage() {
|
||||
].map(([label, value, tone, connectionState]) => <button key={String(label)} className={`is-${tone}`} type="button" onClick={() => apply({ ...criteria, connectionState: String(connectionState) })}><small>{label}</small><strong>{Number(value).toLocaleString('zh-CN')}</strong>{label === '主车辆' ? <em>车辆主档</em> : null}</button>)}</section>
|
||||
{vehiclesQuery.isError ? <InlineError message={vehiclesQuery.error instanceof Error ? vehiclesQuery.error.message : '接入车辆读取失败'} onRetry={() => vehiclesQuery.refetch()} /> : null}
|
||||
<div className={`v2-access-workspace-v3 ${selected ? 'is-inspector-open' : ''}`}><section className="v2-access-table-v3"><header><div className="v2-access-table-title"><strong>车辆协议接入差异</strong><span>优先展示应接与实接差异;时间为各协议最后接收时间</span></div><div className="v2-access-table-actions"><ProtocolCoverage summary={summary} /><button type="button" onClick={() => downloadRows(rows)} disabled={!rows.length}><IconDownload />导出当前页</button></div></header><div className="v2-access-table-scroll-v3"><table><thead><tr><th>车辆</th><th>品牌 / 车型</th><th>应接协议</th>{PROTOCOLS.map((item) => <th key={item}>{item}</th>)}<th>综合状态</th></tr></thead><tbody>{rows.map((row) => <tr key={row.vin} data-testid={`access-row-${row.vin}`} tabIndex={0} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedVIN(row.vin); }}><td><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></td><td><strong>{row.oem || '品牌未维护'}</strong><span>{row.model || row.company || '车型未维护'}</span></td><td><b>{row.expectedProtocols.length} 项</b><span>{row.expectedProtocols.join(' / ')}</span></td>{PROTOCOLS.map((protocol) => <td key={protocol}><ProtocolState status={statusByProtocol(row, protocol)} /></td>)}<td><ConnectionState row={row} /></td></tr>)}</tbody></table>{vehiclesQuery.isFetching ? <div className="v2-access-loading"><i />正在更新车辆接入状态…</div> : null}{!vehiclesQuery.isFetching && !rows.length ? <div className="v2-access-empty">当前筛选条件没有车辆</div> : null}</div><footer><span>第 {page} / {totalPages} 页,共 {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆</span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}>上一页</button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}>下一页</button><select aria-label="每页数量" value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 辆/页</option><option value="50">50 辆/页</option><option value="100">100 辆/页</option></select></div></footer></section>{selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} /> : null}</div>
|
||||
{unresolvedQuery.isError ? <InlineError message={unresolvedQuery.error instanceof Error ? unresolvedQuery.error.message : '待绑定身份读取失败'} onRetry={() => unresolvedQuery.refetch()} /> : null}
|
||||
<IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} />
|
||||
{thresholdQuery.isError ? <InlineError message={thresholdQuery.error instanceof Error ? thresholdQuery.error.message : '接入阈值读取失败'} onRetry={() => thresholdQuery.refetch()} /> : null}
|
||||
<ThresholdSettings config={thresholdQuery.data} draft={thresholdDraft} editable={editable} saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} onChange={setThresholdDraft} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import OperationsPage from './OperationsPage';
|
||||
|
||||
@@ -26,3 +26,25 @@ test('reconciles service identities with bound and identity-required vehicles',
|
||||
expect(screen.getByText('已绑定 1024 · 待绑定 11')).toBeInTheDocument();
|
||||
expect(screen.queryByText('统一车辆视角')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keeps health evidence visible when source readiness fails and retries that section', async () => {
|
||||
mocks.opsHealth.mockResolvedValue({
|
||||
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
|
||||
tdengineWritable: true, mysqlWritable: true,
|
||||
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
|
||||
});
|
||||
mocks.sourceReadiness.mockRejectedValueOnce(new Error('来源就绪度暂时不可用')).mockResolvedValueOnce({
|
||||
totalVehicles: 1035, boundVehicles: 1024, identityRequiredVehicles: 11, onlineVehicles: 234,
|
||||
kafkaLag: 0, activeConnections: 10, redisOnlineKeys: 5, platformRelease: 'test-release', sources: []
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByText('来源就绪度暂时不可用')).toBeInTheDocument();
|
||||
expect(screen.getByText('test-release')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /重试/ }));
|
||||
|
||||
expect(await screen.findByText('1035 / 234')).toBeInTheDocument();
|
||||
expect(screen.queryByText('来源就绪度暂时不可用')).not.toBeInTheDocument();
|
||||
expect(mocks.sourceReadiness).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export default function OperationsPage() {
|
||||
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>
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user