diff --git a/e2e/faults-refresh.spec.ts b/e2e/faults-refresh.spec.ts new file mode 100644 index 0000000..204a482 --- /dev/null +++ b/e2e/faults-refresh.spec.ts @@ -0,0 +1,183 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * The fault dashboard against a real gateway that reports no faults - the case + * where "loaded" and "empty" look alike. A refresh must leave the page as it is + * and must cost one request, however many fault views are on screen. + */ + +import { test, expect, type Page } from '@playwright/test'; + +/** + * Reports whether the first-load skeleton appears at any point from now on, + * including for a single frame that polling a locator would step over. + */ +async function watchForSkeleton(page: Page): Promise<() => Promise> { + await page.evaluate(() => { + const w = window as unknown as { __skeletonSeen?: boolean; __skeletonObserver?: MutationObserver }; + w.__skeletonSeen = false; + const observer = new MutationObserver(() => { + if (document.querySelector('main .animate-pulse')) { + w.__skeletonSeen = true; + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + w.__skeletonObserver = observer; + }); + + return async () => + page.evaluate(() => { + const w = window as unknown as { __skeletonSeen?: boolean; __skeletonObserver?: MutationObserver }; + w.__skeletonObserver?.disconnect(); + return w.__skeletonSeen === true; + }); +} + +/** + * The dashboard reads the gateway-wide fault list. Entity pages read their own + * `/apps//faults`, which ends the same way, so the match has to be exact or a + * detour through an entity inflates the count. + */ +function isFaultListRequest(url: string): boolean { + return new URL(url).pathname === '/api/v1/faults'; +} + +/** + * Serves the fault list from here rather than from the gateway. These tests are about + * when the page refreshes, not about what the gateway holds, and the e2e gateway runs + * without a fault manager - it answers `/faults` with 503 after five seconds, which + * would decide both what is on screen and how the ticks line up. + */ +async function serveEmptyFaultList(page: Page): Promise { + await page.route('**/api/v1/faults?**', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: '{"items":[]}' }) + ); +} + +/** Opens the dashboard and waits for the gateway's (empty) fault list to be on screen. */ +async function openDashboard(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: 'Faults Dashboard' }).click(); + await expect(page.getByText('No faults to display')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('No faults detected')).toBeVisible(); +} + +const POLL_INTERVAL_MS = 5_000; +const OBSERVED_WINDOW_MS = 11_000; + +test.describe('faults dashboard refresh', () => { + test('a refresh of an empty fault list leaves the page as it is', async ({ page }) => { + await serveEmptyFaultList(page); + await openDashboard(page); + const skeletonSeen = await watchForSkeleton(page); + + // The refresh a tab regaining focus triggers. + await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange'))); + await page.waitForTimeout(2_000); + + expect(await skeletonSeen()).toBe(false); + await expect(page.getByText('No faults to display')).toBeVisible(); + }); + + test('opening the dashboard reads the fault list', async ({ page }) => { + await serveEmptyFaultList(page); + await openDashboard(page); + + const faultRequests: string[] = []; + page.on('request', (request) => { + if (isFaultListRequest(request.url())) { + faultRequests.push(request.url()); + } + }); + + // Leave the dashboard for an entity and come back. The sidebar badge stays + // mounted the whole time, so the dashboard is never the session's first fault view. + await page.getByText('Test ECU').first().click(); + await expect(page.getByText('No faults to display')).toBeHidden(); + await page.getByRole('button', { name: 'Faults Dashboard' }).click(); + await expect(page.getByText('No faults to display')).toBeVisible(); + + expect(faultRequests.length).toBeGreaterThan(0); + }); + + test('says the fault list could not be read, rather than showing none', async ({ page }) => { + // Verbatim what the gateway answers when its fault manager is not running. + await page.route('**/api/v1/faults?**', (route) => + route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ + error_code: 'service-unavailable', + message: 'Failed to get faults', + parameters: { details: 'ListFaults service not available' }, + }), + }) + ); + + await page.goto('/'); + await page.getByRole('button', { name: 'Faults Dashboard' }).click(); + + const dashboard = page.locator('main'); + await expect(dashboard.getByText('Fault list unavailable')).toBeVisible({ timeout: 30_000 }); + await expect(dashboard.getByText('ListFaults service not available')).toBeVisible(); + await expect(dashboard.getByText('System is operating normally')).toBeHidden(); + await expect(dashboard.getByText('No faults detected')).toBeHidden(); + }); + + test('a gateway that cannot read faults says so, and keeps saying it', async ({ page }) => { + // No stubbing here. The e2e gateway runs without a fault manager (see + // docker-compose.yml), so `/faults` is a real 503 arriving five seconds late - a + // first load that fails slowly. Pointed at a gateway that HAS a fault manager, + // this test fails by design: it is about the answer that stack gives. + await page.goto('/'); + await page.getByRole('button', { name: 'Faults Dashboard' }).click(); + + const dashboard = page.locator('main'); + await expect(dashboard.getByText('Fault list unavailable')).toBeVisible({ timeout: 30_000 }); + await expect(dashboard.getByText('ListFaults service not available')).toBeVisible(); + + // The skeleton belongs to the first load. Every retry after it keeps the page as + // it is, and nothing on the page claims the system is fine. + const skeletonSeen = await watchForSkeleton(page); + await page.waitForTimeout(11_000); + + expect(await skeletonSeen()).toBe(false); + await expect(dashboard.getByText('Fault list unavailable')).toBeVisible(); + await expect(dashboard.getByText('All Clear')).toBeHidden(); + }); + + test('the fallback poll asks once per interval, not once per mounted view', async ({ page }) => { + // With no fault stream the app falls back to polling. The dashboard and the + // sidebar badge are both on screen and read the same list. An HTTP status is + // what makes the client give up at once - a dropped connection it retries. + await page.route('**/faults/stream**', (route) => + route.fulfill({ status: 404, contentType: 'application/json', body: '{}' }) + ); + await serveEmptyFaultList(page); + await openDashboard(page); + + const faultRequests: string[] = []; + page.on('request', (request) => { + if (isFaultListRequest(request.url())) { + faultRequests.push(request.url()); + } + }); + await page.waitForTimeout(OBSERVED_WINDOW_MS); + + const intervals = Math.floor(OBSERVED_WINDOW_MS / POLL_INTERVAL_MS); + expect(faultRequests.length).toBeGreaterThanOrEqual(intervals); + expect(faultRequests.length).toBeLessThanOrEqual(intervals + 1); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index b2925f6..5438102 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -54,5 +54,8 @@ export default defineConfig({ // it needs a fault manager the scripts gateway does not run. Serial for // the same reason as scripts-serial: one shared gateway. { name: 'rosbag-serial', testMatch: /rosbag-.*\.spec\.ts/, fullyParallel: false, workers: 1 }, + // Reads the scripts gateway without changing it, but counts the requests the + // app makes, so it must not share a run with specs driving the same app. + { name: 'faults-serial', testMatch: /faults-.*\.spec\.ts/, fullyParallel: false, workers: 1 }, ], }); diff --git a/src/components/FaultsDashboard.polling.test.tsx b/src/components/FaultsDashboard.polling.test.tsx new file mode 100644 index 0000000..08becf4 --- /dev/null +++ b/src/components/FaultsDashboard.polling.test.tsx @@ -0,0 +1,614 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * The dashboard and the sidebar badge read the same fault list. These tests pin + * what the user sees while that list refreshes: the page must not fall back to + * its first-load skeleton, and the two views must not each ask the gateway + * separately. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, type RenderResult } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { FaultsDashboard, FaultsCountBadge } from './FaultsDashboard'; +import { useAppStore } from '@/lib/store'; + +const POLL_INTERVAL_MS = 5000; +const FAULT_STREAM_SAFETY_NET_MS = 30000; + +const RAW_FAULT = { + fault_code: 'LIDAR_RANGE_INVALID', + description: 'range invalid', + severity: 2, + severity_label: 'ERROR', + status: 'CONFIRMED', + first_occurred: 1756636800, + last_occurred: 1756636800, + occurrence_count: 1, + reporting_sources: ['/lidar_driver'], +}; + +function clientReturning(items: unknown[]) { + return { + GET: vi.fn(async () => { + await Promise.resolve(); + return { data: { items }, error: undefined }; + }), + }; +} + +/** Records every DOM state the user could have seen while `run` executed. */ +async function framesDuring(container: HTMLElement, run: () => Promise): Promise { + const frames: string[] = []; + const record = () => frames.push(container.querySelectorAll('.animate-pulse').length > 0 ? 'skeleton' : 'content'); + const observer = new MutationObserver(record); + observer.observe(container, { childList: true, subtree: true }); + await run(); + if (observer.takeRecords().length > 0) { + record(); + } + observer.disconnect(); + return frames; +} + +/** Renders and lets the refresh the mount starts finish, so nothing settles outside act. */ +async function mount(ui: ReactElement): Promise { + let result!: RenderResult; + await act(async () => { + result = render(ui); + await vi.advanceTimersByTimeAsync(0); + }); + return result; +} + +/** Lets everything already scheduled run: timers due now, and the promises they start. */ +function renderedCodes(container: HTMLElement): (string | null)[] { + return Array.from(container.querySelectorAll('.font-mono.text-sm')).map((el) => el.textContent); +} + +async function settle() { + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); +} + +/** Moves time forward, letting each tick's request settle before the next one fires. */ +async function advance(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +function connect(client: { GET: unknown }, sseActive: boolean) { + useAppStore.setState({ + isConnected: true, + client, + faults: [], + isLoadingFaults: false, + faultsLoaded: false, + faultsError: null, + // Now a store value, so it outlives a test the way it outlives a page: reset it. + faultsAutoRefresh: true, + faultStreamCleanup: sseActive ? () => {} : null, + } as never); +} + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); +}); + +afterEach(() => { + // Wrapped: this hook runs while the views are still mounted (Testing Library's + // own cleanup is registered earlier and so runs after this one), and an + // unwrapped store write would land on them outside act. + act(() => { + // disconnect, not setState: a read still on the wire is held in module state and + // would answer for the next test, whose own reads would then never leave. + useAppStore.getState().disconnect(); + useAppStore.setState({ isConnected: false, client: null, faults: [], faultStreamCleanup: null } as never); + }); + vi.useRealTimers(); +}); + +describe('FaultsDashboard refresh behaviour', () => { + it('never falls back to the first-load skeleton once the empty list has loaded', async () => { + connect(clientReturning([]), false); + const { container } = await mount(); + + const frames = await framesDuring(container, () => advance(POLL_INTERVAL_MS)); + + expect(frames).not.toContain('skeleton'); + }); + + it('never falls back to the skeleton when the tab regains focus', async () => { + connect(clientReturning([]), true); + const { container } = await mount( + <> + + + + ); + + const frames = await framesDuring(container, async () => { + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); + await Promise.resolve(); + }); + await settle(); + }); + + expect(frames).not.toContain('skeleton'); + }); + + it('still shows the skeleton for the very first load', async () => { + connect(clientReturning([RAW_FAULT]), false); + const { container } = render(); + + expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0); + + await settle(); + expect(container.querySelectorAll('.animate-pulse').length).toBe(0); + }); + + it('asks the gateway once per interval even with the badge and the dashboard mounted', async () => { + const client = clientReturning([RAW_FAULT]); + connect(client, false); + await mount( + <> + + + + ); + expect(client.GET).toHaveBeenCalledTimes(1); + + await advance(POLL_INTERVAL_MS); + expect(client.GET).toHaveBeenCalledTimes(2); + + await advance(POLL_INTERVAL_MS * 2); + expect(client.GET).toHaveBeenCalledTimes(4); + }); + + it('takes its updates from the stream instead of polling for them', async () => { + const client = clientReturning([RAW_FAULT]); + connect(client, true); + const { container } = await mount( + <> + + + + ); + const afterMount = client.GET.mock.calls.length; + + await act(async () => { + useAppStore.getState().applyFaultStreamEvent('fault_confirmed', { + code: 'RAISED_OVER_THE_STREAM', + message: 'reported without a read', + severity: 'error', + status: 'active', + timestamp: '2026-08-31T10:00:00.000Z', + entity_id: 'motor', + entity_type: 'app', + }); + await vi.advanceTimersByTimeAsync(0); + }); + // The event alone put it on screen, and it took no request to do so. + expect(container.textContent).toContain('RAISED_OVER_THE_STREAM'); + expect(client.GET.mock.calls.length).toBe(afterMount); + + // The 5 s poll stays off right up to the safety net. + await advance(FAULT_STREAM_SAFETY_NET_MS - POLL_INTERVAL_MS); + + expect(client.GET.mock.calls.length).toBe(afterMount); + }); + + it('reads the list when a second view opens on top of one already mounted', async () => { + const client = clientReturning([RAW_FAULT]); + connect(client, true); + await mount(); + expect(client.GET).toHaveBeenCalledTimes(1); + + // The badge lives in the sidebar for the whole session, so the dashboard is + // always the second view. Opening it has to show the list as it is now. + await mount(); + + expect(client.GET).toHaveBeenCalledTimes(2); + }); + + it('reads the list as soon as the fault stream stops delivering', async () => { + const client = clientReturning([RAW_FAULT]); + connect(client, true); + await mount( + <> + + + + ); + const whileStreaming = client.GET.mock.calls.length; + + // The stream dying is the one moment the client knows it has missed events. + await act(async () => { + useAppStore.setState({ faultStreamCleanup: null } as never); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(client.GET.mock.calls.length).toBe(whileStreaming + 1); + }); + + it('reads from the gateway it is now connected to', async () => { + const gatewayA = clientReturning([RAW_FAULT]); + connect(gatewayA, true); + await mount( + <> + + + + ); + + // Connecting elsewhere never clears isConnected, so nothing else re-reads. + const gatewayB = clientReturning([]); + await act(async () => { + useAppStore.setState({ client: gatewayB } as never); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(gatewayB.GET).toHaveBeenCalledTimes(1); + }); + + it('re-reads the list once when a fault is cleared, not twice', async () => { + const client = { + ...clientReturning([RAW_FAULT]), + DELETE: vi.fn(async () => ({ data: undefined, error: undefined })), + }; + connect(client, true); + const { container } = await mount(); + const readsBeforeClear = client.GET.mock.calls.length; + + const clearButton = container.querySelector('button[title="Clear fault"]') as HTMLElement; + await act(async () => { + clearButton.click(); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(client.DELETE).toHaveBeenCalledTimes(1); + expect(client.GET.mock.calls.length).toBe(readsBeforeClear + 1); + }); + + it('says the gateway could not read faults instead of reporting none', async () => { + connect(clientReturning([]), true); + const { container } = await mount(); + await act(async () => { + useAppStore.setState({ + faultsError: 'Failed to get faults: ListFaults service not available', + } as never); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(container.textContent).toContain('ListFaults service not available'); + expect(container.textContent).not.toContain('System is operating normally'); + expect(container.textContent).not.toContain('No faults detected'); + // "All Clear" is a statement about the system, and the page cannot make it here. + expect(container.textContent).not.toContain('All Clear'); + }); + + it('stops refreshing while Auto-refresh is off, badge included', async () => { + const client = clientReturning([RAW_FAULT]); + connect(client, false); + await mount( + <> + + + + ); + + const autoRefresh = document.getElementById('auto-refresh') as HTMLElement; + await act(async () => { + autoRefresh.click(); + await vi.advanceTimersByTimeAsync(0); + }); + const afterSwitchOff = client.GET.mock.calls.length; + + await advance(POLL_INTERVAL_MS * 3); + + expect(client.GET.mock.calls.length).toBe(afterSwitchOff); + }); + + it('still checks now and then while the stream is up, in case it delivers nothing', async () => { + // A gateway can hold the stream open and never send an event - an aggregator + // that does not fan the stream out to its peers does exactly this. The page + // would otherwise sit unchanged for the whole session. + const client = clientReturning([RAW_FAULT]); + connect(client, true); + await mount(); + const afterMount = client.GET.mock.calls.length; + + await advance(FAULT_STREAM_SAFETY_NET_MS); + + expect(client.GET.mock.calls.length).toBe(afterMount + 1); + }); + + it('does not let the sidebar badge report a count it could not check', async () => { + connect(clientReturning([RAW_FAULT]), true); + const { container } = await mount(); + await act(async () => { + useAppStore.setState({ + faultsError: 'Failed to get faults: ListFaults service not available', + } as never); + await vi.advanceTimersByTimeAsync(0); + }); + + // The count it already has is worth keeping - marked as unchecked, not thrown away. + expect(container.textContent).toBe('1'); + expect(container.querySelector('[title]')?.getAttribute('title')).toContain('could not be checked'); + }); + + it('does not refresh twice when the tab is restored just before a tick', async () => { + const client = clientReturning([RAW_FAULT]); + connect(client, false); + await mount(); + + await advance(POLL_INTERVAL_MS - 100); + const beforeFocus = client.GET.mock.calls.length; + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(0); + }); + expect(client.GET.mock.calls.length).toBe(beforeFocus + 1); + + await advance(200); + + expect(client.GET.mock.calls.length).toBe(beforeFocus + 1); + }); + + it('reads the new gateway even if the old one never answered', async () => { + const silent = { GET: vi.fn(() => new Promise(() => {})) }; + connect(silent, true); + await mount( + <> + + + + ); + + // Connecting elsewhere goes nowhere near disconnect(), and the read left on the + // wire belongs to a gateway nobody is looking at any more. + const gatewayB = clientReturning([RAW_FAULT]); + await act(async () => { + useAppStore.setState({ + client: gatewayB, + faults: [], + faultsLoaded: false, + faultsError: null, + } as never); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(gatewayB.GET).toHaveBeenCalledTimes(1); + }); + + it('does not bring the skeleton back while the gateway keeps failing', async () => { + const refusing = { + GET: vi.fn(async () => ({ + data: undefined, + error: { message: 'Failed to get faults', parameters: { details: 'ListFaults service not available' } }, + })), + }; + connect(refusing, false); + const { container } = await mount(); + expect(container.textContent).toContain('Fault list unavailable'); + + const frames = await framesDuring(container, () => advance(POLL_INTERVAL_MS * 2)); + + expect(frames).not.toContain('skeleton'); + }); + + it('says a list on screen could not be refreshed, and keeps it', async () => { + let answered = false; + const failsAfterFirst = { + GET: vi.fn(async () => { + if (answered) { + return { + data: undefined, + error: { + message: 'Failed to get faults', + parameters: { details: 'ListFaults service not available' }, + }, + }; + } + answered = true; + return { data: { items: [RAW_FAULT] }, error: undefined }; + }), + }; + connect(failsAfterFirst, false); + const { container } = await mount(); + expect(container.textContent).toContain('LIDAR_RANGE_INVALID'); + + await advance(POLL_INTERVAL_MS); + + expect(container.textContent).toContain('Last refresh failed'); + expect(container.textContent).toContain('ListFaults service not available'); + expect(container.textContent).toContain('LIDAR_RANGE_INVALID'); + }); + + it('leaves a hidden tab alone', async () => { + const client = clientReturning([RAW_FAULT]); + connect(client, false); + await mount(); + const whileVisible = client.GET.mock.calls.length; + + const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true); + await advance(POLL_INTERVAL_MS * 3); + hidden.mockRestore(); + + expect(client.GET.mock.calls.length).toBe(whileVisible); + }); + + it('shows a question mark only when it knows nothing at all', async () => { + connect(clientReturning([]), true); + const { container } = await mount(); + await act(async () => { + useAppStore.setState({ faultsError: 'Failed to get faults' } as never); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(container.textContent).toBe('?'); + }); + + it('keeps Auto-refresh off after leaving the dashboard and coming back', async () => { + const client = clientReturning([RAW_FAULT]); + connect(client, false); + await mount(); + const dashboard = await mount(); + + await act(async () => { + (document.getElementById('auto-refresh') as HTMLElement).click(); + await vi.advanceTimersByTimeAsync(0); + }); + const afterSwitchOff = client.GET.mock.calls.length; + + // Leaving the dashboard must not quietly start refreshing again. + await act(async () => { + dashboard.unmount(); + }); + await advance(POLL_INTERVAL_MS * 2); + expect(client.GET.mock.calls.length).toBe(afterSwitchOff); + + const reopened = await mount(); + expect(reopened.container.querySelector('#auto-refresh')?.getAttribute('data-state')).toBe('unchecked'); + }); + + it('does not read the list again when a paused dashboard is reopened', async () => { + const client = clientReturning([RAW_FAULT]); + connect(client, true); + await mount(); + const dashboard = await mount(); + await act(async () => { + (document.getElementById('auto-refresh') as HTMLElement).click(); + await vi.advanceTimersByTimeAsync(0); + }); + const whilePaused = client.GET.mock.calls.length; + + await act(async () => { + dashboard.unmount(); + }); + await mount(); + + // Reopening a page the user froze must not move it. + expect(client.GET.mock.calls.length).toBe(whilePaused); + }); + + it('still reads on mount when nothing has been loaded yet, even paused', async () => { + const client = clientReturning([RAW_FAULT]); + connect(client, true); + useAppStore.setState({ faultsAutoRefresh: false } as never); + + await mount(); + + // Otherwise a switch left off means an empty page for the rest of the session. + expect(client.GET).toHaveBeenCalledTimes(1); + }); + + it('does not refresh a paused list when the tab comes back', async () => { + const client = clientReturning([RAW_FAULT]); + connect(client, false); + await mount( + <> + + + + ); + await act(async () => { + (document.getElementById('auto-refresh') as HTMLElement).click(); + await vi.advanceTimersByTimeAsync(0); + }); + const afterSwitchOff = client.GET.mock.calls.length; + + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(client.GET.mock.calls.length).toBe(afterSwitchOff); + }); + + it('puts the rows in the same order whichever way they arrived', async () => { + connect(clientReturning([]), true); + const older = { ...RAW_FAULT, fault_code: 'A_EARLIER', first_occurred: 1756636800 }; + const newer = { ...RAW_FAULT, fault_code: 'B_LATER', first_occurred: 1756640000 }; + + const { container } = await mount(); + await act(async () => { + useAppStore.setState({ client: clientReturning([older, newer]), faultsLoaded: false } as never); + await useAppStore.getState().fetchFaults({ force: true }); + await vi.advanceTimersByTimeAsync(0); + }); + const arrivalOrder = renderedCodes(container); + + await act(async () => { + useAppStore.setState({ client: clientReturning([newer, older]) } as never); + await useAppStore.getState().fetchFaults({ force: true }); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(renderedCodes(container)).toEqual(arrivalOrder); + }); + + it('keeps an app and a component of the same name in separate groups', async () => { + connect(clientReturning([]), true); + const { container } = await mount(); + await act(async () => { + useAppStore.setState({ + faults: [ + { + code: 'OVERHEAT', + message: 'app fault', + severity: 'error', + status: 'active', + timestamp: '2026-08-31T10:00:00.000Z', + entity_id: 'motor', + entity_type: 'app', + }, + { + code: 'OVERHEAT', + message: 'component fault', + severity: 'error', + status: 'active', + timestamp: '2026-08-31T10:00:00.000Z', + entity_id: 'motor', + entity_type: 'component', + }, + ], + } as never); + await vi.advanceTimersByTimeAsync(0); + }); + + // One name, two entities: two groups, each labelled with its own type. + const groupLabels = Array.from(container.querySelectorAll('.font-medium.text-sm')).map((el) => el.textContent); + expect(groupLabels.filter((label) => label === 'motor')).toHaveLength(2); + }); + + it('stops polling when the last fault view unmounts', async () => { + const client = clientReturning([RAW_FAULT]); + connect(client, false); + const { unmount } = await mount(); + const afterMount = client.GET.mock.calls.length; + + await act(async () => { + unmount(); + }); + await advance(POLL_INTERVAL_MS * 2); + + expect(client.GET.mock.calls.length).toBe(afterMount); + }); +}); diff --git a/src/components/FaultsDashboard.tsx b/src/components/FaultsDashboard.tsx index 998650b..41d039b 100644 --- a/src/components/FaultsDashboard.tsx +++ b/src/components/FaultsDashboard.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { useShallow } from 'zustand/shallow'; import { AlertTriangle, @@ -29,14 +29,10 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { Skeleton } from '@/components/ui/skeleton'; import { SnapshotCard } from './SnapshotCard'; import { useAppStore } from '@/lib/store'; +import { useFaultPolling } from '@/hooks/useFaultPolling'; import type { Fault, FaultSeverity, FaultStatus, FaultResponse } from '@/lib/types'; import { faultKey, mapFaultEntityTypeToResourceType } from '@/lib/utils'; -/** - * Default polling interval in milliseconds - */ -const DEFAULT_POLL_INTERVAL = 5000; - /** * Get badge variant for fault severity */ @@ -116,6 +112,22 @@ function formatTimestamp(timestamp: string): string { } } +/** Worst first, then most recent, then by identity so equal rows never swap places. */ +const DISPLAY_SEVERITY_RANK: Record = { + critical: 3, + error: 2, + warning: 1, + info: 0, +}; + +function compareFaultsForDisplay(a: Fault, b: Fault): number { + const bySeverity = DISPLAY_SEVERITY_RANK[b.severity] - DISPLAY_SEVERITY_RANK[a.severity]; + if (bySeverity !== 0) return bySeverity; + const byTime = b.timestamp.localeCompare(a.timestamp); + if (byTime !== 0) return byTime; + return faultKey(a).localeCompare(faultKey(b)); +} + /** * Single fault row component with collapsible environment data */ @@ -395,7 +407,7 @@ function DashboardSkeleton() { */ export function FaultsDashboard() { const [isRefreshing, setIsRefreshing] = useState(false); - const [autoRefresh, setAutoRefresh] = useState(true); + // Keyed by faultKey, not by code: one code can be reported by two entities. const [clearingCodes, setClearingCodes] = useState>(new Set()); const [expandedFaults, setExpandedFaults] = useState>(new Set()); const [faultDetails, setFaultDetails] = useState>(new Map()); @@ -412,46 +424,36 @@ export function FaultsDashboard() { const { faults, isLoadingFaults, + faultsError, + autoRefresh, + setAutoRefresh, isConnected, fetchFaults, clearFault, getFaultWithEnvironmentData, - hasFaultStream, } = useAppStore( useShallow((state) => ({ faults: state.faults, isLoadingFaults: state.isLoadingFaults, + faultsError: state.faultsError, + autoRefresh: state.faultsAutoRefresh, + setAutoRefresh: state.setFaultsAutoRefresh, isConnected: state.isConnected, fetchFaults: state.fetchFaults, clearFault: state.clearFault, getFaultWithEnvironmentData: state.getFaultWithEnvironmentData, - hasFaultStream: state.faultStreamCleanup !== null, })) ); - // Load faults on mount - useEffect(() => { - if (isConnected) { - fetchFaults(); - } - }, [isConnected, fetchFaults]); - - // Auto-refresh polling using shared store. - // Skip polling when SSE fault stream is active (it provides real-time updates). - useEffect(() => { - if (!autoRefresh || !isConnected || hasFaultStream) return; - - const interval = setInterval(() => { - fetchFaults(); - }, DEFAULT_POLL_INTERVAL); - - return () => clearInterval(interval); - }, [autoRefresh, isConnected, hasFaultStream, fetchFaults]); + // Refreshes are shared with every other mounted fault view, so the gateway sees + // one request per refresh no matter how many of them are on screen. + useFaultPolling(); // Manual refresh handler const handleRefresh = useCallback(async () => { setIsRefreshing(true); - await fetchFaults(); + // Forced: pressing refresh must start a read, not wait out one already running. + await fetchFaults({ force: true }); setIsRefreshing(false); }, [fetchFaults]); @@ -467,10 +469,8 @@ export function FaultsDashboard() { try { // Map the fault's entity_type to the correct resource type for the API const entityGroup = mapFaultEntityTypeToResourceType(fault.entity_type); - // Use store's clearFault which has proper error handling with toasts + // clearFault re-reads the list itself, whether or not the delete succeeded. await clearFault(entityGroup, fault.entity_id, fault.code); - // Reload faults after clearing - await fetchFaults(); } finally { setClearingCodes((prev) => { const next = new Set(prev); @@ -479,7 +479,7 @@ export function FaultsDashboard() { }); } }, - [fetchFaults, clearFault] + [clearFault] ); // Toggle fault expansion and lazy-load environment data @@ -528,19 +528,25 @@ export function FaultsDashboard() { [getFaultWithEnvironmentData] ); - // Filter faults + // Filter faults, then put them in a fixed order. Rows arrive in whatever order the + // gateway or the stream produced them, and a row that moves while the pointer is on + // its way to Clear is a row that gets the wrong fault cleared. const filteredFaults = useMemo(() => { - return faults.filter((f) => severityFilters.has(f.severity) && statusFilters.has(f.status)); + return faults + .filter((f) => severityFilters.has(f.severity) && statusFilters.has(f.status)) + .sort(compareFaultsForDisplay); }, [faults, severityFilters, statusFilters]); // Group faults by entity const groupedFaults = useMemo(() => { - const groups = new Map(); + const groups = new Map(); for (const fault of filteredFaults) { - const key = fault.entity_id; + // Keyed by type and id: an app and the component it runs on can share a name, + // and merging them puts one entity's faults under another entity's heading. + const key = `${fault.entity_type}/${fault.entity_id}`; if (!groups.has(key)) { - groups.set(key, { entityType: fault.entity_type, faults: [] }); + groups.set(key, { entityId: fault.entity_id, entityType: fault.entity_type, faults: [] }); } groups.get(key)!.faults.push(fault); } @@ -634,9 +640,11 @@ export function FaultsDashboard() { Faults Dashboard - {counts.total === 0 - ? 'No faults detected' - : `${counts.total} fault${counts.total !== 1 ? 's' : ''} detected`} + {faultsError + ? 'Fault status unknown' + : counts.total === 0 + ? 'No faults detected' + : `${counts.total} fault${counts.total !== 1 ? 's' : ''} detected`}
@@ -662,6 +670,11 @@ export function FaultsDashboard() {
+ {faultsError && faults.length > 0 && ( +

+ Last refresh failed, these faults may be out of date: {faultsError} +

+ )} {/* Summary badges */}
{counts.critical > 0 && ( @@ -688,7 +701,7 @@ export function FaultsDashboard() { {counts.info} Info )} - {counts.total === 0 && ( + {counts.total === 0 && !faultsError && filteredFaults.length === 0 && ( All Clear @@ -792,7 +805,31 @@ export function FaultsDashboard() { {/* Faults List */} - {filteredFaults.length === 0 ? ( + {faultsError && faults.length === 0 ? ( + + +
+ +

Fault list unavailable

+

+ The gateway did not answer with a fault list, so this page cannot say whether anything + is wrong. This is not the same as a system without faults. +

+

{faultsError}

+ +
+
+
+ ) : filteredFaults.length === 0 ? (
@@ -809,9 +846,9 @@ export function FaultsDashboard() { ) : groupByEntity ? ( - {groupedFaults.map(([entityId, { entityType, faults: entityFaults }]) => ( + {groupedFaults.map(([groupKey, { entityId, entityType, faults: entityFaults }]) => ( ({ - faults: state.faults, - isConnected: state.isConnected, - fetchFaults: state.fetchFaults, - hasFaultStream: state.faultStreamCleanup !== null, - })) + const { faults, faultsError } = useAppStore( + useShallow((state) => ({ faults: state.faults, faultsError: state.faultsError })) ); - // Trigger initial fetch and set up fallback polling when connected. - // Skip polling when SSE fault stream is active (provides real-time updates). - useEffect(() => { - if (!isConnected) return; - - // Initial fetch - fetchFaults(); - - // Only poll as fallback when SSE stream is not active - let interval: ReturnType | null = null; - if (!hasFaultStream) { - interval = setInterval(() => { - if (!document.hidden) { - fetchFaults(); - } - }, DEFAULT_POLL_INTERVAL); - } - - // Refresh when tab becomes visible - const handleVisibilityChange = () => { - if (!document.hidden) { - fetchFaults(); - } - }; - document.addEventListener('visibilitychange', handleVisibilityChange); - - return () => { - if (interval) clearInterval(interval); - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, [isConnected, hasFaultStream, fetchFaults]); + useFaultPolling(); // Count active critical/error faults const count = useMemo(() => { @@ -901,6 +903,22 @@ export function FaultsCountBadge() { .length; }, [faults]); + // A count nobody could check is worse than no count: it reads as "all good" from + // across the room while the gateway is not answering for faults at all. + if (faultsError) { + // The last known count still says more than a question mark; the marker says it + // was not checked. Only with nothing known at all is there no number to show. + return ( + + {count > 0 ? count : '?'} + + ); + } + if (count === 0) return null; return ( diff --git a/src/hooks/useFaultPolling.ts b/src/hooks/useFaultPolling.ts new file mode 100644 index 0000000..05405d4 --- /dev/null +++ b/src/hooks/useFaultPolling.ts @@ -0,0 +1,158 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useEffect } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { useAppStore } from '@/lib/store'; + +/** Refresh cadence while the SSE fault stream is not carrying updates. */ +export const FAULT_POLL_INTERVAL_MS = 5000; + +/** + * Refresh cadence while the stream is up. A stream that is connected is not proof that + * anything comes down it - an aggregating gateway answers the subscription and then + * fans nothing out - and from the browser that looks exactly like a quiet system. This + * is the slow check that keeps the page from sitting unchanged for a whole session. + */ +export const FAULT_STREAM_SAFETY_NET_MS = 30000; + +// The fault list is one shared resource with several views on it (the dashboard, +// the sidebar badge). These module-level counters keep one timer for all of them, and +// coalesce the reads that coincide, so views opening together cost one request. A view +// opening on its own still reads: it has to show the list as it is now. +let subscribers = 0; +let pollers = 0; +let intervalId: ReturnType | null = null; +let currentPeriod: number | null = null; +let visibilityListener: (() => void) | null = null; + +function isTabVisible(): boolean { + return typeof document === 'undefined' || !document.hidden; +} + +function refreshFaults(): void { + void useAppStore.getState().fetchFaults(); +} + +function refreshIfVisible(): void { + if (isTabVisible()) { + refreshFaults(); + } +} + +/** + * A refresh the tab's return triggered also starts the wait again: the timer was part + * way through its period, and leaving it be puts a second request moments behind this one. + */ +function refreshAndRestartTimer(): void { + if (!isTabVisible() || !useAppStore.getState().faultsAutoRefresh) return; + refreshFaults(); + if (intervalId !== null) { + stopInterval(); + syncInterval(); + } +} + +/** Starts, stops or re-paces the shared timer to match the views and the stream state. */ +function syncInterval(): void { + const { faultStreamCleanup, faultsAutoRefresh } = useAppStore.getState(); + const streamActive = faultStreamCleanup !== null; + // Switched off stops it for everyone: the list is shared, so "off" that still let + // another view pull new rows in would not be off at all. + const shouldRun = pollers > 0 && faultsAutoRefresh; + const period = streamActive ? FAULT_STREAM_SAFETY_NET_MS : FAULT_POLL_INTERVAL_MS; + + if (shouldRun && period !== currentPeriod) { + stopInterval(); + } + if (shouldRun && intervalId === null) { + currentPeriod = period; + intervalId = setInterval(refreshIfVisible, period); + } else if (!shouldRun) { + stopInterval(); + } +} + +function stopInterval(): void { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + currentPeriod = null; + } +} + +/** + * Keeps the shared fault list fresh for as long as at least one view is mounted. + * + * The list refreshes when a view opens, when the tab regains focus, and on a timer: + * every 5 seconds without the SSE fault stream, and every 30 seconds with it, because + * a stream that is connected is not proof that anything comes down it. All of it is + * shared, so two mounted views produce one request per refresh rather than two, and + * the Auto-refresh switch stops the timer for all of them at once. + */ +export function useFaultPolling(): void { + const { isConnected, client, hasFaultStream, autoRefresh } = useAppStore( + useShallow((state) => ({ + isConnected: state.isConnected, + client: state.client, + hasFaultStream: state.faultStreamCleanup !== null, + autoRefresh: state.faultsAutoRefresh, + })) + ); + + useEffect(() => { + if (!isConnected) return; + + // Every view reads when it opens, not only the first one: the sidebar badge is + // mounted for the whole session, so the dashboard is always a later subscriber + // and would otherwise show the list as it stood when the session began. Views + // opening together still cost one request - the store reuses the one in flight. + // Re-runs when the connection is replaced (a new gateway has its own faults) and + // when the fault stream appears or dies, which is when events may have been missed. + subscribers += 1; + if (subscribers === 1) { + visibilityListener = refreshAndRestartTimer; + document.addEventListener('visibilitychange', visibilityListener); + } + // Reopening a view must not move a list the user froze. With nothing loaded yet + // there is nothing to preserve, and skipping would leave an empty page for the + // rest of the session, so that case reads regardless. + const { faultsAutoRefresh, faultsLoaded } = useAppStore.getState(); + if (faultsAutoRefresh || !faultsLoaded) { + refreshFaults(); + } + + return () => { + subscribers -= 1; + if (subscribers === 0 && visibilityListener) { + document.removeEventListener('visibilitychange', visibilityListener); + visibilityListener = null; + } + }; + }, [isConnected, client, hasFaultStream]); + + useEffect(() => { + if (!isConnected) return; + + pollers += 1; + syncInterval(); + + return () => { + pollers -= 1; + syncInterval(); + }; + // syncInterval reads the stream state and the switch from the store; these deps + // are what make it look again when either of them changes. + }, [isConnected, hasFaultStream, autoRefresh]); +} diff --git a/src/lib/store-connect.test.ts b/src/lib/store-connect.test.ts index d05bb91..c130596 100644 --- a/src/lib/store-connect.test.ts +++ b/src/lib/store-connect.test.ts @@ -97,4 +97,53 @@ describe('connect', () => { await Promise.resolve(); expect(useAppStore.getState().scriptsSupported).toBe(true); }); + + it('drops the previous gateway fault stream before serving the new one', async () => { + const mockGet = vi.fn((path: string) => { + if (path === '/health') return Promise.resolve({ error: undefined }); + return Promise.resolve({ data: undefined }); + }); + vi.mocked(createMedkitClient).mockReturnValue({ GET: mockGet } as unknown as MedkitClient); + const previousStream = vi.fn(); + useAppStore.setState({ + loadRootEntities: vi.fn().mockResolvedValue(undefined), + subscribeFaultStream: vi.fn(), + faultStreamCleanup: previousStream, + }); + + await useAppStore.getState().connect('http://other-gateway.local'); + + // Otherwise the old stream keeps writing faults into the new gateway's list for as + // long as the connection sequence takes. + expect(previousStream).toHaveBeenCalledTimes(1); + }); + + it('leaves no faults from the previous gateway on screen', async () => { + const mockGet = vi.fn((path: string) => { + if (path === '/health') return Promise.resolve({ error: undefined }); + return Promise.resolve({ data: undefined }); + }); + vi.mocked(createMedkitClient).mockReturnValue({ GET: mockGet } as unknown as MedkitClient); + useAppStore.setState({ + loadRootEntities: vi.fn().mockResolvedValue(undefined), + subscribeFaultStream: vi.fn(), + faults: [ + { + code: 'PREVIOUS_GATEWAY_FAULT', + message: 'raised on the robot we just left', + severity: 'error', + status: 'active', + timestamp: '2026-08-31T10:00:00.000Z', + entity_id: 'lidar_front', + entity_type: 'app', + }, + ], + faultsLoaded: true, + }); + + await useAppStore.getState().connect('http://other-gateway.local'); + + expect(useAppStore.getState().faults).toEqual([]); + expect(useAppStore.getState().faultsLoaded).toBe(false); + }); }); diff --git a/src/lib/store-faults.test.ts b/src/lib/store-faults.test.ts new file mode 100644 index 0000000..d21a2c6 --- /dev/null +++ b/src/lib/store-faults.test.ts @@ -0,0 +1,498 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * What the shared fault list is allowed to do while it refreshes. The list has + * two writers - polling and the SSE stream - and a refresh can outlive the + * connection that started it, so a late answer must not decide what is on screen. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useAppStore } from './store'; +import type { Fault } from './types'; +import { transformFault } from './transforms'; + +vi.mock('react-toastify', () => ({ + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, +})); + +function raw(overrides: Record = {}) { + return { + fault_code: 'LIDAR_RANGE_INVALID', + description: 'range invalid', + severity: 2, + severity_label: 'ERROR', + status: 'CONFIRMED', + first_occurred: 1756636800, + last_occurred: 1756636800, + occurrence_count: 1, + reporting_sources: ['/lidar_front'], + ...overrides, + }; +} + +/** A client whose answers are under the test's control, however many are in flight. */ +function deferredClient() { + const pending: ((items: unknown[]) => void)[] = []; + const GET = vi.fn( + () => + new Promise((resolve) => { + pending.push((items: unknown[]) => resolve({ data: { items }, error: undefined })); + }) + ); + return { + client: { GET }, + release: (items: unknown[]) => { + while (pending.length > 0) { + pending.shift()!(items); + } + }, + }; +} + +function clientReturning(items: unknown[]) { + return { GET: vi.fn(async () => ({ data: { items }, error: undefined })) }; +} + +function connected(client: unknown) { + useAppStore.setState({ + isConnected: true, + client, + faults: [], + faultsLoaded: false, + isLoadingFaults: false, + faultStreamCleanup: null, + } as never); +} + +beforeEach(() => { + connected(null); +}); + +afterEach(() => { + useAppStore.setState({ isConnected: false, client: null, faults: [] } as never); +}); + +describe('subscribeFaultStream', () => { + it('hands refreshing back to polling when the stream closes without an error', async () => { + const streamClient = { + GET: vi.fn(async () => ({ data: { items: [] }, error: undefined })), + streams: { + faults: () => ({ + close: vi.fn(), + [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }), + }), + }, + }; + connected(streamClient); + + useAppStore.getState().subscribeFaultStream(); + expect(useAppStore.getState().faultStreamCleanup).not.toBeNull(); + + await vi.waitFor(() => expect(useAppStore.getState().faultStreamCleanup).toBeNull()); + }); +}); + +describe('fetchFaults change detection', () => { + it('takes up a fault that is now reported by a different entity', async () => { + connected(clientReturning([raw()])); + await useAppStore.getState().fetchFaults(); + + useAppStore.setState({ client: clientReturning([raw({ reporting_sources: ['/lidar_rear'] })]) } as never); + await useAppStore.getState().fetchFaults(); + + expect(useAppStore.getState().faults[0]?.entity_id).toBe('lidar_rear'); + }); + + it('takes up a changed fault description', async () => { + connected(clientReturning([raw()])); + await useAppStore.getState().fetchFaults(); + + useAppStore.setState({ client: clientReturning([raw({ description: '3 sectors blind' })]) } as never); + await useAppStore.getState().fetchFaults(); + + expect(useAppStore.getState().faults[0]?.message).toBe('3 sectors blind'); + }); +}); + +describe('fetchFaults error reporting', () => { + it('records why the gateway could not answer, instead of an empty list', async () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const refusing = { + GET: vi.fn(async () => ({ + data: undefined, + error: { + error_code: 'service-unavailable', + message: 'Failed to get faults', + parameters: { details: 'ListFaults service not available' }, + }, + })), + }; + connected(refusing); + + await useAppStore.getState().fetchFaults(); + + expect(useAppStore.getState().faultsError).toBe('Failed to get faults: ListFaults service not available'); + expect(useAppStore.getState().faults).toEqual([]); + logged.mockRestore(); + }); + + it('clears the error once the gateway answers again', async () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + connected({ GET: vi.fn(async () => ({ data: undefined, error: { message: 'Failed to get faults' } })) }); + await useAppStore.getState().fetchFaults(); + expect(useAppStore.getState().faultsError).not.toBeNull(); + + useAppStore.setState({ client: clientReturning([raw()]) } as never); + await useAppStore.getState().fetchFaults(); + + expect(useAppStore.getState().faultsError).toBeNull(); + logged.mockRestore(); + }); +}); + +describe('a fault stream event while a read has failed', () => { + function withFailedRead(client: unknown) { + connected(client); + useAppStore.setState({ + faultsError: 'Failed to get faults: ListFaults service not available', + faultsLoaded: true, + } as never); + } + + const streamed: Fault = { + code: 'LIDAR_RANGE_INVALID', + message: 'range invalid', + severity: 'error', + status: 'active', + timestamp: '2026-08-31T10:00:00.000Z', + entity_id: 'lidar_front', + entity_type: 'app', + }; + + it('takes the event as proof the gateway answers, and reads the list again', async () => { + const client = clientReturning([raw()]); + withFailedRead(client); + + useAppStore.getState().applyFaultStreamEvent('fault_confirmed', streamed); + await vi.waitFor(() => expect(client.GET).toHaveBeenCalledTimes(1)); + + // Cleared because a read succeeded, not because an event arrived. + expect(useAppStore.getState().faultsError).toBeNull(); + }); + + it('asks once for a burst of events, not once per event', async () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const refusing = { + GET: vi.fn(async () => ({ data: undefined, error: { message: 'Failed to get faults' } })), + }; + connected(refusing); + // The same failure the gateway keeps giving: a burst of events under one unchanged + // reason buys one attempt. A different reason is a change, and buys its own. + useAppStore.setState({ faultsError: 'Failed to get faults', faultsLoaded: true } as never); + + for (let i = 0; i < 5; i++) { + useAppStore.getState().applyFaultStreamEvent('fault_confirmed', { ...streamed, code: `FAULT_${i}` }); + await Promise.resolve(); + await Promise.resolve(); + } + + // The timer is what retries a failing gateway. An event only buys the first try. + expect(refusing.GET).toHaveBeenCalledTimes(1); + logged.mockRestore(); + }); + + it('leaves a paused list alone, event or no event', async () => { + const client = clientReturning([raw()]); + withFailedRead(client); + useAppStore.setState({ faultsAutoRefresh: false } as never); + + useAppStore.getState().applyFaultStreamEvent('fault_confirmed', streamed); + await Promise.resolve(); + await Promise.resolve(); + + expect(client.GET).not.toHaveBeenCalled(); + expect(useAppStore.getState().faultsError).not.toBeNull(); + }); + + it('does not read again for an event when nothing had failed', async () => { + const client = clientReturning([raw()]); + connected(client); + + useAppStore.getState().applyFaultStreamEvent('fault_confirmed', streamed); + await Promise.resolve(); + await Promise.resolve(); + + expect(client.GET).not.toHaveBeenCalled(); + }); + + it('keeps one fault one row when the gateway names the entity type in neither channel', () => { + // Both channels run through transformFault, which calls an unnamed entity type an + // app. The list is keyed on that type, so the two channels have to agree about it: + // a gateway that starts sending it in one and not the other would split one fault + // into two rows, each with its own clear button. + connected(clientReturning([])); + const fromList = transformFault({ + fault_code: 'LIDAR_RANGE_INVALID', + description: 'range invalid', + severity: 2, + severity_label: 'ERROR', + status: 'CONFIRMED', + first_occurred: 1756636800, + reporting_sources: ['/lidar_front'], + }); + useAppStore.setState({ faults: [fromList] } as never); + + useAppStore.getState().applyFaultStreamEvent('fault_cleared', { ...fromList, status: 'cleared' }); + + expect(useAppStore.getState().faults).toHaveLength(0); + }); +}); + +describe('fetchFaults against a moving connection', () => { + it('ignores an answer that arrives after the session was disconnected', async () => { + const { client, release } = deferredClient(); + connected(client); + + const pending = useAppStore.getState().fetchFaults(); + useAppStore.getState().disconnect(); + release([raw({ fault_code: 'FROM_PREVIOUS_GATEWAY' })]); + await pending; + + expect(useAppStore.getState().faults).toHaveLength(0); + expect(useAppStore.getState().faultsLoaded).toBe(false); + }); + + it('ignores a refresh the fault stream has already overtaken', async () => { + const { client, release } = deferredClient(); + connected(client); + useAppStore.setState({ faultsLoaded: true } as never); + + const pending = useAppStore.getState().fetchFaults(); + const fromStream: Fault = { + code: 'STREAM_FAULT', + message: 'reported over the stream', + severity: 'error', + status: 'active', + timestamp: '2026-08-31T10:00:00.000Z', + entity_id: 'lidar_front', + entity_type: 'app', + }; + useAppStore.setState({ faults: [fromStream] } as never); + release([raw({ fault_code: 'FROM_THE_POLL' })]); + await pending; + + expect(useAppStore.getState().faults.map((f) => f.code)).toEqual(['STREAM_FAULT']); + expect(useAppStore.getState().faultsLoaded).toBe(true); + }); + + it('does not lose the faults that were there before the stream, on the first read', async () => { + // The stream carries what happens next, not what is already raised. Dropping the + // first read because an event landed during it hides every fault that predates + // the subscription until something reads again. + let call = 0; + const client = { + GET: vi.fn(() => { + call += 1; + return call === 1 + ? new Promise((resolve) => { + setTimeout(() => resolve({ data: { items: [raw()] }, error: undefined }), 20); + }) + : Promise.resolve({ data: { items: [raw()] }, error: undefined }); + }), + }; + connected(client); + + const first = useAppStore.getState().fetchFaults(); + useAppStore.getState().applyFaultStreamEvent('fault_confirmed', { + code: 'RAISED_WHILE_READING', + message: 'arrived over the stream', + severity: 'error', + status: 'active', + timestamp: '2026-08-31T10:00:00.000Z', + entity_id: 'lidar_front', + entity_type: 'app', + }); + await first; + + await vi.waitFor(() => { + expect(useAppStore.getState().faults.map((f) => f.code)).toContain('LIDAR_RANGE_INVALID'); + }); + }); + + it('runs one request when several views ask for a refresh at the same time', async () => { + const { client, release } = deferredClient(); + connected(client); + + const first = useAppStore.getState().fetchFaults(); + const second = useAppStore.getState().fetchFaults(); + release([raw()]); + await Promise.all([first, second]); + + expect(client.GET).toHaveBeenCalledTimes(1); + }); + + it('is not wedged by a request that never answers', async () => { + const hung = { GET: vi.fn(() => new Promise(() => {})) }; + connected(hung); + void useAppStore.getState().fetchFaults(); + + useAppStore.getState().disconnect(); + const healthy = clientReturning([raw()]); + connected(healthy); + await useAppStore.getState().fetchFaults(); + + expect(healthy.GET).toHaveBeenCalledTimes(1); + expect(useAppStore.getState().faults).toHaveLength(1); + }); + + it('lets the newest read decide, not the one that happens to finish last', async () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + // One connection, two reads: the first is slow and fails, the second is the + // one the user asked for and succeeds. + let call = 0; + const client = { + GET: vi.fn(() => { + call += 1; + return call === 1 + ? new Promise((resolve) => { + setTimeout( + () => resolve({ data: undefined, error: { message: 'Failed to get faults' } }), + 50 + ); + }) + : Promise.resolve({ data: { items: [raw()] }, error: undefined }); + }), + }; + connected(client); + const stale = useAppStore.getState().fetchFaults(); + + await useAppStore.getState().fetchFaults({ force: true }); + expect(useAppStore.getState().faultsError).toBeNull(); + + await stale; + + expect(useAppStore.getState().faultsError).toBeNull(); + expect(useAppStore.getState().faults).toHaveLength(1); + logged.mockRestore(); + }); + + it('reports a failure the user asked for even while a later read is still out', async () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + let call = 0; + const client = { + GET: vi.fn(() => { + call += 1; + // 1: the refresh the user pressed, fails. 2: a background read, never answers. + return call === 1 + ? Promise.resolve({ data: undefined, error: { message: 'Failed to get faults' } }) + : new Promise(() => {}); + }), + }; + connected(client); + + const forced = useAppStore.getState().fetchFaults({ force: true }); + void useAppStore.getState().fetchFaults(); + await forced; + + expect(useAppStore.getState().faultsError).toBe('Failed to get faults'); + logged.mockRestore(); + }); + + it('clears a fault the stream reports under a different entity type only once', async () => { + connected(clientReturning([])); + useAppStore.setState({ + faults: [ + { + code: 'OVERHEAT', + message: 'app fault', + severity: 'error', + status: 'active', + timestamp: '2026-08-31T10:00:00.000Z', + entity_id: 'motor', + entity_type: 'app', + }, + { + code: 'OVERHEAT', + message: 'component fault', + severity: 'error', + status: 'active', + timestamp: '2026-08-31T10:00:00.000Z', + entity_id: 'motor', + entity_type: 'component', + }, + ], + } as never); + + useAppStore.getState().applyFaultStreamEvent('fault_cleared', { + code: 'OVERHEAT', + message: 'component fault', + severity: 'error', + status: 'cleared', + timestamp: '2026-08-31T10:00:00.000Z', + entity_id: 'motor', + entity_type: 'component', + }); + + expect(useAppStore.getState().faults.map((f) => f.entity_type)).toEqual(['app']); + }); + + it('says the gateway went quiet when the request is aborted', async () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const abandoning = { + GET: vi.fn(async () => { + // What the generated client does when its own deadline runs out. + throw new DOMException('The operation was aborted.', 'AbortError'); + }), + }; + connected(abandoning); + + await useAppStore.getState().fetchFaults(); + + expect(useAppStore.getState().faultsError).toBe('The gateway did not answer in time'); + + // And the shared refresh is free again, so the next read is not swallowed. + const healthy = clientReturning([raw()]); + useAppStore.setState({ client: healthy } as never); + await useAppStore.getState().fetchFaults(); + expect(healthy.GET).toHaveBeenCalledTimes(1); + logged.mockRestore(); + }); + + it('re-reads the list when clearing a fault failed, so no ghost row is left', async () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const client = { + GET: vi.fn(async () => ({ data: { items: [] }, error: undefined })), + DELETE: vi.fn(async () => ({ data: undefined, error: { message: 'fault already cleared' } })), + }; + connected(client); + + await useAppStore.getState().clearFault('apps', 'lidar_front', 'LIDAR_RANGE_INVALID'); + + expect(client.GET).toHaveBeenCalledTimes(1); + logged.mockRestore(); + }); + + it('lets a forced refresh through so a cleared fault is not read back from an older answer', async () => { + const { client, release } = deferredClient(); + connected(client); + + const polling = useAppStore.getState().fetchFaults(); + const forced = useAppStore.getState().fetchFaults({ force: true }); + release([]); + await Promise.all([polling, forced]); + + expect(client.GET).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/store.ts b/src/lib/store.ts index 9931b72..7c8ab78 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -27,6 +27,7 @@ import type { } from './types'; import { createMedkitClient, normalizeBaseUrl, type MedkitClient } from '@selfpatch/ros2-medkit-client-ts'; import type { SovdResourceEntityType } from './types'; +import { faultKey } from './utils'; import { transformFaultsResponse, transformOperationsResponse, @@ -148,6 +149,17 @@ export interface AppState { // Faults state (diagnostic trouble codes) faults: Fault[]; isLoadingFaults: boolean; + /** True once a fault fetch has completed. An empty list is a result, not a pending load. */ + faultsLoaded: boolean; + /** Why the last refresh failed, so an unanswered list is not shown as a healthy empty one. */ + faultsError: string | null; + /** + * Whether the shared fault list keeps refreshing. Lives here rather than in the + * dashboard: it governs every view of the list, and a page the user deliberately + * froze must not start moving again because they navigated away and back. + */ + faultsAutoRefresh: boolean; + setFaultsAutoRefresh: (enabled: boolean) => void; faultStreamCleanup: (() => void) | null; // Lifecycle status cache (apps/components only). @@ -272,7 +284,10 @@ export interface AppState { stopScriptPolling: () => void; // Faults actions - fetchFaults: () => Promise; + /** `force` skips the shared in-flight refresh, for a read that must see the newest state. */ + fetchFaults: (options?: { force?: boolean }) => Promise; + /** Applies one fault stream event to the shared list. */ + applyFaultStreamEvent: (eventType: string, fault: Fault) => void; clearFault: (entityType: SovdResourceEntityType, entityId: string, faultCode: string) => Promise; registerUpdate: (body: { id: string; [key: string]: unknown }, signal?: AbortSignal) => Promise; subscribeFaultStream: () => void; @@ -879,6 +894,70 @@ const statusWatchers = new Map(); */ const statusEpochs = new Map(); +/** + * Everything a fault row shows, so an answer that changed any of it is not mistaken + * for the list already on screen. The entity is part of it because one fault code + * can be reported by two entities and the clear action targets the one displayed. + */ +function faultRowKey(fault: Fault): string { + return `${faultKey(fault)}:${fault.status}:${fault.severity}:${fault.message}:${fault.timestamp}`; +} + +/** + * The gateway's own words for a failed read: SOVD puts the summary in `message` and + * the part naming the cause (a service that is not running, say) in `parameters.details`. + * The page shows both, rather than an empty list that would read as "no faults". + */ +function describeGatewayError(error: unknown): string { + if (!error || typeof error !== 'object') return 'Failed to load faults'; + const { message, parameters } = error as { message?: string; parameters?: { details?: string } }; + const summary = message || 'Failed to load faults'; + const details = parameters?.details; + return details ? `${summary}: ${details}` : summary; +} + +/** + * What the page says when a read fails. The generated client puts its own deadline on + * every request and aborts it, which arrives as a DOMException the generic branch + * would render as "Unknown error" - saying nothing about a gateway that went quiet. + */ +function describeReadFailure(error: unknown): string { + if (error instanceof DOMException && error.name === 'AbortError') { + return 'The gateway did not answer in time'; + } + if (error instanceof Error) return error.message; + return 'Unknown error'; +} + +/** + * The refresh currently on the wire, if any. Every fault view shares one list and + * they refresh on the same events (a timer tick, a tab regaining focus), so without + * this one refresh would put as many requests on the wire as there are views. + */ +let faultsRefreshInFlight: Promise | null = null; + +/** Whose read that is. A read belongs to the connection that started it and to no other. */ +let faultsRefreshClient: unknown = null; + +/** + * Reads are numbered so that answers coming back out of order cannot undo each other. + * A read applies unless a LATER one already has - which is not the same as "unless a + * later one has started": a refresh the user pressed must still be able to report its + * own failure while a background read it overtook is still on the wire. + */ +let faultsReadSeq = 0; +let faultsAppliedSeq = 0; + +/** + * The failure a stream event has already bought a read for. An event says the gateway + * answers, which is worth one attempt; if that attempt fails too, the timer is what + * keeps trying, so a burst of events cannot turn into a burst of failing requests. + */ +let faultsRecoveryAttemptedFor: string | null = null; + +/** Whether the first read of this connection has already been given a second chance. */ +let faultsBaselineReread = false; + /** Reset the status-request dedupe cache. Exposed for tests. */ export function __resetStatusRequestCache(): void { inFlightStatusRequests.clear(); @@ -1069,6 +1148,9 @@ export const useAppStore = create()( // Faults state faults: [], isLoadingFaults: false, + faultsLoaded: false, + faultsError: null, + faultsAutoRefresh: true, faultStreamCleanup: null, // Lifecycle status cache @@ -1119,6 +1201,13 @@ export const useAppStore = create()( isConnecting: false, connectionError: null, client, + // Connecting elsewhere never goes through disconnect(), so without + // this the previous gateway's faults stay on screen - and the clear + // button on such a row would send its code to the new gateway. + faults: [], + isLoadingFaults: false, + faultsLoaded: false, + faultsError: null, }); // A reconnect must not carry executions of the previous gateway: @@ -1150,6 +1239,17 @@ export const useAppStore = create()( }) .finally(() => clearTimeout(capsTimeout)); + // A read left on the wire belongs to the gateway just left. Holding it + // would answer for this one too: the next refresh would wait behind it + // and the page would sit on an empty list nobody had asked about. + faultsRefreshInFlight = null; + faultsRecoveryAttemptedFor = null; + faultsBaselineReread = false; + // Same for its fault stream: it stays open until this connection gets + // as far as subscribing, and until then its events would be written + // into the list of a gateway that never reported them. + get().unsubscribeFaultStream(); + // Load root entities after successful connection await get().loadRootEntities(); @@ -1187,6 +1287,11 @@ export const useAppStore = create()( // Unsubscribe from fault stream get().unsubscribeFaultStream(); + // Whatever is on the wire belongs to the session being left, and a refresh + // for the next one must not be answered by it or queue behind it. + faultsRefreshInFlight = null; + faultsRecoveryAttemptedFor = null; + faultsBaselineReread = false; set({ serverUrl: null, @@ -1204,6 +1309,10 @@ export const useAppStore = create()( statusByEntity: {}, scriptsSupported: false, scriptExecutions: new Map(), + faults: [], + isLoadingFaults: false, + faultsLoaded: false, + faultsError: null, }); }, @@ -2499,41 +2608,159 @@ export const useAppStore = create()( // FAULTS ACTIONS (Diagnostic Trouble Codes) // =========================================================================== - fetchFaults: async () => { - const { client, faults: currentFaults } = get(); + fetchFaults: async (options = {}) => { + const { client, faults: currentFaults, faultsLoaded } = get(); if (!client) return; - // Only show loading spinner on initial load, not background polls - const isInitialLoad = currentFaults.length === 0; + // Shared only within one connection: a read left over from the gateway we + // just left must not stand in for this one's first read, which would leave + // the page on an empty list nobody had asked about. + if (!options.force && faultsRefreshInFlight && faultsRefreshClient === client) { + return faultsRefreshInFlight; + } + + // Only show loading spinner before the first answer arrives. A list that + // came back empty is loaded, so later polls must not re-enter the loading + // state - that swaps the whole dashboard for its skeleton and back. + const isInitialLoad = !faultsLoaded; if (isInitialLoad) { set({ isLoadingFaults: true }); } - try { - const { data: faultsData, error: faultsError } = await client.GET('/faults', { - params: { - // `status=all` is required to include cleared/healed faults - // (no param returns only active). - query: { status: 'all' }, - }, - }); - if (faultsError) throw new Error(faultsError.message || 'Failed to load faults'); - const result = transformFaultsResponse(faultsData); - // Skip state update if faults haven't changed to avoid unnecessary re-renders. - // Compare by serializing fault codes + statuses (cheap and covers all meaningful changes). - const newKey = result.items.map((f: Fault) => `${f.code}:${f.status}:${f.severity}`).join('|'); - const oldKey = currentFaults.map((f) => `${f.code}:${f.status}:${f.severity}`).join('|'); - if (newKey !== oldKey) { - set({ faults: result.items, isLoadingFaults: false }); - } else if (isInitialLoad) { - set({ isLoadingFaults: false }); + const readSeq = (faultsReadSeq += 1); + const run = async () => { + try { + const { data: faultsData, error: faultsError } = await client.GET('/faults', { + params: { + // `status=all` is required to include cleared/healed faults + // (no param returns only active). + query: { status: 'all' }, + }, + }); + // A refresh outlives the session that started it, so an answer from + // the gateway we just left must not populate the next one's list, + // and neither may one a later read has already answered for. + if (get().client !== client || readSeq <= faultsAppliedSeq) return; + if (faultsError) throw new Error(describeGatewayError(faultsError)); + faultsAppliedSeq = readSeq; + const result = transformFaultsResponse(faultsData); + // The fault stream writes this same list. If it did so while this + // request was in flight, its state is the newer of the two. + if (get().faults !== currentFaults) { + if (isInitialLoad && !faultsBaselineReread) { + // Except on the first read: the stream carries what happens + // next, not what was already raised, so keeping only its + // event would hide every fault that predates the + // subscription. Read once more instead - the gateway knows + // about both - and stay unloaded until that lands. Forced, + // because this read is still the one in flight; and once, + // so a busy stream cannot turn it into a chain. + faultsBaselineReread = true; + set({ isLoadingFaults: false, faultsError: null }); + void get().fetchFaults({ force: true }); + return; + } + set({ isLoadingFaults: false, faultsLoaded: true, faultsError: null }); + return; + } + // Skip the state update when nothing a row shows has changed, to avoid + // re-rendering the list for an identical answer. Every field the row + // renders belongs in the key, including the entity: one code can be + // reported by two entities, and clearing acts on the entity shown. + const newKey = result.items.map(faultRowKey).join('|'); + const oldKey = currentFaults.map(faultRowKey).join('|'); + faultsRecoveryAttemptedFor = null; + faultsBaselineReread = false; + if (newKey !== oldKey) { + set({ + faults: result.items, + isLoadingFaults: false, + faultsLoaded: true, + faultsError: null, + }); + } else { + set({ isLoadingFaults: false, faultsLoaded: true, faultsError: null }); + } + } catch (error) { + if (get().client !== client || readSeq <= faultsAppliedSeq) return; + faultsAppliedSeq = readSeq; + const message = describeReadFailure(error); + console.error('[store]', error); + // Only a refresh the user asked for gets a toast. The page carries a + // failed read on its own now, and toasting every background attempt + // stacks one notification per retry on top of it. + if (options.force) { + toast.error(`Failed to load faults: ${message}`); + } + // A failed attempt still ends the initial load - the skeleton must not + // come back on every retry - but it is not an empty fault list either, + // so the reason is kept for the page to show in its place. + set({ isLoadingFaults: false, faultsLoaded: true, faultsError: message }); } - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error('[store]', error); - toast.error(`Failed to load faults: ${message}`); - set({ isLoadingFaults: false }); + }; + + if (options.force) { + return run(); + } + faultsRefreshClient = client; + faultsRefreshInFlight = run().finally(() => { + if (faultsRefreshClient === client) { + faultsRefreshInFlight = null; + faultsRefreshClient = null; + } + }); + return faultsRefreshInFlight; + }, + + setFaultsAutoRefresh: (enabled: boolean) => set({ faultsAutoRefresh: enabled }), + + applyFaultStreamEvent: (eventType: string, fault: Fault) => { + // An event proves the gateway is answering, which a failed read of the whole + // list had left in doubt. It does not prove the list is complete - only a + // read can - so it buys a read rather than clearing the failure by itself. + // A list the user paused stays paused: that switch means "do not move". + const readAgainAfterFailure = () => { + const { faultsError, faultsAutoRefresh } = get(); + if (!faultsError || !faultsAutoRefresh) return; + if (faultsRecoveryAttemptedFor === faultsError) return; + faultsRecoveryAttemptedFor = faultsError; + void get().fetchFaults(); + }; + const { faults } = get(); + // Matched the way the list is keyed, entity type included: one code can be + // reported by an app and by the component it runs on, and they are two rows. + const key = faultKey(fault); + + if (eventType === 'fault_cleared') { + // No toast: clearFault already raises one for a clear the user asked for. + const remaining = faults.filter((f) => faultKey(f) !== key); + if (remaining.length !== faults.length) { + set({ faults: remaining }); + } + readAgainAfterFailure(); + return; } + + const existingIndex = faults.findIndex((f) => faultKey(f) === key); + if (existingIndex >= 0) { + const existing = faults[existingIndex]!; + if ( + existing.status === fault.status && + existing.severity === fault.severity && + existing.message === fault.message && + existing.timestamp === fault.timestamp + ) { + readAgainAfterFailure(); + return; + } + const updated = [...faults]; + updated[existingIndex] = fault; + set({ faults: updated }); + } else { + set({ faults: [...faults, fault] }); + } + toast.warning(`Fault: ${fault.message}`, { autoClose: 5000 }); + readAgainAfterFailure(); }, clearFault: async (entityType: SovdResourceEntityType, entityId: string, faultCode: string) => { @@ -2544,13 +2771,17 @@ export const useAppStore = create()( const { error: clearError } = await deleteEntityFault(client, entityType, entityId, faultCode); if (clearError) throw new Error(clearError.message || 'Failed to clear fault'); toast.success(`Fault ${faultCode} cleared`); - // Refresh faults list - await fetchFaults(); + // Forced: a refresh already on the wire was answered before the delete + // and would read the fault straight back onto the screen. + await fetchFaults({ force: true }); return true; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('[store]', error); toast.error(`Failed to clear fault: ${message}`); + // The delete may have failed because the fault is already gone. Re-read, + // or the row stays on screen as a ghost with a button that does nothing. + await fetchFaults({ force: true }); return false; } }, @@ -2603,39 +2834,14 @@ export const useAppStore = create()( const faultData = faultPayload as Parameters[0]; const fault = transformFault(faultData); - if (event.event === 'fault_cleared') { - // onFaultCleared - no toast, clearFault() already shows one for UI-triggered clears - const { faults } = get(); - const newFaults = faults.filter( - (f) => !(f.code === fault.code && f.entity_id === fault.entity_id) - ); - if (newFaults.length !== faults.length) { - set({ faults: newFaults }); - } - } else { - // fault_confirmed or default message - const { faults } = get(); - const existingIndex = faults.findIndex( - (f) => f.code === fault.code && f.entity_id === fault.entity_id - ); - if (existingIndex >= 0) { - const existing = faults[existingIndex]!; - if ( - existing.status === fault.status && - existing.severity === fault.severity && - existing.message === fault.message && - existing.timestamp === fault.timestamp - ) { - continue; - } - const newFaults = [...faults]; - newFaults[existingIndex] = fault; - set({ faults: newFaults }); - } else { - set({ faults: [...faults, fault] }); - } - toast.warning(`Fault: ${fault.message}`, { autoClose: 5000 }); - } + get().applyFaultStreamEvent(event.event ?? 'fault_confirmed', fault); + } + // The stream can also end without an error - a gateway closing the + // response cleanly looks like this. Updates have stopped either way, + // so refreshing has to go back to polling. + if (running) { + cleanup(); + set({ faultStreamCleanup: null }); } } catch (error) { console.error('[store] subscribeFaultStream: error in consume loop', error); diff --git a/src/lib/transforms.test.ts b/src/lib/transforms.test.ts index 9df43e5..02e4769 100644 --- a/src/lib/transforms.test.ts +++ b/src/lib/transforms.test.ts @@ -373,7 +373,10 @@ describe('transformFaultsResponse', () => { }); it('falls back to items.length when x-medkit count is absent', () => { - const result = transformFaultsResponse({ items: [makeFaultItem(), makeFaultItem()] }); + // Two distinct faults: repeating one item is a duplicate now, and folded away. + const result = transformFaultsResponse({ + items: [makeFaultItem(), makeFaultItem({ fault_code: 'OTHER_FAULT' })], + }); expect(result.count).toBe(2); }); @@ -797,3 +800,70 @@ describe('transformBulkDataDescriptor', () => { expect(result.creation_date).toBe(''); }); }); + +describe('transformFaultsResponse deduplication', () => { + const jam = { + fault_code: 'PLC_JAM_INFEED', + description: 'Carton jam at the induction stop', + severity: 2, + severity_label: 'ERROR', + status: 'CONFIRMED', + first_occurred: 1755511233, + last_occurred: 1755511233, + occurrence_count: 1, + reporting_sources: ['load_process'], + }; + + it('lists a fault once when the gateway returns it twice', () => { + // An aggregating gateway can reach the same fault through more than one of its + // peers. It is one fault on one entity, so it is one row and one count. + const result = transformFaultsResponse({ items: [jam, jam] }); + + expect(result.items).toHaveLength(1); + expect(result.count).toBe(1); + }); + + it('keeps the live revision when two peers disagree about the same fault', () => { + // Peers answer at their own pace: the first route can carry a fault the second + // shows as still active. Dropping the active one hides a live fault. + const result = transformFaultsResponse({ + items: [ + { ...jam, status: 'CLEARED' }, + { ...jam, status: 'CONFIRMED', occurrence_count: 4 }, + ], + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]?.status).toBe('active'); + }); + + it('keeps the higher occurrence count when both revisions are active', () => { + const result = transformFaultsResponse({ + items: [jam, { ...jam, occurrence_count: 7 }], + }); + + expect(result.items[0]?.parameters?.occurrence_count).toBe(7); + }); + + it('keeps the more recent reading when the peers agree on everything else', () => { + const seenEarlier = { ...jam, last_occurred: 1755511233, occurrence_count: undefined }; + const seenLater = { + ...jam, + last_occurred: 1755599999, + occurrence_count: undefined, + description: 'jam persists', + }; + + const result = transformFaultsResponse({ items: [seenEarlier, seenLater] }); + + expect(result.items[0]?.message).toBe('jam persists'); + }); + + it('keeps the same code reported by two entities apart', () => { + const result = transformFaultsResponse({ + items: [jam, { ...jam, reporting_sources: ['unload_process'] }], + }); + + expect(result.items.map((f) => f.entity_id)).toEqual(['load_process', 'unload_process']); + }); +}); diff --git a/src/lib/transforms.ts b/src/lib/transforms.ts index 103cb6d..f453a7c 100644 --- a/src/lib/transforms.ts +++ b/src/lib/transforms.ts @@ -32,6 +32,7 @@ import type { Parameter, } from './types'; import { convertJsonSchemaToTopicSchema } from './schema-utils'; +import { faultKey } from './utils'; // ============================================================================= // unwrapItems @@ -135,6 +136,10 @@ export function transformFault(apiFault: RawFaultItem, syntheticIdSuffix?: strin const entity_id = source.split('/').pop() || 'unknown'; // Use entity_type from raw data if provided, otherwise default to 'app'. + // Both channels that produce faults - the list endpoint and the SSE stream - come + // through here, and the shared list is keyed on this type. They therefore have to + // agree about it: a gateway that starts sending it on one channel and not the other + // would split one fault into two rows, each with its own clear button. // The gateway's fault_to_json does not currently include entity_type, but // faults are always reported by ROS 2 nodes which map to apps. const entity_type = apiFault.entity_type || 'app'; @@ -198,6 +203,38 @@ interface RawFaultsResponse { 'x-medkit'?: { count?: number }; } +/** How live a fault is. Peers can answer at their own pace and disagree about one fault. */ +const FAULT_STATUS_RANK: Record = { + active: 3, + pending: 2, + healed: 1, + cleared: 0, +}; + +/** + * Of two records for one fault, the one that describes it now: still raised beats + * already gone, and at equal standing the one that has occurred more often is the + * later reading. Dropping the wrong one hides a fault that is still up. + */ +function moreCurrentRevision(a: Fault, b: Fault): Fault { + const byStatus = FAULT_STATUS_RANK[b.status] - FAULT_STATUS_RANK[a.status]; + if (byStatus !== 0) return byStatus > 0 ? b : a; + + const occurrences = (f: Fault) => + typeof f.parameters?.occurrence_count === 'number' ? f.parameters.occurrence_count : 0; + const byOccurrences = occurrences(b) - occurrences(a); + if (byOccurrences !== 0) return byOccurrences > 0 ? b : a; + + // Which peer saw it last. `last_occurred` is that; `timestamp` is when the fault + // began, which two records for one fault usually share - it decides only when no + // peer said when it last saw the fault, and without either the first stands. + const lastSeen = (f: Fault) => (typeof f.parameters?.last_occurred === 'number' ? f.parameters.last_occurred : 0); + const byLastSeen = lastSeen(b) - lastSeen(a); + if (byLastSeen !== 0) return byLastSeen > 0 ? b : a; + + return b.timestamp > a.timestamp ? b : a; +} + /** * Transform the raw gateway faults list response into `ListFaultsResponse`. */ @@ -208,8 +245,27 @@ export function transformFaultsResponse(rawData: unknown): ListFaultsResponse { // fallback only catches nullish values, so any other truthy non-array // would crash `.map`. const rawItems = Array.isArray(data.items) ? data.items : []; - const items = rawItems.map((f, idx) => transformFault(f as RawFaultItem, String(idx))); - return { items, count: data['x-medkit']?.count ?? items.length }; + // An aggregating gateway can reach one fault through more than one peer and list + // it twice. A fault is identified by its code on its entity, so the repeats are + // the same fault: one row, counted once. + const indexByKey = new Map(); + const items: Fault[] = []; + for (const [idx, rawItem] of rawItems.entries()) { + const fault = transformFault(rawItem as RawFaultItem, String(idx)); + const key = faultKey(fault); + const existing = indexByKey.get(key); + if (existing === undefined) { + indexByKey.set(key, items.length); + items.push(fault); + continue; + } + items[existing] = moreCurrentRevision(items[existing]!, fault); + } + // The gateway's own count stays authoritative (it may describe more than this + // page), minus whatever repeats were folded away here. + const duplicates = rawItems.length - items.length; + const reported = data['x-medkit']?.count ?? rawItems.length; + return { items, count: Math.max(items.length, reported - duplicates) }; } // =============================================================================