feat(web): complete fleet and instance management slices
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createInstanceApiDataSource,
|
||||
passwordUpdate,
|
||||
tagsFromInput,
|
||||
} from './instance-api-data-source.js';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function response(body: unknown, status = 200, etag?: string): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(etag === undefined ? {} : { ETag: etag }),
|
||||
},
|
||||
});
|
||||
}
|
||||
const instance = {
|
||||
id: 'owner/id',
|
||||
name: 'Owner',
|
||||
origin: 'https://owner.example',
|
||||
tags: ['lab'],
|
||||
revision: 3,
|
||||
credentialConfigured: true,
|
||||
};
|
||||
|
||||
describe('instance API data source', () => {
|
||||
it('carries the exact strong ETag through the owner-bound update lifecycle', async () => {
|
||||
const fetch = vi
|
||||
.fn<typeof globalThis.fetch>()
|
||||
.mockResolvedValueOnce(response(instance, 200, '"opaque-owner-v3"'))
|
||||
.mockResolvedValueOnce(response({ ...instance, revision: 4 }, 200, '"opaque-owner-v4"'))
|
||||
.mockResolvedValueOnce(response({ ...instance, revision: 5 }, 200, '"opaque-owner-v5"'))
|
||||
.mockResolvedValueOnce(response({ reachable: true, authenticated: false }));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const source = createInstanceApiDataSource();
|
||||
|
||||
await source.get('owner/id');
|
||||
await source.update('owner/id', 3, { password: { action: 'preserve' } });
|
||||
await source.update('owner/id', 4, { name: 'Owner 2' });
|
||||
await source.testConnection('owner/id');
|
||||
|
||||
expect(fetch.mock.calls[0]?.[0]).toBe('/api/v1/instances/owner%2Fid');
|
||||
expect(fetch.mock.calls[1]?.[1]).toMatchObject({
|
||||
method: 'PATCH',
|
||||
headers: expect.objectContaining({ 'If-Match': '"opaque-owner-v3"' }),
|
||||
body: JSON.stringify({ password: { action: 'preserve' } }),
|
||||
});
|
||||
expect(fetch.mock.calls[2]?.[1]).toMatchObject({
|
||||
method: 'PATCH',
|
||||
headers: expect.objectContaining({ 'If-Match': '"opaque-owner-v4"' }),
|
||||
});
|
||||
expect(fetch.mock.calls[3]?.[0]).toBe('/api/v1/instances/owner%2Fid/test-connection');
|
||||
});
|
||||
|
||||
it('requires a valid strong ETag before allowing an update', async () => {
|
||||
const fetch = vi
|
||||
.fn<typeof globalThis.fetch>()
|
||||
.mockResolvedValueOnce(response(instance))
|
||||
.mockResolvedValueOnce(response(instance, 200, 'W/"rev-3"'));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const source = createInstanceApiDataSource();
|
||||
|
||||
await expect(source.get('owner/id')).rejects.toThrow(/strong etag/i);
|
||||
await expect(source.get('owner/id')).rejects.toThrow(/strong etag/i);
|
||||
await expect(source.update('owner/id', 3, { name: 'Nope' })).rejects.toThrow(/etag/i);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('captures the create response ETag for the first update', async () => {
|
||||
const fetch = vi
|
||||
.fn<typeof globalThis.fetch>()
|
||||
.mockResolvedValueOnce(response(instance, 201, '"created-owner-v3"'))
|
||||
.mockResolvedValueOnce(response({ ...instance, revision: 4 }, 200, '"created-owner-v4"'));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const source = createInstanceApiDataSource();
|
||||
|
||||
const created = await source.create({ name: instance.name, origin: instance.origin });
|
||||
await source.update(created.id, created.revision, { tags: ['new'] });
|
||||
|
||||
expect(fetch.mock.calls[1]?.[1]?.headers).toEqual(
|
||||
expect.objectContaining({ 'If-Match': '"created-owner-v3"' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('prepares and executes R3 deletion through the durable operation endpoint', async () => {
|
||||
const token = 'a'.repeat(32);
|
||||
const fetch = vi
|
||||
.fn<typeof globalThis.fetch>()
|
||||
.mockResolvedValueOnce(response({ id: 'prep-1', confirmationToken: token }))
|
||||
.mockResolvedValueOnce(response({ id: 'job-1' }, 202));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
|
||||
await createInstanceApiDataSource().delete('owner', 3);
|
||||
expect(JSON.parse(fetch.mock.calls[0]?.[1]?.body as string)).toEqual({
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId: 'owner', revision: 3 }],
|
||||
parameters: { parameterSchemaId: 'deleteInstance.parameters.v1', fields: [] },
|
||||
});
|
||||
expect(fetch.mock.calls[1]?.[0]).toBe('/api/v1/operations/execute');
|
||||
expect(fetch.mock.calls[1]?.[1]).toMatchObject({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ preparationId: 'prep-1', confirmationToken: token }),
|
||||
});
|
||||
expect(
|
||||
fetch.mock.calls.some(
|
||||
([url, init]) => init?.method === 'DELETE' || String(url).includes(token),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects mismatched owners and does not render arbitrary response text as an error', async () => {
|
||||
const fetch = vi
|
||||
.fn<typeof globalThis.fetch>()
|
||||
.mockResolvedValueOnce(response({ ...instance, id: 'intruder' }, 200, '"owner-v3"'))
|
||||
.mockResolvedValueOnce(new Response('password=leaked-secret', { status: 500 }));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const source = createInstanceApiDataSource();
|
||||
|
||||
await expect(source.get('owner')).rejects.toThrow(/does not match this route/i);
|
||||
await expect(source.get('owner')).rejects.toThrow(
|
||||
'The instance operation could not be completed.',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes tags and enforces the password discriminated union', () => {
|
||||
expect(tagsFromInput(' lab, west,lab, ')).toEqual(['lab', 'west']);
|
||||
expect(passwordUpdate('preserve', '')).toEqual({ action: 'preserve' });
|
||||
expect(passwordUpdate('clear', '')).toEqual({ action: 'clear' });
|
||||
expect(() => passwordUpdate('set', '')).toThrow(/enter a password/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
export type PasswordUpdate =
|
||||
| { readonly action: 'preserve' }
|
||||
| { readonly action: 'set'; readonly password: string }
|
||||
| { readonly action: 'clear' };
|
||||
export interface InstanceInput {
|
||||
readonly name: string;
|
||||
readonly origin: string;
|
||||
readonly tags?: readonly string[];
|
||||
readonly password?: PasswordUpdate;
|
||||
}
|
||||
export type InstancePatch = Partial<InstanceInput>;
|
||||
export interface ManagedInstance {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly origin: string;
|
||||
readonly tags: readonly string[];
|
||||
readonly revision: number;
|
||||
readonly credentialConfigured: boolean;
|
||||
}
|
||||
export interface ConnectionResult {
|
||||
readonly reachable: boolean;
|
||||
readonly authenticated: boolean;
|
||||
}
|
||||
export interface InstanceDataSource {
|
||||
get(instanceId: string): Promise<ManagedInstance>;
|
||||
create(input: InstanceInput): Promise<ManagedInstance>;
|
||||
update(instanceId: string, revision: number, patch: InstancePatch): Promise<ManagedInstance>;
|
||||
testConnection(instanceId: string): Promise<ConnectionResult>;
|
||||
delete(instanceId: string, revision: number): Promise<void>;
|
||||
}
|
||||
|
||||
interface ProblemBody {
|
||||
readonly detail?: unknown;
|
||||
}
|
||||
interface Preparation {
|
||||
readonly id: string;
|
||||
readonly confirmationToken: string;
|
||||
}
|
||||
const DELETE_SCHEMA = 'deleteInstance.parameters.v1';
|
||||
const SAFE_FALLBACK = 'The instance operation could not be completed.';
|
||||
|
||||
function ownerPath(instanceId: string, suffix = ''): string {
|
||||
return `/api/v1/instances/${encodeURIComponent(instanceId)}${suffix}`;
|
||||
}
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
function managed(value: unknown, ownerId?: string): ManagedInstance {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.id !== 'string' ||
|
||||
typeof value.name !== 'string' ||
|
||||
typeof value.origin !== 'string' ||
|
||||
!Array.isArray(value.tags) ||
|
||||
!value.tags.every((tag) => typeof tag === 'string') ||
|
||||
!Number.isSafeInteger(value.revision) ||
|
||||
typeof value.credentialConfigured !== 'boolean'
|
||||
)
|
||||
throw new Error('The server returned an invalid instance response.');
|
||||
if (ownerId !== undefined && value.id !== ownerId)
|
||||
throw new Error('The returned instance does not match this route.');
|
||||
return value as unknown as ManagedInstance;
|
||||
}
|
||||
async function safeError(response: Response): Promise<Error> {
|
||||
let body: ProblemBody = {};
|
||||
try {
|
||||
body = (await response.json()) as ProblemBody;
|
||||
} catch {
|
||||
// Never include response text: an upstream failure could contain a credential.
|
||||
}
|
||||
const detail =
|
||||
typeof body.detail === 'string' && body.detail.length <= 300 ? body.detail : SAFE_FALLBACK;
|
||||
return new Error(detail);
|
||||
}
|
||||
interface JsonResponse {
|
||||
readonly body: unknown;
|
||||
readonly response: Response;
|
||||
}
|
||||
async function request(url: string, init?: RequestInit): Promise<JsonResponse> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...init?.headers },
|
||||
});
|
||||
if (!response.ok) throw await safeError(response);
|
||||
return {
|
||||
body: response.status === 204 ? undefined : await response.json(),
|
||||
response,
|
||||
};
|
||||
}
|
||||
async function requestJson(url: string, init?: RequestInit): Promise<unknown> {
|
||||
return (await request(url, init)).body;
|
||||
}
|
||||
function strongEtag(response: Response): string {
|
||||
const value = response.headers.get('ETag');
|
||||
if (value === null || !/^"[^"\r\n]+"$/.test(value))
|
||||
throw new Error('The server did not return a valid strong ETag.');
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createInstanceApiDataSource(): InstanceDataSource {
|
||||
const etags = new Map<string, { readonly revision: number; readonly value: string }>();
|
||||
const instanceResponse = async (
|
||||
url: string,
|
||||
ownerId?: string,
|
||||
init?: RequestInit,
|
||||
): Promise<ManagedInstance> => {
|
||||
const result = await request(url, init);
|
||||
const value = managed(result.body, ownerId);
|
||||
etags.set(value.id, { revision: value.revision, value: strongEtag(result.response) });
|
||||
return value;
|
||||
};
|
||||
return {
|
||||
async get(instanceId) {
|
||||
return instanceResponse(ownerPath(instanceId), instanceId);
|
||||
},
|
||||
async create(input) {
|
||||
return instanceResponse('/api/v1/instances', undefined, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
},
|
||||
async update(instanceId, revision, patch) {
|
||||
const current = etags.get(instanceId);
|
||||
if (current === undefined || current.revision !== revision)
|
||||
throw new Error('A current ETag is required before updating this instance.');
|
||||
return instanceResponse(ownerPath(instanceId), instanceId, {
|
||||
method: 'PATCH',
|
||||
headers: { 'If-Match': current.value },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
},
|
||||
async testConnection(instanceId) {
|
||||
const result = await requestJson(ownerPath(instanceId, '/test-connection'), {
|
||||
method: 'POST',
|
||||
});
|
||||
if (
|
||||
!isRecord(result) ||
|
||||
typeof result.reachable !== 'boolean' ||
|
||||
typeof result.authenticated !== 'boolean'
|
||||
)
|
||||
throw new Error('The server returned an invalid connection result.');
|
||||
return { reachable: result.reachable, authenticated: result.authenticated };
|
||||
},
|
||||
async delete(instanceId, revision) {
|
||||
const prepared = await requestJson('/api/v1/operations/prepare', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId, revision }],
|
||||
parameters: { parameterSchemaId: DELETE_SCHEMA, fields: [] },
|
||||
}),
|
||||
});
|
||||
if (
|
||||
!isRecord(prepared) ||
|
||||
typeof prepared.id !== 'string' ||
|
||||
typeof prepared.confirmationToken !== 'string'
|
||||
)
|
||||
throw new Error('The server returned an invalid deletion confirmation.');
|
||||
const confirmation = prepared as unknown as Preparation;
|
||||
await requestJson('/api/v1/operations/execute', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
preparationId: confirmation.id,
|
||||
confirmationToken: confirmation.confirmationToken,
|
||||
}),
|
||||
});
|
||||
etags.delete(instanceId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function tagsFromInput(value: string): readonly string[] {
|
||||
return [
|
||||
...new Set(
|
||||
value
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
export function passwordUpdate(
|
||||
action: 'preserve' | 'set' | 'clear',
|
||||
password: string,
|
||||
): PasswordUpdate {
|
||||
if (action === 'set') {
|
||||
if (!password) throw new Error('Enter a password to set.');
|
||||
return { action, password };
|
||||
}
|
||||
return { action };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { InstanceEditor, type InstanceDataSource, type ManagedInstance } from './instance-crud.js';
|
||||
|
||||
const owner: ManagedInstance = {
|
||||
id: 'owner',
|
||||
name: 'Owner modem',
|
||||
origin: 'https://owner.example',
|
||||
tags: ['west', 'lab'],
|
||||
revision: 3,
|
||||
credentialConfigured: true,
|
||||
};
|
||||
|
||||
function dataSource(overrides: Partial<InstanceDataSource> = {}): InstanceDataSource {
|
||||
return {
|
||||
get: vi.fn(async () => owner),
|
||||
create: vi.fn(async () => owner),
|
||||
update: vi.fn(async () => ({ ...owner, revision: 4 })),
|
||||
testConnection: vi.fn(async () => ({ reachable: true, authenticated: true })),
|
||||
delete: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('Instance CRUD form', () => {
|
||||
it('creates with tags and an explicit set-password action without rendering the secret', async () => {
|
||||
const user = userEvent.setup();
|
||||
const source = dataSource();
|
||||
render(<InstanceEditor mode="create" dataSource={source} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Name'), 'Lab modem');
|
||||
await user.type(screen.getByLabelText('Origin'), 'https://lab.example/admin');
|
||||
await user.type(screen.getByLabelText('Tags'), 'lab, west, lab');
|
||||
await user.selectOptions(screen.getByLabelText('Authentication method'), 'password');
|
||||
await user.type(screen.getByLabelText('Password'), 'do-not-render');
|
||||
expect(document.body.textContent).not.toContain('do-not-render');
|
||||
await user.click(screen.getByRole('button', { name: 'Add instance' }));
|
||||
|
||||
expect(source.create).toHaveBeenCalledWith({
|
||||
name: 'Lab modem',
|
||||
origin: 'https://lab.example/admin',
|
||||
tags: ['lab', 'west'],
|
||||
password: { action: 'set', password: 'do-not-render' },
|
||||
});
|
||||
});
|
||||
|
||||
it('loads only the route owner and preserves, sets, or clears its credential explicitly', async () => {
|
||||
const user = userEvent.setup();
|
||||
const source = dataSource();
|
||||
const { rerender } = render(
|
||||
<InstanceEditor mode="edit" instanceId="owner" dataSource={source} />,
|
||||
);
|
||||
expect(await screen.findByDisplayValue('Owner modem')).toBeTruthy();
|
||||
expect(source.get).toHaveBeenCalledWith('owner');
|
||||
|
||||
await user.clear(screen.getByLabelText('Name'));
|
||||
await user.type(screen.getByLabelText('Name'), 'Renamed');
|
||||
await user.click(screen.getByRole('button', { name: 'Save changes' }));
|
||||
expect(source.update).toHaveBeenLastCalledWith(
|
||||
'owner',
|
||||
3,
|
||||
expect.objectContaining({ password: { action: 'preserve' } }),
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText('Password action'), 'clear');
|
||||
await user.click(screen.getByRole('button', { name: 'Save changes' }));
|
||||
expect(source.update).toHaveBeenLastCalledWith(
|
||||
'owner',
|
||||
4,
|
||||
expect.objectContaining({ password: { action: 'clear' } }),
|
||||
);
|
||||
|
||||
rerender(<InstanceEditor mode="edit" instanceId="intruder" dataSource={source} />);
|
||||
expect((await screen.findByRole('alert')).textContent).toMatch(/does not match this route/i);
|
||||
expect(screen.queryByDisplayValue('Owner modem')).toBeNull();
|
||||
});
|
||||
|
||||
it('tests the persisted owner connection and keeps safe errors visible while editing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const source = dataSource({
|
||||
update: vi.fn(async () => {
|
||||
throw new Error('Could not save this instance.');
|
||||
}),
|
||||
});
|
||||
render(<InstanceEditor mode="edit" instanceId="owner" dataSource={source} />);
|
||||
await screen.findByDisplayValue('Owner modem');
|
||||
await user.click(screen.getByRole('button', { name: 'Test connection' }));
|
||||
expect((await screen.findByRole('status')).textContent).toMatch(/reachable and authenticated/i);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save changes' }));
|
||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||
'Could not save this instance.',
|
||||
);
|
||||
await user.type(screen.getByLabelText('Name'), ' still here');
|
||||
expect(screen.getByRole('alert')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('requires an owner-bound typed confirmation before deletion', async () => {
|
||||
const user = userEvent.setup();
|
||||
const source = dataSource();
|
||||
render(<InstanceEditor mode="edit" instanceId="owner" dataSource={source} />);
|
||||
await screen.findByDisplayValue('Owner modem');
|
||||
await user.click(screen.getByRole('button', { name: 'Delete instance' }));
|
||||
expect(
|
||||
(screen.getByRole('button', { name: 'Confirm deletion' }) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
await user.type(screen.getByLabelText('Type owner to confirm'), 'owner');
|
||||
await user.click(screen.getByRole('button', { name: 'Confirm deletion' }));
|
||||
expect(source.delete).toHaveBeenCalledWith('owner', 3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
|
||||
import {
|
||||
createInstanceApiDataSource,
|
||||
passwordUpdate,
|
||||
tagsFromInput,
|
||||
type InstanceDataSource,
|
||||
type ManagedInstance,
|
||||
} from './instance-api-data-source.js';
|
||||
export type { InstanceDataSource, ManagedInstance } from './instance-api-data-source.js';
|
||||
|
||||
export interface InstanceEditorProps {
|
||||
readonly mode: 'create' | 'edit';
|
||||
readonly instanceId?: string;
|
||||
readonly dataSource?: InstanceDataSource;
|
||||
}
|
||||
type PasswordAction = 'preserve' | 'set' | 'clear';
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error && error.message ? error.message : 'The instance operation failed.';
|
||||
}
|
||||
|
||||
export function InstanceEditor({
|
||||
mode,
|
||||
instanceId,
|
||||
dataSource = createInstanceApiDataSource(),
|
||||
}: InstanceEditorProps) {
|
||||
const [owner, setOwner] = useState<ManagedInstance>();
|
||||
const [name, setName] = useState('');
|
||||
const [origin, setOrigin] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [authMethod, setAuthMethod] = useState<'none' | 'password'>('none');
|
||||
const [passwordAction, setPasswordAction] = useState<PasswordAction>(
|
||||
mode === 'create' ? 'set' : 'preserve',
|
||||
);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string>();
|
||||
const [status, setStatus] = useState<string>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [confirmation, setConfirmation] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'edit') return;
|
||||
if (!instanceId) {
|
||||
setError('The instance route is missing an owner.');
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
setOwner(undefined);
|
||||
setError(undefined);
|
||||
void dataSource
|
||||
.get(instanceId)
|
||||
.then((loaded) => {
|
||||
if (!active) return;
|
||||
if (loaded.id !== instanceId)
|
||||
throw new Error('The returned instance does not match this route.');
|
||||
setOwner(loaded);
|
||||
setName(loaded.name);
|
||||
setOrigin(loaded.origin);
|
||||
setTags(loaded.tags.join(', '));
|
||||
setAuthMethod(loaded.credentialConfigured ? 'password' : 'none');
|
||||
setPasswordAction('preserve');
|
||||
setPassword('');
|
||||
})
|
||||
.catch((cause: unknown) => active && setError(message(cause)));
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [dataSource, instanceId, mode]);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setStatus(undefined);
|
||||
try {
|
||||
const action =
|
||||
authMethod === 'none' ? (mode === 'create' ? undefined : 'clear') : passwordAction;
|
||||
const secret = action ? passwordUpdate(action, password) : undefined;
|
||||
const input = {
|
||||
name,
|
||||
origin,
|
||||
tags: tagsFromInput(tags),
|
||||
...(secret ? { password: secret } : {}),
|
||||
};
|
||||
const saved =
|
||||
mode === 'create'
|
||||
? await dataSource.create(input)
|
||||
: await dataSource.update(owner!.id, owner!.revision, input);
|
||||
if (mode === 'edit' && saved.id !== instanceId)
|
||||
throw new Error('The returned instance does not match this route.');
|
||||
setOwner(saved);
|
||||
setPassword('');
|
||||
setPasswordAction('preserve');
|
||||
setStatus(mode === 'create' ? 'Instance added.' : 'Changes saved.');
|
||||
setError(undefined);
|
||||
} catch (cause) {
|
||||
setError(message(cause));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
if (!owner || owner.id !== instanceId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await dataSource.testConnection(owner.id);
|
||||
setStatus(
|
||||
result.reachable
|
||||
? result.authenticated
|
||||
? 'Connection is reachable and authenticated.'
|
||||
: 'Connection is reachable; authentication is required.'
|
||||
: 'Connection is not reachable.',
|
||||
);
|
||||
setError(undefined);
|
||||
} catch (cause) {
|
||||
setError(message(cause));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!owner || owner.id !== instanceId || confirmation !== owner.id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await dataSource.delete(owner.id, owner.revision);
|
||||
setStatus('Instance deletion accepted.');
|
||||
setError(undefined);
|
||||
setConfirming(false);
|
||||
} catch (cause) {
|
||||
setError(message(cause));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'edit' && !owner)
|
||||
return error ? (
|
||||
<p role="alert" className="state-panel state-error">
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<p role="status">Loading instance…</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="instance-editor">
|
||||
<h1>{mode === 'create' ? 'Add instance' : 'Instance settings'}</h1>
|
||||
{error ? (
|
||||
<p role="alert" className="state-panel state-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{status ? (
|
||||
<p role="status" className="state-panel">
|
||||
{status}
|
||||
</p>
|
||||
) : null}
|
||||
<form onSubmit={submit}>
|
||||
<label>
|
||||
Name
|
||||
<input required value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Origin
|
||||
<input
|
||||
required
|
||||
type="url"
|
||||
value={origin}
|
||||
onChange={(event) => setOrigin(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Tags
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
aria-describedby="tags-help"
|
||||
/>
|
||||
</label>
|
||||
<small id="tags-help">Comma-separated tags</small>
|
||||
<label>
|
||||
Authentication method
|
||||
<select
|
||||
value={authMethod}
|
||||
onChange={(event) => setAuthMethod(event.target.value as 'none' | 'password')}
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="password">Password</option>
|
||||
</select>
|
||||
</label>
|
||||
{authMethod === 'password' ? (
|
||||
<>
|
||||
{mode === 'edit' ? (
|
||||
<label>
|
||||
Password action
|
||||
<select
|
||||
value={passwordAction}
|
||||
onChange={(event) => {
|
||||
setPasswordAction(event.target.value as PasswordAction);
|
||||
setPassword('');
|
||||
}}
|
||||
>
|
||||
<option value="preserve">Keep saved password</option>
|
||||
<option value="set">Set new password</option>
|
||||
<option value="clear">Clear saved password</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
{mode === 'create' || passwordAction === 'set' ? (
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<div className="form-actions">
|
||||
<button disabled={busy} type="submit">
|
||||
{mode === 'create' ? 'Add instance' : 'Save changes'}
|
||||
</button>
|
||||
{mode === 'edit' ? (
|
||||
<button disabled={busy} type="button" onClick={() => void testConnection()}>
|
||||
Test connection
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
{mode === 'edit' ? (
|
||||
<section className="danger-zone" aria-labelledby="danger-heading">
|
||||
<h2 id="danger-heading">Danger zone</h2>
|
||||
{!confirming ? (
|
||||
<button type="button" onClick={() => setConfirming(true)}>
|
||||
Delete instance
|
||||
</button>
|
||||
) : (
|
||||
<div>
|
||||
<p>Deletion cannot be undone.</p>
|
||||
<label>
|
||||
Type {owner!.id} to confirm
|
||||
<input
|
||||
value={confirmation}
|
||||
onChange={(event) => setConfirmation(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
disabled={busy || confirmation !== owner!.id}
|
||||
type="button"
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
Confirm deletion
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirming(false);
|
||||
setConfirmation('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { InstanceContext, InstanceModule } from '../app-shell.js';
|
||||
import {
|
||||
InstanceDetail,
|
||||
type CapabilityDataSource,
|
||||
type InstanceCapabilityMap,
|
||||
} from './instance-detail.js';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const owner: InstanceContext = {
|
||||
id: 'owner',
|
||||
name: 'Owner modem',
|
||||
origin: 'https://owner.example/path',
|
||||
status: 'online',
|
||||
authentication: 'authenticated',
|
||||
freshness: 'fresh',
|
||||
};
|
||||
|
||||
const capabilities: InstanceCapabilityMap = {
|
||||
overview: { state: 'supported' },
|
||||
messages: { state: 'degraded', explanation: 'Message history is read-only.' },
|
||||
calls: { state: 'unsupported', explanation: 'This modem has no voice support.' },
|
||||
esim: { state: 'unknown', explanation: 'Capability discovery did not report eSIM.' },
|
||||
};
|
||||
|
||||
describe('InstanceDetail', () => {
|
||||
it('makes only supported modules actionable and explains every other capability state', () => {
|
||||
render(
|
||||
<InstanceDetail
|
||||
instanceId="owner"
|
||||
module="overview"
|
||||
instance={owner}
|
||||
capabilities={capabilities}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Overview' }).getAttribute('href')).toBe(
|
||||
'/instances/owner/overview',
|
||||
);
|
||||
expect(screen.queryByRole('link', { name: 'Messages' })).toBeNull();
|
||||
expect(screen.getByText('Message history is read-only.')).toBeTruthy();
|
||||
expect(screen.getByText('This modem has no voice support.')).toBeTruthy();
|
||||
expect(screen.getByText('Capability discovery did not report eSIM.')).toBeTruthy();
|
||||
expect(screen.getAllByText('Capability state is unknown.').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('never renders or loads context when the direct-route owner does not match', () => {
|
||||
const load = vi.fn<CapabilityDataSource['load']>();
|
||||
render(
|
||||
<InstanceDetail
|
||||
instanceId="someone-else"
|
||||
module="messages"
|
||||
instance={owner}
|
||||
capabilityDataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Owner modem')).toBeNull();
|
||||
expect(screen.queryByRole('navigation', { name: 'Instance modules' })).toBeNull();
|
||||
expect(screen.getByText(/Instance context is unavailable/i)).toBeTruthy();
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts the prior owner load and fences stale results to the latest owner', async () => {
|
||||
const pending = new Map<
|
||||
string,
|
||||
{ resolve: (value: InstanceCapabilityMap) => void; signal: AbortSignal }
|
||||
>();
|
||||
const dataSource: CapabilityDataSource = {
|
||||
load(instanceId, signal) {
|
||||
return new Promise((resolve) => pending.set(instanceId, { resolve, signal }));
|
||||
},
|
||||
};
|
||||
const second: InstanceContext = { ...owner, id: 'second', name: 'Second modem' };
|
||||
const { rerender } = render(
|
||||
<InstanceDetail
|
||||
instanceId="owner"
|
||||
module="overview"
|
||||
instance={owner}
|
||||
capabilityDataSource={dataSource}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(pending.has('owner')).toBe(true));
|
||||
|
||||
rerender(
|
||||
<InstanceDetail
|
||||
instanceId="second"
|
||||
module="overview"
|
||||
instance={second}
|
||||
capabilityDataSource={dataSource}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(pending.has('second')).toBe(true));
|
||||
expect(pending.get('owner')?.signal.aborted).toBe(true);
|
||||
|
||||
pending.get('second')?.resolve({ overview: { state: 'supported' } });
|
||||
expect(await screen.findByRole('link', { name: 'Overview' })).toBeTruthy();
|
||||
pending
|
||||
.get('owner')
|
||||
?.resolve({ overview: { state: 'unsupported', explanation: 'Stale owner result' } });
|
||||
await waitFor(() => expect(screen.queryByText('Stale owner result')).toBeNull());
|
||||
expect(screen.getByText('Second modem')).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each<[InstanceModule, string]>([
|
||||
['cellular', 'Cellular'],
|
||||
['device-network', 'Device Network'],
|
||||
['automation', 'Automation'],
|
||||
['ota', 'OTA'],
|
||||
])('uses the existing module contract for %s', (module, label) => {
|
||||
render(
|
||||
<InstanceDetail
|
||||
instanceId="owner"
|
||||
module={module}
|
||||
instance={owner}
|
||||
capabilities={{ [module]: { state: 'supported' } }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: label })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { InstanceContext, InstanceModule } from '../app-shell.js';
|
||||
import { canonicalHttpOrigin } from '../fleet/fleet-page.js';
|
||||
|
||||
export type CapabilityState = 'supported' | 'degraded' | 'unsupported' | 'unknown';
|
||||
|
||||
export interface InstanceCapability {
|
||||
readonly state: CapabilityState;
|
||||
readonly explanation?: string;
|
||||
}
|
||||
|
||||
export type InstanceCapabilityMap = Readonly<Partial<Record<InstanceModule, InstanceCapability>>>;
|
||||
|
||||
export interface CapabilityDataSource {
|
||||
load(instanceId: string, signal: AbortSignal): Promise<InstanceCapabilityMap>;
|
||||
}
|
||||
|
||||
export interface InstanceDetailProps {
|
||||
readonly instanceId: string;
|
||||
readonly module: InstanceModule;
|
||||
readonly instance?: InstanceContext;
|
||||
readonly capabilities?: InstanceCapabilityMap;
|
||||
readonly capabilityDataSource?: CapabilityDataSource;
|
||||
}
|
||||
|
||||
export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> = {
|
||||
overview: 'Overview',
|
||||
cellular: 'Cellular',
|
||||
'device-network': 'Device Network',
|
||||
messages: 'Messages',
|
||||
calls: 'Calls',
|
||||
esim: 'eSIM',
|
||||
notifications: 'Notifications',
|
||||
automation: 'Automation',
|
||||
ota: 'OTA',
|
||||
};
|
||||
|
||||
const MODULES = Object.keys(INSTANCE_MODULE_LABELS) as readonly InstanceModule[];
|
||||
|
||||
const DEFAULT_EXPLANATIONS: Readonly<Record<Exclude<CapabilityState, 'supported'>, string>> = {
|
||||
degraded: 'This module is available with limited functionality.',
|
||||
unsupported: 'This instance does not support this module.',
|
||||
unknown: 'Capability state is unknown.',
|
||||
};
|
||||
|
||||
function capabilityFor(map: InstanceCapabilityMap, module: InstanceModule): InstanceCapability {
|
||||
return map[module] ?? { state: 'unknown' };
|
||||
}
|
||||
|
||||
function explanation(capability: InstanceCapability): string | null {
|
||||
if (capability.state === 'supported') return null;
|
||||
return capability.explanation?.trim() || DEFAULT_EXPLANATIONS[capability.state];
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-fenced instance context and capability navigation for every instance route.
|
||||
* Capability discovery is injected: this framework deliberately assumes no API or operation IDs.
|
||||
*/
|
||||
export function InstanceDetail({
|
||||
instanceId,
|
||||
module,
|
||||
instance,
|
||||
capabilities,
|
||||
capabilityDataSource,
|
||||
}: InstanceDetailProps) {
|
||||
const ownsRoute = instance?.id === instanceId;
|
||||
const [loadedCapabilities, setLoadedCapabilities] = useState<InstanceCapabilityMap | undefined>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const requestRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const request = ++requestRef.current;
|
||||
setLoadedCapabilities(undefined);
|
||||
setLoadError(null);
|
||||
|
||||
if (!ownsRoute || capabilities || !capabilityDataSource) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void capabilityDataSource.load(instanceId, controller.signal).then(
|
||||
(result) => {
|
||||
if (!controller.signal.aborted && requestRef.current === request) {
|
||||
setLoadedCapabilities(result);
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!controller.signal.aborted && requestRef.current === request) {
|
||||
setLoadError(error instanceof Error ? error.message : 'Capability discovery failed.');
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
return () => controller.abort();
|
||||
}, [capabilities, capabilityDataSource, instanceId, ownsRoute]);
|
||||
|
||||
if (!ownsRoute) {
|
||||
return (
|
||||
<section>
|
||||
<h1>{INSTANCE_MODULE_LABELS[module]}</h1>
|
||||
<p>Instance context is unavailable for this route.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const map = capabilities ?? loadedCapabilities ?? {};
|
||||
const origin = canonicalHttpOrigin(instance.origin);
|
||||
const activeCapability = capabilityFor(map, module);
|
||||
|
||||
return (
|
||||
<section className="instance-detail">
|
||||
<aside className="instance-context" aria-label="Current instance">
|
||||
<strong>{instance.name}</strong>
|
||||
<code>{instance.id}</code>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{instance.status}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Authentication</dt>
|
||||
<dd>{instance.authentication}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Freshness</dt>
|
||||
<dd>{instance.freshness}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{origin ? (
|
||||
<a href={origin} target="_blank" rel="noopener noreferrer">
|
||||
Open original site
|
||||
</a>
|
||||
) : null}
|
||||
<nav aria-label="Instance modules">
|
||||
<ul>
|
||||
{MODULES.map((item) => {
|
||||
const capability = capabilityFor(map, item);
|
||||
const reason = explanation(capability);
|
||||
return (
|
||||
<li key={item} data-capability-state={capability.state}>
|
||||
{capability.state === 'supported' ? (
|
||||
<a
|
||||
href={`/instances/${encodeURIComponent(instanceId)}/${item}`}
|
||||
aria-current={module === item ? 'page' : undefined}
|
||||
>
|
||||
{INSTANCE_MODULE_LABELS[item]}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<span>{INSTANCE_MODULE_LABELS[item]}</span>
|
||||
<small>{reason}</small>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="instance-module-detail">
|
||||
<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>
|
||||
) : null}
|
||||
{!loading && activeCapability.state !== 'supported' ? (
|
||||
<p data-capability-state={activeCapability.state}>{explanation(activeCapability)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user