feat(web): add overview system module foundation
This commit is contained in:
@@ -7,6 +7,7 @@ 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 { InstanceEditor, type InstanceDataSource } from './instances/instance-crud.js';
|
||||||
|
import { OverviewSystemPage, type OverviewDataSource } from './instances/overview-system.js';
|
||||||
import {
|
import {
|
||||||
InstanceDetail,
|
InstanceDetail,
|
||||||
INSTANCE_MODULE_LABELS,
|
INSTANCE_MODULE_LABELS,
|
||||||
@@ -61,6 +62,7 @@ export interface AppShellProps {
|
|||||||
instanceDataSource?: InstanceDataSource;
|
instanceDataSource?: InstanceDataSource;
|
||||||
capabilities?: InstanceCapabilityMap;
|
capabilities?: InstanceCapabilityMap;
|
||||||
capabilityDataSource?: CapabilityDataSource;
|
capabilityDataSource?: CapabilityDataSource;
|
||||||
|
overviewDataSource?: OverviewDataSource;
|
||||||
eventStreamClient?: EventStreamClient;
|
eventStreamClient?: EventStreamClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +130,9 @@ function Page({
|
|||||||
instanceDataSource,
|
instanceDataSource,
|
||||||
capabilities,
|
capabilities,
|
||||||
capabilityDataSource,
|
capabilityDataSource,
|
||||||
|
overviewDataSource,
|
||||||
fleetRefreshSignal,
|
fleetRefreshSignal,
|
||||||
|
detailRefreshSignal,
|
||||||
}: {
|
}: {
|
||||||
route: ResolvedRoute;
|
route: ResolvedRoute;
|
||||||
fleetDataSource: FleetDataSource | undefined;
|
fleetDataSource: FleetDataSource | undefined;
|
||||||
@@ -137,7 +141,9 @@ function Page({
|
|||||||
instanceDataSource: InstanceDataSource | undefined;
|
instanceDataSource: InstanceDataSource | undefined;
|
||||||
capabilities: InstanceCapabilityMap | undefined;
|
capabilities: InstanceCapabilityMap | undefined;
|
||||||
capabilityDataSource: CapabilityDataSource | undefined;
|
capabilityDataSource: CapabilityDataSource | undefined;
|
||||||
|
overviewDataSource: OverviewDataSource | undefined;
|
||||||
fleetRefreshSignal: number;
|
fleetRefreshSignal: number;
|
||||||
|
detailRefreshSignal: number;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
if (route.kind === 'fleet')
|
if (route.kind === 'fleet')
|
||||||
return (
|
return (
|
||||||
@@ -179,6 +185,17 @@ function Page({
|
|||||||
{...(instance ? { instance } : {})}
|
{...(instance ? { instance } : {})}
|
||||||
{...(capabilities ? { capabilities } : {})}
|
{...(capabilities ? { capabilities } : {})}
|
||||||
{...(capabilityDataSource ? { capabilityDataSource } : {})}
|
{...(capabilityDataSource ? { capabilityDataSource } : {})}
|
||||||
|
{...(module === 'overview' && instance
|
||||||
|
? {
|
||||||
|
moduleContent: (
|
||||||
|
<OverviewSystemPage
|
||||||
|
instance={instance}
|
||||||
|
{...(overviewDataSource ? { dataSource: overviewDataSource } : {})}
|
||||||
|
refreshSignal={detailRefreshSignal}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -209,6 +226,7 @@ export function AppShell({
|
|||||||
instanceDataSource,
|
instanceDataSource,
|
||||||
capabilities,
|
capabilities,
|
||||||
capabilityDataSource,
|
capabilityDataSource,
|
||||||
|
overviewDataSource,
|
||||||
eventStreamClient,
|
eventStreamClient,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
|
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
|
||||||
@@ -261,7 +279,9 @@ export function AppShell({
|
|||||||
instanceDataSource={instanceDataSource}
|
instanceDataSource={instanceDataSource}
|
||||||
capabilities={capabilities}
|
capabilities={capabilities}
|
||||||
capabilityDataSource={capabilityDataSource}
|
capabilityDataSource={capabilityDataSource}
|
||||||
|
overviewDataSource={overviewDataSource}
|
||||||
fleetRefreshSignal={refresh.fleet}
|
fleetRefreshSignal={refresh.fleet}
|
||||||
|
detailRefreshSignal={refresh.detail}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,4 +53,24 @@ export {
|
|||||||
type InvalidationBatch,
|
type InvalidationBatch,
|
||||||
} from './events/event-invalidation-controller.js';
|
} from './events/event-invalidation-controller.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
OverviewSystemPage,
|
||||||
|
type OverviewDataSource,
|
||||||
|
type OverviewFieldValue,
|
||||||
|
type OverviewSection,
|
||||||
|
type OverviewSnapshot,
|
||||||
|
type OverviewSystemPageProps,
|
||||||
|
} from './instances/overview-system.js';
|
||||||
|
export {
|
||||||
|
createOperationClient,
|
||||||
|
OperationClientError,
|
||||||
|
type OperationCatalogEntry,
|
||||||
|
type OperationCatalogPage,
|
||||||
|
type OperationCatalogQuery,
|
||||||
|
type OperationClient,
|
||||||
|
type OperationJob,
|
||||||
|
type PrepareOperationInput,
|
||||||
|
type PreparedOperation,
|
||||||
|
} from './operations/operation-client.js';
|
||||||
|
|
||||||
export const webWorkspaceReady = true;
|
export const webWorkspaceReady = true;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type { InstanceContext, InstanceModule } from '../app-shell.js';
|
import type { InstanceContext, InstanceModule } from '../app-shell.js';
|
||||||
@@ -22,6 +23,7 @@ export interface InstanceDetailProps {
|
|||||||
readonly instance?: InstanceContext;
|
readonly instance?: InstanceContext;
|
||||||
readonly capabilities?: InstanceCapabilityMap;
|
readonly capabilities?: InstanceCapabilityMap;
|
||||||
readonly capabilityDataSource?: CapabilityDataSource;
|
readonly capabilityDataSource?: CapabilityDataSource;
|
||||||
|
readonly moduleContent?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> = {
|
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' };
|
return map[module] ?? { state: 'unknown' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canRender(capability: InstanceCapability): boolean {
|
||||||
|
return capability.state === 'supported' || capability.state === 'degraded';
|
||||||
|
}
|
||||||
|
|
||||||
function explanation(capability: InstanceCapability): string | null {
|
function explanation(capability: InstanceCapability): string | null {
|
||||||
if (capability.state === 'supported') return null;
|
if (capability.state === 'supported') return null;
|
||||||
return capability.explanation?.trim() || DEFAULT_EXPLANATIONS[capability.state];
|
return capability.explanation?.trim() || DEFAULT_EXPLANATIONS[capability.state];
|
||||||
@@ -63,6 +69,7 @@ export function InstanceDetail({
|
|||||||
instance,
|
instance,
|
||||||
capabilities,
|
capabilities,
|
||||||
capabilityDataSource,
|
capabilityDataSource,
|
||||||
|
moduleContent,
|
||||||
}: InstanceDetailProps) {
|
}: InstanceDetailProps) {
|
||||||
const ownsRoute = instance?.id === instanceId;
|
const ownsRoute = instance?.id === instanceId;
|
||||||
const [loadedCapabilities, setLoadedCapabilities] = useState<InstanceCapabilityMap | undefined>();
|
const [loadedCapabilities, setLoadedCapabilities] = useState<InstanceCapabilityMap | undefined>();
|
||||||
@@ -90,8 +97,9 @@ export function InstanceDetail({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
|
void error;
|
||||||
if (!controller.signal.aborted && requestRef.current === request) {
|
if (!controller.signal.aborted && requestRef.current === request) {
|
||||||
setLoadError(error instanceof Error ? error.message : 'Capability discovery failed.');
|
setLoadError('Capability discovery failed.');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -166,12 +174,20 @@ export function InstanceDetail({
|
|||||||
<h1>{INSTANCE_MODULE_LABELS[module]}</h1>
|
<h1>{INSTANCE_MODULE_LABELS[module]}</h1>
|
||||||
{loading ? <p role="status">Loading capabilities…</p> : null}
|
{loading ? <p role="status">Loading capabilities…</p> : null}
|
||||||
{loadError ? <p role="alert">Capabilities unavailable: {loadError}</p> : null}
|
{loadError ? <p role="alert">Capabilities unavailable: {loadError}</p> : null}
|
||||||
{!loading && activeCapability.state === 'supported' ? (
|
{!loading && canRender(activeCapability) ? (
|
||||||
<p>
|
<>
|
||||||
Inspect {INSTANCE_MODULE_LABELS[module].toLowerCase()} data and available operations.
|
{activeCapability.state === 'degraded' ? (
|
||||||
</p>
|
<p data-capability-state="degraded">{explanation(activeCapability)}</p>
|
||||||
|
) : null}
|
||||||
|
{moduleContent ?? (
|
||||||
|
<p>
|
||||||
|
Inspect {INSTANCE_MODULE_LABELS[module].toLowerCase()} data and available
|
||||||
|
operations.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{!loading && activeCapability.state !== 'supported' ? (
|
{!loading && !canRender(activeCapability) ? (
|
||||||
<p data-capability-state={activeCapability.state}>{explanation(activeCapability)}</p>
|
<p data-capability-state={activeCapability.state}>{explanation(activeCapability)}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { OperationClientError, createOperationClient } from './operation-client.js';
|
||||||
|
|
||||||
|
const response = (body: unknown, init: ResponseInit = {}) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json', ...init.headers },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
operationId: 'safeOp',
|
||||||
|
title: 'Safe operation',
|
||||||
|
risk: 'R1',
|
||||||
|
capability: 'command',
|
||||||
|
batchable: false,
|
||||||
|
parameterSchemaId: 'safe.params.v1',
|
||||||
|
};
|
||||||
|
const page = { items: [entry], page: { page: 1, pageSize: 25, total: 1 } };
|
||||||
|
const preparation = {
|
||||||
|
id: 'prep-1',
|
||||||
|
status: 'prepared',
|
||||||
|
operationId: 'safeOp',
|
||||||
|
risk: 'R1',
|
||||||
|
expiresAt: '2030-01-01T00:00:00.000Z',
|
||||||
|
confirmationToken: 'opaque-secret',
|
||||||
|
confirmationPrompt: 'Confirm safe operation',
|
||||||
|
targetCount: 1,
|
||||||
|
};
|
||||||
|
const job = {
|
||||||
|
id: 'job-1',
|
||||||
|
operationId: 'safeOp',
|
||||||
|
status: 'succeeded',
|
||||||
|
rootJobId: 'job-1',
|
||||||
|
items: [{ id: 'item-1', targetId: 'instance-1', state: 'succeeded' }],
|
||||||
|
attempts: [
|
||||||
|
{
|
||||||
|
id: 'attempt-1',
|
||||||
|
state: 'succeeded',
|
||||||
|
startedAt: '2029-01-01T00:00:00.000Z',
|
||||||
|
finishedAt: '2029-01-01T00:00:01.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: '2029-01-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('safe operation client', () => {
|
||||||
|
it('loads a strictly validated catalog using same-origin credentials', async () => {
|
||||||
|
const fetcher = vi.fn(async () => response(page));
|
||||||
|
const client = createOperationClient(fetcher as typeof fetch);
|
||||||
|
|
||||||
|
await expect(client.list({ risk: 'R1', search: 'safe value' })).resolves.toEqual(page);
|
||||||
|
expect(fetcher).toHaveBeenCalledWith('/api/v1/operations?risk=R1&search=safe+value', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ ...page, leaked: true },
|
||||||
|
{ ...page, items: [{ ...entry, method: 'POST' }] },
|
||||||
|
{ ...page, items: [{ ...entry, risk: 'R9' }] },
|
||||||
|
{ ...page, page: { ...page.page, total: -1 } },
|
||||||
|
])('rejects malformed or expanded catalog envelopes', async (body) => {
|
||||||
|
const client = createOperationClient(vi.fn(async () => response(body)) as typeof fetch);
|
||||||
|
await expect(client.list()).rejects.toThrow('Operation catalog response is invalid.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only prepares an operation from the fetched catalog and retains its token privately', async () => {
|
||||||
|
const fetcher = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(response(page))
|
||||||
|
.mockResolvedValueOnce(response(preparation));
|
||||||
|
const client = createOperationClient(fetcher as typeof fetch);
|
||||||
|
await client.list();
|
||||||
|
|
||||||
|
const result = await client.prepare({
|
||||||
|
operationId: 'safeOp',
|
||||||
|
targets: [{ instanceId: 'instance-1', revision: 2 }],
|
||||||
|
parameters: { parameterSchemaId: 'safe.params.v1', fields: [] },
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: 'prep-1',
|
||||||
|
status: 'prepared',
|
||||||
|
operationId: 'safeOp',
|
||||||
|
risk: 'R1',
|
||||||
|
expiresAt: preparation.expiresAt,
|
||||||
|
confirmationPrompt: 'Confirm safe operation',
|
||||||
|
targetCount: 1,
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(result)).not.toContain('opaque-secret');
|
||||||
|
expect(fetcher.mock.calls[1]).toEqual([
|
||||||
|
'/api/v1/operations/prepare',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({
|
||||||
|
operationId: 'safeOp',
|
||||||
|
targets: [{ instanceId: 'instance-1', revision: 2 }],
|
||||||
|
parameters: { parameterSchemaId: 'safe.params.v1', fields: [] },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses uncatalogued operations and mismatched schemas without a request', async () => {
|
||||||
|
const fetcher = vi.fn(async () => response(page));
|
||||||
|
const client = createOperationClient(fetcher as typeof fetch);
|
||||||
|
await client.list();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.prepare({
|
||||||
|
operationId: 'hiddenOp',
|
||||||
|
targets: [{ instanceId: 'i' }],
|
||||||
|
parameters: { parameterSchemaId: 'hidden.v1', fields: [] },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Operation is not present in the loaded catalog.');
|
||||||
|
await expect(
|
||||||
|
client.prepare({
|
||||||
|
operationId: 'safeOp',
|
||||||
|
targets: [{ instanceId: 'i' }],
|
||||||
|
parameters: { parameterSchemaId: 'wrong', fields: [] },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Operation parameter schema does not match the catalog.');
|
||||||
|
expect(fetcher).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executes with the in-memory token exactly once and validates public Job fields', async () => {
|
||||||
|
const fetcher = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(response(page))
|
||||||
|
.mockResolvedValueOnce(response(preparation))
|
||||||
|
.mockResolvedValueOnce(response(job, { status: 202 }));
|
||||||
|
const client = createOperationClient(fetcher as typeof fetch);
|
||||||
|
await client.list();
|
||||||
|
await client.prepare({
|
||||||
|
operationId: 'safeOp',
|
||||||
|
targets: [{ instanceId: 'instance-1' }],
|
||||||
|
parameters: { parameterSchemaId: 'safe.params.v1', fields: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.execute('prep-1')).resolves.toEqual(job);
|
||||||
|
expect(JSON.parse(fetcher.mock.calls[2][1].body)).toEqual({
|
||||||
|
preparationId: 'prep-1',
|
||||||
|
confirmationToken: 'opaque-secret',
|
||||||
|
});
|
||||||
|
await expect(client.execute('prep-1')).rejects.toThrow(
|
||||||
|
'No in-memory confirmation is available for this preparation.',
|
||||||
|
);
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects expanded preparation and Job payloads', async () => {
|
||||||
|
const badPreparationClient = createOperationClient(
|
||||||
|
vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(response(page))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
response({ ...preparation, transportPath: '/private' }),
|
||||||
|
) as typeof fetch,
|
||||||
|
);
|
||||||
|
await badPreparationClient.list();
|
||||||
|
await expect(
|
||||||
|
badPreparationClient.prepare({
|
||||||
|
operationId: 'safeOp',
|
||||||
|
targets: [{ instanceId: 'i' }],
|
||||||
|
parameters: { parameterSchemaId: 'safe.params.v1', fields: [] },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Operation preparation response is invalid.');
|
||||||
|
|
||||||
|
const badJobClient = createOperationClient(
|
||||||
|
vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(response(page))
|
||||||
|
.mockResolvedValueOnce(response(preparation))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
response({ ...job, upstreamResponse: 'secret' }, { status: 202 }),
|
||||||
|
) as typeof fetch,
|
||||||
|
);
|
||||||
|
await badJobClient.list();
|
||||||
|
await badJobClient.prepare({
|
||||||
|
operationId: 'safeOp',
|
||||||
|
targets: [{ instanceId: 'i' }],
|
||||||
|
parameters: { parameterSchemaId: 'safe.params.v1', fields: [] },
|
||||||
|
});
|
||||||
|
await expect(badJobClient.execute('prep-1')).rejects.toThrow(
|
||||||
|
'Operation execution response is invalid.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts Problem Details detail, validation messages, and unknown response fields', async () => {
|
||||||
|
const problem = {
|
||||||
|
type: 'https://example.invalid/problem',
|
||||||
|
title: 'Bad Request',
|
||||||
|
status: 400,
|
||||||
|
detail: 'token=server-secret',
|
||||||
|
code: 'VALIDATION_FAILED',
|
||||||
|
requestId: 'request-1',
|
||||||
|
validation: [{ field: 'parameters', code: 'INVALID', message: 'secret field value' }],
|
||||||
|
debug: 'private stack',
|
||||||
|
};
|
||||||
|
const client = createOperationClient(
|
||||||
|
vi.fn(async () =>
|
||||||
|
response(problem, { status: 400, headers: { 'content-type': 'application/problem+json' } }),
|
||||||
|
) as typeof fetch,
|
||||||
|
);
|
||||||
|
|
||||||
|
const error = await client.list().catch((caught: unknown) => caught);
|
||||||
|
expect(error).toBeInstanceOf(OperationClientError);
|
||||||
|
expect(error).toMatchObject({ status: 400, code: 'VALIDATION_FAILED', requestId: 'request-1' });
|
||||||
|
expect(JSON.stringify(error)).not.toContain('server-secret');
|
||||||
|
expect(JSON.stringify(error)).not.toContain('secret field value');
|
||||||
|
expect(JSON.stringify(error)).not.toContain('private stack');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import type {
|
||||||
|
OperationCatalogEntry,
|
||||||
|
OperationPage,
|
||||||
|
OperationPageQuery,
|
||||||
|
PrepareOperationRequest,
|
||||||
|
RiskLevel,
|
||||||
|
} from '@multi-simadmin/contracts';
|
||||||
|
|
||||||
|
type OperationCatalogPage = OperationPage;
|
||||||
|
type OperationCatalogQuery = OperationPageQuery;
|
||||||
|
type PrepareOperationInput = PrepareOperationRequest;
|
||||||
|
export type OperationCapability = OperationCatalogEntry['capability'];
|
||||||
|
export type {
|
||||||
|
OperationCatalogEntry,
|
||||||
|
OperationPage as OperationCatalogPage,
|
||||||
|
OperationPageQuery as OperationCatalogQuery,
|
||||||
|
PrepareOperationRequest as PrepareOperationInput,
|
||||||
|
RiskLevel,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The confirmation token is deliberately absent from this public view. */
|
||||||
|
export interface PreparedOperation {
|
||||||
|
readonly id: string;
|
||||||
|
readonly status: 'prepared';
|
||||||
|
readonly operationId: string;
|
||||||
|
readonly risk: RiskLevel;
|
||||||
|
readonly expiresAt: string;
|
||||||
|
readonly confirmationPrompt: string;
|
||||||
|
readonly targetCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JobStatus =
|
||||||
|
| 'queued'
|
||||||
|
| 'running'
|
||||||
|
| 'cancelling'
|
||||||
|
| 'succeeded'
|
||||||
|
| 'partially-succeeded'
|
||||||
|
| 'failed'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'unknown-result';
|
||||||
|
export type JobItemState = 'succeeded' | 'failed' | 'skipped' | 'cancelled' | 'unknown-result';
|
||||||
|
export type AttemptState = 'running' | 'succeeded' | 'failed' | 'cancelled' | 'unknown-result';
|
||||||
|
export interface OperationJob {
|
||||||
|
readonly id: string;
|
||||||
|
readonly operationId: string;
|
||||||
|
readonly status: JobStatus;
|
||||||
|
readonly retryOfJobId?: string;
|
||||||
|
readonly rootJobId: string;
|
||||||
|
readonly items: readonly Readonly<{
|
||||||
|
id: string;
|
||||||
|
targetId: string;
|
||||||
|
state: JobItemState;
|
||||||
|
sourceJobItemId?: string;
|
||||||
|
error?: RedactedProblem;
|
||||||
|
}>[];
|
||||||
|
readonly attempts: readonly Readonly<{
|
||||||
|
id: string;
|
||||||
|
state: AttemptState;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
}>[];
|
||||||
|
readonly createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedactedProblem {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code: string;
|
||||||
|
readonly requestId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OperationClientError extends Error implements RedactedProblem {
|
||||||
|
override readonly name = 'OperationClientError';
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly code: string,
|
||||||
|
readonly requestId: string,
|
||||||
|
) {
|
||||||
|
super(`Operation request failed (${status}, ${code}).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperationClient {
|
||||||
|
list(query?: OperationCatalogQuery): Promise<OperationCatalogPage>;
|
||||||
|
prepare(input: PrepareOperationInput): Promise<PreparedOperation>;
|
||||||
|
execute(preparationId: string): Promise<OperationJob>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const risks = new Set(['R0', 'R1', 'R2', 'R3']);
|
||||||
|
const capabilities = new Set(['query', 'command', 'job']);
|
||||||
|
const preparationStatuses = new Set(['prepared', 'consumed', 'expired', 'invalidated']);
|
||||||
|
const jobStatuses = new Set([
|
||||||
|
'queued',
|
||||||
|
'running',
|
||||||
|
'cancelling',
|
||||||
|
'succeeded',
|
||||||
|
'partially-succeeded',
|
||||||
|
'failed',
|
||||||
|
'cancelled',
|
||||||
|
'unknown-result',
|
||||||
|
]);
|
||||||
|
const itemStates = new Set(['succeeded', 'failed', 'skipped', 'cancelled', 'unknown-result']);
|
||||||
|
const attemptStates = new Set(['running', 'succeeded', 'failed', 'cancelled', 'unknown-result']);
|
||||||
|
|
||||||
|
const record = (value: unknown): Record<string, unknown> | undefined =>
|
||||||
|
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
const exactKeys = (
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
required: readonly string[],
|
||||||
|
optional: readonly string[] = [],
|
||||||
|
) => {
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
return (
|
||||||
|
required.every((key) => key in value) &&
|
||||||
|
keys.every((key) => required.includes(key) || optional.includes(key))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const string = (value: unknown): value is string => typeof value === 'string' && value.length > 0;
|
||||||
|
const integer = (value: unknown): value is number =>
|
||||||
|
Number.isSafeInteger(value) && (value as number) >= 0;
|
||||||
|
const timestamp = (value: unknown): value is string =>
|
||||||
|
string(value) &&
|
||||||
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test(value) &&
|
||||||
|
Number.isFinite(Date.parse(value));
|
||||||
|
|
||||||
|
function safePrepareInput(value: PrepareOperationInput): PrepareOperationInput | undefined {
|
||||||
|
const input = record(value);
|
||||||
|
const parameters = record(input?.parameters);
|
||||||
|
if (
|
||||||
|
!input ||
|
||||||
|
!exactKeys(input, ['operationId', 'targets', 'parameters']) ||
|
||||||
|
!string(input.operationId) ||
|
||||||
|
!Array.isArray(input.targets) ||
|
||||||
|
input.targets.length < 1 ||
|
||||||
|
!parameters ||
|
||||||
|
!exactKeys(parameters, ['parameterSchemaId', 'fields']) ||
|
||||||
|
!string(parameters.parameterSchemaId) ||
|
||||||
|
!Array.isArray(parameters.fields)
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
const targets = input.targets.map((value) => {
|
||||||
|
const target = record(value);
|
||||||
|
if (
|
||||||
|
!target ||
|
||||||
|
!exactKeys(target, ['instanceId'], ['revision']) ||
|
||||||
|
!string(target.instanceId) ||
|
||||||
|
(target.revision !== undefined && (!integer(target.revision) || target.revision < 1))
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
return target;
|
||||||
|
});
|
||||||
|
const fields = parameters.fields.map((value) => {
|
||||||
|
const field = record(value);
|
||||||
|
if (
|
||||||
|
!field ||
|
||||||
|
!exactKeys(field, ['fieldId', 'kind', 'value']) ||
|
||||||
|
!string(field.fieldId) ||
|
||||||
|
!string(field.kind)
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
const valid =
|
||||||
|
(field.kind === 'string' && typeof field.value === 'string') ||
|
||||||
|
(field.kind === 'number' &&
|
||||||
|
typeof field.value === 'number' &&
|
||||||
|
Number.isFinite(field.value)) ||
|
||||||
|
(field.kind === 'boolean' && typeof field.value === 'boolean') ||
|
||||||
|
(field.kind === 'string-list' &&
|
||||||
|
Array.isArray(field.value) &&
|
||||||
|
field.value.every((item) => typeof item === 'string')) ||
|
||||||
|
(field.kind === 'number-list' &&
|
||||||
|
Array.isArray(field.value) &&
|
||||||
|
field.value.every((item) => typeof item === 'number' && Number.isFinite(item))) ||
|
||||||
|
(field.kind === 'null' && field.value === null);
|
||||||
|
return valid ? field : undefined;
|
||||||
|
});
|
||||||
|
if (targets.some((target) => !target) || fields.some((field) => !field)) return undefined;
|
||||||
|
return {
|
||||||
|
operationId: input.operationId,
|
||||||
|
targets: targets as unknown as PrepareOperationInput['targets'],
|
||||||
|
parameters: {
|
||||||
|
parameterSchemaId: parameters.parameterSchemaId,
|
||||||
|
fields: fields as unknown as PrepareOperationInput['parameters']['fields'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProblem(value: unknown): RedactedProblem | undefined {
|
||||||
|
const body = record(value);
|
||||||
|
if (!body || !integer(body.status) || !string(body.code) || !string(body.requestId))
|
||||||
|
return undefined;
|
||||||
|
return { status: body.status, code: body.code, requestId: body.requestId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCatalog(value: unknown): OperationCatalogPage | undefined {
|
||||||
|
const body = record(value);
|
||||||
|
if (!body || !exactKeys(body, ['items', 'page']) || !Array.isArray(body.items)) return undefined;
|
||||||
|
const metadata = record(body.page);
|
||||||
|
if (
|
||||||
|
!metadata ||
|
||||||
|
!exactKeys(metadata, ['page', 'pageSize', 'total']) ||
|
||||||
|
!integer(metadata.page) ||
|
||||||
|
metadata.page < 1 ||
|
||||||
|
!integer(metadata.pageSize) ||
|
||||||
|
metadata.pageSize < 1 ||
|
||||||
|
!integer(metadata.total)
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
const items: OperationCatalogEntry[] = [];
|
||||||
|
for (const itemValue of body.items) {
|
||||||
|
const item = record(itemValue);
|
||||||
|
if (
|
||||||
|
!item ||
|
||||||
|
!exactKeys(item, [
|
||||||
|
'operationId',
|
||||||
|
'title',
|
||||||
|
'risk',
|
||||||
|
'capability',
|
||||||
|
'batchable',
|
||||||
|
'parameterSchemaId',
|
||||||
|
]) ||
|
||||||
|
!string(item.operationId) ||
|
||||||
|
!string(item.title) ||
|
||||||
|
!risks.has(item.risk as string) ||
|
||||||
|
!capabilities.has(item.capability as string) ||
|
||||||
|
typeof item.batchable !== 'boolean' ||
|
||||||
|
!string(item.parameterSchemaId)
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
items.push(item as unknown as OperationCatalogEntry);
|
||||||
|
}
|
||||||
|
return { items, page: metadata as unknown as OperationCatalogPage['page'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrivatePreparation extends PreparedOperation {
|
||||||
|
readonly confirmationToken: string;
|
||||||
|
}
|
||||||
|
function parsePreparation(value: unknown): PrivatePreparation | undefined {
|
||||||
|
const body = record(value);
|
||||||
|
if (
|
||||||
|
!body ||
|
||||||
|
!exactKeys(body, [
|
||||||
|
'id',
|
||||||
|
'status',
|
||||||
|
'operationId',
|
||||||
|
'risk',
|
||||||
|
'expiresAt',
|
||||||
|
'confirmationToken',
|
||||||
|
'confirmationPrompt',
|
||||||
|
'targetCount',
|
||||||
|
]) ||
|
||||||
|
!string(body.id) ||
|
||||||
|
!preparationStatuses.has(body.status as string) ||
|
||||||
|
body.status !== 'prepared' ||
|
||||||
|
!string(body.operationId) ||
|
||||||
|
!risks.has(body.risk as string) ||
|
||||||
|
!timestamp(body.expiresAt) ||
|
||||||
|
!string(body.confirmationToken) ||
|
||||||
|
!string(body.confirmationPrompt) ||
|
||||||
|
!integer(body.targetCount)
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
return body as unknown as PrivatePreparation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNestedProblem(value: unknown): RedactedProblem | undefined {
|
||||||
|
const body = record(value);
|
||||||
|
if (
|
||||||
|
!body ||
|
||||||
|
!exactKeys(body, ['type', 'title', 'status', 'detail', 'code', 'requestId'], ['validation'])
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
return parseProblem(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJob(value: unknown): OperationJob | undefined {
|
||||||
|
const body = record(value);
|
||||||
|
if (
|
||||||
|
!body ||
|
||||||
|
!exactKeys(
|
||||||
|
body,
|
||||||
|
['id', 'operationId', 'status', 'rootJobId', 'items', 'attempts', 'createdAt'],
|
||||||
|
['retryOfJobId'],
|
||||||
|
) ||
|
||||||
|
!string(body.id) ||
|
||||||
|
!string(body.operationId) ||
|
||||||
|
!jobStatuses.has(body.status as string) ||
|
||||||
|
!string(body.rootJobId) ||
|
||||||
|
(body.retryOfJobId !== undefined && !string(body.retryOfJobId)) ||
|
||||||
|
!Array.isArray(body.items) ||
|
||||||
|
!Array.isArray(body.attempts) ||
|
||||||
|
!timestamp(body.createdAt)
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
const items = body.items.map((value) => {
|
||||||
|
const item = record(value);
|
||||||
|
if (
|
||||||
|
!item ||
|
||||||
|
!exactKeys(item, ['id', 'targetId', 'state'], ['sourceJobItemId', 'error']) ||
|
||||||
|
!string(item.id) ||
|
||||||
|
!string(item.targetId) ||
|
||||||
|
!itemStates.has(item.state as string) ||
|
||||||
|
(item.sourceJobItemId !== undefined && !string(item.sourceJobItemId))
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
const error = item.error === undefined ? undefined : parseNestedProblem(item.error);
|
||||||
|
if (item.error !== undefined && !error) return undefined;
|
||||||
|
return { ...item, ...(error ? { error } : {}) };
|
||||||
|
});
|
||||||
|
const attempts = body.attempts.map((value) => {
|
||||||
|
const attempt = record(value);
|
||||||
|
if (
|
||||||
|
!attempt ||
|
||||||
|
!exactKeys(attempt, ['id', 'state', 'startedAt'], ['finishedAt']) ||
|
||||||
|
!string(attempt.id) ||
|
||||||
|
!attemptStates.has(attempt.state as string) ||
|
||||||
|
!timestamp(attempt.startedAt) ||
|
||||||
|
(attempt.finishedAt !== undefined && !timestamp(attempt.finishedAt))
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
return attempt;
|
||||||
|
});
|
||||||
|
if (items.some((item) => !item) || attempts.some((attempt) => !attempt)) return undefined;
|
||||||
|
return { ...body, items, attempts } as unknown as OperationJob;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jsonResponse(response: Response): Promise<unknown> {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
} catch {
|
||||||
|
body = undefined;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const problem = parseProblem(body);
|
||||||
|
throw problem && problem.status === response.status
|
||||||
|
? new OperationClientError(problem.status, problem.code, problem.requestId)
|
||||||
|
: new OperationClientError(response.status, 'REQUEST_FAILED', 'unavailable');
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = (body: unknown): RequestInit => ({
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
function queryString(query: OperationCatalogQuery): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const key of [
|
||||||
|
'page',
|
||||||
|
'pageSize',
|
||||||
|
'sort',
|
||||||
|
'direction',
|
||||||
|
'risk',
|
||||||
|
'capability',
|
||||||
|
'batchable',
|
||||||
|
'search',
|
||||||
|
] as const) {
|
||||||
|
const value = query[key];
|
||||||
|
if (value !== undefined) params.set(key, String(value));
|
||||||
|
}
|
||||||
|
const encoded = params.toString();
|
||||||
|
return encoded ? `?${encoded}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOperationClient(fetcher: typeof fetch = fetch): OperationClient {
|
||||||
|
const catalog = new Map<string, OperationCatalogEntry>();
|
||||||
|
const confirmations = new Map<string, Readonly<{ token: string; operationId: string }>>();
|
||||||
|
return {
|
||||||
|
async list(query = {}) {
|
||||||
|
const body = await jsonResponse(
|
||||||
|
await fetcher(`/api/v1/operations${queryString(query)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const parsed = parseCatalog(body);
|
||||||
|
if (!parsed) throw new Error('Operation catalog response is invalid.');
|
||||||
|
catalog.clear();
|
||||||
|
for (const item of parsed.items) catalog.set(item.operationId, item);
|
||||||
|
return parsed;
|
||||||
|
},
|
||||||
|
async prepare(input) {
|
||||||
|
const safeInput = safePrepareInput(input);
|
||||||
|
if (!safeInput) throw new Error('Operation preparation request is invalid.');
|
||||||
|
const allowed = catalog.get(safeInput.operationId);
|
||||||
|
if (!allowed) throw new Error('Operation is not present in the loaded catalog.');
|
||||||
|
if (allowed.parameterSchemaId !== safeInput.parameters.parameterSchemaId)
|
||||||
|
throw new Error('Operation parameter schema does not match the catalog.');
|
||||||
|
const parsed = parsePreparation(
|
||||||
|
await jsonResponse(await fetcher('/api/v1/operations/prepare', post(safeInput))),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!parsed ||
|
||||||
|
parsed.operationId !== safeInput.operationId ||
|
||||||
|
parsed.risk !== allowed.risk ||
|
||||||
|
parsed.targetCount !== safeInput.targets.length
|
||||||
|
)
|
||||||
|
throw new Error('Operation preparation response is invalid.');
|
||||||
|
confirmations.set(parsed.id, {
|
||||||
|
token: parsed.confirmationToken,
|
||||||
|
operationId: parsed.operationId,
|
||||||
|
});
|
||||||
|
const publicPreparation: PreparedOperation = {
|
||||||
|
id: parsed.id,
|
||||||
|
status: parsed.status,
|
||||||
|
operationId: parsed.operationId,
|
||||||
|
risk: parsed.risk,
|
||||||
|
expiresAt: parsed.expiresAt,
|
||||||
|
confirmationPrompt: parsed.confirmationPrompt,
|
||||||
|
targetCount: parsed.targetCount,
|
||||||
|
};
|
||||||
|
return publicPreparation;
|
||||||
|
},
|
||||||
|
async execute(preparationId) {
|
||||||
|
const confirmation = confirmations.get(preparationId);
|
||||||
|
if (!confirmation)
|
||||||
|
throw new Error('No in-memory confirmation is available for this preparation.');
|
||||||
|
confirmations.delete(preparationId);
|
||||||
|
const parsed = parseJob(
|
||||||
|
await jsonResponse(
|
||||||
|
await fetcher(
|
||||||
|
'/api/v1/operations/execute',
|
||||||
|
post({
|
||||||
|
preparationId,
|
||||||
|
confirmationToken: confirmation.token,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!parsed || parsed.operationId !== confirmation.operationId)
|
||||||
|
throw new Error('Operation execution response is invalid.');
|
||||||
|
return parsed;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user