feat(web): add cellular and network module slices
This commit is contained in:
@@ -6,8 +6,13 @@ import { createEventStreamClient, type EventStreamClient } from './events/event-
|
|||||||
|
|
||||||
import { FleetPage, type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
import { FleetPage, type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
||||||
import { createFleetApiDataSource } from './fleet/fleet-api-data-source.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 { OverviewSystemPage, type OverviewDataSource } from './instances/overview-system.js';
|
||||||
|
import { InstanceEditor, type InstanceDataSource } from './instances/instance-crud.js';
|
||||||
import {
|
import {
|
||||||
InstanceDetail,
|
InstanceDetail,
|
||||||
INSTANCE_MODULE_LABELS,
|
INSTANCE_MODULE_LABELS,
|
||||||
@@ -63,6 +68,8 @@ export interface AppShellProps {
|
|||||||
capabilities?: InstanceCapabilityMap;
|
capabilities?: InstanceCapabilityMap;
|
||||||
capabilityDataSource?: CapabilityDataSource;
|
capabilityDataSource?: CapabilityDataSource;
|
||||||
overviewDataSource?: OverviewDataSource;
|
overviewDataSource?: OverviewDataSource;
|
||||||
|
cellularDataSource?: CellularDataSource;
|
||||||
|
deviceNetworkDataSource?: DeviceNetworkDataSource;
|
||||||
eventStreamClient?: EventStreamClient;
|
eventStreamClient?: EventStreamClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +138,8 @@ function Page({
|
|||||||
capabilities,
|
capabilities,
|
||||||
capabilityDataSource,
|
capabilityDataSource,
|
||||||
overviewDataSource,
|
overviewDataSource,
|
||||||
|
cellularDataSource,
|
||||||
|
deviceNetworkDataSource,
|
||||||
fleetRefreshSignal,
|
fleetRefreshSignal,
|
||||||
detailRefreshSignal,
|
detailRefreshSignal,
|
||||||
}: {
|
}: {
|
||||||
@@ -142,6 +151,8 @@ function Page({
|
|||||||
capabilities: InstanceCapabilityMap | undefined;
|
capabilities: InstanceCapabilityMap | undefined;
|
||||||
capabilityDataSource: CapabilityDataSource | undefined;
|
capabilityDataSource: CapabilityDataSource | undefined;
|
||||||
overviewDataSource: OverviewDataSource | undefined;
|
overviewDataSource: OverviewDataSource | undefined;
|
||||||
|
cellularDataSource: CellularDataSource | undefined;
|
||||||
|
deviceNetworkDataSource: DeviceNetworkDataSource | undefined;
|
||||||
fleetRefreshSignal: number;
|
fleetRefreshSignal: number;
|
||||||
detailRefreshSignal: number;
|
detailRefreshSignal: number;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
@@ -196,6 +207,28 @@ function Page({
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
|
{...(module === 'cellular' && instance
|
||||||
|
? {
|
||||||
|
moduleContent: (
|
||||||
|
<CellularModule
|
||||||
|
instance={instance}
|
||||||
|
{...(cellularDataSource ? { dataSource: cellularDataSource } : {})}
|
||||||
|
refreshSignal={detailRefreshSignal}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
{...(module === 'device-network' && instance
|
||||||
|
? {
|
||||||
|
moduleContent: (
|
||||||
|
<DeviceNetworkModule
|
||||||
|
instance={instance}
|
||||||
|
{...(deviceNetworkDataSource ? { dataSource: deviceNetworkDataSource } : {})}
|
||||||
|
refreshSignal={detailRefreshSignal}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -227,6 +260,8 @@ export function AppShell({
|
|||||||
capabilities,
|
capabilities,
|
||||||
capabilityDataSource,
|
capabilityDataSource,
|
||||||
overviewDataSource,
|
overviewDataSource,
|
||||||
|
cellularDataSource,
|
||||||
|
deviceNetworkDataSource,
|
||||||
eventStreamClient,
|
eventStreamClient,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
|
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
|
||||||
@@ -280,6 +315,8 @@ export function AppShell({
|
|||||||
capabilities={capabilities}
|
capabilities={capabilities}
|
||||||
capabilityDataSource={capabilityDataSource}
|
capabilityDataSource={capabilityDataSource}
|
||||||
overviewDataSource={overviewDataSource}
|
overviewDataSource={overviewDataSource}
|
||||||
|
cellularDataSource={cellularDataSource}
|
||||||
|
deviceNetworkDataSource={deviceNetworkDataSource}
|
||||||
fleetRefreshSignal={refresh.fleet}
|
fleetRefreshSignal={refresh.fleet}
|
||||||
detailRefreshSignal={refresh.detail}
|
detailRefreshSignal={refresh.detail}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -73,4 +73,22 @@ export {
|
|||||||
type PreparedOperation,
|
type PreparedOperation,
|
||||||
} from './operations/operation-client.js';
|
} 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;
|
export const webWorkspaceReady = true;
|
||||||
|
|||||||
@@ -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<CellularDataSource['load']>(
|
||||||
|
(_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(<CellularModule instance={owner} dataSource={pending.source} />);
|
||||||
|
|
||||||
|
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(<CellularModule instance={owner} />);
|
||||||
|
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<CellularDataSource['load']>().mockResolvedValue(snapshot);
|
||||||
|
const { rerender } = render(<CellularModule instance={owner} dataSource={{ load }} />);
|
||||||
|
expect(await screen.findByText('-71 dBm')).toBeTruthy();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<CellularModule
|
||||||
|
instance={{
|
||||||
|
...owner,
|
||||||
|
id: 'bravo',
|
||||||
|
name: 'Bravo',
|
||||||
|
status: 'auth-required',
|
||||||
|
authentication: 'auth-required',
|
||||||
|
}}
|
||||||
|
dataSource={{ load }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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<CellularDataSource['load']>()
|
||||||
|
.mockResolvedValueOnce(snapshot)
|
||||||
|
.mockRejectedValueOnce(new Error('secret upstream details'))
|
||||||
|
.mockResolvedValueOnce({ ...snapshot, signal: { rssi: '-65 dBm' } });
|
||||||
|
const source = { load };
|
||||||
|
const { rerender } = render(
|
||||||
|
<CellularModule instance={owner} dataSource={source} refreshSignal={0} />,
|
||||||
|
);
|
||||||
|
expect(await screen.findByText('-71 dBm')).toBeTruthy();
|
||||||
|
|
||||||
|
rerender(<CellularModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||||
|
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<CellularDataSource['load']>()
|
||||||
|
.mockRejectedValueOnce('private failure')
|
||||||
|
.mockResolvedValueOnce(snapshot);
|
||||||
|
render(<CellularModule instance={owner} dataSource={{ load }} />);
|
||||||
|
|
||||||
|
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<CellularDataSource['load']>((instanceId, signal) =>
|
||||||
|
instanceId === 'alpha'
|
||||||
|
? alpha.source.load(instanceId, signal)
|
||||||
|
: bravo.source.load(instanceId, signal),
|
||||||
|
);
|
||||||
|
const source = { load };
|
||||||
|
const { rerender } = render(<CellularModule instance={owner} dataSource={source} />);
|
||||||
|
const alphaSignal = load.mock.calls[0]?.[1];
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<CellularModule instance={{ ...owner, id: 'bravo', name: 'Bravo' }} dataSource={source} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<CellularModule
|
||||||
|
instance={owner}
|
||||||
|
dataSource={{
|
||||||
|
load: async () => ({
|
||||||
|
...snapshot,
|
||||||
|
operators: { current: '<img src=x onerror=alert(1)>' },
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(await screen.findByText('<img src=x onerror=alert(1)>')).toBeTruthy();
|
||||||
|
expect(document.querySelector('img')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<CellularSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Record<string, CellularFieldValue | undefined>>;
|
||||||
|
const entries = fields.filter(([, key]) => safeValues[key] !== undefined);
|
||||||
|
return (
|
||||||
|
<section className="cellular-card" aria-label={label}>
|
||||||
|
<h2>{label}</h2>
|
||||||
|
{entries.length ? (
|
||||||
|
<dl>
|
||||||
|
{entries.map(([labelText, fieldKey]) => {
|
||||||
|
const value = safeValues[fieldKey];
|
||||||
|
return (
|
||||||
|
<div key={fieldKey}>
|
||||||
|
<dt>{labelText}</dt>
|
||||||
|
<dd>{value == null ? 'Unavailable' : String(value)}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dl>
|
||||||
|
) : (
|
||||||
|
<p>No {label.toLocaleLowerCase()} data was supplied.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CellularModule({ instance, dataSource, refreshSignal }: CellularModuleProps) {
|
||||||
|
const requestOwner = useRef(0);
|
||||||
|
const [retry, setRetry] = useState(0);
|
||||||
|
const [state, setState] = useState<ReadState>({ 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 (
|
||||||
|
<div className="state-panel state-error" role="alert">
|
||||||
|
Authentication is required before cellular data can be read for this instance.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dataSource) {
|
||||||
|
return (
|
||||||
|
<div className="state-panel" role="status" aria-label="Cellular unavailable">
|
||||||
|
No safe cellular read data source is available. This console will not invent or call an
|
||||||
|
uncontracted production endpoint.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownedSnapshot =
|
||||||
|
state.kind !== 'idle' && state.snapshot?.ownerId === instance.id ? state.snapshot : undefined;
|
||||||
|
|
||||||
|
if ((state.kind === 'idle' || state.kind === 'loading') && !ownedSnapshot) {
|
||||||
|
return (
|
||||||
|
<p role="status" aria-label="Cellular loading status">
|
||||||
|
Loading cellular data…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.kind === 'error' && !ownedSnapshot) {
|
||||||
|
return (
|
||||||
|
<div className="state-panel state-error" role="alert">
|
||||||
|
<p>Unable to load cellular data: {state.message}</p>
|
||||||
|
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||||
|
Retry loading cellular data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ownedSnapshot) return null;
|
||||||
|
const snapshot = ownedSnapshot.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cellular-module">
|
||||||
|
{state.kind === 'loading' ? <p role="status">Refreshing cellular data…</p> : null}
|
||||||
|
{state.kind === 'error' ? (
|
||||||
|
<div className="state-panel state-error" role="alert">
|
||||||
|
<p>Refresh failed; showing the last known cellular data.</p>
|
||||||
|
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||||
|
Retry loading cellular data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{instance.freshness !== 'fresh' ? (
|
||||||
|
<p className="state-panel" role="status" aria-label="Cellular freshness">
|
||||||
|
Cellular data is {instance.freshness}; verify freshness before relying on these values.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null}
|
||||||
|
<div className="cellular-grid">
|
||||||
|
{SECTIONS.map(([key, label, fields]) => (
|
||||||
|
<StructuredSection key={key} label={label} values={snapshot[key]} fields={fields} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<section className="state-panel" aria-label="Network registration operations">
|
||||||
|
<h2>Network registration operations</h2>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<DeviceNetworkDataSource['load']>(
|
||||||
|
(_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(<DeviceNetworkModule instance={owner} dataSource={pending.source} />);
|
||||||
|
|
||||||
|
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(<DeviceNetworkModule instance={owner} dataSource={{ load: async () => 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(<DeviceNetworkModule instance={owner} />);
|
||||||
|
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<DeviceNetworkDataSource['load']>().mockResolvedValue(snapshot);
|
||||||
|
const { rerender } = render(<DeviceNetworkModule instance={owner} dataSource={{ load }} />);
|
||||||
|
expect(await screen.findByText('gateway.example.test')).toBeTruthy();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<DeviceNetworkModule
|
||||||
|
instance={{
|
||||||
|
...owner,
|
||||||
|
id: 'bravo',
|
||||||
|
name: 'Bravo',
|
||||||
|
status: 'auth-required',
|
||||||
|
authentication: 'auth-required',
|
||||||
|
}}
|
||||||
|
dataSource={{ load }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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<DeviceNetworkDataSource['load']>()
|
||||||
|
.mockRejectedValueOnce(new Error('secret upstream URL and credential'))
|
||||||
|
.mockResolvedValueOnce(snapshot);
|
||||||
|
render(<DeviceNetworkModule instance={owner} dataSource={{ load }} />);
|
||||||
|
|
||||||
|
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<DeviceNetworkDataSource['load']>()
|
||||||
|
.mockResolvedValueOnce(snapshot)
|
||||||
|
.mockRejectedValueOnce(new Error('unsafe details'))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
...snapshot,
|
||||||
|
ddns: { ...snapshot.ddns, config: { hostname: 'new.example.test' } },
|
||||||
|
});
|
||||||
|
const source = { load };
|
||||||
|
const { rerender } = render(
|
||||||
|
<DeviceNetworkModule instance={owner} dataSource={source} refreshSignal={0} />,
|
||||||
|
);
|
||||||
|
expect(await screen.findByText('gateway.example.test')).toBeTruthy();
|
||||||
|
|
||||||
|
rerender(<DeviceNetworkModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||||
|
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<DeviceNetworkDataSource['load']>((instanceId, signal) =>
|
||||||
|
instanceId === 'alpha'
|
||||||
|
? alpha.source.load(instanceId, signal)
|
||||||
|
: bravo.source.load(instanceId, signal),
|
||||||
|
);
|
||||||
|
const source = { load };
|
||||||
|
const { rerender } = render(
|
||||||
|
<DeviceNetworkModule instance={owner} dataSource={source} refreshSignal={0} />,
|
||||||
|
);
|
||||||
|
const alphaSignal = load.mock.calls[0]?.[1];
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<DeviceNetworkModule
|
||||||
|
instance={{ ...owner, id: 'bravo', name: 'Bravo' }}
|
||||||
|
dataSource={source}
|
||||||
|
refreshSignal={0}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<DeviceNetworkModule
|
||||||
|
instance={{ ...owner, id: 'bravo', name: 'Bravo' }}
|
||||||
|
dataSource={source}
|
||||||
|
refreshSignal={1}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(load).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks owner-declared stale data while preserving the structured snapshot', async () => {
|
||||||
|
render(
|
||||||
|
<DeviceNetworkModule
|
||||||
|
instance={{ ...owner, freshness: 'stale' }}
|
||||||
|
dataSource={{ load: async () => snapshot }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await screen.findByRole('status', { name: 'Device Network freshness' })).textContent,
|
||||||
|
).toMatch(/stale/i);
|
||||||
|
expect(screen.getByText('gateway.example.test')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<DeviceNetworkSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<dl>
|
||||||
|
{values.map(([label, value]) => (
|
||||||
|
<div key={label}>
|
||||||
|
<dt>{label}</dt>
|
||||||
|
<dd>{display(value)}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="device-network-card" aria-label={label}>
|
||||||
|
<h2>{label}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ? <p>Observed {snapshot.observedAt}</p> : null}
|
||||||
|
<div className="device-network-grid">
|
||||||
|
<Section label="WLAN status">
|
||||||
|
<Fields
|
||||||
|
values={[
|
||||||
|
['Enabled', status.enabled],
|
||||||
|
['Radio state', status.radioState],
|
||||||
|
['Connection state', status.connectionState],
|
||||||
|
['Active profile', status.activeProfile],
|
||||||
|
['SSID', status.ssid],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section label="WLAN profiles">
|
||||||
|
{snapshot.wlan.profiles.length ? (
|
||||||
|
snapshot.wlan.profiles.map((profile, index) => (
|
||||||
|
<article key={`${profile.name ?? 'profile'}-${index}`}>
|
||||||
|
<Fields
|
||||||
|
values={[
|
||||||
|
['Name', profile.name],
|
||||||
|
['SSID', profile.ssid],
|
||||||
|
['Security', profile.security],
|
||||||
|
['Enabled', profile.enabled],
|
||||||
|
['Priority', profile.priority],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p>No WLAN profiles were supplied.</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
<Section label="Interfaces and addresses">
|
||||||
|
{snapshot.interfaces.length ? (
|
||||||
|
snapshot.interfaces.map((networkInterface, index) => (
|
||||||
|
<article key={`${networkInterface.name ?? 'interface'}-${index}`}>
|
||||||
|
<Fields
|
||||||
|
values={[
|
||||||
|
['Name', networkInterface.name],
|
||||||
|
['Kind', networkInterface.kind],
|
||||||
|
['State', networkInterface.state],
|
||||||
|
['MAC address', networkInterface.macAddress],
|
||||||
|
['MTU', networkInterface.mtu],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<h3>Addresses</h3>
|
||||||
|
{networkInterface.addresses.length ? (
|
||||||
|
<ul>
|
||||||
|
{networkInterface.addresses.map((address, addressIndex) => (
|
||||||
|
<li key={`${address.address ?? 'address'}-${addressIndex}`}>
|
||||||
|
<Fields
|
||||||
|
values={[
|
||||||
|
['Family', address.family],
|
||||||
|
[
|
||||||
|
'Address',
|
||||||
|
address.address == null
|
||||||
|
? null
|
||||||
|
: address.prefixLength == null
|
||||||
|
? address.address
|
||||||
|
: `${address.address}/${address.prefixLength}`,
|
||||||
|
],
|
||||||
|
['Scope', address.scope],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p>No addresses were supplied.</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p>No network interfaces were supplied.</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
<Section label="DDNS status">
|
||||||
|
<Fields
|
||||||
|
values={[
|
||||||
|
['Enabled', ddnsStatus.enabled],
|
||||||
|
['State', ddnsStatus.state],
|
||||||
|
['Last update', ddnsStatus.lastUpdateAt],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section label="DDNS configuration">
|
||||||
|
<Fields
|
||||||
|
values={[
|
||||||
|
['Provider', config.provider],
|
||||||
|
['Hostname', config.hostname],
|
||||||
|
['Update interval (seconds)', config.updateIntervalSeconds],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section label="DDNS log summary">
|
||||||
|
<Fields
|
||||||
|
values={[
|
||||||
|
['Total entries', logs.totalEntries],
|
||||||
|
['Successful updates', logs.successfulUpdates],
|
||||||
|
['Failed updates', logs.failedUpdates],
|
||||||
|
['Last event', logs.lastEventAt],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
<section className="state-panel" aria-label="Device Network actions">
|
||||||
|
<h2>Device Network actions</h2>
|
||||||
|
<p>R1 configuration actions are unavailable until executable backend support exists.</p>
|
||||||
|
<p>R2 operational actions are unavailable until executable backend support exists.</p>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceNetworkModule({
|
||||||
|
instance,
|
||||||
|
dataSource,
|
||||||
|
refreshSignal,
|
||||||
|
}: DeviceNetworkModuleProps) {
|
||||||
|
const requestOwner = useRef(0);
|
||||||
|
const [retry, setRetry] = useState(0);
|
||||||
|
const [state, setState] = useState<ReadState>({ 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 (
|
||||||
|
<div className="state-panel state-error" role="alert">
|
||||||
|
Authentication is required before Device Network data can be read for this instance.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dataSource)
|
||||||
|
return (
|
||||||
|
<div className="state-panel" role="status" aria-label="Device Network unavailable">
|
||||||
|
No safe Device Network read data source is available. This console will not invent or call
|
||||||
|
an uncontracted production endpoint.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const retainedSnapshot =
|
||||||
|
state.ownerId === instance.id && (state.kind === 'loading' || state.kind === 'error')
|
||||||
|
? state.snapshot
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if ((state.kind === 'idle' || state.kind === 'loading') && !retainedSnapshot)
|
||||||
|
return (
|
||||||
|
<p role="status" aria-label="Device Network loading status">
|
||||||
|
Loading Device Network…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (state.kind === 'error' && !retainedSnapshot)
|
||||||
|
return (
|
||||||
|
<div className="state-panel state-error" role="alert">
|
||||||
|
<p>Unable to load Device Network: {SAFE_LOAD_ERROR}</p>
|
||||||
|
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||||
|
Retry loading Device Network
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentSnapshot =
|
||||||
|
state.kind === 'ready' && state.ownerId === instance.id ? state.snapshot : retainedSnapshot;
|
||||||
|
if (!currentSnapshot) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="device-network-module">
|
||||||
|
{state.kind === 'loading' ? <p role="status">Refreshing Device Network…</p> : null}
|
||||||
|
{state.kind === 'error' ? (
|
||||||
|
<div className="state-panel state-error" role="alert">
|
||||||
|
<p>Refresh failed; showing the last known Device Network data.</p>
|
||||||
|
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||||
|
Retry loading Device Network
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{instance.freshness !== 'fresh' ? (
|
||||||
|
<p className="state-panel" role="status" aria-label="Device Network freshness">
|
||||||
|
Device Network data is {instance.freshness}; verify freshness before relying on these
|
||||||
|
values.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<SnapshotView snapshot={currentSnapshot} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user