feat(web): complete fleet and instance management slices
This commit is contained in:
@@ -6,7 +6,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { AppShell, type InstanceContext } from './app-shell.js';
|
import { AppShell, type InstanceContext } from './app-shell.js';
|
||||||
import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
const snapshot: FleetSnapshot = {
|
const snapshot: FleetSnapshot = {
|
||||||
instances: [
|
instances: [
|
||||||
@@ -19,8 +22,29 @@ const snapshot: FleetSnapshot = {
|
|||||||
{ id: 'alpha', name: 'Alpha', url: 'https://alpha.example/admin' },
|
{ id: 'alpha', name: 'Alpha', url: 'https://alpha.example/admin' },
|
||||||
],
|
],
|
||||||
statuses: new Map([
|
statuses: new Map([
|
||||||
['bravo', { reachable: true, authenticated: false, latencyMs: 40 }],
|
[
|
||||||
['alpha', { reachable: true, authenticated: true, latencyMs: 10 }],
|
'bravo',
|
||||||
|
{
|
||||||
|
reachable: true,
|
||||||
|
authenticated: false,
|
||||||
|
latencyMs: 40,
|
||||||
|
summary: {
|
||||||
|
version: '2.0',
|
||||||
|
capabilities: ['sms'],
|
||||||
|
freshness: 'stale',
|
||||||
|
anomalies: ['clock drift'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'alpha',
|
||||||
|
{
|
||||||
|
reachable: true,
|
||||||
|
authenticated: true,
|
||||||
|
latencyMs: 10,
|
||||||
|
summary: { version: '1.0', capabilities: ['ussd'], freshness: 'fresh' },
|
||||||
|
},
|
||||||
|
],
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,7 +53,8 @@ function source(load: FleetDataSource['load']): FleetDataSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('React AppShell and Fleet vertical slice', () => {
|
describe('React AppShell and Fleet vertical slice', () => {
|
||||||
it('loads real injected data and renders canonical origins and owner routes', async () => {
|
it('loads real injected data and renders canonical origins and owner routes without React key warnings', async () => {
|
||||||
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
let resolve!: (value: FleetSnapshot) => void;
|
let resolve!: (value: FleetSnapshot) => void;
|
||||||
const dataSource = source(() => new Promise((done) => (resolve = done)));
|
const dataSource = source(() => new Promise((done) => (resolve = done)));
|
||||||
render(<AppShell pathname="/fleet" version="0.1.0" fleetDataSource={dataSource} />);
|
render(<AppShell pathname="/fleet" version="0.1.0" fleetDataSource={dataSource} />);
|
||||||
@@ -49,6 +74,11 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
.getAttribute('href'),
|
.getAttribute('href'),
|
||||||
).toBe('http://bravo.example:8080');
|
).toBe('http://bravo.example:8080');
|
||||||
expect(screen.getByText('Version 0.1.0')).toBeTruthy();
|
expect(screen.getByText('Version 0.1.0')).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
consoleError.mock.calls.some((call) =>
|
||||||
|
call.some((argument) => String(argument).includes('unique "key" prop')),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports accessible search, status filtering, sorting, and visible selection', async () => {
|
it('supports accessible search, status filtering, sorting, and visible selection', async () => {
|
||||||
@@ -102,6 +132,49 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports metadata filters, column visibility, pagination, and a batch-action entry', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
|
||||||
|
await screen.findByRole('row', { name: /Alpha/ });
|
||||||
|
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', { name: 'Capability' }), 'sms');
|
||||||
|
expect(screen.queryByRole('row', { name: /Alpha/ })).toBeNull();
|
||||||
|
expect(screen.getByRole('row', { name: /Bravo/ }).textContent).toContain('clock drift');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Choose columns' }));
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: 'Show anomalies column' }));
|
||||||
|
expect(screen.queryByRole('columnheader', { name: 'Anomalies' })).toBeNull();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: 'Select Bravo' }));
|
||||||
|
expect(
|
||||||
|
(screen.getByRole('button', { name: 'Batch actions' }) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(false);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Batch actions' }));
|
||||||
|
expect(screen.getByText('Choose an action for 1 selected instance.')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates fleet rows and selects only the current page', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const many: FleetSnapshot = {
|
||||||
|
instances: Array.from({ length: 11 }, (_, index) => ({
|
||||||
|
id: `instance-${String(index + 1).padStart(2, '0')}`,
|
||||||
|
name: `Instance ${String(index + 1).padStart(2, '0')}`,
|
||||||
|
url: `https://instance-${index + 1}.example`,
|
||||||
|
})),
|
||||||
|
statuses: new Map(),
|
||||||
|
};
|
||||||
|
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => many)} />);
|
||||||
|
await screen.findByRole('row', { name: /Instance 01/ });
|
||||||
|
|
||||||
|
expect(screen.queryByRole('row', { name: /Instance 11/ })).toBeNull();
|
||||||
|
expect(screen.getByText('Page 1 of 2')).toBeTruthy();
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: 'Select all visible instances' }));
|
||||||
|
expect(screen.getByText('10 selected')).toBeTruthy();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Next page' }));
|
||||||
|
expect(await screen.findByRole('row', { name: /Instance 11/ })).toBeTruthy();
|
||||||
|
expect(screen.getByText('Page 2 of 2')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('binds instance context to the route owner and never leaks mismatched context', () => {
|
it('binds instance context to the route owner and never leaks mismatched context', () => {
|
||||||
const instance: InstanceContext = {
|
const instance: InstanceContext = {
|
||||||
id: 'owner',
|
id: 'owner',
|
||||||
|
|||||||
+47
-84
@@ -1,12 +1,14 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import {
|
import { FleetPage, type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
||||||
FleetPage,
|
|
||||||
canonicalHttpOrigin,
|
|
||||||
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 {
|
||||||
|
InstanceDetail,
|
||||||
|
INSTANCE_MODULE_LABELS,
|
||||||
|
type CapabilityDataSource,
|
||||||
|
type InstanceCapabilityMap,
|
||||||
|
} from './instances/instance-detail.js';
|
||||||
|
|
||||||
export type GlobalSection = 'fleet' | 'jobs' | 'audit' | 'settings';
|
export type GlobalSection = 'fleet' | 'jobs' | 'audit' | 'settings';
|
||||||
export type InstanceModule =
|
export type InstanceModule =
|
||||||
@@ -52,19 +54,12 @@ export interface AppShellProps {
|
|||||||
instance?: InstanceContext;
|
instance?: InstanceContext;
|
||||||
fleetDataSource?: FleetDataSource;
|
fleetDataSource?: FleetDataSource;
|
||||||
fleetData?: FleetSnapshot;
|
fleetData?: FleetSnapshot;
|
||||||
|
instanceDataSource?: InstanceDataSource;
|
||||||
|
capabilities?: InstanceCapabilityMap;
|
||||||
|
capabilityDataSource?: CapabilityDataSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODULE_LABELS: Readonly<Record<InstanceModule, string>> = {
|
const MODULE_LABELS = INSTANCE_MODULE_LABELS;
|
||||||
overview: 'Overview',
|
|
||||||
cellular: 'Cellular',
|
|
||||||
'device-network': 'Device Network',
|
|
||||||
messages: 'Messages',
|
|
||||||
calls: 'Calls',
|
|
||||||
esim: 'eSIM',
|
|
||||||
notifications: 'Notifications',
|
|
||||||
automation: 'Automation',
|
|
||||||
ota: 'OTA',
|
|
||||||
};
|
|
||||||
const INSTANCE_MODULES = new Set(Object.keys(MODULE_LABELS));
|
const INSTANCE_MODULES = new Set(Object.keys(MODULE_LABELS));
|
||||||
|
|
||||||
function normalize(pathname: string): string {
|
function normalize(pathname: string): string {
|
||||||
@@ -120,65 +115,22 @@ function section(route: ResolvedRoute): GlobalSection | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InstanceSidebar({
|
|
||||||
instance,
|
|
||||||
module,
|
|
||||||
}: {
|
|
||||||
instance: InstanceContext;
|
|
||||||
module: InstanceModule;
|
|
||||||
}) {
|
|
||||||
const origin = canonicalHttpOrigin(instance.origin);
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
{Object.entries(MODULE_LABELS).map(([key, label]) => (
|
|
||||||
<li key={key}>
|
|
||||||
<a
|
|
||||||
href={`/instances/${encodeURIComponent(instance.id)}/${key}`}
|
|
||||||
aria-current={module === key ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Page({
|
function Page({
|
||||||
route,
|
route,
|
||||||
fleetDataSource,
|
fleetDataSource,
|
||||||
fleetData,
|
fleetData,
|
||||||
instance,
|
instance,
|
||||||
|
instanceDataSource,
|
||||||
|
capabilities,
|
||||||
|
capabilityDataSource,
|
||||||
}: {
|
}: {
|
||||||
route: ResolvedRoute;
|
route: ResolvedRoute;
|
||||||
fleetDataSource: FleetDataSource | undefined;
|
fleetDataSource: FleetDataSource | undefined;
|
||||||
fleetData: FleetSnapshot | undefined;
|
fleetData: FleetSnapshot | undefined;
|
||||||
instance: InstanceContext | undefined;
|
instance: InstanceContext | undefined;
|
||||||
|
instanceDataSource: InstanceDataSource | undefined;
|
||||||
|
capabilities: InstanceCapabilityMap | undefined;
|
||||||
|
capabilityDataSource: CapabilityDataSource | undefined;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
if (route.kind === 'fleet')
|
if (route.kind === 'fleet')
|
||||||
return (
|
return (
|
||||||
@@ -187,6 +139,21 @@ function Page({
|
|||||||
{...(fleetData ? { initialData: fleetData } : {})}
|
{...(fleetData ? { initialData: fleetData } : {})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
if (route.kind === 'instance-new')
|
||||||
|
return (
|
||||||
|
<InstanceEditor
|
||||||
|
mode="create"
|
||||||
|
{...(instanceDataSource ? { dataSource: instanceDataSource } : {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (route.kind === 'settings-instance-detail')
|
||||||
|
return (
|
||||||
|
<InstanceEditor
|
||||||
|
mode="edit"
|
||||||
|
{...(route.params?.instanceId ? { instanceId: route.params.instanceId } : {})}
|
||||||
|
{...(instanceDataSource ? { dataSource: instanceDataSource } : {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
if (route.kind === 'not-found')
|
if (route.kind === 'not-found')
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
@@ -195,18 +162,16 @@ function Page({
|
|||||||
<a href="/fleet">Return to Fleet</a>
|
<a href="/fleet">Return to Fleet</a>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
if (route.kind.startsWith('instance-') && route.kind !== 'instance-new') {
|
if (route.kind.startsWith('instance-')) {
|
||||||
const module = route.kind.slice('instance-'.length) as InstanceModule;
|
const module = route.kind.slice('instance-'.length) as InstanceModule;
|
||||||
const ownsRoute = instance?.id === route.params?.instanceId;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<InstanceDetail
|
||||||
<h1>{MODULE_LABELS[module]}</h1>
|
instanceId={route.params?.instanceId ?? ''}
|
||||||
{ownsRoute ? (
|
module={module}
|
||||||
<p>Inspect {MODULE_LABELS[module].toLowerCase()} data and available operations.</p>
|
{...(instance ? { instance } : {})}
|
||||||
) : (
|
{...(capabilities ? { capabilities } : {})}
|
||||||
<p>Instance context is unavailable for this route.</p>
|
{...(capabilityDataSource ? { capabilityDataSource } : {})}
|
||||||
)}
|
/>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const labels: Partial<Record<RouteKind, string>> = {
|
const labels: Partial<Record<RouteKind, string>> = {
|
||||||
@@ -233,15 +198,13 @@ export function AppShell({
|
|||||||
instance,
|
instance,
|
||||||
fleetDataSource,
|
fleetDataSource,
|
||||||
fleetData,
|
fleetData,
|
||||||
|
instanceDataSource,
|
||||||
|
capabilities,
|
||||||
|
capabilityDataSource,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
const resolved = resolveRoute(pathname);
|
const resolved = resolveRoute(pathname);
|
||||||
const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved;
|
const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved;
|
||||||
const currentSection = section(route);
|
const currentSection = section(route);
|
||||||
const instanceModule =
|
|
||||||
route.kind.startsWith('instance-') && route.kind !== 'instance-new'
|
|
||||||
? (route.kind.slice('instance-'.length) as InstanceModule)
|
|
||||||
: null;
|
|
||||||
const owner = instanceModule && instance?.id === route.params?.instanceId ? instance : undefined;
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell" data-route={route.kind}>
|
<div className="app-shell" data-route={route.kind}>
|
||||||
<a className="skip-link" href="#main-content">
|
<a className="skip-link" href="#main-content">
|
||||||
@@ -273,15 +236,15 @@ export function AppShell({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
{owner && instanceModule ? (
|
|
||||||
<InstanceSidebar instance={owner} module={instanceModule} />
|
|
||||||
) : null}
|
|
||||||
<main id="main-content" tabIndex={-1}>
|
<main id="main-content" tabIndex={-1}>
|
||||||
<Page
|
<Page
|
||||||
route={route}
|
route={route}
|
||||||
fleetDataSource={fleetDataSource ?? createFleetApiDataSource()}
|
fleetDataSource={fleetDataSource ?? createFleetApiDataSource()}
|
||||||
fleetData={fleetData}
|
fleetData={fleetData}
|
||||||
instance={instance}
|
instance={instance}
|
||||||
|
instanceDataSource={instanceDataSource}
|
||||||
|
capabilities={capabilities}
|
||||||
|
capabilityDataSource={capabilityDataSource}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createFleetApiDataSource } from './fleet-api-data-source.js';
|
||||||
|
|
||||||
|
describe('Fleet API data source', () => {
|
||||||
|
it('parses the frozen InstancePage envelope and maps origin to the Fleet URL', async () => {
|
||||||
|
const fetcher = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'alpha',
|
||||||
|
name: 'Alpha',
|
||||||
|
origin: 'https://alpha.example/admin',
|
||||||
|
tags: ['lab'],
|
||||||
|
revision: 1,
|
||||||
|
capabilityStatus: 'unknown',
|
||||||
|
freshness: 'unknown',
|
||||||
|
credentialConfigured: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
page: { page: 1, pageSize: 20, total: 1 },
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const snapshot = await createFleetApiDataSource(fetcher as typeof fetch).load();
|
||||||
|
expect(snapshot.instances).toEqual([
|
||||||
|
{ id: 'alpha', name: 'Alpha', url: 'https://alpha.example/admin', tags: ['lab'] },
|
||||||
|
]);
|
||||||
|
expect(fetcher).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/instances',
|
||||||
|
expect.objectContaining({ credentials: 'same-origin' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects legacy or malformed envelopes instead of rendering a false empty fleet', async () => {
|
||||||
|
const fetcher = vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200 }));
|
||||||
|
await expect(createFleetApiDataSource(fetcher as typeof fetch).load()).rejects.toThrow(
|
||||||
|
'Fleet response is invalid.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,27 @@ import type { FleetDataSource, FleetSnapshot } from './fleet-page.js';
|
|||||||
import type { FleetInstance } from './fleet-table-view-model.js';
|
import type { FleetInstance } from './fleet-table-view-model.js';
|
||||||
|
|
||||||
interface InstancePage {
|
interface InstancePage {
|
||||||
readonly data?: readonly FleetInstance[];
|
readonly items?: readonly unknown[];
|
||||||
|
readonly page?: {
|
||||||
|
readonly page?: unknown;
|
||||||
|
readonly pageSize?: unknown;
|
||||||
|
readonly total?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInstance(value: unknown): FleetInstance {
|
||||||
|
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
||||||
|
throw new Error('Fleet response is invalid.');
|
||||||
|
const item = value as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof item.id !== 'string' ||
|
||||||
|
typeof item.name !== 'string' ||
|
||||||
|
typeof item.origin !== 'string' ||
|
||||||
|
!Array.isArray(item.tags) ||
|
||||||
|
!item.tags.every((tag) => typeof tag === 'string')
|
||||||
|
)
|
||||||
|
throw new Error('Fleet response is invalid.');
|
||||||
|
return { id: item.id, name: item.name, url: item.origin, tags: item.tags };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDataSource {
|
export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDataSource {
|
||||||
@@ -15,8 +35,8 @@ export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDa
|
|||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`Fleet request failed (${response.status}).`);
|
if (!response.ok) throw new Error(`Fleet request failed (${response.status}).`);
|
||||||
const body = (await response.json()) as InstancePage;
|
const body = (await response.json()) as InstancePage;
|
||||||
if (!Array.isArray(body.data)) throw new Error('Fleet response is invalid.');
|
if (!Array.isArray(body.items)) throw new Error('Fleet response is invalid.');
|
||||||
return { instances: body.data, statuses: new Map() };
|
return { instances: body.items.map(parseInstance), statuses: new Map() };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildFleetTableViewModel,
|
buildFleetTableViewModel,
|
||||||
|
type FleetAuthFilter,
|
||||||
type FleetFilter,
|
type FleetFilter,
|
||||||
type FleetInstance,
|
type FleetInstance,
|
||||||
type FleetSortColumn,
|
type FleetSortColumn,
|
||||||
@@ -13,11 +14,9 @@ export interface FleetSnapshot {
|
|||||||
readonly instances: readonly FleetInstance[];
|
readonly instances: readonly FleetInstance[];
|
||||||
readonly statuses: ReadonlyMap<string, FleetStatus>;
|
readonly statuses: ReadonlyMap<string, FleetStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FleetDataSource {
|
export interface FleetDataSource {
|
||||||
load(): Promise<FleetSnapshot>;
|
load(): Promise<FleetSnapshot>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FleetPageProps {
|
export interface FleetPageProps {
|
||||||
readonly dataSource?: FleetDataSource;
|
readonly dataSource?: FleetDataSource;
|
||||||
readonly initialData?: FleetSnapshot;
|
readonly initialData?: FleetSnapshot;
|
||||||
@@ -30,6 +29,18 @@ const STATUS_LABELS: Readonly<Record<string, string>> = {
|
|||||||
offline: 'Offline',
|
offline: 'Offline',
|
||||||
unknown: 'Unknown',
|
unknown: 'Unknown',
|
||||||
};
|
};
|
||||||
|
const COLUMN_LABELS: Readonly<Record<FleetSortColumn | 'origin', string>> = {
|
||||||
|
name: 'Name',
|
||||||
|
status: 'Status',
|
||||||
|
latency: 'Latency',
|
||||||
|
version: 'Version',
|
||||||
|
capabilities: 'Capabilities',
|
||||||
|
tags: 'Tags',
|
||||||
|
freshness: 'Freshness',
|
||||||
|
anomalies: 'Anomalies',
|
||||||
|
origin: 'Origin',
|
||||||
|
};
|
||||||
|
const ALL_COLUMNS = Object.keys(COLUMN_LABELS) as readonly (FleetSortColumn | 'origin')[];
|
||||||
|
|
||||||
export function canonicalHttpOrigin(value: string): string | null {
|
export function canonicalHttpOrigin(value: string): string | null {
|
||||||
try {
|
try {
|
||||||
@@ -49,11 +60,19 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
|
|||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [filter, setFilter] = useState<FleetFilter>('all');
|
const [filter, setFilter] = useState<FleetFilter>('all');
|
||||||
|
const [auth, setAuth] = useState<FleetAuthFilter>('all');
|
||||||
|
const [capability, setCapability] = useState('');
|
||||||
|
const [version, setVersion] = useState('');
|
||||||
|
const [tag, setTag] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
const [sort, setSort] = useState<{ column: FleetSortColumn; direction: SortDirection }>({
|
const [sort, setSort] = useState<{ column: FleetSortColumn; direction: SortDirection }>({
|
||||||
column: 'name',
|
column: 'name',
|
||||||
direction: 'asc',
|
direction: 'asc',
|
||||||
});
|
});
|
||||||
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
|
||||||
|
const [shownColumns, setShownColumns] = useState<ReadonlySet<string>>(new Set(ALL_COLUMNS));
|
||||||
|
const [columnsOpen, setColumnsOpen] = useState(false);
|
||||||
|
const [batchOpen, setBatchOpen] = useState(false);
|
||||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,24 +107,36 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
|
|||||||
buildFleetTableViewModel(snapshot?.instances ?? [], snapshot?.statuses ?? new Map(), {
|
buildFleetTableViewModel(snapshot?.instances ?? [], snapshot?.statuses ?? new Map(), {
|
||||||
query,
|
query,
|
||||||
filter,
|
filter,
|
||||||
|
auth,
|
||||||
|
...(capability ? { capability } : {}),
|
||||||
|
...(version ? { version } : {}),
|
||||||
|
...(tag ? { tag } : {}),
|
||||||
sort,
|
sort,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
|
page,
|
||||||
}),
|
}),
|
||||||
[filter, query, selectedIds, snapshot, sort],
|
[auth, capability, filter, page, query, selectedIds, snapshot, sort, tag, version],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectAllRef.current)
|
if (selectAllRef.current)
|
||||||
selectAllRef.current.indeterminate = model.visibleSelection.indeterminate;
|
selectAllRef.current.indeterminate = model.visibleSelection.indeterminate;
|
||||||
}, [model.visibleSelection.indeterminate]);
|
}, [model.visibleSelection.indeterminate]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (page !== model.page) setPage(model.page);
|
||||||
|
}, [model.page, page]);
|
||||||
|
|
||||||
|
function resetPage(action: () => void): void {
|
||||||
|
action();
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
function changeSort(column: FleetSortColumn): void {
|
function changeSort(column: FleetSortColumn): void {
|
||||||
setSort((current) => ({
|
setSort((current) => ({
|
||||||
column,
|
column,
|
||||||
direction: current.column === column && current.direction === 'asc' ? 'desc' : 'asc',
|
direction: current.column === column && current.direction === 'asc' ? 'desc' : 'asc',
|
||||||
}));
|
}));
|
||||||
|
setPage(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOne(id: string, checked: boolean): void {
|
function toggleOne(id: string, checked: boolean): void {
|
||||||
setSelectedIds((current) => {
|
setSelectedIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
@@ -114,7 +145,6 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleVisible(checked: boolean): void {
|
function toggleVisible(checked: boolean): void {
|
||||||
setSelectedIds((current) => {
|
setSelectedIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
@@ -125,6 +155,37 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function toggleColumn(column: string): void {
|
||||||
|
setShownColumns((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(column)) next.delete(column);
|
||||||
|
else next.add(column);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectFilter = (
|
||||||
|
label: string,
|
||||||
|
value: string,
|
||||||
|
setValue: (value: string) => void,
|
||||||
|
choices: readonly string[],
|
||||||
|
allLabel: string,
|
||||||
|
) => (
|
||||||
|
<label>
|
||||||
|
<span>{label}</span>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => resetPage(() => setValue(event.currentTarget.value))}
|
||||||
|
>
|
||||||
|
<option value="">{allLabel}</option>
|
||||||
|
{choices.map((choice) => (
|
||||||
|
<option key={choice} value={choice}>
|
||||||
|
{choice}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="fleet-panel" aria-labelledby="fleet-title">
|
<section className="fleet-panel" aria-labelledby="fleet-title">
|
||||||
@@ -133,17 +194,32 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
|
|||||||
<h1 id="fleet-title">Fleet</h1>
|
<h1 id="fleet-title">Fleet</h1>
|
||||||
<p>Find, compare, and manage SimAdmin instances.</p>
|
<p>Find, compare, and manage SimAdmin instances.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="fleet-actions">
|
||||||
<span className="selection-summary" aria-live="polite">
|
<span className="selection-summary" aria-live="polite">
|
||||||
{model.selectedIds.length} selected
|
{model.selectedIds.length} selected
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={model.selectedIds.length === 0}
|
||||||
|
onClick={() => setBatchOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
Batch actions
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{batchOpen ? (
|
||||||
|
<div className="batch-entry" role="region" aria-label="Batch action entry">
|
||||||
|
Choose an action for {model.selectedIds.length} selected{' '}
|
||||||
|
{model.selectedIds.length === 1 ? 'instance' : 'instances'}.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="fleet-toolbar">
|
<div className="fleet-toolbar">
|
||||||
<label>
|
<label>
|
||||||
<span>Search instances</span>
|
<span>Search instances</span>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
onChange={(event) => resetPage(() => setQuery(event.currentTarget.value))}
|
||||||
placeholder="Name, ID, tag, origin…"
|
placeholder="Name, ID, tag, origin…"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -151,7 +227,9 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
|
|||||||
<span>Status</span>
|
<span>Status</span>
|
||||||
<select
|
<select
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={(event) => setFilter(event.currentTarget.value as FleetFilter)}
|
onChange={(event) =>
|
||||||
|
resetPage(() => setFilter(event.currentTarget.value as FleetFilter))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<option value="all">All statuses</option>
|
<option value="all">All statuses</option>
|
||||||
<option value="online">Online</option>
|
<option value="online">Online</option>
|
||||||
@@ -160,6 +238,54 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
|
|||||||
<option value="unknown">Unknown</option>
|
<option value="unknown">Unknown</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Authentication</span>
|
||||||
|
<select
|
||||||
|
value={auth}
|
||||||
|
onChange={(event) =>
|
||||||
|
resetPage(() => setAuth(event.currentTarget.value as FleetAuthFilter))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="all">All authentication</option>
|
||||||
|
<option value="authenticated">Authenticated</option>
|
||||||
|
<option value="required">Authentication required</option>
|
||||||
|
<option value="unknown">Unknown</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{selectFilter(
|
||||||
|
'Capability',
|
||||||
|
capability,
|
||||||
|
setCapability,
|
||||||
|
model.facets.capabilities,
|
||||||
|
'All capabilities',
|
||||||
|
)}
|
||||||
|
{selectFilter('Version', version, setVersion, model.facets.versions, 'All versions')}
|
||||||
|
{selectFilter('Tag', tag, setTag, model.facets.tags, 'All tags')}
|
||||||
|
<div className="column-picker">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={columnsOpen}
|
||||||
|
onClick={() => setColumnsOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
Choose columns
|
||||||
|
</button>
|
||||||
|
{columnsOpen ? (
|
||||||
|
<fieldset>
|
||||||
|
<legend>Visible columns</legend>
|
||||||
|
{ALL_COLUMNS.map((column) => (
|
||||||
|
<label key={column}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`Show ${column} column`}
|
||||||
|
checked={shownColumns.has(column)}
|
||||||
|
onChange={() => toggleColumn(column)}
|
||||||
|
/>
|
||||||
|
{COLUMN_LABELS[column]}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!snapshot && !error ? (
|
{!snapshot && !error ? (
|
||||||
@@ -181,10 +307,11 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
|
|||||||
{model.emptyReason === 'search' ? (
|
{model.emptyReason === 'search' ? (
|
||||||
<p>No instances match your search and filters.</p>
|
<p>No instances match your search and filters.</p>
|
||||||
) : null}
|
) : null}
|
||||||
{model.emptyReason === 'filter' ? <p>No instances match this status filter.</p> : null}
|
{model.emptyReason === 'filter' ? <p>No instances match the active filters.</p> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{snapshot && model.rows.length > 0 ? (
|
{snapshot && model.rows.length > 0 ? (
|
||||||
|
<>
|
||||||
<div className="table-scroll" tabIndex={0}>
|
<div className="table-scroll" tabIndex={0}>
|
||||||
<table className="dense-table">
|
<table className="dense-table">
|
||||||
<caption>Fleet instances</caption>
|
<caption>Fleet instances</caption>
|
||||||
@@ -199,55 +326,59 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
|
|||||||
onChange={(event) => toggleVisible(event.currentTarget.checked)}
|
onChange={(event) => toggleVisible(event.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
{(['name', 'status', 'latency'] as const).map((column) => {
|
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
||||||
const label = column[0]!.toUpperCase() + column.slice(1);
|
|
||||||
const active = sort.column === column;
|
|
||||||
return (
|
|
||||||
<th
|
<th
|
||||||
key={column}
|
key={column}
|
||||||
scope="col"
|
scope="col"
|
||||||
aria-sort={
|
aria-sort={
|
||||||
active ? (sort.direction === 'asc' ? 'ascending' : 'descending') : undefined
|
column !== 'origin' && sort.column === column
|
||||||
|
? sort.direction === 'asc'
|
||||||
|
? 'ascending'
|
||||||
|
: 'descending'
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{column === 'origin' ? (
|
||||||
|
COLUMN_LABELS[column]
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => changeSort(column)}
|
onClick={() => changeSort(column)}
|
||||||
aria-label={`Sort by ${column}, currently ${active ? sort.direction : 'not sorted'}`}
|
aria-label={`Sort by ${column}, currently ${sort.column === column ? sort.direction : 'not sorted'}`}
|
||||||
>
|
>
|
||||||
{label}
|
{COLUMN_LABELS[column]}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
<th scope="col">Origin</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{model.rows.map((row) => {
|
{model.rows.map((row) => {
|
||||||
const origin = canonicalHttpOrigin(row.instance.url);
|
const origin = canonicalHttpOrigin(row.instance.url);
|
||||||
return (
|
const cells: Readonly<Record<string, React.ReactNode>> = {
|
||||||
<tr key={row.id} aria-selected={row.selected}>
|
name: (
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label={`Select ${row.displayName}`}
|
|
||||||
checked={row.selected}
|
|
||||||
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<th scope="row">
|
<th scope="row">
|
||||||
<a href={`/instances/${encodeURIComponent(row.id)}/overview`}>
|
<a href={`/instances/${encodeURIComponent(row.id)}/overview`}>
|
||||||
{row.displayName}
|
{row.displayName}
|
||||||
</a>
|
</a>
|
||||||
<small>{row.id}</small>
|
<small>{row.id}</small>
|
||||||
</th>
|
</th>
|
||||||
|
),
|
||||||
|
status: (
|
||||||
<td>
|
<td>
|
||||||
<span className={`status status-${row.statusKind}`}>
|
<span className={`status status-${row.statusKind}`}>
|
||||||
{STATUS_LABELS[row.statusKind]}
|
{STATUS_LABELS[row.statusKind]}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</td>
|
),
|
||||||
|
latency: <td>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</td>,
|
||||||
|
version: <td>{row.version ?? '—'}</td>,
|
||||||
|
capabilities: <td>{row.capabilities.join(', ') || '—'}</td>,
|
||||||
|
tags: <td>{row.tags.join(', ') || '—'}</td>,
|
||||||
|
freshness: <td>{row.freshness}</td>,
|
||||||
|
anomalies: <td>{row.anomalies.join(', ') || '—'}</td>,
|
||||||
|
origin: (
|
||||||
<td>
|
<td>
|
||||||
{origin ? (
|
{origin ? (
|
||||||
<a
|
<a
|
||||||
@@ -262,12 +393,49 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
|
|||||||
<span>Invalid origin</span>
|
<span>Invalid origin</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<tr key={row.id} aria-selected={row.selected}>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`Select ${row.displayName}`}
|
||||||
|
checked={row.selected}
|
||||||
|
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
||||||
|
<Fragment key={column}>{cells[column]}</Fragment>
|
||||||
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<nav className="pagination" aria-label="Fleet pagination">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Previous page"
|
||||||
|
disabled={model.page === 1}
|
||||||
|
onClick={() => setPage((value) => value - 1)}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
Page {model.page} of {model.pageCount}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Next page"
|
||||||
|
disabled={model.page === model.pageCount}
|
||||||
|
onClick={() => setPage((value) => value + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,7 +23,18 @@ const instances: readonly FleetInstance[] = [
|
|||||||
const statuses: ReadonlyMap<string, FleetStatus> = new Map([
|
const statuses: ReadonlyMap<string, FleetStatus> = new Map([
|
||||||
[
|
[
|
||||||
'charlie',
|
'charlie',
|
||||||
{ reachable: true, authenticated: true, latencyMs: 55, summary: { carrier: 'Acme' } },
|
{
|
||||||
|
reachable: true,
|
||||||
|
authenticated: true,
|
||||||
|
latencyMs: 55,
|
||||||
|
summary: {
|
||||||
|
carrier: 'Acme',
|
||||||
|
version: '2.4.1',
|
||||||
|
capabilities: ['sms', 'ussd'],
|
||||||
|
freshness: 'stale',
|
||||||
|
anomalies: ['latency spike'],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
['alpha', { reachable: false, latencyMs: 8 }],
|
['alpha', { reachable: false, latencyMs: 8 }],
|
||||||
['bravo', { reachable: true, authenticated: false, latencyMs: 20 }],
|
['bravo', { reachable: true, authenticated: false, latencyMs: 20 }],
|
||||||
@@ -123,4 +134,69 @@ describe('fleet table view model', () => {
|
|||||||
buildFleetTableViewModel(instances, statuses, { sort: { column: 'name', direction: 'desc' } });
|
buildFleetTableViewModel(instances, statuses, { sort: { column: 'name', direction: 'desc' } });
|
||||||
expect(instances.map((instance) => instance.id)).toEqual(originalOrder);
|
expect(instances.map((instance) => instance.id)).toEqual(originalOrder);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('derives optional operational metadata from status summary with safe defaults', () => {
|
||||||
|
const model = buildFleetTableViewModel(instances, statuses);
|
||||||
|
const charlie = model.rows.find((row) => row.id === 'charlie');
|
||||||
|
const alpha = model.rows.find((row) => row.id === 'alpha');
|
||||||
|
|
||||||
|
expect(charlie).toMatchObject({
|
||||||
|
version: '2.4.1',
|
||||||
|
capabilities: ['sms', 'ussd'],
|
||||||
|
freshness: 'stale',
|
||||||
|
anomalies: ['latency spike'],
|
||||||
|
tags: ['west'],
|
||||||
|
});
|
||||||
|
expect(alpha).toMatchObject({
|
||||||
|
version: null,
|
||||||
|
capabilities: [],
|
||||||
|
freshness: 'unknown',
|
||||||
|
anomalies: [],
|
||||||
|
tags: ['east'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines authentication, capability, version, and tag filters', () => {
|
||||||
|
expect(
|
||||||
|
buildFleetTableViewModel(instances, statuses, {
|
||||||
|
auth: 'authenticated',
|
||||||
|
capability: 'sms',
|
||||||
|
version: '2.4.1',
|
||||||
|
tag: 'west',
|
||||||
|
}).rows.map((row) => row.id),
|
||||||
|
).toEqual(['charlie']);
|
||||||
|
expect(
|
||||||
|
buildFleetTableViewModel(instances, statuses, { auth: 'required' }).rows.map((row) => row.id),
|
||||||
|
).toEqual(['bravo']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts metadata columns, including scalar freshness, and paginates after filtering and sorting', () => {
|
||||||
|
const model = buildFleetTableViewModel(instances, statuses, {
|
||||||
|
sort: { column: 'version', direction: 'asc' },
|
||||||
|
page: 2,
|
||||||
|
pageSize: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.rows.map((row) => row.id)).toEqual(['bravo', 'delta']);
|
||||||
|
expect(model.filteredCount).toBe(4);
|
||||||
|
expect(model.page).toBe(2);
|
||||||
|
expect(model.pageCount).toBe(2);
|
||||||
|
expect(model.visibleSelection.rowCount).toBe(2);
|
||||||
|
expect(
|
||||||
|
buildFleetTableViewModel(instances, statuses, {
|
||||||
|
sort: { column: 'freshness', direction: 'asc' },
|
||||||
|
}).rows.map((row) => row.id),
|
||||||
|
).toEqual(['charlie', 'alpha', 'bravo', 'delta']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps invalid pages and exposes filter choices from available data', () => {
|
||||||
|
const model = buildFleetTableViewModel(instances, statuses, { page: 99, pageSize: 3 });
|
||||||
|
expect(model.page).toBe(2);
|
||||||
|
expect(model.rows.map((row) => row.id)).toEqual(['delta']);
|
||||||
|
expect(model.facets).toEqual({
|
||||||
|
capabilities: ['sms', 'ussd'],
|
||||||
|
versions: ['2.4.1'],
|
||||||
|
tags: ['east', 'west'],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
export type FleetStatusKind = 'online' | 'auth' | 'offline' | 'unknown';
|
export type FleetStatusKind = 'online' | 'auth' | 'offline' | 'unknown';
|
||||||
export type FleetFilter = 'all' | FleetStatusKind;
|
export type FleetFilter = 'all' | FleetStatusKind;
|
||||||
export type FleetSortColumn = 'name' | 'status' | 'latency';
|
export type FleetAuthFilter = 'all' | 'authenticated' | 'required' | 'unknown';
|
||||||
|
export type FleetSortColumn =
|
||||||
|
| 'name'
|
||||||
|
| 'status'
|
||||||
|
| 'latency'
|
||||||
|
| 'version'
|
||||||
|
| 'capabilities'
|
||||||
|
| 'tags'
|
||||||
|
| 'freshness'
|
||||||
|
| 'anomalies';
|
||||||
export type SortDirection = 'asc' | 'desc';
|
export type SortDirection = 'asc' | 'desc';
|
||||||
export type EmptyFleetReason = 'config' | 'search' | 'filter';
|
export type EmptyFleetReason = 'config' | 'search' | 'filter';
|
||||||
|
|
||||||
@@ -22,13 +31,18 @@ export interface FleetStatus {
|
|||||||
export interface FleetTableOptions {
|
export interface FleetTableOptions {
|
||||||
readonly query?: string;
|
readonly query?: string;
|
||||||
readonly filter?: FleetFilter;
|
readonly filter?: FleetFilter;
|
||||||
readonly sort?: Readonly<{
|
readonly auth?: FleetAuthFilter;
|
||||||
column: FleetSortColumn;
|
readonly capability?: string;
|
||||||
direction: SortDirection;
|
readonly version?: string;
|
||||||
}>;
|
readonly tag?: string;
|
||||||
|
readonly sort?: Readonly<{ column: FleetSortColumn; direction: SortDirection }>;
|
||||||
readonly selectedIds?: ReadonlySet<string>;
|
readonly selectedIds?: ReadonlySet<string>;
|
||||||
|
readonly page?: number;
|
||||||
|
readonly pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FleetFreshness = 'fresh' | 'stale' | 'unknown';
|
||||||
|
|
||||||
export interface FleetTableRow {
|
export interface FleetTableRow {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly displayName: string;
|
readonly displayName: string;
|
||||||
@@ -37,6 +51,11 @@ export interface FleetTableRow {
|
|||||||
readonly statusKind: FleetStatusKind;
|
readonly statusKind: FleetStatusKind;
|
||||||
readonly latencyMs?: number;
|
readonly latencyMs?: number;
|
||||||
readonly selected: boolean;
|
readonly selected: boolean;
|
||||||
|
readonly version: string | null;
|
||||||
|
readonly capabilities: readonly string[];
|
||||||
|
readonly tags: readonly string[];
|
||||||
|
readonly freshness: FleetFreshness;
|
||||||
|
readonly anomalies: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VisibleSelection {
|
export interface VisibleSelection {
|
||||||
@@ -49,10 +68,19 @@ export interface VisibleSelection {
|
|||||||
export interface FleetTableViewModel {
|
export interface FleetTableViewModel {
|
||||||
readonly rows: readonly FleetTableRow[];
|
readonly rows: readonly FleetTableRow[];
|
||||||
readonly totalCount: number;
|
readonly totalCount: number;
|
||||||
|
readonly filteredCount: number;
|
||||||
readonly visibleCount: number;
|
readonly visibleCount: number;
|
||||||
|
readonly page: number;
|
||||||
|
readonly pageSize: number;
|
||||||
|
readonly pageCount: number;
|
||||||
readonly selectedIds: readonly string[];
|
readonly selectedIds: readonly string[];
|
||||||
readonly visibleSelection: VisibleSelection;
|
readonly visibleSelection: VisibleSelection;
|
||||||
readonly emptyReason: EmptyFleetReason | null;
|
readonly emptyReason: EmptyFleetReason | null;
|
||||||
|
readonly facets: Readonly<{
|
||||||
|
capabilities: readonly string[];
|
||||||
|
versions: readonly string[];
|
||||||
|
tags: readonly string[];
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_RANK: Readonly<Record<FleetStatusKind, number>> = {
|
const STATUS_RANK: Readonly<Record<FleetStatusKind, number>> = {
|
||||||
@@ -74,13 +102,57 @@ function displayName(instance: FleetInstance): string {
|
|||||||
return name ? name : instance.id;
|
return name ? name : instance.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summaryString(status: FleetStatus | undefined, key: string): string | null {
|
||||||
|
const value = status?.summary?.[key];
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryStrings(status: FleetStatus | undefined, key: string): readonly string[] {
|
||||||
|
const value = status?.summary?.[key];
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.filter((item): item is string => typeof item === 'string' && Boolean(item.trim()))
|
||||||
|
.map((item) => item.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadata(instance: FleetInstance, status: FleetStatus | undefined) {
|
||||||
|
const freshnessValue = summaryString(status, 'freshness');
|
||||||
|
const freshness: FleetFreshness =
|
||||||
|
freshnessValue === 'fresh' || freshnessValue === 'stale' ? freshnessValue : 'unknown';
|
||||||
|
return {
|
||||||
|
version: summaryString(status, 'version'),
|
||||||
|
capabilities: summaryStrings(status, 'capabilities'),
|
||||||
|
tags: (instance.tags ?? []).filter((tag) => Boolean(tag.trim())),
|
||||||
|
freshness,
|
||||||
|
anomalies: summaryStrings(status, 'anomalies'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type FleetMetadata = ReturnType<typeof metadata>;
|
||||||
|
|
||||||
|
type FleetMetadataSortColumn = Exclude<FleetSortColumn, 'name' | 'status' | 'latency'>;
|
||||||
|
|
||||||
|
function metadataSortValue(
|
||||||
|
metadataValue: FleetMetadata,
|
||||||
|
column: FleetMetadataSortColumn,
|
||||||
|
): string | null {
|
||||||
|
if (column === 'version') return metadataValue.version;
|
||||||
|
if (column === 'freshness') return metadataValue.freshness;
|
||||||
|
return metadataValue[column].join(', ') || null;
|
||||||
|
}
|
||||||
|
|
||||||
function searchableText(instance: FleetInstance, status: FleetStatus | undefined): string {
|
function searchableText(instance: FleetInstance, status: FleetStatus | undefined): string {
|
||||||
|
const details = metadata(instance, status);
|
||||||
return [
|
return [
|
||||||
instance.id,
|
instance.id,
|
||||||
instance.name ?? '',
|
instance.name ?? '',
|
||||||
instance.url,
|
instance.url,
|
||||||
instance.description ?? '',
|
instance.description ?? '',
|
||||||
...(instance.tags ?? []),
|
...details.tags,
|
||||||
|
details.version ?? '',
|
||||||
|
...details.capabilities,
|
||||||
|
details.freshness,
|
||||||
|
...details.anomalies,
|
||||||
status?.summary === undefined ? '' : JSON.stringify(status.summary),
|
status?.summary === undefined ? '' : JSON.stringify(status.summary),
|
||||||
]
|
]
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -91,6 +163,11 @@ function compareNames(left: FleetInstance, right: FleetInstance): number {
|
|||||||
return displayName(left).localeCompare(displayName(right), undefined, { sensitivity: 'base' });
|
return displayName(left).localeCompare(displayName(right), undefined, { sensitivity: 'base' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareOptional(left: string | null, right: string | null): number {
|
||||||
|
if (left === null || right === null) return left === right ? 0 : left === null ? 1 : -1;
|
||||||
|
return left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
|
||||||
function compareInstances(
|
function compareInstances(
|
||||||
left: FleetInstance,
|
left: FleetInstance,
|
||||||
right: FleetInstance,
|
right: FleetInstance,
|
||||||
@@ -100,7 +177,10 @@ function compareInstances(
|
|||||||
): number {
|
): number {
|
||||||
const leftStatus = statuses.get(left.id);
|
const leftStatus = statuses.get(left.id);
|
||||||
const rightStatus = statuses.get(right.id);
|
const rightStatus = statuses.get(right.id);
|
||||||
let comparison: number;
|
const leftMeta = metadata(left, leftStatus);
|
||||||
|
const rightMeta = metadata(right, rightStatus);
|
||||||
|
let comparison = 0;
|
||||||
|
let missingComparison = false;
|
||||||
|
|
||||||
if (column === 'status') {
|
if (column === 'status') {
|
||||||
comparison =
|
comparison =
|
||||||
@@ -108,16 +188,37 @@ function compareInstances(
|
|||||||
} else if (column === 'latency') {
|
} else if (column === 'latency') {
|
||||||
const leftMissing = leftStatus?.latencyMs === undefined;
|
const leftMissing = leftStatus?.latencyMs === undefined;
|
||||||
const rightMissing = rightStatus?.latencyMs === undefined;
|
const rightMissing = rightStatus?.latencyMs === undefined;
|
||||||
if (leftMissing !== rightMissing) return leftMissing ? 1 : -1;
|
if (leftMissing !== rightMissing) {
|
||||||
comparison = (leftStatus?.latencyMs ?? 0) - (rightStatus?.latencyMs ?? 0);
|
comparison = leftMissing ? 1 : -1;
|
||||||
} else {
|
missingComparison = true;
|
||||||
comparison = compareNames(left, right);
|
} else comparison = (leftStatus?.latencyMs ?? 0) - (rightStatus?.latencyMs ?? 0);
|
||||||
|
} else if (column === 'name') comparison = compareNames(left, right);
|
||||||
|
else {
|
||||||
|
const leftValue = metadataSortValue(leftMeta, column);
|
||||||
|
const rightValue = metadataSortValue(rightMeta, column);
|
||||||
|
comparison = compareOptional(leftValue, rightValue);
|
||||||
|
missingComparison = (leftValue === null) !== (rightValue === null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (comparison !== 0) return direction === 'asc' ? comparison : -comparison;
|
if (comparison !== 0)
|
||||||
|
return missingComparison ? comparison : direction === 'asc' ? comparison : -comparison;
|
||||||
return compareNames(left, right) || left.id.localeCompare(right.id);
|
return compareNames(left, right) || left.id.localeCompare(right.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function authenticationMatches(status: FleetStatus | undefined, filter: FleetAuthFilter): boolean {
|
||||||
|
if (filter === 'all') return true;
|
||||||
|
if (filter === 'unknown') return status?.authenticated === undefined;
|
||||||
|
return filter === 'authenticated'
|
||||||
|
? status?.authenticated === true
|
||||||
|
: status?.authenticated === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedUnique(values: readonly string[]): readonly string[] {
|
||||||
|
return [...new Set(values)].sort((left, right) =>
|
||||||
|
left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildFleetTableViewModel(
|
export function buildFleetTableViewModel(
|
||||||
instances: readonly FleetInstance[],
|
instances: readonly FleetInstance[],
|
||||||
statuses: ReadonlyMap<string, FleetStatus>,
|
statuses: ReadonlyMap<string, FleetStatus>,
|
||||||
@@ -125,23 +226,32 @@ export function buildFleetTableViewModel(
|
|||||||
): FleetTableViewModel {
|
): FleetTableViewModel {
|
||||||
const query = options.query?.trim().toLocaleLowerCase() ?? '';
|
const query = options.query?.trim().toLocaleLowerCase() ?? '';
|
||||||
const filter = options.filter ?? 'all';
|
const filter = options.filter ?? 'all';
|
||||||
|
const auth = options.auth ?? 'all';
|
||||||
const sort = options.sort ?? { column: 'name', direction: 'asc' };
|
const sort = options.sort ?? { column: 'name', direction: 'asc' };
|
||||||
|
const pageSize = Math.max(1, Math.floor(options.pageSize ?? 10));
|
||||||
const knownIds = new Set(instances.map((instance) => instance.id));
|
const knownIds = new Set(instances.map((instance) => instance.id));
|
||||||
const selectedIds = [...(options.selectedIds ?? [])]
|
const selectedIds = [...(options.selectedIds ?? [])].filter((id) => knownIds.has(id)).sort();
|
||||||
.filter((id) => knownIds.has(id))
|
|
||||||
.sort((left, right) => left.localeCompare(right));
|
|
||||||
const selectedIdSet = new Set(selectedIds);
|
const selectedIdSet = new Set(selectedIds);
|
||||||
|
|
||||||
const visibleInstances = instances
|
const filteredInstances = instances
|
||||||
.filter((instance) => {
|
.filter((instance) => {
|
||||||
const status = statuses.get(instance.id);
|
const status = statuses.get(instance.id);
|
||||||
|
const details = metadata(instance, status);
|
||||||
if (filter !== 'all' && fleetStatusKind(status) !== filter) return false;
|
if (filter !== 'all' && fleetStatusKind(status) !== filter) return false;
|
||||||
|
if (!authenticationMatches(status, auth)) return false;
|
||||||
|
if (options.capability && !details.capabilities.includes(options.capability)) return false;
|
||||||
|
if (options.version && details.version !== options.version) return false;
|
||||||
|
if (options.tag && !details.tags.includes(options.tag)) return false;
|
||||||
return !query || searchableText(instance, status).includes(query);
|
return !query || searchableText(instance, status).includes(query);
|
||||||
})
|
})
|
||||||
.slice()
|
.slice()
|
||||||
.sort((left, right) => compareInstances(left, right, statuses, sort.column, sort.direction));
|
.sort((left, right) => compareInstances(left, right, statuses, sort.column, sort.direction));
|
||||||
|
|
||||||
const rows: FleetTableRow[] = visibleInstances.map((instance) => {
|
const filteredCount = filteredInstances.length;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(filteredCount / pageSize));
|
||||||
|
const page = Math.min(pageCount, Math.max(1, Math.floor(options.page ?? 1)));
|
||||||
|
const pageInstances = filteredInstances.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
const rows: FleetTableRow[] = pageInstances.map((instance) => {
|
||||||
const status = statuses.get(instance.id);
|
const status = statuses.get(instance.id);
|
||||||
return {
|
return {
|
||||||
id: instance.id,
|
id: instance.id,
|
||||||
@@ -151,32 +261,44 @@ export function buildFleetTableViewModel(
|
|||||||
statusKind: fleetStatusKind(status),
|
statusKind: fleetStatusKind(status),
|
||||||
...(status?.latencyMs === undefined ? {} : { latencyMs: status.latencyMs }),
|
...(status?.latencyMs === undefined ? {} : { latencyMs: status.latencyMs }),
|
||||||
selected: selectedIdSet.has(instance.id),
|
selected: selectedIdSet.has(instance.id),
|
||||||
|
...metadata(instance, status),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const visibleSelectedCount = rows.reduce((count, row) => count + (row.selected ? 1 : 0), 0);
|
const visibleSelectedCount = rows.reduce((count, row) => count + Number(row.selected), 0);
|
||||||
const rowCount = rows.length;
|
const hasMetadataFilter =
|
||||||
|
auth !== 'all' || Boolean(options.capability || options.version || options.tag);
|
||||||
const emptyReason: EmptyFleetReason | null =
|
const emptyReason: EmptyFleetReason | null =
|
||||||
instances.length === 0
|
instances.length === 0
|
||||||
? 'config'
|
? 'config'
|
||||||
: rowCount > 0
|
: filteredCount > 0
|
||||||
? null
|
? null
|
||||||
: query
|
: query
|
||||||
? 'search'
|
? 'search'
|
||||||
: filter !== 'all'
|
: filter !== 'all' || hasMetadataFilter
|
||||||
? 'filter'
|
? 'filter'
|
||||||
: null;
|
: null;
|
||||||
|
const allMetadata = instances.map((instance) => metadata(instance, statuses.get(instance.id)));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows,
|
rows,
|
||||||
totalCount: instances.length,
|
totalCount: instances.length,
|
||||||
visibleCount: rowCount,
|
filteredCount,
|
||||||
|
visibleCount: rows.length,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
pageCount,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
visibleSelection: {
|
visibleSelection: {
|
||||||
checked: rowCount > 0 && visibleSelectedCount === rowCount,
|
checked: rows.length > 0 && visibleSelectedCount === rows.length,
|
||||||
indeterminate: visibleSelectedCount > 0 && visibleSelectedCount < rowCount,
|
indeterminate: visibleSelectedCount > 0 && visibleSelectedCount < rows.length,
|
||||||
selectedCount: visibleSelectedCount,
|
selectedCount: visibleSelectedCount,
|
||||||
rowCount,
|
rowCount: rows.length,
|
||||||
},
|
},
|
||||||
emptyReason,
|
emptyReason,
|
||||||
|
facets: {
|
||||||
|
capabilities: sortedUnique(allMetadata.flatMap((item) => item.capabilities)),
|
||||||
|
versions: sortedUnique(allMetadata.flatMap((item) => (item.version ? [item.version] : []))),
|
||||||
|
tags: sortedUnique(allMetadata.flatMap((item) => item.tags)),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,23 @@ export {
|
|||||||
type FleetSnapshot,
|
type FleetSnapshot,
|
||||||
} from './fleet/fleet-page.js';
|
} from './fleet/fleet-page.js';
|
||||||
export * from './fleet/fleet-table-view-model.js';
|
export * from './fleet/fleet-table-view-model.js';
|
||||||
|
export { InstanceEditor, type InstanceEditorProps } from './instances/instance-crud.js';
|
||||||
|
export {
|
||||||
|
InstanceDetail,
|
||||||
|
INSTANCE_MODULE_LABELS,
|
||||||
|
type CapabilityDataSource,
|
||||||
|
type CapabilityState,
|
||||||
|
type InstanceCapability,
|
||||||
|
type InstanceCapabilityMap,
|
||||||
|
type InstanceDetailProps,
|
||||||
|
} from './instances/instance-detail.js';
|
||||||
|
export {
|
||||||
|
createInstanceApiDataSource,
|
||||||
|
passwordUpdate,
|
||||||
|
tagsFromInput,
|
||||||
|
type InstanceDataSource,
|
||||||
|
type ManagedInstance,
|
||||||
|
type PasswordUpdate,
|
||||||
|
} from './instances/instance-api-data-source.js';
|
||||||
|
|
||||||
export const webWorkspaceReady = true;
|
export const webWorkspaceReady = true;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -200,6 +200,33 @@ tbody th small {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
border-color: currentColor;
|
border-color: currentColor;
|
||||||
}
|
}
|
||||||
|
.instance-editor {
|
||||||
|
max-width: 48rem;
|
||||||
|
}
|
||||||
|
.instance-editor form,
|
||||||
|
.instance-editor form label,
|
||||||
|
.danger-zone label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.instance-editor form {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.danger-zone {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.danger-zone div {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
@media (max-width: 48rem) {
|
@media (max-width: 48rem) {
|
||||||
.app-topbar {
|
.app-topbar {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user