diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index 724d17e..87c1121 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -6,6 +6,7 @@ import { createEventStreamClient, type EventStreamClient } from './events/event- import { FleetPage, type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js'; import { createFleetApiDataSource } from './fleet/fleet-api-data-source.js'; +import { CallsModule, type CallsDataSource } from './instances/calls-module.js'; import { CellularModule, type CellularDataSource } from './instances/cellular-module.js'; import { DeviceNetworkModule, @@ -13,6 +14,7 @@ import { } from './instances/device-network-module.js'; import { OverviewSystemPage, type OverviewDataSource } from './instances/overview-system.js'; import { InstanceEditor, type InstanceDataSource } from './instances/instance-crud.js'; +import { MessagesModule, type MessagesDataSource } from './instances/messages-module.js'; import { InstanceDetail, INSTANCE_MODULE_LABELS, @@ -70,6 +72,8 @@ export interface AppShellProps { overviewDataSource?: OverviewDataSource; cellularDataSource?: CellularDataSource; deviceNetworkDataSource?: DeviceNetworkDataSource; + messagesDataSource?: MessagesDataSource; + callsDataSource?: CallsDataSource; eventStreamClient?: EventStreamClient; } @@ -140,6 +144,8 @@ function Page({ overviewDataSource, cellularDataSource, deviceNetworkDataSource, + messagesDataSource, + callsDataSource, fleetRefreshSignal, detailRefreshSignal, }: { @@ -153,6 +159,8 @@ function Page({ overviewDataSource: OverviewDataSource | undefined; cellularDataSource: CellularDataSource | undefined; deviceNetworkDataSource: DeviceNetworkDataSource | undefined; + messagesDataSource: MessagesDataSource | undefined; + callsDataSource: CallsDataSource | undefined; fleetRefreshSignal: number; detailRefreshSignal: number; }): ReactNode { @@ -229,6 +237,28 @@ function Page({ ), } : {})} + {...(module === 'messages' && instance + ? { + moduleContent: ( + + ), + } + : {})} + {...(module === 'calls' && instance + ? { + moduleContent: ( + + ), + } + : {})} /> ); } @@ -262,6 +292,8 @@ export function AppShell({ overviewDataSource, cellularDataSource, deviceNetworkDataSource, + messagesDataSource, + callsDataSource, eventStreamClient, }: AppShellProps) { const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []); @@ -317,6 +349,8 @@ export function AppShell({ overviewDataSource={overviewDataSource} cellularDataSource={cellularDataSource} deviceNetworkDataSource={deviceNetworkDataSource} + messagesDataSource={messagesDataSource} + callsDataSource={callsDataSource} fleetRefreshSignal={refresh.fleet} detailRefreshSignal={refresh.detail} /> diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts index ed5b52a..8fc0307 100644 --- a/apps/web/src/index.ts +++ b/apps/web/src/index.ts @@ -91,4 +91,17 @@ export { type DeviceNetworkSnapshot, } from './instances/device-network-module.js'; +export { + CallsModule, + type CallsDataSource, + type CallsModuleProps, + type CallsSnapshot, +} from './instances/calls-module.js'; +export { + MessagesModule, + type MessagesDataSource, + type MessagesModuleProps, + type MessagesSnapshot, +} from './instances/messages-module.js'; + export const webWorkspaceReady = true; diff --git a/apps/web/src/instances/calls-module.test.tsx b/apps/web/src/instances/calls-module.test.tsx new file mode 100644 index 0000000..5aff5ae --- /dev/null +++ b/apps/web/src/instances/calls-module.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { InstanceContext } from '../app-shell.js'; +import { CallsModule, type CallsDataSource, type CallsSnapshot } from './calls-module.js'; + +afterEach(cleanup); + +const owner: InstanceContext = { + id: 'alpha', + name: 'Alpha', + origin: 'https://alpha.example', + status: 'online', + authentication: 'authenticated', + freshness: 'fresh', +}; + +const snapshot: CallsSnapshot = { + observedAt: '2026-07-17T12:30:00Z', + calls: { state: 'active', active: 2, ringing: 1, held: 0 }, + devices: { state: 'available', total: 4, online: 3, busy: 2 }, +}; + +function deferredSource() { + let resolvePending!: (value: CallsSnapshot) => void; + const load = vi.fn( + (_instanceId, _signal) => + new Promise((done) => { + void _instanceId; + void _signal; + resolvePending = done; + }), + ); + return { source: { load }, load, resolve: (value: CallsSnapshot) => resolvePending(value) }; +} + +describe('isolated Calls read module', () => { + it('loads only through the injected source and renders aggregate call and device status', async () => { + const pending = deferredSource(); + render(); + expect(screen.getByRole('status', { name: 'Calls loading status' })).toBeTruthy(); + expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal)); + pending.resolve(snapshot); + + const calls = await screen.findByRole('region', { name: 'Call status' }); + const devices = screen.getByRole('region', { name: 'Device status' }); + expect(within(calls).getByText('active')).toBeTruthy(); + expect(within(calls).getByText('2')).toBeTruthy(); + expect(within(devices).getByText('available')).toBeTruthy(); + expect(screen.getByText(/Observed 2026-07-17T12:30:00Z/)).toBeTruthy(); + }); + + it('strictly allowlists aggregate fields and exposes no phone numbers, audio, logs, or controls', async () => { + const unsafe = { + ...snapshot, + phoneNumber: '+1-555-0100', + audio: 'private recording body', + logBody: 'private call log body', + calls: { ...snapshot.calls, caller: '+1-555-0101', transcript: 'private transcript' }, + devices: { ...snapshot.devices, phoneNumber: '+1-555-0102', audioUrl: 'secret-audio-url' }, + } as CallsSnapshot; + render( unsafe }} />); + expect(await screen.findByText('available')).toBeTruthy(); + expect(document.body.textContent).not.toMatch(/555|private|secret-audio/i); + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('fails closed without authentication or an injected source', async () => { + const load = vi.fn().mockResolvedValue(snapshot); + const { rerender } = render( + , + ); + expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i); + expect(load).not.toHaveBeenCalled(); + + rerender(); + expect(screen.getByRole('status', { name: 'Calls unavailable' }).textContent).toMatch( + /no safe calls read data source.*will not invent.*production endpoint/i, + ); + }); + + it('uses fixed safe errors, retries, and retains the same owner snapshot on refresh failure', async () => { + const user = userEvent.setup(); + const load = vi + .fn() + .mockResolvedValueOnce(snapshot) + .mockRejectedValueOnce(new Error('secret endpoint and telephone')) + .mockResolvedValueOnce({ ...snapshot, calls: { ...snapshot.calls, active: 3 } }); + const source = { load }; + const { rerender } = render( + , + ); + expect(await screen.findByText('available')).toBeTruthy(); + rerender(); + + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toMatch(/showing the last known Calls data/i); + expect(alert.textContent).not.toMatch(/secret endpoint|telephone/i); + expect(screen.getByText('available')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: 'Retry loading Calls' })); + expect( + await within(screen.getByRole('region', { name: 'Call status' })).findByText('3'), + ).toBeTruthy(); + }); + + it('aborts superseded reads and fences late data from another owner', async () => { + const alpha = deferredSource(); + const bravo = deferredSource(); + const load = vi.fn((id, signal) => + id === 'alpha' ? alpha.source.load(id, signal) : bravo.source.load(id, signal), + ); + const source = { load }; + const { rerender } = render(); + const alphaSignal = load.mock.calls[0]?.[1]; + rerender( + , + ); + expect(alphaSignal?.aborted).toBe(true); + alpha.resolve({ ...snapshot, calls: { state: 'late-alpha' } }); + bravo.resolve({ ...snapshot, calls: { state: 'current-bravo' } }); + expect(await screen.findByText('current-bravo')).toBeTruthy(); + expect(screen.queryByText('late-alpha')).toBeNull(); + }); +}); diff --git a/apps/web/src/instances/calls-module.tsx b/apps/web/src/instances/calls-module.tsx new file mode 100644 index 0000000..4779295 --- /dev/null +++ b/apps/web/src/instances/calls-module.tsx @@ -0,0 +1,218 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; + +import type { InstanceContext } from '../app-shell.js'; + +/** Bounded values permitted in aggregate call/device status. */ +export type CallsStatusValue = string | number | boolean | null; + +/** Aggregate call state only. Per-call identity, phone numbers, logs, and media are unsupported. */ +export interface AggregateCallStatus { + readonly state?: CallsStatusValue; + readonly total?: CallsStatusValue; + readonly active?: CallsStatusValue; + readonly ringing?: CallsStatusValue; + readonly held?: CallsStatusValue; + readonly failed?: CallsStatusValue; +} + +/** Aggregate device state only. Device phone numbers and media are intentionally unsupported. */ +export interface AggregateCallDeviceStatus { + readonly state?: CallsStatusValue; + readonly total?: CallsStatusValue; + readonly online?: CallsStatusValue; + readonly offline?: CallsStatusValue; + readonly busy?: CallsStatusValue; +} + +export interface CallsSnapshot { + readonly observedAt?: string; + readonly calls: AggregateCallStatus; + readonly devices: AggregateCallDeviceStatus; +} + +export interface CallsDataSource { + /** Supplied by the authenticated owner; this isolated module defines no production endpoint. */ + load(instanceId: string, signal: AbortSignal): Promise; +} + +export interface CallsModuleProps { + readonly instance: InstanceContext; + readonly dataSource?: CallsDataSource; + /** Change this owner-provided value to request another read. */ + readonly refreshSignal?: unknown; +} + +type OwnedSnapshot = { readonly ownerId: string; readonly value: CallsSnapshot }; +type ReadState = + | { kind: 'idle' } + | { kind: 'loading'; snapshot?: OwnedSnapshot } + | { kind: 'ready'; snapshot: OwnedSnapshot } + | { kind: 'error'; snapshot?: OwnedSnapshot }; + +const SAFE_LOAD_ERROR = 'Calls data could not be loaded.'; + +const CALL_FIELDS = [ + ['State', 'state'], + ['Total', 'total'], + ['Active', 'active'], + ['Ringing', 'ringing'], + ['Held', 'held'], + ['Failed', 'failed'], +] as const; + +const DEVICE_FIELDS = [ + ['State', 'state'], + ['Total', 'total'], + ['Online', 'online'], + ['Offline', 'offline'], + ['Busy', 'busy'], +] as const; + +function Section({ label, children }: { label: string; children: ReactNode }) { + return ( +
+

{label}

+ {children} +
+ ); +} + +function AggregateFields({ + values, + fields, +}: { + values: object; + fields: readonly (readonly [label: string, key: string])[]; +}) { + const safeValues = values as Readonly>; + const supplied = fields.filter(([, key]) => safeValues[key] !== undefined); + if (!supplied.length) return

No aggregate status was supplied.

; + return ( +
+ {supplied.map(([label, key]) => ( +
+
{label}
+
{safeValues[key] == null ? 'Unavailable' : String(safeValues[key])}
+
+ ))} +
+ ); +} + +function SnapshotView({ snapshot }: { snapshot: CallsSnapshot }) { + return ( + <> + {snapshot.observedAt ?

Observed {snapshot.observedAt}

: null} +
+
+ +
+
+ +
+
+ + ); +} + +export function CallsModule({ instance, dataSource, refreshSignal }: CallsModuleProps) { + const requestFence = useRef(0); + const [retry, setRetry] = useState(0); + const [state, setState] = useState({ kind: 'idle' }); + + useEffect(() => { + const request = ++requestFence.current; + const ownerId = instance.id; + const controller = new AbortController(); + + if (instance.authentication !== 'authenticated' || !dataSource) { + setState({ kind: 'idle' }); + return () => controller.abort(); + } + + setState((current) => { + const snapshot = + current.kind !== 'idle' && current.snapshot?.ownerId === ownerId + ? current.snapshot + : undefined; + return { kind: 'loading', ...(snapshot ? { snapshot } : {}) }; + }); + + void dataSource.load(ownerId, controller.signal).then( + (value) => { + if (request === requestFence.current && !controller.signal.aborted) + setState({ kind: 'ready', snapshot: { ownerId, value } }); + }, + (_reason: unknown) => { + void _reason; + if (request === requestFence.current && !controller.signal.aborted) + setState((current) => { + const snapshot = + current.kind === 'loading' && current.snapshot?.ownerId === ownerId + ? current.snapshot + : undefined; + return { kind: 'error', ...(snapshot ? { snapshot } : {}) }; + }); + }, + ); + + return () => controller.abort(); + }, [dataSource, instance.authentication, instance.id, refreshSignal, retry]); + + if (instance.authentication !== 'authenticated') + return ( +
+ Authentication is required before Calls data can be read for this instance. +
+ ); + + if (!dataSource) + return ( +
+ No safe Calls read data source is available. This console will not invent or call an + uncontracted production endpoint. +
+ ); + + const snapshot = + state.kind !== 'idle' && state.snapshot?.ownerId === instance.id ? state.snapshot : undefined; + + if ((state.kind === 'idle' || state.kind === 'loading') && !snapshot) + return ( +

+ Loading Calls… +

+ ); + + if (state.kind === 'error' && !snapshot) + return ( +
+

Unable to load Calls: {SAFE_LOAD_ERROR}

+ +
+ ); + + if (!snapshot) return null; + + return ( +
+ {state.kind === 'loading' ?

Refreshing Calls…

: null} + {state.kind === 'error' ? ( +
+

Refresh failed; showing the last known Calls data.

+ +
+ ) : null} + {instance.freshness !== 'fresh' ? ( +

+ Calls data is {instance.freshness}; verify freshness before relying on these values. +

+ ) : null} + +
+ ); +} diff --git a/apps/web/src/instances/messages-module.test.tsx b/apps/web/src/instances/messages-module.test.tsx new file mode 100644 index 0000000..9acc783 --- /dev/null +++ b/apps/web/src/instances/messages-module.test.tsx @@ -0,0 +1,215 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { InstanceContext } from '../app-shell.js'; +import { + MessagesModule, + type MessagesDataSource, + type MessagesSnapshot, +} from './messages-module.js'; + +afterEach(cleanup); + +const owner: InstanceContext = { + id: 'alpha', + name: 'Alpha', + origin: 'https://alpha.example', + status: 'online', + authentication: 'authenticated', + freshness: 'fresh', +}; + +const snapshot: MessagesSnapshot = { + observedAt: '2026-07-17T12:00:00Z', + sms: { + total: 42, + inbound: 25, + outbound: 17, + unread: 3, + failed: 2, + queued: 1, + lastActivityAt: '2026-07-17T11:55:00Z', + // Deliberate excess fields: payloads and recipient data must never reach the DOM. + body: 'private message body', + content: 'private message content', + recipient: '+15550199', + recipientCredential: 'secret-recipient-token', + }, + devices: [ + { + deviceId: 'modem-1', + label: 'Primary modem', + state: 'online', + total: 30, + inbound: 18, + outbound: 12, + unread: 2, + failed: 1, + queued: 0, + lastActivityAt: '2026-07-17T11:54:00Z', + body: 'device body must not render', + phoneNumber: '+15550123', + password: 'device credential', + }, + ], +}; + +function deferredSource() { + let resolve!: (value: MessagesSnapshot) => void; + let reject!: (reason: unknown) => void; + const load = vi.fn( + (_instanceId, _signal) => + new Promise((done, fail) => { + void _instanceId; + void _signal; + resolve = done; + reject = fail; + }), + ); + return { + source: { load }, + load, + resolve: (value: MessagesSnapshot) => resolve(value), + reject: (reason: unknown) => reject(reason), + }; +} + +describe('Messages isolated read module', () => { + it('loads only through the injected exact-owner source and renders aggregate SMS/device metadata', async () => { + const pending = deferredSource(); + render(); + + expect(screen.getByRole('status', { name: 'Messages loading status' })).toBeTruthy(); + expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal)); + pending.resolve(snapshot); + + const sms = await screen.findByRole('region', { name: 'SMS aggregate' }); + expect(within(sms).getByText('42')).toBeTruthy(); + expect(within(sms).getByText('2026-07-17T11:55:00Z')).toBeTruthy(); + const devices = screen.getByRole('region', { name: 'Device message aggregates' }); + expect(within(devices).getByText('modem-1')).toBeTruthy(); + expect(within(devices).getByText('Primary modem')).toBeTruthy(); + expect(screen.getByText(/Observed 2026-07-17T12:00:00Z/)).toBeTruthy(); + }); + + it('uses an explicit safe allowlist and exposes no bodies, content, recipients, credentials, or write actions', async () => { + render( snapshot }} />); + expect(await screen.findByText('Primary modem')).toBeTruthy(); + const text = document.body.textContent ?? ''; + for (const secret of [ + 'private message body', + 'private message content', + '+15550199', + 'secret-recipient-token', + 'device body must not render', + '+15550123', + 'device credential', + ]) { + expect(text).not.toContain(secret); + } + expect(screen.queryByText(/recipient|phone number|password/i)).toBeNull(); + expect(screen.queryByRole('button')).toBeNull(); + expect(screen.getByText(/aggregate metadata only/i)).toBeTruthy(); + expect( + screen.getByText(/sending, deleting, and changing messages are unavailable/i), + ).toBeTruthy(); + }); + + it('does not invent an endpoint when no source is injected', () => { + render(); + expect(screen.getByRole('status', { name: 'Messages unavailable' }).textContent).toMatch( + /no safe messages read data source.*uncontracted production endpoint/i, + ); + }); + + it('requires the exact owner to remain authenticated and clears prior owner data', async () => { + const load = vi.fn().mockResolvedValue(snapshot); + const { rerender } = render(); + expect(await screen.findByText('Primary modem')).toBeTruthy(); + + rerender( + , + ); + expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i); + expect(screen.queryByText('Primary modem')).toBeNull(); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('uses a fixed safe error and retries without exposing rejection details', async () => { + const user = userEvent.setup(); + const load = vi + .fn() + .mockRejectedValueOnce(new Error('secret URL and recipient credential')) + .mockResolvedValueOnce(snapshot); + render(); + + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Messages data could not be loaded.'); + expect(alert.textContent).not.toContain('secret URL'); + await user.click(screen.getByRole('button', { name: 'Retry loading Messages' })); + expect(await screen.findByText('Primary modem')).toBeTruthy(); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('retains only the same owner last-good snapshot during refresh and after refresh failure', async () => { + const user = userEvent.setup(); + const load = vi + .fn() + .mockResolvedValueOnce(snapshot) + .mockRejectedValueOnce(new Error('unsafe detail')) + .mockResolvedValueOnce({ ...snapshot, devices: [{ label: 'Replacement modem', total: 4 }] }); + const source = { load }; + const { rerender } = render( + , + ); + expect(await screen.findByText('Primary modem')).toBeTruthy(); + + rerender(); + expect(screen.getByText('Primary modem')).toBeTruthy(); + expect((await screen.findByRole('alert')).textContent).toMatch(/showing the last known/i); + expect(screen.getByText('Primary modem')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: 'Retry loading Messages' })); + expect(await screen.findByText('Replacement modem')).toBeTruthy(); + }); + + it('aborts replaced reads and fences late responses from another owner', async () => { + const alpha = deferredSource(); + const bravo = deferredSource(); + const load = vi.fn((instanceId, signal) => + instanceId === 'alpha' + ? alpha.source.load(instanceId, signal) + : bravo.source.load(instanceId, signal), + ); + const source = { load }; + const { rerender } = render(); + const alphaSignal = load.mock.calls[0]?.[1]; + + rerender( + , + ); + expect(alphaSignal?.aborted).toBe(true); + expect(screen.queryByText('Primary modem')).toBeNull(); + alpha.resolve(snapshot); + bravo.resolve({ ...snapshot, devices: [{ label: 'Bravo modem', total: 9 }] }); + expect(await screen.findByText('Bravo modem')).toBeTruthy(); + expect(screen.queryByText('Primary modem')).toBeNull(); + }); + + it('marks owner-declared stale data while preserving aggregates', async () => { + render( + snapshot }} + />, + ); + expect((await screen.findByRole('status', { name: 'Messages freshness' })).textContent).toMatch( + /stale/i, + ); + expect(screen.getByText('Primary modem')).toBeTruthy(); + }); +}); diff --git a/apps/web/src/instances/messages-module.tsx b/apps/web/src/instances/messages-module.tsx new file mode 100644 index 0000000..7292064 --- /dev/null +++ b/apps/web/src/instances/messages-module.tsx @@ -0,0 +1,247 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; + +import type { InstanceContext } from '../app-shell.js'; + +/** Bounded primitives permitted at the Messages presentation boundary. */ +export type MessageMetadataValue = string | number | boolean | null; + +/** Aggregate SMS metadata only. Message bodies, content, recipients, and credentials are absent. */ +export interface SmsAggregate { + readonly total?: number | null; + readonly inbound?: number | null; + readonly outbound?: number | null; + readonly unread?: number | null; + readonly failed?: number | null; + readonly queued?: number | null; + readonly lastActivityAt?: string | null; +} + +/** Non-sensitive device identity/status and aggregate counts only. */ +export interface DeviceMessageAggregate extends SmsAggregate { + readonly deviceId?: string | null; + readonly label?: string | null; + readonly state?: string | null; +} + +export interface MessagesSnapshot { + readonly observedAt?: string; + readonly sms: SmsAggregate; + readonly devices: readonly DeviceMessageAggregate[]; +} + +export interface MessagesDataSource { + /** Supplied by the authenticated owner; this isolated module defines no network endpoint. */ + load(instanceId: string, signal: AbortSignal): Promise; +} + +export interface MessagesModuleProps { + readonly instance: InstanceContext; + readonly dataSource?: MessagesDataSource; + /** Change this owner-provided value to request another read. */ + readonly refreshSignal?: unknown; +} + +type ReadState = + | { kind: 'idle'; ownerId: string } + | { kind: 'loading'; ownerId: string; snapshot?: MessagesSnapshot } + | { kind: 'ready'; ownerId: string; snapshot: MessagesSnapshot } + | { kind: 'error'; ownerId: string; snapshot?: MessagesSnapshot }; + +const SAFE_LOAD_ERROR = 'Messages data could not be loaded.'; + +const AGGREGATE_FIELDS = [ + ['Total', 'total'], + ['Inbound', 'inbound'], + ['Outbound', 'outbound'], + ['Unread', 'unread'], + ['Failed', 'failed'], + ['Queued', 'queued'], + ['Last activity', 'lastActivityAt'], +] as const; + +function display(value: MessageMetadataValue | undefined): string { + return value == null ? 'Unavailable' : String(value); +} + +function Fields({ + values, +}: { + values: readonly (readonly [label: string, value: MessageMetadataValue | undefined])[]; +}) { + return ( +
+ {values.map(([label, value]) => ( +
+
{label}
+
{display(value)}
+
+ ))} +
+ ); +} + +function Section({ label, children }: { label: string; children: ReactNode }) { + return ( +
+

{label}

+ {children} +
+ ); +} + +function aggregateFields( + aggregate: SmsAggregate, +): readonly (readonly [string, MessageMetadataValue | undefined])[] { + // This constant-key projection is the presentation allowlist. Never enumerate source objects. + return AGGREGATE_FIELDS.map(([label, key]) => [label, aggregate[key]] as const); +} + +function SnapshotView({ snapshot }: { snapshot: MessagesSnapshot }) { + return ( + <> + {snapshot.observedAt ?

Observed {snapshot.observedAt}

: null} +

