diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index e4c1648..724d17e 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -6,8 +6,13 @@ 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 { InstanceEditor, type InstanceDataSource } from './instances/instance-crud.js'; +import { CellularModule, type CellularDataSource } from './instances/cellular-module.js'; +import { + DeviceNetworkModule, + type DeviceNetworkDataSource, +} 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 { InstanceDetail, INSTANCE_MODULE_LABELS, @@ -63,6 +68,8 @@ export interface AppShellProps { capabilities?: InstanceCapabilityMap; capabilityDataSource?: CapabilityDataSource; overviewDataSource?: OverviewDataSource; + cellularDataSource?: CellularDataSource; + deviceNetworkDataSource?: DeviceNetworkDataSource; eventStreamClient?: EventStreamClient; } @@ -131,6 +138,8 @@ function Page({ capabilities, capabilityDataSource, overviewDataSource, + cellularDataSource, + deviceNetworkDataSource, fleetRefreshSignal, detailRefreshSignal, }: { @@ -142,6 +151,8 @@ function Page({ capabilities: InstanceCapabilityMap | undefined; capabilityDataSource: CapabilityDataSource | undefined; overviewDataSource: OverviewDataSource | undefined; + cellularDataSource: CellularDataSource | undefined; + deviceNetworkDataSource: DeviceNetworkDataSource | undefined; fleetRefreshSignal: number; detailRefreshSignal: number; }): ReactNode { @@ -196,6 +207,28 @@ function Page({ ), } : {})} + {...(module === 'cellular' && instance + ? { + moduleContent: ( + + ), + } + : {})} + {...(module === 'device-network' && instance + ? { + moduleContent: ( + + ), + } + : {})} /> ); } @@ -227,6 +260,8 @@ export function AppShell({ capabilities, capabilityDataSource, overviewDataSource, + cellularDataSource, + deviceNetworkDataSource, eventStreamClient, }: AppShellProps) { const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []); @@ -280,6 +315,8 @@ export function AppShell({ capabilities={capabilities} capabilityDataSource={capabilityDataSource} overviewDataSource={overviewDataSource} + cellularDataSource={cellularDataSource} + deviceNetworkDataSource={deviceNetworkDataSource} fleetRefreshSignal={refresh.fleet} detailRefreshSignal={refresh.detail} /> diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts index 347bfc4..ed5b52a 100644 --- a/apps/web/src/index.ts +++ b/apps/web/src/index.ts @@ -73,4 +73,22 @@ export { type PreparedOperation, } from './operations/operation-client.js'; +export { + CellularModule, + type CellularDataSource, + type CellularFieldValue, + type CellularLocation, + type CellularModuleProps, + type CellularNetworkRegistration, + type CellularOperators, + type CellularSignal, + type CellularSnapshot, +} from './instances/cellular-module.js'; +export { + DeviceNetworkModule, + type DeviceNetworkDataSource, + type DeviceNetworkModuleProps, + type DeviceNetworkSnapshot, +} from './instances/device-network-module.js'; + export const webWorkspaceReady = true; diff --git a/apps/web/src/instances/cellular-module.test.tsx b/apps/web/src/instances/cellular-module.test.tsx new file mode 100644 index 0000000..393149d --- /dev/null +++ b/apps/web/src/instances/cellular-module.test.tsx @@ -0,0 +1,180 @@ +// @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 { + CellularModule, + type CellularDataSource, + type CellularSnapshot, +} from './cellular-module.js'; + +afterEach(cleanup); + +const owner: InstanceContext = { + id: 'alpha', + name: 'Alpha', + origin: 'https://alpha.example', + status: 'online', + authentication: 'authenticated', + freshness: 'fresh', +}; + +const snapshot: CellularSnapshot = { + observedAt: '2026-07-17T12:00:00Z', + networkRegistration: { state: 'registered', roaming: false }, + signal: { rssi: '-71 dBm', quality: 82 }, + cellsLocation: { cell: '12345', area: 19, latitude: null }, + operators: { current: 'Example Mobile', available: 3 }, +}; + +function deferredSource() { + let resolve!: (value: CellularSnapshot) => 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: CellularSnapshot) => resolve(value), + reject: (reason: unknown) => reject(reason), + }; +} + +describe('Phase 6.2 Cellular read module', () => { + it('reads via the injected source and renders four structured, primitive-only sections', async () => { + const pending = deferredSource(); + render(); + + expect(screen.getByRole('status', { name: 'Cellular loading status' })).toBeTruthy(); + expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal)); + pending.resolve(snapshot); + + for (const heading of ['Network registration', 'Signal', 'Cells / location', 'Operators']) { + expect(await screen.findByRole('heading', { name: heading })).toBeTruthy(); + } + expect( + within(screen.getByRole('region', { name: 'Signal' })).getByText('-71 dBm'), + ).toBeTruthy(); + expect(screen.getByText('Unavailable')).toBeTruthy(); + expect(screen.getByText(/Observed 2026-07-17T12:00:00Z/)).toBeTruthy(); + expect(screen.queryByRole('button', { name: /register|operator|network/i })).toBeNull(); + expect(screen.getByText(/automatic network registration is available only/i)).toBeTruthy(); + expect(screen.getByText(/audited R2 prepare, confirm, and execute flow/i)).toBeTruthy(); + }); + + it('is honest when no read source is injected', () => { + render(); + expect(screen.getByRole('status', { name: 'Cellular unavailable' }).textContent).toMatch( + /no safe cellular read data source.*will not invent.*production endpoint/i, + ); + }); + + it('requires an authenticated owner and clears another owner’s retained data', async () => { + const load = vi.fn().mockResolvedValue(snapshot); + const { rerender } = render(); + expect(await screen.findByText('-71 dBm')).toBeTruthy(); + + rerender( + , + ); + expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i); + expect(screen.queryByText('-71 dBm')).toBeNull(); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('reloads on refreshSignal and retains stale data with a safe fixed refresh error', async () => { + const user = userEvent.setup(); + const load = vi + .fn() + .mockResolvedValueOnce(snapshot) + .mockRejectedValueOnce(new Error('secret upstream details')) + .mockResolvedValueOnce({ ...snapshot, signal: { rssi: '-65 dBm' } }); + const source = { load }; + const { rerender } = render( + , + ); + expect(await screen.findByText('-71 dBm')).toBeTruthy(); + + rerender(); + expect( + await screen.findByText(/Refresh failed; showing the last known cellular data/i), + ).toBeTruthy(); + expect(screen.getByText('-71 dBm')).toBeTruthy(); + expect(screen.queryByText(/secret upstream details/i)).toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Retry loading cellular data' })); + expect(await screen.findByText('-65 dBm')).toBeTruthy(); + expect(load).toHaveBeenCalledTimes(3); + }); + + it('shows a fixed initial error and supports retry', async () => { + const user = userEvent.setup(); + const load = vi + .fn() + .mockRejectedValueOnce('private failure') + .mockResolvedValueOnce(snapshot); + render(); + + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toMatch(/Cellular data could not be loaded/i); + expect(alert.textContent).not.toMatch(/private failure/i); + await user.click(screen.getByRole('button', { name: 'Retry loading cellular data' })); + expect(await screen.findByText('-71 dBm')).toBeTruthy(); + }); + + it('aborts superseded reads and fences late results from the prior 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); + alpha.resolve({ ...snapshot, signal: { rssi: 'alpha stale' } }); + bravo.resolve({ ...snapshot, signal: { rssi: 'bravo current' } }); + expect(await screen.findByText('bravo current')).toBeTruthy(); + expect(screen.queryByText('alpha stale')).toBeNull(); + }); + + it('renders supplied strings as text rather than HTML', async () => { + render( + ({ + ...snapshot, + operators: { current: '' }, + }), + }} + />, + ); + expect(await screen.findByText('')).toBeTruthy(); + expect(document.querySelector('img')).toBeNull(); + }); +}); diff --git a/apps/web/src/instances/cellular-module.tsx b/apps/web/src/instances/cellular-module.tsx new file mode 100644 index 0000000..64c1037 --- /dev/null +++ b/apps/web/src/instances/cellular-module.tsx @@ -0,0 +1,256 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { InstanceContext } from '../app-shell.js'; + +/** Cellular data is deliberately limited to display-safe primitive values. */ +export type CellularFieldValue = string | number | boolean | null; +export interface CellularNetworkRegistration { + readonly state?: CellularFieldValue; + readonly mode?: CellularFieldValue; + readonly operator?: CellularFieldValue; + readonly roaming?: CellularFieldValue; +} +export interface CellularSignal { + readonly rssi?: CellularFieldValue; + readonly rsrp?: CellularFieldValue; + readonly rsrq?: CellularFieldValue; + readonly sinr?: CellularFieldValue; + readonly quality?: CellularFieldValue; +} +export interface CellularLocation { + readonly cell?: CellularFieldValue; + readonly area?: CellularFieldValue; + readonly technology?: CellularFieldValue; + readonly latitude?: CellularFieldValue; + readonly longitude?: CellularFieldValue; +} +export interface CellularOperators { + readonly current?: CellularFieldValue; + readonly available?: CellularFieldValue; +} + +export interface CellularSnapshot { + readonly observedAt?: string; + readonly networkRegistration: CellularNetworkRegistration; + readonly signal: CellularSignal; + readonly cellsLocation: CellularLocation; + readonly operators: CellularOperators; +} + +export interface CellularDataSource { + /** Injected by the application owner; this module assumes no production endpoint. */ + load(instanceId: string, signal: AbortSignal): Promise; +} + +export interface CellularModuleProps { + readonly instance: InstanceContext; + readonly dataSource?: CellularDataSource; + /** Change this value when the owning application requests a refresh. */ + readonly refreshSignal?: unknown; +} + +type OwnedSnapshot = { readonly ownerId: string; readonly value: CellularSnapshot }; +type ReadState = + | { kind: 'idle' } + | { kind: 'loading'; snapshot?: OwnedSnapshot } + | { kind: 'ready'; snapshot: OwnedSnapshot } + | { kind: 'error'; message: string; snapshot?: OwnedSnapshot }; + +const SECTIONS = [ + [ + 'networkRegistration', + 'Network registration', + [ + ['State', 'state'], + ['Mode', 'mode'], + ['Operator', 'operator'], + ['Roaming', 'roaming'], + ], + ], + [ + 'signal', + 'Signal', + [ + ['RSSI', 'rssi'], + ['RSRP', 'rsrp'], + ['RSRQ', 'rsrq'], + ['SINR', 'sinr'], + ['Quality', 'quality'], + ], + ], + [ + 'cellsLocation', + 'Cells / location', + [ + ['Cell', 'cell'], + ['Area', 'area'], + ['Technology', 'technology'], + ['Latitude', 'latitude'], + ['Longitude', 'longitude'], + ], + ], + [ + 'operators', + 'Operators', + [ + ['Current', 'current'], + ['Available', 'available'], + ], + ], +] as const; + +function StructuredSection({ + label, + values, + fields, +}: { + label: string; + values: object; + fields: readonly (readonly [string, string])[]; +}) { + const safeValues = values as Readonly>; + const entries = fields.filter(([, key]) => safeValues[key] !== undefined); + return ( +
+

{label}

+ {entries.length ? ( +
+ {entries.map(([labelText, fieldKey]) => { + const value = safeValues[fieldKey]; + return ( +
+
{labelText}
+
{value == null ? 'Unavailable' : String(value)}
+
+ ); + })} +
+ ) : ( +

No {label.toLocaleLowerCase()} data was supplied.

+ )} +
+ ); +} + +export function CellularModule({ instance, dataSource, refreshSignal }: CellularModuleProps) { + const requestOwner = useRef(0); + const [retry, setRetry] = useState(0); + const [state, setState] = useState({ kind: 'idle' }); + + useEffect(() => { + const request = ++requestOwner.current; + 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 === instance.id + ? current.snapshot + : undefined; + return { kind: 'loading', ...(snapshot ? { snapshot } : {}) }; + }); + void dataSource.load(instance.id, controller.signal).then( + (value) => { + if (request === requestOwner.current && !controller.signal.aborted) { + setState({ kind: 'ready', snapshot: { ownerId: instance.id, value } }); + } + }, + (reason: unknown) => { + void reason; + if (request === requestOwner.current && !controller.signal.aborted) { + setState((current) => { + const snapshot = + current.kind === 'loading' && current.snapshot?.ownerId === instance.id + ? current.snapshot + : undefined; + return { + kind: 'error', + message: 'Cellular data could not be loaded.', + ...(snapshot ? { snapshot } : {}), + }; + }); + } + }, + ); + return () => controller.abort(); + }, [dataSource, instance.authentication, instance.id, refreshSignal, retry]); + + if (instance.authentication !== 'authenticated') { + return ( +
+ Authentication is required before cellular data can be read for this instance. +
+ ); + } + + if (!dataSource) { + return ( +
+ No safe cellular read data source is available. This console will not invent or call an + uncontracted production endpoint. +
+ ); + } + + const ownedSnapshot = + state.kind !== 'idle' && state.snapshot?.ownerId === instance.id ? state.snapshot : undefined; + + if ((state.kind === 'idle' || state.kind === 'loading') && !ownedSnapshot) { + return ( +

+ Loading cellular data… +

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

Unable to load cellular data: {state.message}

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

Refreshing cellular data…

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

Refresh failed; showing the last known cellular data.

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

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

+ ) : null} + {snapshot.observedAt ?

Observed {snapshot.observedAt}

: null} +
+ {SECTIONS.map(([key, label, fields]) => ( + + ))} +
+
+

