feat(web): add overview system module foundation
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { InstanceContext, InstanceModule } from '../app-shell.js';
|
||||
@@ -22,6 +23,7 @@ export interface InstanceDetailProps {
|
||||
readonly instance?: InstanceContext;
|
||||
readonly capabilities?: InstanceCapabilityMap;
|
||||
readonly capabilityDataSource?: CapabilityDataSource;
|
||||
readonly moduleContent?: ReactNode;
|
||||
}
|
||||
|
||||
export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> = {
|
||||
@@ -48,6 +50,10 @@ function capabilityFor(map: InstanceCapabilityMap, module: InstanceModule): Inst
|
||||
return map[module] ?? { state: 'unknown' };
|
||||
}
|
||||
|
||||
function canRender(capability: InstanceCapability): boolean {
|
||||
return capability.state === 'supported' || capability.state === 'degraded';
|
||||
}
|
||||
|
||||
function explanation(capability: InstanceCapability): string | null {
|
||||
if (capability.state === 'supported') return null;
|
||||
return capability.explanation?.trim() || DEFAULT_EXPLANATIONS[capability.state];
|
||||
@@ -63,6 +69,7 @@ export function InstanceDetail({
|
||||
instance,
|
||||
capabilities,
|
||||
capabilityDataSource,
|
||||
moduleContent,
|
||||
}: InstanceDetailProps) {
|
||||
const ownsRoute = instance?.id === instanceId;
|
||||
const [loadedCapabilities, setLoadedCapabilities] = useState<InstanceCapabilityMap | undefined>();
|
||||
@@ -90,8 +97,9 @@ export function InstanceDetail({
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
void error;
|
||||
if (!controller.signal.aborted && requestRef.current === request) {
|
||||
setLoadError(error instanceof Error ? error.message : 'Capability discovery failed.');
|
||||
setLoadError('Capability discovery failed.');
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
@@ -166,12 +174,20 @@ export function InstanceDetail({
|
||||
<h1>{INSTANCE_MODULE_LABELS[module]}</h1>
|
||||
{loading ? <p role="status">Loading capabilities…</p> : null}
|
||||
{loadError ? <p role="alert">Capabilities unavailable: {loadError}</p> : null}
|
||||
{!loading && activeCapability.state === 'supported' ? (
|
||||
<p>
|
||||
Inspect {INSTANCE_MODULE_LABELS[module].toLowerCase()} data and available operations.
|
||||
</p>
|
||||
{!loading && canRender(activeCapability) ? (
|
||||
<>
|
||||
{activeCapability.state === 'degraded' ? (
|
||||
<p data-capability-state="degraded">{explanation(activeCapability)}</p>
|
||||
) : null}
|
||||
{moduleContent ?? (
|
||||
<p>
|
||||
Inspect {INSTANCE_MODULE_LABELS[module].toLowerCase()} data and available
|
||||
operations.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{!loading && activeCapability.state !== 'supported' ? (
|
||||
{!loading && !canRender(activeCapability) ? (
|
||||
<p data-capability-state={activeCapability.state}>{explanation(activeCapability)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// @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 { AppShell, type InstanceContext } from '../app-shell.js';
|
||||
import {
|
||||
OverviewSystemPage,
|
||||
type OverviewDataSource,
|
||||
type OverviewSnapshot,
|
||||
} from './overview-system.js';
|
||||
import type { InstanceCapabilityMap } from './instance-detail.js';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const owner: InstanceContext = {
|
||||
id: 'alpha',
|
||||
name: 'Alpha',
|
||||
origin: 'https://alpha.example',
|
||||
status: 'online',
|
||||
authentication: 'authenticated',
|
||||
freshness: 'fresh',
|
||||
};
|
||||
const overviewCapability: InstanceCapabilityMap = {
|
||||
overview: { state: 'supported' },
|
||||
};
|
||||
const snapshot: OverviewSnapshot = {
|
||||
observedAt: '2026-07-17T10:00:00Z',
|
||||
device: { Model: 'SIMBox 8', Uptime: '2 days' },
|
||||
sim: { Slots: 8, Active: 6 },
|
||||
network: { Operator: 'Example Mobile', Registration: 'registered' },
|
||||
stats: { 'Messages today': 42, Calls: 7 },
|
||||
cpu: { Usage: '18%', Temperature: '46 °C' },
|
||||
connectivity: { State: 'connected', Latency: '21 ms' },
|
||||
};
|
||||
|
||||
function deferredSource() {
|
||||
let resolve!: (value: OverviewSnapshot) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const load = vi.fn<OverviewDataSource['load']>(
|
||||
(_instanceId, _signal) =>
|
||||
new Promise((done, fail) => {
|
||||
void _instanceId;
|
||||
void _signal;
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
}),
|
||||
);
|
||||
return {
|
||||
source: { load },
|
||||
load,
|
||||
resolve: (value: OverviewSnapshot) => resolve(value),
|
||||
reject: (reason: unknown) => reject(reason),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Phase 6.1 Overview / System read slice', () => {
|
||||
it('loads only through an injected source and renders six structured sections', async () => {
|
||||
const pending = deferredSource();
|
||||
render(
|
||||
<AppShell
|
||||
pathname="/instances/alpha/overview"
|
||||
instance={owner}
|
||||
capabilities={overviewCapability}
|
||||
overviewDataSource={pending.source}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Overview loading status' }).textContent).toContain(
|
||||
'Loading overview',
|
||||
);
|
||||
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
|
||||
pending.resolve(snapshot);
|
||||
|
||||
for (const heading of ['Device', 'SIM', 'Network', 'Statistics', 'CPU', 'Connectivity']) {
|
||||
expect(await screen.findByRole('heading', { name: heading })).toBeTruthy();
|
||||
}
|
||||
expect(
|
||||
within(screen.getByRole('region', { name: 'Device' })).getByText('SIMBox 8'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(/Observed 2026-07-17T10:00:00Z/)).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /restart/i })).toBeNull();
|
||||
expect(screen.getByText(/Restart is unavailable.*R3/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('is honest when no safe read source is injected and never attempts a production endpoint', () => {
|
||||
render(
|
||||
<AppShell
|
||||
pathname="/instances/alpha/overview"
|
||||
instance={owner}
|
||||
capabilities={overviewCapability}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('status', { name: 'Overview unavailable' }).textContent).toMatch(
|
||||
/no safe overview read data source/i,
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /restart/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows authentication-required without loading or leaking prior owner data', async () => {
|
||||
const load = vi.fn<OverviewDataSource['load']>().mockResolvedValue(snapshot);
|
||||
const { rerender } = render(<OverviewSystemPage instance={owner} dataSource={{ load }} />);
|
||||
expect(await screen.findByText('SIMBox 8')).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<OverviewSystemPage
|
||||
instance={{
|
||||
...owner,
|
||||
id: 'bravo',
|
||||
name: 'Bravo',
|
||||
authentication: 'auth-required',
|
||||
status: 'auth-required',
|
||||
}}
|
||||
dataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i);
|
||||
expect(screen.queryByText('SIMBox 8')).toBeNull();
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks injected data stale from owner context while retaining structured values', async () => {
|
||||
render(
|
||||
<OverviewSystemPage
|
||||
instance={{ ...owner, freshness: 'stale' }}
|
||||
dataSource={{ load: async () => snapshot }}
|
||||
/>,
|
||||
);
|
||||
expect((await screen.findByRole('status', { name: 'Overview freshness' })).textContent).toMatch(
|
||||
/stale/i,
|
||||
);
|
||||
expect(screen.getByText('SIMBox 8')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces read errors and supports a real injected retry', async () => {
|
||||
const user = userEvent.setup();
|
||||
const load = vi
|
||||
.fn<OverviewDataSource['load']>()
|
||||
.mockRejectedValueOnce(new Error('collector unavailable'))
|
||||
.mockResolvedValueOnce(snapshot);
|
||||
render(<OverviewSystemPage instance={owner} dataSource={{ load }} />);
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||
'Overview data could not be loaded.',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading overview' }));
|
||||
expect(await screen.findByText('SIMBox 8')).toBeTruthy();
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('aborts old reads, fences late owners, and reloads on refreshSignal', async () => {
|
||||
const alpha = deferredSource();
|
||||
const bravo = deferredSource();
|
||||
const load = vi.fn<OverviewDataSource['load']>((instanceId, signal) =>
|
||||
instanceId === 'alpha'
|
||||
? alpha.source.load(instanceId, signal)
|
||||
: bravo.source.load(instanceId, signal),
|
||||
);
|
||||
const source = { load };
|
||||
const { rerender } = render(
|
||||
<OverviewSystemPage instance={owner} dataSource={source} refreshSignal={0} />,
|
||||
);
|
||||
const alphaSignal = load.mock.calls[0]?.[1];
|
||||
|
||||
rerender(
|
||||
<OverviewSystemPage
|
||||
instance={{ ...owner, id: 'bravo', name: 'Bravo' }}
|
||||
dataSource={source}
|
||||
refreshSignal={0}
|
||||
/>,
|
||||
);
|
||||
expect(alphaSignal?.aborted).toBe(true);
|
||||
alpha.resolve({ ...snapshot, device: { Model: 'Alpha stale' } });
|
||||
bravo.resolve({ ...snapshot, device: { Model: 'Bravo current' } });
|
||||
expect(await screen.findByText('Bravo current')).toBeTruthy();
|
||||
expect(screen.queryByText('Alpha stale')).toBeNull();
|
||||
|
||||
rerender(
|
||||
<OverviewSystemPage
|
||||
instance={{ ...owner, id: 'bravo', name: 'Bravo' }}
|
||||
dataSource={source}
|
||||
refreshSignal={1}
|
||||
/>,
|
||||
);
|
||||
expect(load).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('keeps unsupported capability explicit and does not invoke the overview source', () => {
|
||||
const load = vi.fn<OverviewDataSource['load']>();
|
||||
render(
|
||||
<AppShell
|
||||
pathname="/instances/alpha/overview"
|
||||
instance={owner}
|
||||
capabilities={{
|
||||
overview: { state: 'unsupported', explanation: 'No overview read support' },
|
||||
}}
|
||||
overviewDataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByText('No overview read support').length).toBeGreaterThan(0);
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.js';
|
||||
|
||||
export type OverviewFieldValue = string | number | boolean | null;
|
||||
export type OverviewSection = Readonly<Record<string, OverviewFieldValue>>;
|
||||
|
||||
export interface OverviewSnapshot {
|
||||
readonly observedAt?: string;
|
||||
readonly device: OverviewSection;
|
||||
readonly sim: OverviewSection;
|
||||
readonly network: OverviewSection;
|
||||
readonly stats: OverviewSection;
|
||||
readonly cpu: OverviewSection;
|
||||
readonly connectivity: OverviewSection;
|
||||
}
|
||||
|
||||
export interface OverviewDataSource {
|
||||
/** Implementations are injected by the application owner; this component has no production endpoint. */
|
||||
load(instanceId: string, signal: AbortSignal): Promise<OverviewSnapshot>;
|
||||
}
|
||||
|
||||
export interface OverviewSystemPageProps {
|
||||
readonly instance: InstanceContext;
|
||||
readonly dataSource?: OverviewDataSource;
|
||||
/** Change this value when an external owner has requested a refresh. */
|
||||
readonly refreshSignal?: unknown;
|
||||
}
|
||||
|
||||
type ReadState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading'; snapshot?: OverviewSnapshot }
|
||||
| { kind: 'ready'; snapshot: OverviewSnapshot }
|
||||
| { kind: 'error'; message: string; snapshot?: OverviewSnapshot };
|
||||
|
||||
const SECTIONS = [
|
||||
['device', 'Device'],
|
||||
['sim', 'SIM'],
|
||||
['network', 'Network'],
|
||||
['stats', 'Statistics'],
|
||||
['cpu', 'CPU'],
|
||||
['connectivity', 'Connectivity'],
|
||||
] as const;
|
||||
|
||||
function StructuredSection({ label, values }: { label: string; values: OverviewSection }) {
|
||||
const entries = Object.entries(values);
|
||||
return (
|
||||
<section className="overview-card" aria-label={label}>
|
||||
<h2>{label}</h2>
|
||||
{entries.length ? (
|
||||
<dl>
|
||||
{entries.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<dt>{key}</dt>
|
||||
<dd>{value == null ? 'Unavailable' : String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : (
|
||||
<p>No {label.toLocaleLowerCase()} data was supplied.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewSystemPage({
|
||||
instance,
|
||||
dataSource,
|
||||
refreshSignal,
|
||||
}: OverviewSystemPageProps) {
|
||||
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 === 'auth-required') {
|
||||
setState({ kind: 'idle' });
|
||||
return () => controller.abort();
|
||||
}
|
||||
if (!dataSource) {
|
||||
setState({ kind: 'idle' });
|
||||
return () => controller.abort();
|
||||
}
|
||||
|
||||
setState((current) => ({
|
||||
kind: 'loading',
|
||||
...(current.kind === 'ready' || current.kind === 'error'
|
||||
? current.snapshot
|
||||
? { snapshot: current.snapshot }
|
||||
: {}
|
||||
: {}),
|
||||
}));
|
||||
void dataSource.load(instance.id, controller.signal).then(
|
||||
(snapshot) => {
|
||||
if (request === requestOwner.current && !controller.signal.aborted)
|
||||
setState({ kind: 'ready', snapshot });
|
||||
},
|
||||
(reason: unknown) => {
|
||||
void reason;
|
||||
if (request === requestOwner.current && !controller.signal.aborted)
|
||||
setState((current) => ({
|
||||
kind: 'error',
|
||||
message: 'Overview data could not be loaded.',
|
||||
...(current.kind === 'loading' && current.snapshot
|
||||
? { snapshot: current.snapshot }
|
||||
: {}),
|
||||
}));
|
||||
},
|
||||
);
|
||||
return () => controller.abort();
|
||||
}, [dataSource, instance.authentication, instance.id, refreshSignal, retry]);
|
||||
|
||||
if (instance.authentication === 'auth-required')
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
Authentication is required before overview data can be read for this instance.
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!dataSource)
|
||||
return (
|
||||
<div className="state-panel" role="status" aria-label="Overview unavailable">
|
||||
No safe overview read data source is available. This console will not invent or call an
|
||||
uncontracted production endpoint.
|
||||
</div>
|
||||
);
|
||||
|
||||
const retainedSnapshot =
|
||||
state.kind === 'loading' || state.kind === 'error' ? state.snapshot : undefined;
|
||||
|
||||
if ((state.kind === 'loading' || state.kind === 'idle') && !retainedSnapshot)
|
||||
return (
|
||||
<p role="status" aria-label="Overview loading status">
|
||||
Loading overview…
|
||||
</p>
|
||||
);
|
||||
|
||||
if (state.kind === 'error' && !state.snapshot)
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Unable to load overview: {state.message}</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading overview
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const snapshot = state.kind === 'ready' ? state.snapshot : retainedSnapshot;
|
||||
if (!snapshot) return null;
|
||||
|
||||
return (
|
||||
<div className="overview-system">
|
||||
{state.kind === 'loading' ? <p role="status">Refreshing overview…</p> : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Refresh failed; showing the last known overview.</p>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||
Retry loading overview
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{instance.freshness !== 'fresh' ? (
|
||||
<p className="state-panel" role="status" aria-label="Overview freshness">
|
||||
Overview data is {instance.freshness}; verify freshness before relying on these values.
|
||||
</p>
|
||||
) : null}
|
||||
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null}
|
||||
<div className="overview-grid">
|
||||
{SECTIONS.map(([key, label]) => (
|
||||
<StructuredSection key={key} label={label} values={snapshot[key]} />
|
||||
))}
|
||||
</div>
|
||||
<section className="state-panel" aria-label="System actions">
|
||||
<h2>System actions</h2>
|
||||
<p>
|
||||
Restart is unavailable because the backend R3 restart operation is not implemented. No
|
||||
action is offered.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user