Aggregate metadata only; sensitive payload and addressing details are excluded.

+
+
+ +
+
+ {snapshot.devices.length ? ( + snapshot.devices.map((device, index) => ( +
+ +
+ )) + ) : ( +

No device message aggregates were supplied.

+ )} +
+
+
+

Messages actions

+

Sending, deleting, and changing messages are unavailable in this read-only module.

+
+ + ); +} + +export function MessagesModule({ instance, dataSource, refreshSignal }: MessagesModuleProps) { + const requestOwner = useRef(0); + const [retry, setRetry] = useState(0); + const [state, setState] = useState({ kind: 'idle', ownerId: instance.id }); + + useEffect(() => { + const request = ++requestOwner.current; + const ownerId = instance.id; + const controller = new AbortController(); + + if (instance.authentication !== 'authenticated' || !dataSource) { + setState({ kind: 'idle', ownerId }); + return () => controller.abort(); + } + + setState((current) => ({ + kind: 'loading', + ownerId, + ...(current.ownerId === ownerId && + (current.kind === 'ready' || current.kind === 'error') && + current.snapshot + ? { snapshot: current.snapshot } + : {}), + })); + + void dataSource.load(ownerId, controller.signal).then( + (snapshot) => { + if (request === requestOwner.current && !controller.signal.aborted) { + setState({ kind: 'ready', ownerId, snapshot }); + } + }, + (_reason: unknown) => { + void _reason; + if (request === requestOwner.current && !controller.signal.aborted) { + setState((current) => ({ + kind: 'error', + ownerId, + ...(current.ownerId === ownerId && current.kind === 'loading' && current.snapshot + ? { snapshot: current.snapshot } + : {}), + })); + } + }, + ); + + return () => controller.abort(); + }, [dataSource, instance.authentication, instance.id, refreshSignal, retry]); + + if (instance.authentication !== 'authenticated') { + return ( +
+ Authentication is required before Messages data can be read for this instance. +
+ ); + } + + if (!dataSource) { + return ( +
+ No safe Messages read data source is available. This console will not invent or call an + uncontracted production endpoint. +
+ ); + } + + const retainedSnapshot = + state.ownerId === instance.id && (state.kind === 'loading' || state.kind === 'error') + ? state.snapshot + : undefined; + + if ((state.kind === 'idle' || state.kind === 'loading') && !retainedSnapshot) { + return ( +

+ Loading Messages… +

+ ); + } + + if (state.kind === 'error' && !retainedSnapshot) { + return ( +
+

Unable to load Messages: {SAFE_LOAD_ERROR}

+ +
+ ); + } + + const currentSnapshot = + state.kind === 'ready' && state.ownerId === instance.id ? state.snapshot : retainedSnapshot; + if (!currentSnapshot) return null; + + return ( +
+ {state.kind === 'loading' ?

Refreshing Messages…

: null} + {state.kind === 'error' ? ( +
+

Refresh failed; showing the last known Messages data.

+ +
+ ) : null} + {instance.freshness !== 'fresh' ? ( +

+ Messages data is {instance.freshness}; verify freshness before relying on these values. +

+ ) : null} + +
+ ); +}