357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|