From e46e81ea778b9b358974007ee401dcdd07850a51 Mon Sep 17 00:00:00 2001 From: chick Date: Fri, 17 Jul 2026 23:08:03 +0800 Subject: [PATCH] feat(web): add jobs audit and settings workspaces --- apps/web/src/app-shell.tsx | 45 ++ apps/web/src/audit/audit-page.test.tsx | 203 ++++++++ apps/web/src/audit/audit-page.tsx | 445 ++++++++++++++++ apps/web/src/fleet/fleet-page.tsx | 2 +- apps/web/src/index.ts | 17 + apps/web/src/jobs/jobs-page.test.tsx | 205 ++++++++ apps/web/src/jobs/jobs-page.tsx | 480 ++++++++++++++++++ .../settings/instance-settings-page.test.tsx | 157 ++++++ .../src/settings/instance-settings-page.tsx | 281 ++++++++++ 9 files changed, 1834 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/audit/audit-page.test.tsx create mode 100644 apps/web/src/audit/audit-page.tsx create mode 100644 apps/web/src/jobs/jobs-page.test.tsx create mode 100644 apps/web/src/jobs/jobs-page.tsx create mode 100644 apps/web/src/settings/instance-settings-page.test.tsx create mode 100644 apps/web/src/settings/instance-settings-page.tsx diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index 359240f..77a3748 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -1,3 +1,4 @@ +import { AuditPage, type AuditDataSource } from './audit/audit-page.js'; import type { ReactNode } from 'react'; import { useMemo } from 'react'; @@ -16,12 +17,14 @@ import { import { EsimModule, type EsimDataSource } from './instances/esim-module.js'; import { OverviewSystemPage, type OverviewDataSource } from './instances/overview-system.js'; import { InstanceEditor, type InstanceDataSource } from './instances/instance-crud.js'; +import { JobsPage, type JobsDataSource } from './jobs/jobs-page.js'; import { MessagesModule, type MessagesDataSource } from './instances/messages-module.js'; import { NotificationsModule, type NotificationsDataSource, } from './instances/notifications-module.js'; import { OtaModule, type OtaDataSource } from './instances/ota-module.js'; +import { InstanceSettingsPage } from './settings/instance-settings-page.js'; import { InstanceDetail, INSTANCE_MODULE_LABELS, @@ -85,6 +88,8 @@ export interface AppShellProps { notificationsDataSource?: NotificationsDataSource; automationDataSource?: AutomationDataSource; otaDataSource?: OtaDataSource; + jobsDataSource?: JobsDataSource; + auditDataSource?: AuditDataSource; eventStreamClient?: EventStreamClient; } @@ -161,6 +166,8 @@ function Page({ notificationsDataSource, automationDataSource, otaDataSource, + jobsDataSource, + auditDataSource, fleetRefreshSignal, detailRefreshSignal, }: { @@ -180,6 +187,8 @@ function Page({ notificationsDataSource: NotificationsDataSource | undefined; automationDataSource: AutomationDataSource | undefined; otaDataSource: OtaDataSource | undefined; + jobsDataSource: JobsDataSource | undefined; + auditDataSource: AuditDataSource | undefined; fleetRefreshSignal: number; detailRefreshSignal: number; }): ReactNode { @@ -206,6 +215,38 @@ function Page({ {...(instanceDataSource ? { dataSource: instanceDataSource } : {})} /> ); + if (route.kind === 'jobs') + return ( + + ); + if (route.kind === 'audit') + return ( + + ); + if (route.kind === 'settings-instances') + return ( + + ); + if (route.kind === 'settings-system') + return ( +
+

System settings

+

+ System settings are unavailable because the control plane does not expose a settings + contract. +

+
+ ); if (route.kind === 'not-found') return (
@@ -361,6 +402,8 @@ export function AppShell({ notificationsDataSource, automationDataSource, otaDataSource, + jobsDataSource, + auditDataSource, eventStreamClient, }: AppShellProps) { const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []); @@ -422,6 +465,8 @@ export function AppShell({ notificationsDataSource={notificationsDataSource} automationDataSource={automationDataSource} otaDataSource={otaDataSource} + jobsDataSource={jobsDataSource} + auditDataSource={auditDataSource} fleetRefreshSignal={refresh.fleet} detailRefreshSignal={refresh.detail} /> diff --git a/apps/web/src/audit/audit-page.test.tsx b/apps/web/src/audit/audit-page.test.tsx new file mode 100644 index 0000000..d1b4b2e --- /dev/null +++ b/apps/web/src/audit/audit-page.test.tsx @@ -0,0 +1,203 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { AuditPageQuery } from '@multi-simadmin/contracts'; + +import { + AuditPage, + datetimeLocalToUtc, + sanitizeAuditPage, + type AuditDataSource, +} from './audit-page.js'; + +afterEach(cleanup); + +const event = { + id: 'evt-1', + occurredAt: '2026-07-17T10:00:00.000Z', + actorId: 'operator@example.test', + action: 'message.send', + outcome: 'partially-succeeded', + requestId: 'req-7', + instanceId: 'alpha', + jobId: 'job-42', + itemId: 'item-3', + attemptId: 'attempt-2', + preparationId: 'prep-1', + parameterSummary: [{ fieldId: 'destination', displayValue: 'must-not-render', redacted: true }], +}; + +function deferredSource() { + let resolve!: (value: unknown) => void; + const load = vi.fn( + (query, signal) => + new Promise((done) => { + void query; + void signal; + resolve = done; + }), + ); + return { source: { load }, load, resolve: (value: unknown) => resolve(value) }; +} + +describe('Audit workspace frozen contract', () => { + it('is explicitly unavailable without an injected source', () => { + render(); + expect(screen.getByRole('status', { name: 'Audit unavailable' }).textContent).toMatch( + /no audit data source was provided/i, + ); + expect(screen.queryByRole('table')).toBeNull(); + }); + + it('accepts the AuditPage envelope and renders only contract fields', async () => { + const pending = deferredSource(); + render(); + expect(pending.load).toHaveBeenCalledWith( + { sort: 'occurredAt', direction: 'desc', page: 1, pageSize: 25 }, + expect.any(AbortSignal), + ); + pending.resolve({ items: [event], page: { page: 1, pageSize: 25, total: 1 } }); + + const table = await screen.findByRole('table', { name: 'Audit events' }); + for (const text of [ + event.occurredAt, + event.actorId, + event.action, + 'Partially succeeded', + event.requestId, + event.instanceId, + event.jobId, + event.itemId, + event.attemptId, + event.preparationId, + 'destination: [REDACTED]', + ]) { + expect(within(table).getByText(text)).toBeTruthy(); + } + expect(within(table).queryByText('must-not-render')).toBeNull(); + expect(screen.queryByRole('button', { name: /delete|edit|replay|export/i })).toBeNull(); + }); + + it('fails the entire page closed when any audit item is malformed or unredacted', () => { + const clean = sanitizeAuditPage({ + items: [ + event, + { ...event, id: 'failed', outcome: 'failed', parameterSummary: undefined }, + { ...event, id: 'bad-old-fields', occurredAt: undefined, timestamp: event.occurredAt }, + { ...event, id: 'bad-outcome', outcome: 'success' }, + { + ...event, + id: 'unredacted', + parameterSummary: [{ fieldId: 'token', displayValue: 'secret', redacted: false }], + }, + ], + page: { page: 1, pageSize: 25, total: 5 }, + }); + expect(clean).toBeNull(); + expect( + sanitizeAuditPage({ + items: Array.from({ length: 101 }, (_, index) => ({ ...event, id: `evt-${index}` })), + page: { page: 1, pageSize: 101, total: 101 }, + }), + ).toBeNull(); + expect(sanitizeAuditPage({ entries: [event], total: 1 })).toBeNull(); + expect( + sanitizeAuditPage({ items: [event], page: { page: 0, pageSize: 25, total: 1 } }), + ).toBeNull(); + }); + + it('converts datetime-local wall time through the local timezone and rejects invalid values', () => { + const wallTime = '2026-07-01T10:00'; + expect(datetimeLocalToUtc(wallTime)).toBe(new Date(wallTime).toISOString()); + expect(datetimeLocalToUtc('not-a-date')).toBeUndefined(); + expect(datetimeLocalToUtc('2026-02-31T10:00')).toBeUndefined(); + }); + + it('uses exactly the frozen AuditPageQuery keys for filters, sorting, and pagination', async () => { + const user = userEvent.setup(); + const load = vi.fn().mockResolvedValue({ + items: [], + page: { page: 1, pageSize: 25, total: 80 }, + }); + render(); + await screen.findByText('No audit events match the current query.'); + + await user.type(screen.getByRole('textbox', { name: 'Actor ID' }), 'alice'); + await user.type(screen.getByRole('textbox', { name: 'Instance ID' }), 'alpha'); + await user.type(screen.getByRole('textbox', { name: 'Job ID' }), 'job-1'); + await user.type(screen.getByRole('textbox', { name: 'Operation ID' }), 'call.start'); + await user.selectOptions(screen.getByRole('combobox', { name: 'Outcome' }), 'failed'); + await user.type(screen.getByRole('textbox', { name: 'Request ID' }), 'req-1'); + await user.type(screen.getByLabelText('Occurred from'), '2026-07-01T10:00'); + await user.type(screen.getByLabelText('Occurred to'), '2026-07-17T10:00'); + await user.click(screen.getByRole('button', { name: /sort by action/i })); + await user.click(screen.getByRole('button', { name: 'Next page' })); + + const query = load.mock.calls.at(-1)?.[0] as AuditPageQuery; + expect(query).toEqual({ + actorId: 'alice', + instanceId: 'alpha', + jobId: 'job-1', + operationId: 'call.start', + outcome: 'failed', + occurredFrom: new Date('2026-07-01T10:00').toISOString(), + occurredTo: new Date('2026-07-17T10:00').toISOString(), + requestId: 'req-1', + sort: 'action', + direction: 'asc', + page: 2, + pageSize: 25, + }); + expect(Object.keys(query).sort()).toEqual( + [ + 'actorId', + 'direction', + 'instanceId', + 'jobId', + 'occurredFrom', + 'occurredTo', + 'operationId', + 'outcome', + 'page', + 'pageSize', + 'requestId', + 'sort', + ].sort(), + ); + }); + + it('shows safe errors, aborts superseded reads, and fences late results', async () => { + const first = deferredSource(); + const second = deferredSource(); + let call = 0; + const load = vi.fn((query, signal) => + (call++ === 0 ? first : second).source.load(query, signal), + ); + const { rerender } = render(); + const oldSignal = load.mock.calls[0]?.[1]; + rerender(); + expect(oldSignal?.aborted).toBe(true); + first.resolve({ + items: [{ ...event, actorId: 'stale' }], + page: { page: 1, pageSize: 25, total: 1 }, + }); + second.resolve({ + items: [{ ...event, actorId: 'current' }], + page: { page: 1, pageSize: 25, total: 1 }, + }); + expect(await screen.findByText('current')).toBeTruthy(); + expect(screen.queryByText('stale')).toBeNull(); + + cleanup(); + const retryLoad = vi + .fn() + .mockRejectedValue(new Error('private detail')); + render(); + expect((await screen.findByRole('alert')).textContent).toContain( + 'Audit events could not be loaded.', + ); + expect(screen.queryByText('private detail')).toBeNull(); + }); +}); diff --git a/apps/web/src/audit/audit-page.tsx b/apps/web/src/audit/audit-page.tsx new file mode 100644 index 0000000..f98a068 --- /dev/null +++ b/apps/web/src/audit/audit-page.tsx @@ -0,0 +1,445 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { + AUDIT_OUTCOMES, + type AuditEvent, + type AuditOutcome, + type AuditPage as AuditPageEnvelope, + type AuditPageQuery, + type SortDirection, +} from '@multi-simadmin/contracts'; + +const MAX_EVENTS = 100; +const MAX_PARAMETERS = 50; +const MAX_STRING = 256; +const PAGE_SIZE = 25; +type AuditSort = NonNullable; + +export interface AuditDataSource { + load(query: AuditPageQuery, signal: AbortSignal): Promise; +} + +export interface AuditPageProps { + readonly dataSource?: AuditDataSource; + readonly refreshSignal?: number; +} + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function requiredString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const clean = value.trim(); + return clean.length > 0 && clean.length <= MAX_STRING ? clean : null; +} + +function optionalString(value: unknown): string | undefined | null { + if (value === undefined) return undefined; + return requiredString(value); +} + +function occurredAt(value: unknown): string | null { + if (typeof value !== 'string' || value.length > 64 || !Number.isFinite(Date.parse(value))) + return null; + return new Date(Date.parse(value)).toISOString(); +} + +function sanitizeEvent(value: unknown): AuditEvent | null { + const source = record(value); + if (!source) return null; + const id = requiredString(source.id); + const cleanOccurredAt = occurredAt(source.occurredAt); + const actorId = requiredString(source.actorId); + const action = requiredString(source.action); + const requestId = requiredString(source.requestId); + const outcome = AUDIT_OUTCOMES.includes(source.outcome as AuditOutcome) + ? (source.outcome as AuditOutcome) + : null; + const instanceId = optionalString(source.instanceId); + const jobId = optionalString(source.jobId); + const itemId = optionalString(source.itemId); + const attemptId = optionalString(source.attemptId); + const preparationId = optionalString(source.preparationId); + if ( + !id || + !cleanOccurredAt || + !actorId || + !action || + !requestId || + !outcome || + instanceId === null || + jobId === null || + itemId === null || + attemptId === null || + preparationId === null + ) + return null; + + let parameterSummary: AuditEvent['parameterSummary']; + if (source.parameterSummary !== undefined) { + if (!Array.isArray(source.parameterSummary) || source.parameterSummary.length > MAX_PARAMETERS) + return null; + const parameters = []; + for (const value of source.parameterSummary) { + const parameter = record(value); + const fieldId = requiredString(parameter?.fieldId); + // Never trust an upstream display value. An explicit redaction assertion is required and the + // only value admitted to the render model is our own literal marker. + if ( + !parameter || + !fieldId || + parameter.redacted !== true || + typeof parameter.displayValue !== 'string' + ) + return null; + parameters.push({ fieldId, displayValue: '[REDACTED]', redacted: true } as const); + } + parameterSummary = parameters; + } + + return { + id, + occurredAt: cleanOccurredAt, + actorId, + action, + outcome, + requestId, + ...(instanceId === undefined ? {} : { instanceId }), + ...(jobId === undefined ? {} : { jobId }), + ...(itemId === undefined ? {} : { itemId }), + ...(attemptId === undefined ? {} : { attemptId }), + ...(preparationId === undefined ? {} : { preparationId }), + ...(parameterSummary === undefined ? {} : { parameterSummary }), + }; +} + +function positiveInteger(value: unknown): number | null { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : null; +} + +export function sanitizeAuditPage(value: unknown): AuditPageEnvelope | null { + const source = record(value); + const page = record(source?.page); + if (!source || !Array.isArray(source.items) || source.items.length > MAX_EVENTS || !page) + return null; + const pageNumber = positiveInteger(page.page); + const pageSize = positiveInteger(page.pageSize); + const total = + typeof page.total === 'number' && Number.isSafeInteger(page.total) && page.total >= 0 + ? page.total + : null; + if (!pageNumber || !pageSize || total === null) return null; + const items: AuditEvent[] = []; + for (const candidate of source.items) { + const item = sanitizeEvent(candidate); + if (!item) return null; + items.push(item); + } + return { items, page: { page: pageNumber, pageSize, total } }; +} + +export function datetimeLocalToUtc(value: string): string | undefined { + if (!value) return undefined; + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value)) return undefined; + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return undefined; + const roundTrip = `${date.getFullYear().toString().padStart(4, '0')}-${(date.getMonth() + 1) + .toString() + .padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}T${date + .getHours() + .toString() + .padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`; + return roundTrip === value ? date.toISOString() : undefined; +} + +function outcomeLabel(value: AuditOutcome): string { + return value + .split('-') + .map((part, index) => (index === 0 ? part.charAt(0).toUpperCase() + part.slice(1) : part)) + .join(' '); +} + +export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) { + const [filters, setFilters] = useState({ + actorId: '', + instanceId: '', + jobId: '', + operationId: '', + outcome: '', + occurredFrom: '', + occurredTo: '', + requestId: '', + }); + const [sort, setSort] = useState<{ field: AuditSort; direction: SortDirection }>({ + field: 'occurredAt', + direction: 'desc', + }); + const [page, setPage] = useState(1); + const [attempt, setAttempt] = useState(0); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const sequence = useRef(0); + + const query = useMemo(() => { + const cleanActorId = requiredString(filters.actorId); + const cleanInstanceId = requiredString(filters.instanceId); + const cleanJobId = requiredString(filters.jobId); + const cleanOperationId = requiredString(filters.operationId); + const cleanRequestId = requiredString(filters.requestId); + const cleanOccurredFrom = datetimeLocalToUtc(filters.occurredFrom); + const cleanOccurredTo = datetimeLocalToUtc(filters.occurredTo); + return { + ...(cleanActorId ? { actorId: cleanActorId } : {}), + ...(cleanInstanceId ? { instanceId: cleanInstanceId } : {}), + ...(cleanJobId ? { jobId: cleanJobId } : {}), + ...(cleanOperationId ? { operationId: cleanOperationId } : {}), + ...(AUDIT_OUTCOMES.includes(filters.outcome as AuditOutcome) + ? { outcome: filters.outcome as AuditOutcome } + : {}), + ...(cleanOccurredFrom ? { occurredFrom: cleanOccurredFrom } : {}), + ...(cleanOccurredTo ? { occurredTo: cleanOccurredTo } : {}), + ...(cleanRequestId ? { requestId: cleanRequestId } : {}), + sort: sort.field, + direction: sort.direction, + page, + pageSize: PAGE_SIZE, + }; + }, [filters, page, sort]); + + useEffect(() => { + if (!dataSource) { + setResult(null); + setLoading(false); + setError(false); + return; + } + const controller = new AbortController(); + const request = ++sequence.current; + setLoading(true); + setError(false); + setResult(null); + void dataSource.load(query, controller.signal).then( + (raw) => { + if (request !== sequence.current || controller.signal.aborted) return; + const clean = sanitizeAuditPage(raw); + setLoading(false); + if (clean) setResult(clean); + else setError(true); + }, + () => { + if (request !== sequence.current || controller.signal.aborted) return; + setLoading(false); + setError(true); + }, + ); + return () => controller.abort(); + }, [attempt, dataSource, query, refreshSignal]); + + function changeFilter(field: keyof typeof filters, value: string): void { + setFilters((current) => ({ ...current, [field]: value })); + setPage(1); + } + function changeSort(field: AuditSort): void { + setSort((current) => ({ + field, + direction: current.field === field && current.direction === 'asc' ? 'desc' : 'asc', + })); + setPage(1); + } + + if (!dataSource) + return ( +
+

Audit

+

+ Audit is unavailable at runtime because no audit data source was provided. +

+
+ ); + + const sortableColumns: readonly [AuditSort, string][] = [ + ['occurredAt', 'Occurred at'], + ['action', 'Action'], + ['outcome', 'Outcome'], + ]; + const pageCount = Math.max(1, Math.ceil((result?.page.total ?? 0) / PAGE_SIZE)); + const identifiers: readonly [keyof typeof filters, string][] = [ + ['actorId', 'Actor ID'], + ['instanceId', 'Instance ID'], + ['jobId', 'Job ID'], + ['operationId', 'Operation ID'], + ['requestId', 'Request ID'], + ]; + + return ( +
+
+
+

Audit

+

Read-only operational audit events.

+
+ +
+
+ {identifiers.map(([field, label]) => ( + + ))} + + + +
+ {loading ? ( +

+ Loading audit events… +

+ ) : null} + {error ? ( +
+

Audit events could not be loaded.

+ +
+ ) : null} + {!loading && result?.items.length === 0 ? ( +

No audit events match the current query.

+ ) : null} + {!loading && result ? ( +
+ + + + {sortableColumns.map(([field, label]) => ( + + ))} + {[ + 'Actor ID', + 'Request ID', + 'Instance ID', + 'Job ID', + 'Item ID', + 'Attempt ID', + 'Preparation ID', + 'Parameters', + ].map((label) => ( + + ))} + + + + {result.items.map((item) => ( + + + + + + + + + + + + + + ))} + +
+ + + {label} +
+ + {item.action}{outcomeLabel(item.outcome)}{item.actorId}{item.requestId}{item.instanceId ?? '—'}{item.jobId ?? '—'}{item.itemId ?? '—'}{item.attemptId ?? '—'}{item.preparationId ?? '—'} + {item.parameterSummary?.length + ? item.parameterSummary + .map((parameter) => `${parameter.fieldId}: ${parameter.displayValue}`) + .join(', ') + : '—'} +
+
+ ) : null} + {!loading && result ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/fleet/fleet-page.tsx b/apps/web/src/fleet/fleet-page.tsx index c9aab42..8937877 100644 --- a/apps/web/src/fleet/fleet-page.tsx +++ b/apps/web/src/fleet/fleet-page.tsx @@ -15,7 +15,7 @@ export interface FleetSnapshot { readonly statuses: ReadonlyMap; } export interface FleetDataSource { - load(): Promise; + load(signal?: AbortSignal): Promise; } export interface FleetPageProps { readonly dataSource?: FleetDataSource; diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts index ec379c6..696c087 100644 --- a/apps/web/src/index.ts +++ b/apps/web/src/index.ts @@ -131,4 +131,21 @@ export { type OtaSnapshot, } from './instances/ota-module.js'; +export { + AuditPage, + sanitizeAuditPage, + type AuditDataSource, + type AuditPageProps, +} from './audit/audit-page.js'; +export { + JobsPage, + sanitizeJobPage, + type JobsDataSource, + type JobsPageProps, +} from './jobs/jobs-page.js'; +export { + InstanceSettingsPage, + type InstanceSettingsPageProps, +} from './settings/instance-settings-page.js'; + export const webWorkspaceReady = true; diff --git a/apps/web/src/jobs/jobs-page.test.tsx b/apps/web/src/jobs/jobs-page.test.tsx new file mode 100644 index 0000000..14c55ee --- /dev/null +++ b/apps/web/src/jobs/jobs-page.test.tsx @@ -0,0 +1,205 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { JobsPage, sanitizeJobPage, type JobsDataSource } from './jobs-page.js'; + +import type { Job, JobPageQuery } from '@multi-simadmin/contracts'; + +afterEach(cleanup); + +const job: Job = { + id: 'job-1', + operationId: 'message.send', + status: 'failed', + rootJobId: 'job-root', + retryOfJobId: 'job-0', + createdAt: '2026-07-17T10:00:00.000Z', + items: [ + { + id: 'item-1', + targetId: 'alpha', + state: 'failed', + error: { + type: 'secret-type', + title: 'Delivery failed', + status: 502, + detail: 'raw secret detail', + code: 'UPSTREAM_FAILURE', + requestId: 'secret-request', + }, + }, + ], + attempts: [ + { + id: 'attempt-1', + state: 'failed', + startedAt: '2026-07-17T10:00:01.000Z', + finishedAt: '2026-07-17T10:00:02.000Z', + }, + ], +}; + +function deferredSource() { + let resolve!: (value: unknown) => void; + const load = vi.fn( + () => + new Promise((done) => { + resolve = done; + }), + ); + return { source: { load }, load, resolve: (value: unknown) => resolve(value) }; +} + +describe('Phase 7 Jobs workspace', () => { + it('is explicitly runtime-unavailable without an injected source and offers no mutation controls', () => { + render(); + expect(screen.getByRole('status', { name: 'Jobs unavailable' }).textContent).toMatch( + /no jobs data source was provided/i, + ); + expect(screen.queryByRole('button', { name: /cancel|retry/i })).toBeNull(); + expect(screen.queryByRole('table')).toBeNull(); + }); + + it('loads through the injected source and renders sanitized, read-only job data', async () => { + const pending = deferredSource(); + render(); + expect(screen.getByRole('status', { name: 'Jobs loading status' })).toBeTruthy(); + expect(pending.load).toHaveBeenCalledWith( + { page: 1, pageSize: 25, sort: 'createdAt', direction: 'desc' }, + expect.any(AbortSignal), + ); + pending.resolve({ + items: [job], + page: { page: 1, pageSize: 25, total: 1 }, + confirmationToken: 'never-show', + }); + const table = await screen.findByRole('table', { name: 'Jobs' }); + const columnHeaders = within(table).getAllByRole('columnheader'); + expect(columnHeaders).toHaveLength(7); + for (const header of columnHeaders) expect(header.getAttribute('scope')).toBe('col'); + expect( + within(table).getByRole('columnheader', { name: 'Created' }).getAttribute('aria-sort'), + ).toBe('descending'); + expect( + within(table).getByRole('columnheader', { name: 'Operation' }).getAttribute('aria-sort'), + ).toBeNull(); + for (const text of [ + 'job-1', + 'message.send', + 'Failed', + 'job-root', + 'alpha', + 'Delivery failed', + 'UPSTREAM_FAILURE', + '502', + ]) { + expect(within(table).getAllByText(text).length).toBeGreaterThan(0); + } + for (const secret of ['raw secret detail', 'secret-request', 'secret-type', 'never-show']) { + expect(screen.queryByText(secret)).toBeNull(); + } + expect(screen.queryByRole('button', { name: /cancel job|retry job/i })).toBeNull(); + }); + + it('rejects non-canonical timestamps, oversized identities and arrays, invalid items, and bad envelopes', () => { + const long = 'x'.repeat(400); + const envelope = (candidate: unknown, page = { page: 1, pageSize: 25, total: 1 }) => ({ + items: [candidate], + page, + }); + for (const candidate of [ + { ...job, id: long }, + { ...job, createdAt: '2026-07-17T12:00:00+02:00' }, + { ...job, status: 'done' }, + { ...job, items: [{ id: 'x', targetId: 'alpha', state: 'running' }] }, + { + ...job, + items: Array.from({ length: 101 }, (_, index) => ({ + id: `i-${index}`, + targetId: 'alpha', + state: 'failed', + })), + }, + { ...job, retryOfJobId: long }, + { ...job, items: [{ ...job.items[0], error: { ...job.items[0]?.error, status: 999 } }] }, + ]) + expect(sanitizeJobPage(envelope(candidate))).toBeNull(); + for (const page of [ + { page: 0, pageSize: 25, total: 1 }, + { page: 1, pageSize: 101, total: 1 }, + { page: 1, pageSize: 25, total: 0 }, + { page: 1.5, pageSize: 25, total: 1 }, + ]) + expect(sanitizeJobPage(envelope(job, page))).toBeNull(); + expect( + sanitizeJobPage({ + items: Array.from({ length: 101 }, () => job), + page: { page: 1, pageSize: 100, total: 101 }, + }), + ).toBeNull(); + expect(sanitizeJobPage([])).toBeNull(); + }); + + it('sends status, operation, instance, sorting, pagination, and explicit refresh queries', async () => { + const user = userEvent.setup(); + const load = vi + .fn() + .mockResolvedValue({ items: [], page: { page: 1, pageSize: 25, total: 80 } }); + render(); + await screen.findByText('No jobs match the current query.'); + await user.selectOptions(screen.getByRole('combobox', { name: 'Status' }), 'failed'); + await user.type(screen.getByRole('textbox', { name: 'Operation' }), 'call.start'); + await user.type(screen.getByRole('textbox', { name: 'Root job' }), 'root-1'); + await user.type(screen.getByRole('textbox', { name: 'Instance' }), 'alpha'); + await user.click(screen.getByRole('button', { name: /sort by operation/i })); + await user.click(screen.getByRole('button', { name: 'Next page' })); + await user.click(screen.getByRole('button', { name: 'Refresh jobs' })); + expect(load.mock.calls.at(-1)?.[0] as JobPageQuery).toEqual({ + status: 'failed', + operationId: 'call.start', + rootJobId: 'root-1', + instanceId: 'alpha', + sort: 'operationId', + direction: 'asc', + page: 2, + pageSize: 25, + }); + }); + + it('shows only safe ProblemDetails errors, aborts superseded reads, and fences late results', async () => { + const first = deferredSource(); + const second = deferredSource(); + let call = 0; + const load = vi.fn((query, signal) => + (call++ === 0 ? first : second).source.load(query, signal), + ); + const source = { load }; + const { rerender } = render(); + const oldSignal = load.mock.calls[0]?.[1]; + rerender(); + expect(oldSignal?.aborted).toBe(true); + first.resolve({ items: [{ ...job, id: 'stale' }], page: { page: 1, pageSize: 25, total: 1 } }); + second.resolve({ + items: [{ ...job, id: 'current' }], + page: { page: 1, pageSize: 25, total: 1 }, + }); + expect(await screen.findByText('current')).toBeTruthy(); + expect(screen.queryByText('stale')).toBeNull(); + + cleanup(); + const retryLoad = vi.fn().mockRejectedValueOnce({ + title: 'Service unavailable', + status: 503, + code: 'JOBS_DOWN', + detail: 'database password', + confirmationToken: 'secret', + }); + render(); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toMatch(/JOBS_DOWN.*Service unavailable.*503/); + expect(alert.textContent).not.toMatch(/database password|secret/); + expect(screen.queryByRole('button', { name: /retry/i })).toBeNull(); + }); +}); diff --git a/apps/web/src/jobs/jobs-page.tsx b/apps/web/src/jobs/jobs-page.tsx new file mode 100644 index 0000000..2545ef2 --- /dev/null +++ b/apps/web/src/jobs/jobs-page.tsx @@ -0,0 +1,480 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { + ATTEMPT_STATUSES, + JOB_ITEM_TERMINAL_STATES, + JOB_STATUSES, + MAX_PAGE_SIZE, + type Attempt, + type Job, + type JobItem, + type JobPage, + type JobPageQuery, + type JobStatus, + type SortDirection, +} from '@multi-simadmin/contracts'; + +const PAGE_SIZE = 25; +const MAX_STRING = 256; +const TERMINAL_ITEMS = new Set(JOB_ITEM_TERMINAL_STATES); +const JOB_STATUS_SET = new Set(JOB_STATUSES); +const ATTEMPT_STATUS_SET = new Set(ATTEMPT_STATUSES); + +type SafeProblem = Pick, 'code' | 'title' | 'status'>; +type SafeItem = Omit & { readonly error?: SafeProblem }; +type SafeJob = Omit & { readonly items: readonly SafeItem[] }; +export interface SafeJobPage { + readonly items: readonly SafeJob[]; + readonly page: JobPage['page']; +} + +export interface JobsDataSource { + load(query: JobPageQuery, signal: AbortSignal): Promise; +} +export interface JobsPageProps { + readonly dataSource?: JobsDataSource; + readonly refreshSignal?: number; +} + +type SortField = NonNullable; + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} +function boundedString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 && value.length <= MAX_STRING ? value : null; +} +function timestamp(value: unknown): string | null { + if (typeof value !== 'string' || value.length > MAX_STRING) return null; + const time = Date.parse(value); + if (!Number.isFinite(time)) return null; + const canonical = new Date(time).toISOString(); + return canonical === value ? value : null; +} +function parseProblem(value: unknown): SafeProblem | undefined { + const source = record(value); + if (!source) return undefined; + const code = boundedString(source.code); + const title = boundedString(source.title); + if ( + !code || + !title || + typeof source.status !== 'number' || + !Number.isInteger(source.status) || + source.status < 100 || + source.status > 599 + ) + return undefined; + return { code, title, status: source.status }; +} +function hasContractProblem(value: unknown): boolean { + const source = record(value); + return Boolean( + source && + boundedString(source.type) && + boundedString(source.detail) && + boundedString(source.requestId) && + parseProblem(source), + ); +} +function parseItem(value: unknown): SafeItem | null { + const source = record(value); + if (!source) return null; + const id = boundedString(source.id); + const targetId = boundedString(source.targetId); + const state = + typeof source.state === 'string' && TERMINAL_ITEMS.has(source.state) + ? (source.state as SafeItem['state']) + : null; + if (!id || !targetId || !state) return null; + const sourceJobItemId = boundedString(source.sourceJobItemId); + const error = parseProblem(source.error); + if (source.sourceJobItemId !== undefined && !sourceJobItemId) return null; + if (source.error !== undefined && (!error || !hasContractProblem(source.error))) return null; + return { + id, + targetId, + state, + ...(sourceJobItemId ? { sourceJobItemId } : {}), + ...(error ? { error } : {}), + }; +} +function parseAttempt(value: unknown): Attempt | null { + const source = record(value); + if (!source) return null; + const id = boundedString(source.id); + const startedAt = timestamp(source.startedAt); + const state = + typeof source.state === 'string' && ATTEMPT_STATUS_SET.has(source.state) + ? (source.state as Attempt['state']) + : null; + if (!id || !startedAt || !state) return null; + const finishedAt = timestamp(source.finishedAt); + if (source.finishedAt !== undefined && !finishedAt) return null; + return { id, state, startedAt, ...(finishedAt ? { finishedAt } : {}) }; +} +function parseJob(value: unknown): SafeJob | null { + const source = record(value); + if (!source || !Array.isArray(source.items) || !Array.isArray(source.attempts)) return null; + const id = boundedString(source.id); + const operationId = boundedString(source.operationId); + const rootJobId = boundedString(source.rootJobId); + const createdAt = timestamp(source.createdAt); + const status = + typeof source.status === 'string' && JOB_STATUS_SET.has(source.status) + ? (source.status as JobStatus) + : null; + if (!id || !operationId || !rootJobId || !createdAt || !status) return null; + if (source.items.length > MAX_PAGE_SIZE || source.attempts.length > MAX_PAGE_SIZE) return null; + const items = source.items.map(parseItem); + const attempts = source.attempts.map(parseAttempt); + if (items.some((item) => item === null) || attempts.some((attempt) => attempt === null)) + return null; + const retryOfJobId = boundedString(source.retryOfJobId); + if (source.retryOfJobId !== undefined && !retryOfJobId) return null; + return { + id, + operationId, + rootJobId, + createdAt, + status, + items: items as SafeItem[], + attempts: attempts as Attempt[], + ...(retryOfJobId ? { retryOfJobId } : {}), + }; +} + +/** Converts an untrusted transport response into the only shape retained by this UI. */ +export function sanitizeJobPage(value: unknown): SafeJobPage | null { + const source = record(value); + if (!source || !Array.isArray(source.items)) return null; + const page = record(source.page); + if (!page) return null; + if ( + source.items.length > MAX_PAGE_SIZE || + !Number.isSafeInteger(page.page) || + (page.page as number) < 1 || + !Number.isSafeInteger(page.pageSize) || + (page.pageSize as number) < 1 || + (page.pageSize as number) > MAX_PAGE_SIZE || + !Number.isSafeInteger(page.total) || + (page.total as number) < 0 || + source.items.length > (page.pageSize as number) || + source.items.length > (page.total as number) + ) + return null; + const items: SafeJob[] = []; + for (const candidate of source.items) { + const parsed = parseJob(candidate); + if (!parsed) return null; + items.push(parsed); + } + return { + items, + page: { + page: page.page as number, + pageSize: page.pageSize as number, + total: page.total as number, + }, + }; +} + +function statusLabel(value: string): string { + return value + .split('-') + .map((part) => part[0]?.toUpperCase() + part.slice(1)) + .join(' '); +} +function safeLoadError(value: unknown): SafeProblem | null { + return parseProblem(value) ?? null; +} + +export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [failed, setFailed] = useState(null); + const [manualRefresh, setManualRefresh] = useState(0); + const [status, setStatus] = useState(''); + const [operation, setOperation] = useState(''); + const [rootJob, setRootJob] = useState(''); + const [instance, setInstance] = useState(''); + const [sort, setSort] = useState('createdAt'); + const [direction, setDirection] = useState('desc'); + const [page, setPage] = useState(1); + const request = useRef(0); + + const query = useMemo( + () => ({ + ...(status ? { status } : {}), + ...(operation ? { operationId: operation } : {}), + ...(rootJob ? { rootJobId: rootJob } : {}), + ...(instance ? { instanceId: instance } : {}), + sort, + direction, + page, + pageSize: PAGE_SIZE, + }), + [direction, instance, operation, page, rootJob, sort, status], + ); + + useEffect(() => { + if (!dataSource) return; + const controller = new AbortController(); + const requestId = ++request.current; + setLoading(true); + setFailed(null); + void dataSource.load(query, controller.signal).then( + (raw) => { + if (controller.signal.aborted || request.current !== requestId) return; + const clean = sanitizeJobPage(raw); + if (!clean) { + setResult(null); + setFailed(true); + } else { + setResult(clean); + } + setLoading(false); + }, + (reason: unknown) => { + if (controller.signal.aborted || request.current !== requestId) return; + setResult(null); + setFailed(safeLoadError(reason) ?? true); + setLoading(false); + }, + ); + return () => controller.abort(); + }, [dataSource, manualRefresh, query, refreshSignal]); + + if (!dataSource) { + return ( +
+

Jobs

+

+ Jobs are runtime-unavailable because no jobs data source was provided. +

+
+ ); + } + + const total = result?.page.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const changeFilter = (change: () => void) => { + change(); + setPage(1); + }; + const changeSort = (field: SortField) => { + if (sort === field) setDirection((current) => (current === 'asc' ? 'desc' : 'asc')); + else { + setSort(field); + setDirection('asc'); + } + setPage(1); + }; + + return ( +
+
+

Jobs

+

Read-only operation history.

+
+
+ + + + + +
+ {loading ? ( +

+ Loading jobs… +

+ ) : null} + {failed ? ( +
+

+ Jobs could not be loaded. + {failed !== true ? ` ${failed.code}: ${failed.title} (${failed.status})` : ''} +

+
+ ) : null} + {!loading && !failed && result?.items.length === 0 ? ( +

No jobs match the current query.

+ ) : null} + {!loading && !failed && result ? ( + <> + + + + + + + + + + + + + + {result.items.map((item) => ( + + + + + + + + + + ))} + +
+ + Job + + + + Root jobItemsAttempts
{item.createdAt}{item.id}{item.operationId}{statusLabel(item.status)}{item.rootJobId} + {item.items.length === 0 ? ( + + {item.status === 'queued' || + item.status === 'running' || + item.status === 'cancelling' + ? 'No terminal item results while this job is active.' + : 'No terminal item results.'} + + ) : null} + {item.items.map((entry) => ( +
+ {entry.targetId}{statusLabel(entry.state)} + {entry.error ? ( + <> + {' '} + — {entry.error.code}{entry.error.title} —{' '} + {entry.error.status} + + ) : null} +
+ ))} +
+ {item.attempts.map((entry) => ( +
+ {entry.id} — {statusLabel(entry.state)} +
+ ))} +
+ + + ) : null} +
+ ); +} diff --git a/apps/web/src/settings/instance-settings-page.test.tsx b/apps/web/src/settings/instance-settings-page.test.tsx new file mode 100644 index 0000000..51fcb4e --- /dev/null +++ b/apps/web/src/settings/instance-settings-page.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + InstanceSettingsPage, + sanitizeFleetSnapshot, + type FleetDataSource, + type FleetSnapshot, +} from './instance-settings-page.js'; + +afterEach(cleanup); + +function deferredSource() { + let resolve!: (value: FleetSnapshot) => void; + let reject!: (reason: unknown) => void; + const load = vi.fn( + () => + new Promise((done, fail) => { + resolve = done; + reject = fail; + }), + ); + return { + source: { load }, + load, + resolve: (value: FleetSnapshot) => resolve(value), + reject: (reason: unknown) => reject(reason), + }; +} + +const snapshot: FleetSnapshot = { + instances: [ + { + id: 'west/one', + name: 'West modem', + url: 'https://operator:secret@west.example:8443/admin?token=secret', + description: 'must not appear', + tags: ['private-tag'], + }, + { id: 'offline', url: 'javascript:alert(1)' }, + ], + statuses: new Map([ + [ + 'west/one', + { + reachable: true, + authenticated: true, + latencyMs: 12, + summary: { + capabilities: ['messages', 'calls', 7], + freshness: 'fresh', + password: 'never-render', + credentialConfigured: true, + version: 'also-not-part-of-this-page', + }, + }, + ], + ['offline', { reachable: false }], + ]), +}; + +describe('Settings instances page', () => { + it('rejects identity values instead of trimming, truncating, or admitting controls', () => { + const longId = 'x'.repeat(257); + const source = (id: string, name?: string) => ({ + instances: [{ id, url: 'https://example.test', ...(name === undefined ? {} : { name }) }], + statuses: new Map(), + }); + + for (const id of [' padded', 'padded ', longId, 'line\nbreak', 'null\0byte']) { + expect(sanitizeFleetSnapshot(source(id))).toEqual({ instances: [], statuses: new Map() }); + } + expect(sanitizeFleetSnapshot(source('stable-id', ` ${'n'.repeat(300)} `))?.instances).toEqual( + [{ id: 'stable-id', url: 'https://example.test', name: 'n'.repeat(256) }], + ); + }); + + it('is explicitly unavailable without an injected fleet source', () => { + render(); + expect(screen.getByRole('heading', { name: 'Instances' })).toBeTruthy(); + expect(screen.getByRole('status', { name: 'Instances unavailable' }).textContent).toMatch( + /no fleet data source/i, + ); + expect(screen.getByRole('link', { name: 'Add instance' }).getAttribute('href')).toBe( + '/instances/new', + ); + }); + + it('loads and renders an accessible, safe settings list using only fleet fields', async () => { + const pending = deferredSource(); + render(); + expect(screen.getByRole('status', { name: 'Instances loading status' })).toBeTruthy(); + expect(pending.load).toHaveBeenCalledWith(expect.any(AbortSignal)); + pending.resolve(snapshot); + + const list = await screen.findByRole('list', { name: 'Configured instances' }); + const west = within(list).getByRole('listitem', { name: 'West modem' }); + expect(within(west).getByRole('link', { name: 'West modem' }).getAttribute('href')).toBe( + '/settings/instances/west%2Fone', + ); + const origin = within(west).getByRole('link', { name: 'Open West modem origin' }); + expect(origin.getAttribute('href')).toBe('https://west.example:8443'); + expect(origin.getAttribute('rel')).toBe('noopener noreferrer'); + for (const value of ['Online', 'Authenticated', 'Fresh', 'messages, calls']) + expect(within(west).getByText(value)).toBeTruthy(); + + const offline = within(list).getByRole('listitem', { name: 'offline' }); + expect(within(offline).getByText('Invalid origin')).toBeTruthy(); + expect(within(offline).getByText('Offline')).toBeTruthy(); + expect(within(offline).queryByText(/authentication|freshness|capabilities/i)).toBeNull(); + + expect(document.body.textContent).not.toMatch( + /secret|must not appear|private-tag|never-render|credentialConfigured|also-not-part/, + ); + }); + + it('shows a fixed error, retries, and never exposes rejection details', async () => { + const user = userEvent.setup(); + const load = vi + .fn() + .mockRejectedValueOnce(new Error('password=top-secret')) + .mockResolvedValueOnce({ instances: [], statuses: new Map() }); + render(); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toBe('Instances could not be loaded.Retry loading instances'); + expect(document.body.textContent).not.toContain('top-secret'); + await user.click(screen.getByRole('button', { name: 'Retry loading instances' })); + expect(await screen.findByText('No instances are configured.')).toBeTruthy(); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('aborts superseded loads and fences late results', async () => { + const first = deferredSource(); + const second = deferredSource(); + let calls = 0; + const load = vi.fn((signal) => + (calls++ === 0 ? first : second).source.load(signal), + ); + const source = { load }; + const { rerender } = render(); + const oldSignal = load.mock.calls[0]?.[0]; + rerender(); + expect(oldSignal?.aborted).toBe(true); + first.resolve({ + instances: [{ id: 'stale', url: 'https://stale.example' }], + statuses: new Map(), + }); + second.resolve({ + instances: [{ id: 'current', url: 'https://current.example' }], + statuses: new Map(), + }); + expect(await screen.findByRole('link', { name: 'current' })).toBeTruthy(); + expect(screen.queryByRole('link', { name: 'stale' })).toBeNull(); + }); +}); diff --git a/apps/web/src/settings/instance-settings-page.tsx b/apps/web/src/settings/instance-settings-page.tsx new file mode 100644 index 0000000..3888829 --- /dev/null +++ b/apps/web/src/settings/instance-settings-page.tsx @@ -0,0 +1,281 @@ +import { useEffect, useRef, useState } from 'react'; + +import { + canonicalHttpOrigin, + type FleetDataSource, + type FleetSnapshot, +} from '../fleet/fleet-page.js'; +import type { FleetInstance, FleetStatus } from '../fleet/fleet-table-view-model.js'; + +export type { FleetDataSource, FleetSnapshot } from '../fleet/fleet-page.js'; + +const MAX_INSTANCES = 1000; +const MAX_STRING = 256; +const FRESHNESS = new Set(['fresh', 'stale', 'unknown']); + +interface SafeInstance { + readonly id: string; + readonly name?: string; + readonly url: string; +} +interface SafeStatus { + readonly reachable: boolean; + readonly authenticated?: boolean; + readonly capabilities?: readonly string[]; + readonly freshness?: 'fresh' | 'stale' | 'unknown'; +} +interface SafeSnapshot { + readonly instances: readonly SafeInstance[]; + readonly statuses: ReadonlyMap; +} + +export interface InstanceSettingsPageProps { + readonly dataSource?: FleetDataSource; + readonly initialData?: FleetSnapshot; + readonly refreshSignal?: number; +} + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function boundedDisplayString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const clean = value.trim(); + return clean ? clean.slice(0, MAX_STRING) : null; +} + +function identityString(value: unknown): string | null { + return typeof value === 'string' && + value.length > 0 && + value.length <= MAX_STRING && + value === value.trim() && + !/[\u0000-\u001f\u007f-\u009f]/u.test(value) + ? value + : null; +} + +function safeInstance(value: unknown): SafeInstance | null { + const source = record(value); + if (!source) return null; + const id = identityString(source.id); + const url = boundedDisplayString(source.url); + if (!id || !url) return null; + const name = boundedDisplayString(source.name); + return { id, url, ...(name ? { name } : {}) }; +} + +function safeStatus(value: unknown): SafeStatus | null { + const source = record(value); + if (!source || typeof source.reachable !== 'boolean') return null; + const summary = record(source.summary); + const rawCapabilities = summary?.capabilities; + const capabilities = Array.isArray(rawCapabilities) + ? rawCapabilities + .map(boundedDisplayString) + .filter((entry): entry is string => entry !== null) + .slice(0, 100) + : undefined; + const rawFreshness = summary?.freshness; + const freshness = + typeof rawFreshness === 'string' && FRESHNESS.has(rawFreshness) + ? (rawFreshness as SafeStatus['freshness']) + : undefined; + return { + reachable: source.reachable, + ...(typeof source.authenticated === 'boolean' ? { authenticated: source.authenticated } : {}), + ...(capabilities?.length ? { capabilities } : {}), + ...(freshness ? { freshness } : {}), + }; +} + +/** Retains only fields with defined Fleet semantics; arbitrary summary data is discarded. */ +export function sanitizeFleetSnapshot(value: unknown): SafeSnapshot | null { + const source = record(value); + if (!source || !Array.isArray(source.instances) || !(source.statuses instanceof Map)) return null; + const instances: SafeInstance[] = []; + const ids = new Set(); + for (const candidate of source.instances) { + const instance = safeInstance(candidate); + if (!instance || ids.has(instance.id)) continue; + instances.push(instance); + ids.add(instance.id); + if (instances.length === MAX_INSTANCES) break; + } + const statuses = new Map(); + for (const id of ids) { + const status = safeStatus(source.statuses.get(id)); + if (status) statuses.set(id, status); + } + return { instances, statuses }; +} + +function statusLabel(status: SafeStatus | undefined): string | null { + if (!status) return null; + if (!status.reachable) return 'Offline'; + if (status.authenticated === false) return 'Authentication required'; + return 'Online'; +} + +function authLabel(status: SafeStatus | undefined): string | null { + if (status?.authenticated === true) return 'Authenticated'; + if (status?.authenticated === false) return 'Authentication required'; + return null; +} + +function titleCase(value: string): string { + return value[0]!.toUpperCase() + value.slice(1); +} + +export function InstanceSettingsPage({ + dataSource, + initialData, + refreshSignal = 0, +}: InstanceSettingsPageProps) { + const [snapshot, setSnapshot] = useState(() => + initialData ? sanitizeFleetSnapshot(initialData) : null, + ); + const [loading, setLoading] = useState(false); + const [failed, setFailed] = useState(false); + const [attempt, setAttempt] = useState(0); + const request = useRef(0); + + useEffect(() => { + if (initialData && refreshSignal === 0 && attempt === 0) { + const clean = sanitizeFleetSnapshot(initialData); + setSnapshot(clean); + setFailed(clean === null); + setLoading(false); + return; + } + if (!dataSource) return; + const controller = new AbortController(); + const requestId = ++request.current; + setLoading(true); + setFailed(false); + void dataSource.load(controller.signal).then( + (raw) => { + if (controller.signal.aborted || request.current !== requestId) return; + const clean = sanitizeFleetSnapshot(raw); + setSnapshot(clean); + setFailed(clean === null); + setLoading(false); + }, + () => { + if (controller.signal.aborted || request.current !== requestId) return; + setSnapshot(null); + setFailed(true); + setLoading(false); + }, + ); + return () => controller.abort(); + }, [attempt, dataSource, initialData, refreshSignal]); + + const unavailable = !dataSource && !initialData; + + return ( +
+
+
+

Instances

+

Configure the SimAdmin instances available to this workspace.

+
+ Add instance +
+ + {unavailable ? ( +

+ Instances are runtime-unavailable because no fleet data source was provided. +

+ ) : null} + {loading ? ( +

+ Loading instances… +

+ ) : null} + {failed ? ( +
+

Instances could not be loaded.

+ {dataSource ? ( + + ) : null} +
+ ) : null} + {!loading && !failed && snapshot?.instances.length === 0 ? ( +

No instances are configured.

+ ) : null} + {!loading && !failed && snapshot && snapshot.instances.length > 0 ? ( +
    + {snapshot.instances.map((instance) => { + const displayName = instance.name ?? instance.id; + const origin = canonicalHttpOrigin(instance.url); + const status = snapshot.statuses.get(instance.id); + const state = statusLabel(status); + const authentication = authLabel(status); + return ( +
  • +

    + + {displayName} + +

    + {instance.name ?

    {instance.id}

    : null} +
    +
    +
    Origin
    +
    + {origin ? ( + + {origin} + + ) : ( + 'Invalid origin' + )} +
    +
    + {state ? ( +
    +
    Status
    +
    {state}
    +
    + ) : null} + {authentication ? ( +
    +
    Authentication
    +
    {authentication}
    +
    + ) : null} + {status?.freshness ? ( +
    +
    Freshness
    +
    {titleCase(status.freshness)}
    +
    + ) : null} + {status?.capabilities?.length ? ( +
    +
    Capabilities
    +
    {status.capabilities.join(', ')}
    +
    + ) : null} +
    +
  • + ); + })} +
+ ) : null} +
+ ); +} + +// Compile-time checks that the sanitizer's accepted source fields remain Fleet-owned. +void ({} as FleetInstance); +void ({} as FleetStatus);