Network registration operations

+

+ Automatic network registration is available only through the audited R2 prepare, confirm, + and execute flow. Manual registration and monitoring controls remain unavailable. This + read-only panel does not bypass those gates. +

+
+
+ ); +} diff --git a/apps/web/src/instances/device-network-module.test.tsx b/apps/web/src/instances/device-network-module.test.tsx new file mode 100644 index 0000000..fa06740 --- /dev/null +++ b/apps/web/src/instances/device-network-module.test.tsx @@ -0,0 +1,258 @@ +// @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 { + DeviceNetworkModule, + type DeviceNetworkDataSource, + type DeviceNetworkSnapshot, +} from './device-network-module.js'; + +afterEach(cleanup); + +const owner: InstanceContext = { + id: 'alpha', + name: 'Alpha', + origin: 'https://alpha.example', + status: 'online', + authentication: 'authenticated', + freshness: 'fresh', +}; + +const snapshot: DeviceNetworkSnapshot = { + observedAt: '2026-07-17T11:00:00Z', + wlan: { + status: { + enabled: true, + radioState: 'on', + connectionState: 'connected', + activeProfile: 'office', + ssid: 'Operations Wi-Fi', + }, + profiles: [ + { + name: 'office', + ssid: 'Operations Wi-Fi', + security: 'WPA3', + enabled: true, + priority: 1, + // Deliberate excess field: the module must only render its bounded allowlist. + password: 'never-render-this', + }, + ], + }, + interfaces: [ + { + name: 'wlan0', + kind: 'wireless', + state: 'up', + macAddress: '00:11:22:33:44:55', + mtu: 1500, + addresses: [{ family: 'IPv4', address: '192.0.2.10', prefixLength: 24, scope: 'global' }], + }, + ], + ddns: { + status: { + enabled: true, + state: 'updated', + lastUpdateAt: '2026-07-17T10:58:00Z', + }, + config: { + provider: 'Example DNS', + hostname: 'gateway.example.test', + updateIntervalSeconds: 300, + // Deliberate excess fields must not escape into the DOM. + username: 'private-user', + password: 'private-password', + }, + logSummary: { + totalEntries: 12, + successfulUpdates: 11, + failedUpdates: 1, + lastEventAt: '2026-07-17T10:58:00Z', + }, + }, +}; + +function deferredSource() { + let resolve!: (value: DeviceNetworkSnapshot) => 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: DeviceNetworkSnapshot) => resolve(value), + reject: (reason: unknown) => reject(reason), + }; +} + +describe('Phase 6.3 Device Network read module', () => { + it('loads through the injected owner source and renders structured safe read data', async () => { + const pending = deferredSource(); + render(); + + expect(screen.getByRole('status', { name: 'Device Network loading status' })).toBeTruthy(); + expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal)); + pending.resolve(snapshot); + + expect(await screen.findByRole('heading', { name: 'WLAN status' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'WLAN profiles' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'Interfaces and addresses' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'DDNS status' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'DDNS configuration' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'DDNS log summary' })).toBeTruthy(); + expect( + within(screen.getByRole('region', { name: 'WLAN profiles' })).getByText('WPA3'), + ).toBeTruthy(); + expect(screen.getByText('192.0.2.10/24')).toBeTruthy(); + expect(screen.getByText('gateway.example.test')).toBeTruthy(); + expect(screen.getByText(/Observed 2026-07-17T11:00:00Z/)).toBeTruthy(); + }); + + it('does not render passwords, credentials, arbitrary fields, log bodies, or write controls', async () => { + render( snapshot }} />); + expect((await screen.findAllByText('Operations Wi-Fi')).length).toBe(2); + expect(document.body.textContent).not.toContain('never-render-this'); + expect(document.body.textContent).not.toContain('private-user'); + expect(document.body.textContent).not.toContain('private-password'); + expect(screen.queryByText(/password/i)).toBeNull(); + expect(screen.queryByRole('button')).toBeNull(); + expect(screen.getByText(/R1.*unavailable.*executable backend support/i)).toBeTruthy(); + expect(screen.getByText(/R2.*unavailable.*executable backend support/i)).toBeTruthy(); + }); + + it('is honest when no source is injected and does not invent an endpoint', () => { + render(); + expect(screen.getByRole('status', { name: 'Device Network unavailable' }).textContent).toMatch( + /no safe device network read data source.*uncontracted production endpoint/i, + ); + }); + + it('requires authentication without calling the source or exposing old owner data', async () => { + const load = vi.fn().mockResolvedValue(snapshot); + const { rerender } = render(); + expect(await screen.findByText('gateway.example.test')).toBeTruthy(); + + rerender( + , + ); + expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i); + expect(screen.queryByText('gateway.example.test')).toBeNull(); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('uses a fixed safe error and retries the injected source', async () => { + const user = userEvent.setup(); + const load = vi + .fn() + .mockRejectedValueOnce(new Error('secret upstream URL and credential')) + .mockResolvedValueOnce(snapshot); + render(); + + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Device Network data could not be loaded.'); + expect(alert.textContent).not.toContain('secret upstream'); + await user.click(screen.getByRole('button', { name: 'Retry loading Device Network' })); + expect(await screen.findByText('gateway.example.test')).toBeTruthy(); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('retains the same owner last good snapshot when refresh fails', async () => { + const user = userEvent.setup(); + const load = vi + .fn() + .mockResolvedValueOnce(snapshot) + .mockRejectedValueOnce(new Error('unsafe details')) + .mockResolvedValueOnce({ + ...snapshot, + ddns: { ...snapshot.ddns, config: { hostname: 'new.example.test' } }, + }); + const source = { load }; + const { rerender } = render( + , + ); + expect(await screen.findByText('gateway.example.test')).toBeTruthy(); + + rerender(); + expect((await screen.findByRole('alert')).textContent).toMatch(/showing the last known/i); + expect(screen.getByText('gateway.example.test')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: 'Retry loading Device Network' })); + expect(await screen.findByText('new.example.test')).toBeTruthy(); + }); + + it('aborts old reads, fences late owners, and reloads on refreshSignal', 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('gateway.example.test')).toBeNull(); + alpha.resolve({ + ...snapshot, + ddns: { ...snapshot.ddns, config: { hostname: 'late-alpha.example.test' } }, + }); + bravo.resolve({ + ...snapshot, + ddns: { ...snapshot.ddns, config: { hostname: 'bravo.example.test' } }, + }); + expect(await screen.findByText('bravo.example.test')).toBeTruthy(); + expect(screen.queryByText('late-alpha.example.test')).toBeNull(); + + rerender( + , + ); + expect(load).toHaveBeenCalledTimes(3); + }); + + it('marks owner-declared stale data while preserving the structured snapshot', async () => { + render( + snapshot }} + />, + ); + expect( + (await screen.findByRole('status', { name: 'Device Network freshness' })).textContent, + ).toMatch(/stale/i); + expect(screen.getByText('gateway.example.test')).toBeTruthy(); + }); +}); diff --git a/apps/web/src/instances/device-network-module.tsx b/apps/web/src/instances/device-network-module.tsx new file mode 100644 index 0000000..cc6d44b --- /dev/null +++ b/apps/web/src/instances/device-network-module.tsx @@ -0,0 +1,356 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; + +import type { InstanceContext } from '../app-shell.js'; + +/** Only these bounded primitives may cross the injected read boundary into the UI. */ +export type DeviceNetworkValue = string | number | boolean | null; + +export interface WlanStatus { + readonly enabled?: boolean | null; + readonly radioState?: string | null; + readonly connectionState?: string | null; + readonly activeProfile?: string | null; + readonly ssid?: string | null; +} + +export interface WlanProfile { + readonly name?: string | null; + readonly ssid?: string | null; + readonly security?: string | null; + readonly enabled?: boolean | null; + readonly priority?: number | null; +} + +export interface NetworkAddress { + readonly family?: string | null; + readonly address?: string | null; + readonly prefixLength?: number | null; + readonly scope?: string | null; +} + +export interface NetworkInterface { + readonly name?: string | null; + readonly kind?: string | null; + readonly state?: string | null; + readonly macAddress?: string | null; + readonly mtu?: number | null; + readonly addresses: readonly NetworkAddress[]; +} + +export interface DdnsStatus { + readonly enabled?: boolean | null; + readonly state?: string | null; + readonly lastUpdateAt?: string | null; +} + +/** Credentials are intentionally absent. Do not add password, token, secret, or username fields. */ +export interface DdnsConfig { + readonly provider?: string | null; + readonly hostname?: string | null; + readonly updateIntervalSeconds?: number | null; +} + +/** This is aggregate metadata only; raw DDNS log messages are intentionally unsupported. */ +export interface DdnsLogSummary { + readonly totalEntries?: number | null; + readonly successfulUpdates?: number | null; + readonly failedUpdates?: number | null; + readonly lastEventAt?: string | null; +} + +export interface DeviceNetworkSnapshot { + readonly observedAt?: string; + readonly wlan: { + readonly status: WlanStatus; + readonly profiles: readonly WlanProfile[]; + }; + readonly interfaces: readonly NetworkInterface[]; + readonly ddns: { + readonly status: DdnsStatus; + readonly config: DdnsConfig; + readonly logSummary: DdnsLogSummary; + }; +} + +export interface DeviceNetworkDataSource { + /** Implementations are injected by the owner; this module defines no production endpoint. */ + load(instanceId: string, signal: AbortSignal): Promise; +} + +export interface DeviceNetworkModuleProps { + readonly instance: InstanceContext; + readonly dataSource?: DeviceNetworkDataSource; + /** Change this owner-provided value to request another read. */ + readonly refreshSignal?: unknown; +} + +type ReadState = + | { kind: 'idle'; ownerId: string } + | { kind: 'loading'; ownerId: string; snapshot?: DeviceNetworkSnapshot } + | { kind: 'ready'; ownerId: string; snapshot: DeviceNetworkSnapshot } + | { kind: 'error'; ownerId: string; snapshot?: DeviceNetworkSnapshot }; + +const SAFE_LOAD_ERROR = 'Device Network data could not be loaded.'; + +function display(value: DeviceNetworkValue | undefined): string { + return value == null ? 'Unavailable' : String(value); +} + +function Fields({ + values, +}: { + values: readonly (readonly [label: string, value: DeviceNetworkValue | undefined])[]; +}) { + return ( +
+ {values.map(([label, value]) => ( +
+
{label}
+
{display(value)}
+
+ ))} +
+ ); +} + +function Section({ label, children }: { label: string; children: ReactNode }) { + return ( +
+

{label}

+ {children} +
+ ); +} + +function SnapshotView({ snapshot }: { snapshot: DeviceNetworkSnapshot }) { + const status = snapshot.wlan.status; + const ddnsStatus = snapshot.ddns.status; + const config = snapshot.ddns.config; + const logs = snapshot.ddns.logSummary; + return ( + <> + {snapshot.observedAt ?

Observed {snapshot.observedAt}

: null} +
+
+ +
+
+ {snapshot.wlan.profiles.length ? ( + snapshot.wlan.profiles.map((profile, index) => ( +
+ +
+ )) + ) : ( +

No WLAN profiles were supplied.

+ )} +
+
+ {snapshot.interfaces.length ? ( + snapshot.interfaces.map((networkInterface, index) => ( +
+ +

Addresses

+ {networkInterface.addresses.length ? ( +
    + {networkInterface.addresses.map((address, addressIndex) => ( +
  • + +
  • + ))} +
+ ) : ( +

No addresses were supplied.

+ )} +
+ )) + ) : ( +

No network interfaces were supplied.

+ )} +
+
+ +
+
+ +
+
+ +
+
+
+

Device Network actions

+

R1 configuration actions are unavailable until executable backend support exists.

+

R2 operational actions are unavailable until executable backend support exists.

+
+ + ); +} + +export function DeviceNetworkModule({ + instance, + dataSource, + refreshSignal, +}: DeviceNetworkModuleProps) { + 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 Device Network data can be read for this instance. +
+ ); + } + + if (!dataSource) + return ( +
+ No safe Device Network 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 Device Network… +

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

Unable to load Device Network: {SAFE_LOAD_ERROR}

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

Refreshing Device Network…

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

Refresh failed; showing the last known Device Network data.

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

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

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