From 6e817f380a99d55ffcb03bfbd22afe81d8eeb0e9 Mon Sep 17 00:00:00 2001 From: chick Date: Fri, 17 Jul 2026 17:12:35 +0800 Subject: [PATCH] feat(web): complete fleet and instance management slices --- apps/web/src/app-shell.integration.test.tsx | 81 ++++- apps/web/src/app-shell.tsx | 131 +++---- .../src/fleet/fleet-api-data-source.test.ts | 44 +++ apps/web/src/fleet/fleet-api-data-source.ts | 26 +- apps/web/src/fleet/fleet-page.tsx | 340 +++++++++++++----- .../src/fleet/fleet-table-view-model.test.ts | 78 +++- apps/web/src/fleet/fleet-table-view-model.ts | 172 +++++++-- apps/web/src/index.ts | 18 + .../instance-api-data-source.test.ts | 135 +++++++ .../src/instances/instance-api-data-source.ts | 191 ++++++++++ apps/web/src/instances/instance-crud.test.tsx | 116 ++++++ apps/web/src/instances/instance-crud.tsx | 276 ++++++++++++++ .../src/instances/instance-detail.test.tsx | 125 +++++++ apps/web/src/instances/instance-detail.tsx | 180 ++++++++++ apps/web/src/styles.css | 27 ++ 15 files changed, 1737 insertions(+), 203 deletions(-) create mode 100644 apps/web/src/fleet/fleet-api-data-source.test.ts create mode 100644 apps/web/src/instances/instance-api-data-source.test.ts create mode 100644 apps/web/src/instances/instance-api-data-source.ts create mode 100644 apps/web/src/instances/instance-crud.test.tsx create mode 100644 apps/web/src/instances/instance-crud.tsx create mode 100644 apps/web/src/instances/instance-detail.test.tsx create mode 100644 apps/web/src/instances/instance-detail.tsx diff --git a/apps/web/src/app-shell.integration.test.tsx b/apps/web/src/app-shell.integration.test.tsx index 9a2fc42..73b06f9 100644 --- a/apps/web/src/app-shell.integration.test.tsx +++ b/apps/web/src/app-shell.integration.test.tsx @@ -6,7 +6,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { AppShell, type InstanceContext } from './app-shell.js'; import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js'; -afterEach(cleanup); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); const snapshot: FleetSnapshot = { instances: [ @@ -19,8 +22,29 @@ const snapshot: FleetSnapshot = { { id: 'alpha', name: 'Alpha', url: 'https://alpha.example/admin' }, ], 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', () => { - 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; const dataSource = source(() => new Promise((done) => (resolve = done))); render(); @@ -49,6 +74,11 @@ describe('React AppShell and Fleet vertical slice', () => { .getAttribute('href'), ).toBe('http://bravo.example:8080'); 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 () => { @@ -102,6 +132,49 @@ describe('React AppShell and Fleet vertical slice', () => { unmount(); }); + it('supports metadata filters, column visibility, pagination, and a batch-action entry', async () => { + const user = userEvent.setup(); + render( 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( 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', () => { const instance: InstanceContext = { id: 'owner', diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index 255496e..0895999 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -1,12 +1,14 @@ import type { ReactNode } from 'react'; -import { - FleetPage, - canonicalHttpOrigin, - type FleetDataSource, - type FleetSnapshot, -} from './fleet/fleet-page.js'; +import { FleetPage, type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js'; import { createFleetApiDataSource } from './fleet/fleet-api-data-source.js'; +import { 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 InstanceModule = @@ -52,19 +54,12 @@ export interface AppShellProps { instance?: InstanceContext; fleetDataSource?: FleetDataSource; fleetData?: FleetSnapshot; + instanceDataSource?: InstanceDataSource; + capabilities?: InstanceCapabilityMap; + capabilityDataSource?: CapabilityDataSource; } -const MODULE_LABELS: Readonly> = { - overview: 'Overview', - cellular: 'Cellular', - 'device-network': 'Device Network', - messages: 'Messages', - calls: 'Calls', - esim: 'eSIM', - notifications: 'Notifications', - automation: 'Automation', - ota: 'OTA', -}; +const MODULE_LABELS = INSTANCE_MODULE_LABELS; const INSTANCE_MODULES = new Set(Object.keys(MODULE_LABELS)); function normalize(pathname: string): string { @@ -120,65 +115,22 @@ function section(route: ResolvedRoute): GlobalSection | undefined { return undefined; } -function InstanceSidebar({ - instance, - module, -}: { - instance: InstanceContext; - module: InstanceModule; -}) { - const origin = canonicalHttpOrigin(instance.origin); - return ( - - ); -} - function Page({ route, fleetDataSource, fleetData, instance, + instanceDataSource, + capabilities, + capabilityDataSource, }: { route: ResolvedRoute; fleetDataSource: FleetDataSource | undefined; fleetData: FleetSnapshot | undefined; instance: InstanceContext | undefined; + instanceDataSource: InstanceDataSource | undefined; + capabilities: InstanceCapabilityMap | undefined; + capabilityDataSource: CapabilityDataSource | undefined; }): ReactNode { if (route.kind === 'fleet') return ( @@ -187,6 +139,21 @@ function Page({ {...(fleetData ? { initialData: fleetData } : {})} /> ); + if (route.kind === 'instance-new') + return ( + + ); + if (route.kind === 'settings-instance-detail') + return ( + + ); if (route.kind === 'not-found') return (
@@ -195,18 +162,16 @@ function Page({ Return to Fleet
); - if (route.kind.startsWith('instance-') && route.kind !== 'instance-new') { + if (route.kind.startsWith('instance-')) { const module = route.kind.slice('instance-'.length) as InstanceModule; - const ownsRoute = instance?.id === route.params?.instanceId; return ( - <> -

{MODULE_LABELS[module]}

- {ownsRoute ? ( -

Inspect {MODULE_LABELS[module].toLowerCase()} data and available operations.

- ) : ( -

Instance context is unavailable for this route.

- )} - + ); } const labels: Partial> = { @@ -233,15 +198,13 @@ export function AppShell({ instance, fleetDataSource, fleetData, + instanceDataSource, + capabilities, + capabilityDataSource, }: AppShellProps) { const resolved = resolveRoute(pathname); const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved; 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 ( diff --git a/apps/web/src/fleet/fleet-api-data-source.test.ts b/apps/web/src/fleet/fleet-api-data-source.test.ts new file mode 100644 index 0000000..97e489e --- /dev/null +++ b/apps/web/src/fleet/fleet-api-data-source.test.ts @@ -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.', + ); + }); +}); diff --git a/apps/web/src/fleet/fleet-api-data-source.ts b/apps/web/src/fleet/fleet-api-data-source.ts index 63efe83..4e474fc 100644 --- a/apps/web/src/fleet/fleet-api-data-source.ts +++ b/apps/web/src/fleet/fleet-api-data-source.ts @@ -2,7 +2,27 @@ import type { FleetDataSource, FleetSnapshot } from './fleet-page.js'; import type { FleetInstance } from './fleet-table-view-model.js'; 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; + 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 { @@ -15,8 +35,8 @@ export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDa }); if (!response.ok) throw new Error(`Fleet request failed (${response.status}).`); const body = (await response.json()) as InstancePage; - if (!Array.isArray(body.data)) throw new Error('Fleet response is invalid.'); - return { instances: body.data, statuses: new Map() }; + if (!Array.isArray(body.items)) throw new Error('Fleet response is invalid.'); + return { instances: body.items.map(parseInstance), statuses: new Map() }; }, }; } diff --git a/apps/web/src/fleet/fleet-page.tsx b/apps/web/src/fleet/fleet-page.tsx index ff1f585..551e015 100644 --- a/apps/web/src/fleet/fleet-page.tsx +++ b/apps/web/src/fleet/fleet-page.tsx @@ -1,7 +1,8 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { buildFleetTableViewModel, + type FleetAuthFilter, type FleetFilter, type FleetInstance, type FleetSortColumn, @@ -13,11 +14,9 @@ export interface FleetSnapshot { readonly instances: readonly FleetInstance[]; readonly statuses: ReadonlyMap; } - export interface FleetDataSource { load(): Promise; } - export interface FleetPageProps { readonly dataSource?: FleetDataSource; readonly initialData?: FleetSnapshot; @@ -30,6 +29,18 @@ const STATUS_LABELS: Readonly> = { offline: 'Offline', unknown: 'Unknown', }; +const COLUMN_LABELS: Readonly> = { + 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 { try { @@ -49,11 +60,19 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) { const [attempt, setAttempt] = useState(0); const [query, setQuery] = useState(''); const [filter, setFilter] = useState('all'); + const [auth, setAuth] = useState('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 }>({ column: 'name', direction: 'asc', }); const [selectedIds, setSelectedIds] = useState>(new Set()); + const [shownColumns, setShownColumns] = useState>(new Set(ALL_COLUMNS)); + const [columnsOpen, setColumnsOpen] = useState(false); + const [batchOpen, setBatchOpen] = useState(false); const selectAllRef = useRef(null); useEffect(() => { @@ -88,24 +107,36 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) { buildFleetTableViewModel(snapshot?.instances ?? [], snapshot?.statuses ?? new Map(), { query, filter, + auth, + ...(capability ? { capability } : {}), + ...(version ? { version } : {}), + ...(tag ? { tag } : {}), sort, selectedIds, + page, }), - [filter, query, selectedIds, snapshot, sort], + [auth, capability, filter, page, query, selectedIds, snapshot, sort, tag, version], ); useEffect(() => { if (selectAllRef.current) selectAllRef.current.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 { setSort((current) => ({ column, direction: current.column === column && current.direction === 'asc' ? 'desc' : 'asc', })); + setPage(1); } - function toggleOne(id: string, checked: boolean): void { setSelectedIds((current) => { const next = new Set(current); @@ -114,7 +145,6 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) { return next; }); } - function toggleVisible(checked: boolean): void { setSelectedIds((current) => { const next = new Set(current); @@ -125,6 +155,37 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) { 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, + ) => ( + + ); return (
@@ -133,17 +194,32 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {

Fleet

Find, compare, and manage SimAdmin instances.

- - {model.selectedIds.length} selected - +
+ + {model.selectedIds.length} selected + + +
+ {batchOpen ? ( +
+ Choose an action for {model.selectedIds.length} selected{' '} + {model.selectedIds.length === 1 ? 'instance' : 'instances'}. +
+ ) : null}
@@ -151,7 +227,9 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) { Status + + {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')} +
+ + {columnsOpen ? ( +
+ Visible columns + {ALL_COLUMNS.map((column) => ( + + ))} +
+ ) : null} +
{!snapshot && !error ? ( @@ -181,93 +307,135 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) { {model.emptyReason === 'search' ? (

No instances match your search and filters.

) : null} - {model.emptyReason === 'filter' ?

No instances match this status filter.

: null} + {model.emptyReason === 'filter' ?

No instances match the active filters.

: null} ) : null} {snapshot && model.rows.length > 0 ? ( -
- - - - - - {(['name', 'status', 'latency'] as const).map((column) => { - const label = column[0]!.toUpperCase() + column.slice(1); - const active = sort.column === column; - return ( + <> +
+
Fleet instances
- toggleVisible(event.currentTarget.checked)} - /> -
+ + + + + {ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => ( + ))} + + + + {model.rows.map((row) => { + const origin = canonicalHttpOrigin(row.instance.url); + const cells: Readonly> = { + name: ( + + ), + status: ( + + ), + latency: , + version: , + capabilities: , + tags: , + freshness: , + anomalies: , + origin: ( + + ), + }; + return ( + + + {ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => ( + {cells[column]} + ))} + ); })} - - - - - {model.rows.map((row) => { - const origin = canonicalHttpOrigin(row.instance.url); - return ( - - - - - - - - ); - })} - -
Fleet instances
+ toggleVisible(event.currentTarget.checked)} + /> + - + {column === 'origin' ? ( + COLUMN_LABELS[column] + ) : ( + + )}
+ + {row.displayName} + + {row.id} + + + {STATUS_LABELS[row.statusKind]} + + {row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}{row.version ?? '—'}{row.capabilities.join(', ') || '—'}{row.tags.join(', ') || '—'}{row.freshness}{row.anomalies.join(', ') || '—'} + {origin ? ( + + {origin} + + ) : ( + Invalid origin + )} +
+ toggleOne(row.id, event.currentTarget.checked)} + /> +
Origin
- toggleOne(row.id, event.currentTarget.checked)} - /> - - - {row.displayName} - - {row.id} - - - {STATUS_LABELS[row.statusKind]} - - {row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`} - {origin ? ( - - {origin} - - ) : ( - Invalid origin - )} -
-
+ + + + + ) : null}
); diff --git a/apps/web/src/fleet/fleet-table-view-model.test.ts b/apps/web/src/fleet/fleet-table-view-model.test.ts index 375f149..7ab0a87 100644 --- a/apps/web/src/fleet/fleet-table-view-model.test.ts +++ b/apps/web/src/fleet/fleet-table-view-model.test.ts @@ -23,7 +23,18 @@ const instances: readonly FleetInstance[] = [ const statuses: ReadonlyMap = new Map([ [ '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 }], ['bravo', { reachable: true, authenticated: false, latencyMs: 20 }], @@ -123,4 +134,69 @@ describe('fleet table view model', () => { buildFleetTableViewModel(instances, statuses, { sort: { column: 'name', direction: 'desc' } }); 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'], + }); + }); }); diff --git a/apps/web/src/fleet/fleet-table-view-model.ts b/apps/web/src/fleet/fleet-table-view-model.ts index 3e3114a..e4585ad 100644 --- a/apps/web/src/fleet/fleet-table-view-model.ts +++ b/apps/web/src/fleet/fleet-table-view-model.ts @@ -1,6 +1,15 @@ export type FleetStatusKind = 'online' | 'auth' | 'offline' | 'unknown'; 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 EmptyFleetReason = 'config' | 'search' | 'filter'; @@ -22,13 +31,18 @@ export interface FleetStatus { export interface FleetTableOptions { readonly query?: string; readonly filter?: FleetFilter; - readonly sort?: Readonly<{ - column: FleetSortColumn; - direction: SortDirection; - }>; + readonly auth?: FleetAuthFilter; + readonly capability?: string; + readonly version?: string; + readonly tag?: string; + readonly sort?: Readonly<{ column: FleetSortColumn; direction: SortDirection }>; readonly selectedIds?: ReadonlySet; + readonly page?: number; + readonly pageSize?: number; } +export type FleetFreshness = 'fresh' | 'stale' | 'unknown'; + export interface FleetTableRow { readonly id: string; readonly displayName: string; @@ -37,6 +51,11 @@ export interface FleetTableRow { readonly statusKind: FleetStatusKind; readonly latencyMs?: number; 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 { @@ -49,10 +68,19 @@ export interface VisibleSelection { export interface FleetTableViewModel { readonly rows: readonly FleetTableRow[]; readonly totalCount: number; + readonly filteredCount: number; readonly visibleCount: number; + readonly page: number; + readonly pageSize: number; + readonly pageCount: number; readonly selectedIds: readonly string[]; readonly visibleSelection: VisibleSelection; readonly emptyReason: EmptyFleetReason | null; + readonly facets: Readonly<{ + capabilities: readonly string[]; + versions: readonly string[]; + tags: readonly string[]; + }>; } const STATUS_RANK: Readonly> = { @@ -74,13 +102,57 @@ function displayName(instance: FleetInstance): string { 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; + +type FleetMetadataSortColumn = Exclude; + +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 { + const details = metadata(instance, status); return [ instance.id, instance.name ?? '', instance.url, instance.description ?? '', - ...(instance.tags ?? []), + ...details.tags, + details.version ?? '', + ...details.capabilities, + details.freshness, + ...details.anomalies, status?.summary === undefined ? '' : JSON.stringify(status.summary), ] .join(' ') @@ -91,6 +163,11 @@ function compareNames(left: FleetInstance, right: FleetInstance): number { 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( left: FleetInstance, right: FleetInstance, @@ -100,7 +177,10 @@ function compareInstances( ): number { const leftStatus = statuses.get(left.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') { comparison = @@ -108,16 +188,37 @@ function compareInstances( } else if (column === 'latency') { const leftMissing = leftStatus?.latencyMs === undefined; const rightMissing = rightStatus?.latencyMs === undefined; - if (leftMissing !== rightMissing) return leftMissing ? 1 : -1; - comparison = (leftStatus?.latencyMs ?? 0) - (rightStatus?.latencyMs ?? 0); - } else { - comparison = compareNames(left, right); + if (leftMissing !== rightMissing) { + comparison = leftMissing ? 1 : -1; + missingComparison = true; + } 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); } +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( instances: readonly FleetInstance[], statuses: ReadonlyMap, @@ -125,23 +226,32 @@ export function buildFleetTableViewModel( ): FleetTableViewModel { const query = options.query?.trim().toLocaleLowerCase() ?? ''; const filter = options.filter ?? 'all'; + const auth = options.auth ?? 'all'; 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 selectedIds = [...(options.selectedIds ?? [])] - .filter((id) => knownIds.has(id)) - .sort((left, right) => left.localeCompare(right)); + const selectedIds = [...(options.selectedIds ?? [])].filter((id) => knownIds.has(id)).sort(); const selectedIdSet = new Set(selectedIds); - const visibleInstances = instances + const filteredInstances = instances .filter((instance) => { const status = statuses.get(instance.id); + const details = metadata(instance, status); 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); }) .slice() .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); return { id: instance.id, @@ -151,32 +261,44 @@ export function buildFleetTableViewModel( statusKind: fleetStatusKind(status), ...(status?.latencyMs === undefined ? {} : { latencyMs: status.latencyMs }), selected: selectedIdSet.has(instance.id), + ...metadata(instance, status), }; }); - const visibleSelectedCount = rows.reduce((count, row) => count + (row.selected ? 1 : 0), 0); - const rowCount = rows.length; + const visibleSelectedCount = rows.reduce((count, row) => count + Number(row.selected), 0); + const hasMetadataFilter = + auth !== 'all' || Boolean(options.capability || options.version || options.tag); const emptyReason: EmptyFleetReason | null = instances.length === 0 ? 'config' - : rowCount > 0 + : filteredCount > 0 ? null : query ? 'search' - : filter !== 'all' + : filter !== 'all' || hasMetadataFilter ? 'filter' : null; + const allMetadata = instances.map((instance) => metadata(instance, statuses.get(instance.id))); return { rows, totalCount: instances.length, - visibleCount: rowCount, + filteredCount, + visibleCount: rows.length, + page, + pageSize, + pageCount, selectedIds, visibleSelection: { - checked: rowCount > 0 && visibleSelectedCount === rowCount, - indeterminate: visibleSelectedCount > 0 && visibleSelectedCount < rowCount, + checked: rows.length > 0 && visibleSelectedCount === rows.length, + indeterminate: visibleSelectedCount > 0 && visibleSelectedCount < rows.length, selectedCount: visibleSelectedCount, - rowCount, + rowCount: rows.length, }, 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)), + }, }; } diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts index 38a09d2..008bb9c 100644 --- a/apps/web/src/index.ts +++ b/apps/web/src/index.ts @@ -15,5 +15,23 @@ export { type FleetSnapshot, } from './fleet/fleet-page.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; diff --git a/apps/web/src/instances/instance-api-data-source.test.ts b/apps/web/src/instances/instance-api-data-source.test.ts new file mode 100644 index 0000000..751d5ec --- /dev/null +++ b/apps/web/src/instances/instance-api-data-source.test.ts @@ -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() + .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() + .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() + .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() + .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() + .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); + }); +}); diff --git a/apps/web/src/instances/instance-api-data-source.ts b/apps/web/src/instances/instance-api-data-source.ts new file mode 100644 index 0000000..f51fc80 --- /dev/null +++ b/apps/web/src/instances/instance-api-data-source.ts @@ -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; +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; + create(input: InstanceInput): Promise; + update(instanceId: string, revision: number, patch: InstancePatch): Promise; + testConnection(instanceId: string): Promise; + delete(instanceId: string, revision: number): Promise; +} + +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 { + 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 { + 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 { + 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 { + 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(); + const instanceResponse = async ( + url: string, + ownerId?: string, + init?: RequestInit, + ): Promise => { + 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 }; +} diff --git a/apps/web/src/instances/instance-crud.test.tsx b/apps/web/src/instances/instance-crud.test.tsx new file mode 100644 index 0000000..3a49251 --- /dev/null +++ b/apps/web/src/instances/instance-crud.test.tsx @@ -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 { + 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(); + + 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( + , + ); + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/apps/web/src/instances/instance-crud.tsx b/apps/web/src/instances/instance-crud.tsx new file mode 100644 index 0000000..3e6721e --- /dev/null +++ b/apps/web/src/instances/instance-crud.tsx @@ -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(); + const [name, setName] = useState(''); + const [origin, setOrigin] = useState(''); + const [tags, setTags] = useState(''); + const [authMethod, setAuthMethod] = useState<'none' | 'password'>('none'); + const [passwordAction, setPasswordAction] = useState( + mode === 'create' ? 'set' : 'preserve', + ); + const [password, setPassword] = useState(''); + const [error, setError] = useState(); + const [status, setStatus] = useState(); + 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 ? ( +

+ {error} +

+ ) : ( +

Loading instance…

+ ); + + return ( +
+

{mode === 'create' ? 'Add instance' : 'Instance settings'}

+ {error ? ( +

+ {error} +

+ ) : null} + {status ? ( +

+ {status} +

+ ) : null} +
+ + + + Comma-separated tags + + {authMethod === 'password' ? ( + <> + {mode === 'edit' ? ( + + ) : null} + {mode === 'create' || passwordAction === 'set' ? ( + + ) : null} + + ) : null} +
+ + {mode === 'edit' ? ( + + ) : null} +
+
+ {mode === 'edit' ? ( +
+

Danger zone

+ {!confirming ? ( + + ) : ( +
+

Deletion cannot be undone.

+ + + +
+ )} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/instances/instance-detail.test.tsx b/apps/web/src/instances/instance-detail.test.tsx new file mode 100644 index 0000000..5a35fa0 --- /dev/null +++ b/apps/web/src/instances/instance-detail.test.tsx @@ -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( + , + ); + + 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(); + render( + , + ); + + 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( + , + ); + await waitFor(() => expect(pending.has('owner')).toBe(true)); + + rerender( + , + ); + 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( + , + ); + expect(screen.getByRole('heading', { name: label })).toBeTruthy(); + }); +}); diff --git a/apps/web/src/instances/instance-detail.tsx b/apps/web/src/instances/instance-detail.tsx new file mode 100644 index 0000000..fd30153 --- /dev/null +++ b/apps/web/src/instances/instance-detail.tsx @@ -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>>; + +export interface CapabilityDataSource { + load(instanceId: string, signal: AbortSignal): Promise; +} + +export interface InstanceDetailProps { + readonly instanceId: string; + readonly module: InstanceModule; + readonly instance?: InstanceContext; + readonly capabilities?: InstanceCapabilityMap; + readonly capabilityDataSource?: CapabilityDataSource; +} + +export const INSTANCE_MODULE_LABELS: Readonly> = { + 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, 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(); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(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 ( +
+

{INSTANCE_MODULE_LABELS[module]}

+

Instance context is unavailable for this route.

+
+ ); + } + + const map = capabilities ?? loadedCapabilities ?? {}; + const origin = canonicalHttpOrigin(instance.origin); + const activeCapability = capabilityFor(map, module); + + return ( +
+ +
+

{INSTANCE_MODULE_LABELS[module]}

+ {loading ?

Loading capabilities…

: null} + {loadError ?

Capabilities unavailable: {loadError}

: null} + {!loading && activeCapability.state === 'supported' ? ( +

+ Inspect {INSTANCE_MODULE_LABELS[module].toLowerCase()} data and available operations. +

+ ) : null} + {!loading && activeCapability.state !== 'supported' ? ( +

{explanation(activeCapability)}

+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index f7527dd..33673aa 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -200,6 +200,33 @@ tbody th small { color: var(--danger); 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) { .app-topbar { flex-wrap: wrap;