fix(web): expose secondary query failures

This commit is contained in:
lingniu
2026-07-16 07:45:15 +08:00
parent e9b7c0d31f
commit be1ff4ff0d
4 changed files with 50 additions and 3 deletions

View File

@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 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 { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import type { AccessVehicleRow, Page } from '../../api/types'; 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(); expect(await screen.findByText('新接入车牌')).toBeInTheDocument();
await waitFor(() => expect(screen.queryByText('正在更新车辆接入状态…')).not.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);
});

View File

@@ -112,9 +112,10 @@ export default function AccessPage() {
const apply = (next: Filters) => { setDraft(next); setCriteria(next); setOffset(0); setSelectedVIN(''); syncURL(next); }; const apply = (next: Filters) => { setDraft(next); setCriteria(next); setOffset(0); setSelectedVIN(''); syncURL(next); };
const submit = (event: FormEvent) => { event.preventDefault(); apply(draft); }; 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 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"> 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> <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} {summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
<section className="v2-access-kpis-v3">{[ <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> ].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} {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> <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} /> <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)} /> <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>; </div>;
} }

View File

@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 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 { afterEach, expect, test, vi } from 'vitest';
import OperationsPage from './OperationsPage'; 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.getByText('已绑定 1024 · 待绑定 11')).toBeInTheDocument();
expect(screen.queryByText('统一车辆视角')).not.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);
});

View File

@@ -15,6 +15,7 @@ export default function OperationsPage() {
return <div className="v2-ops-page"> 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> <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} {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"> <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?.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></small><strong>{data?.activeConnections?.toLocaleString('zh-CN') ?? '—'}</strong><span>capacity-check</span></article>