feat(web): add jobs audit and settings workspaces
This commit is contained in:
@@ -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 (
|
||||
<JobsPage
|
||||
{...(jobsDataSource ? { dataSource: jobsDataSource } : {})}
|
||||
refreshSignal={fleetRefreshSignal}
|
||||
/>
|
||||
);
|
||||
if (route.kind === 'audit')
|
||||
return (
|
||||
<AuditPage
|
||||
{...(auditDataSource ? { dataSource: auditDataSource } : {})}
|
||||
refreshSignal={fleetRefreshSignal}
|
||||
/>
|
||||
);
|
||||
if (route.kind === 'settings-instances')
|
||||
return (
|
||||
<InstanceSettingsPage
|
||||
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
|
||||
{...(fleetData ? { initialData: fleetData } : {})}
|
||||
refreshSignal={fleetRefreshSignal}
|
||||
/>
|
||||
);
|
||||
if (route.kind === 'settings-system')
|
||||
return (
|
||||
<section aria-labelledby="system-settings-title">
|
||||
<h1 id="system-settings-title">System settings</h1>
|
||||
<p role="status">
|
||||
System settings are unavailable because the control plane does not expose a settings
|
||||
contract.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
if (route.kind === 'not-found')
|
||||
return (
|
||||
<section>
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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<AuditDataSource['load']>(
|
||||
(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(<AuditPage />);
|
||||
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(<AuditPage dataSource={pending.source} />);
|
||||
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<AuditDataSource['load']>().mockResolvedValue({
|
||||
items: [],
|
||||
page: { page: 1, pageSize: 25, total: 80 },
|
||||
});
|
||||
render(<AuditPage dataSource={{ load }} />);
|
||||
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<AuditDataSource['load']>((query, signal) =>
|
||||
(call++ === 0 ? first : second).source.load(query, signal),
|
||||
);
|
||||
const { rerender } = render(<AuditPage dataSource={{ load }} refreshSignal={0} />);
|
||||
const oldSignal = load.mock.calls[0]?.[1];
|
||||
rerender(<AuditPage dataSource={{ load }} refreshSignal={1} />);
|
||||
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<AuditDataSource['load']>()
|
||||
.mockRejectedValue(new Error('private detail'));
|
||||
render(<AuditPage dataSource={{ load: retryLoad }} />);
|
||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||
'Audit events could not be loaded.',
|
||||
);
|
||||
expect(screen.queryByText('private detail')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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<AuditPageQuery['sort']>;
|
||||
|
||||
export interface AuditDataSource {
|
||||
load(query: AuditPageQuery, signal: AbortSignal): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface AuditPageProps {
|
||||
readonly dataSource?: AuditDataSource;
|
||||
readonly refreshSignal?: number;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: 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<AuditPageEnvelope | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const sequence = useRef(0);
|
||||
|
||||
const query = useMemo<AuditPageQuery>(() => {
|
||||
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 (
|
||||
<section aria-labelledby="audit-title">
|
||||
<h1 id="audit-title">Audit</h1>
|
||||
<p role="status" aria-label="Audit unavailable">
|
||||
Audit is unavailable at runtime because no audit data source was provided.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
|
||||
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 (
|
||||
<section className="fleet-panel" aria-labelledby="audit-title">
|
||||
<div className="fleet-heading">
|
||||
<div>
|
||||
<h1 id="audit-title">Audit</h1>
|
||||
<p>Read-only operational audit events.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setAttempt((value) => value + 1)}>
|
||||
Refresh audit events
|
||||
</button>
|
||||
</div>
|
||||
<div className="fleet-toolbar">
|
||||
{identifiers.map(([field, label]) => (
|
||||
<label key={field}>
|
||||
<span>{label}</span>
|
||||
<input
|
||||
aria-label={label}
|
||||
value={filters[field]}
|
||||
onChange={(event) => changeFilter(field, event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label>
|
||||
<span>Outcome</span>
|
||||
<select
|
||||
aria-label="Outcome"
|
||||
value={filters.outcome}
|
||||
onChange={(event) => changeFilter('outcome', event.currentTarget.value)}
|
||||
>
|
||||
<option value="">All outcomes</option>
|
||||
{AUDIT_OUTCOMES.map((outcome) => (
|
||||
<option key={outcome} value={outcome}>
|
||||
{outcomeLabel(outcome)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Occurred from</span>
|
||||
<input
|
||||
aria-label="Occurred from"
|
||||
type="datetime-local"
|
||||
value={filters.occurredFrom}
|
||||
onChange={(event) => changeFilter('occurredFrom', event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Occurred to</span>
|
||||
<input
|
||||
aria-label="Occurred to"
|
||||
type="datetime-local"
|
||||
value={filters.occurredTo}
|
||||
onChange={(event) => changeFilter('occurredTo', event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p role="status" aria-label="Audit loading status">
|
||||
Loading audit events…
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div role="alert" className="state-panel state-error">
|
||||
<p>Audit events could not be loaded.</p>
|
||||
<button type="button" onClick={() => setAttempt((value) => value + 1)}>
|
||||
Retry loading audit events
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && result?.items.length === 0 ? (
|
||||
<p className="state-panel">No audit events match the current query.</p>
|
||||
) : null}
|
||||
{!loading && result ? (
|
||||
<div className="table-scroll" tabIndex={0}>
|
||||
<table className="dense-table" aria-label="Audit events">
|
||||
<thead>
|
||||
<tr>
|
||||
{sortableColumns.map(([field, label]) => (
|
||||
<th
|
||||
key={field}
|
||||
scope="col"
|
||||
aria-sort={
|
||||
sort.field === field
|
||||
? sort.direction === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Sort by ${field}`}
|
||||
onClick={() => changeSort(field)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
{[
|
||||
'Actor ID',
|
||||
'Request ID',
|
||||
'Instance ID',
|
||||
'Job ID',
|
||||
'Item ID',
|
||||
'Attempt ID',
|
||||
'Preparation ID',
|
||||
'Parameters',
|
||||
].map((label) => (
|
||||
<th key={label} scope="col">
|
||||
{label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<time dateTime={item.occurredAt}>{item.occurredAt}</time>
|
||||
</td>
|
||||
<td>{item.action}</td>
|
||||
<td>{outcomeLabel(item.outcome)}</td>
|
||||
<td>{item.actorId}</td>
|
||||
<td>{item.requestId}</td>
|
||||
<td>{item.instanceId ?? '—'}</td>
|
||||
<td>{item.jobId ?? '—'}</td>
|
||||
<td>{item.itemId ?? '—'}</td>
|
||||
<td>{item.attemptId ?? '—'}</td>
|
||||
<td>{item.preparationId ?? '—'}</td>
|
||||
<td>
|
||||
{item.parameterSummary?.length
|
||||
? item.parameterSummary
|
||||
.map((parameter) => `${parameter.fieldId}: ${parameter.displayValue}`)
|
||||
.join(', ')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && result ? (
|
||||
<nav className="pagination" aria-label="Audit pagination">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous page"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage((value) => value - 1)}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span>
|
||||
Page {page} of {pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next page"
|
||||
disabled={page >= pageCount}
|
||||
onClick={() => setPage((value) => value + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export interface FleetSnapshot {
|
||||
readonly statuses: ReadonlyMap<string, FleetStatus>;
|
||||
}
|
||||
export interface FleetDataSource {
|
||||
load(): Promise<FleetSnapshot>;
|
||||
load(signal?: AbortSignal): Promise<FleetSnapshot>;
|
||||
}
|
||||
export interface FleetPageProps {
|
||||
readonly dataSource?: FleetDataSource;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<JobsDataSource['load']>(
|
||||
() =>
|
||||
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(<JobsPage />);
|
||||
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(<JobsPage dataSource={pending.source} />);
|
||||
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<JobsDataSource['load']>()
|
||||
.mockResolvedValue({ items: [], page: { page: 1, pageSize: 25, total: 80 } });
|
||||
render(<JobsPage dataSource={{ load }} />);
|
||||
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<JobsDataSource['load']>((query, signal) =>
|
||||
(call++ === 0 ? first : second).source.load(query, signal),
|
||||
);
|
||||
const source = { load };
|
||||
const { rerender } = render(<JobsPage dataSource={source} refreshSignal={0} />);
|
||||
const oldSignal = load.mock.calls[0]?.[1];
|
||||
rerender(<JobsPage dataSource={source} refreshSignal={1} />);
|
||||
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<JobsDataSource['load']>().mockRejectedValueOnce({
|
||||
title: 'Service unavailable',
|
||||
status: 503,
|
||||
code: 'JOBS_DOWN',
|
||||
detail: 'database password',
|
||||
confirmationToken: 'secret',
|
||||
});
|
||||
render(<JobsPage dataSource={{ load: retryLoad }} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string>(JOB_ITEM_TERMINAL_STATES);
|
||||
const JOB_STATUS_SET = new Set<string>(JOB_STATUSES);
|
||||
const ATTEMPT_STATUS_SET = new Set<string>(ATTEMPT_STATUSES);
|
||||
|
||||
type SafeProblem = Pick<NonNullable<JobItem['error']>, 'code' | 'title' | 'status'>;
|
||||
type SafeItem = Omit<JobItem, 'error'> & { readonly error?: SafeProblem };
|
||||
type SafeJob = Omit<Job, 'items'> & { readonly items: readonly SafeItem[] };
|
||||
export interface SafeJobPage {
|
||||
readonly items: readonly SafeJob[];
|
||||
readonly page: JobPage['page'];
|
||||
}
|
||||
|
||||
export interface JobsDataSource {
|
||||
load(query: JobPageQuery, signal: AbortSignal): Promise<unknown>;
|
||||
}
|
||||
export interface JobsPageProps {
|
||||
readonly dataSource?: JobsDataSource;
|
||||
readonly refreshSignal?: number;
|
||||
}
|
||||
|
||||
type SortField = NonNullable<JobPageQuery['sort']>;
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: 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<SafeJobPage | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [failed, setFailed] = useState<SafeProblem | true | null>(null);
|
||||
const [manualRefresh, setManualRefresh] = useState(0);
|
||||
const [status, setStatus] = useState<JobStatus | ''>('');
|
||||
const [operation, setOperation] = useState('');
|
||||
const [rootJob, setRootJob] = useState('');
|
||||
const [instance, setInstance] = useState('');
|
||||
const [sort, setSort] = useState<SortField>('createdAt');
|
||||
const [direction, setDirection] = useState<SortDirection>('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
const request = useRef(0);
|
||||
|
||||
const query = useMemo<JobPageQuery>(
|
||||
() => ({
|
||||
...(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 (
|
||||
<section aria-labelledby="jobs-title">
|
||||
<h1 id="jobs-title">Jobs</h1>
|
||||
<p role="status" aria-label="Jobs unavailable">
|
||||
Jobs are runtime-unavailable because no jobs data source was provided.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section aria-labelledby="jobs-title">
|
||||
<header>
|
||||
<h1 id="jobs-title">Jobs</h1>
|
||||
<p>Read-only operation history.</p>
|
||||
</header>
|
||||
<div className="jobs-toolbar">
|
||||
<label>
|
||||
Status
|
||||
<select
|
||||
aria-label="Status"
|
||||
value={status}
|
||||
onChange={(event) =>
|
||||
changeFilter(() => setStatus(event.currentTarget.value as JobStatus | ''))
|
||||
}
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{JOB_STATUSES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{statusLabel(value)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Operation
|
||||
<input
|
||||
aria-label="Operation"
|
||||
value={operation}
|
||||
onChange={(event) => changeFilter(() => setOperation(event.currentTarget.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Root job
|
||||
<input
|
||||
aria-label="Root job"
|
||||
value={rootJob}
|
||||
onChange={(event) => changeFilter(() => setRootJob(event.currentTarget.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Instance
|
||||
<input
|
||||
aria-label="Instance"
|
||||
value={instance}
|
||||
onChange={(event) => changeFilter(() => setInstance(event.currentTarget.value))}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Refresh jobs"
|
||||
onClick={() => setManualRefresh((value) => value + 1)}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p role="status" aria-label="Jobs loading status">
|
||||
Loading jobs…
|
||||
</p>
|
||||
) : null}
|
||||
{failed ? (
|
||||
<div role="alert">
|
||||
<p>
|
||||
Jobs could not be loaded.
|
||||
{failed !== true ? ` ${failed.code}: ${failed.title} (${failed.status})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !failed && result?.items.length === 0 ? (
|
||||
<p>No jobs match the current query.</p>
|
||||
) : null}
|
||||
{!loading && !failed && result ? (
|
||||
<>
|
||||
<table aria-label="Jobs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
aria-sort={
|
||||
sort === 'createdAt'
|
||||
? direction === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Sort by created time"
|
||||
onClick={() => changeSort('createdAt')}
|
||||
>
|
||||
Created
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col">Job</th>
|
||||
<th
|
||||
scope="col"
|
||||
aria-sort={
|
||||
sort === 'operationId'
|
||||
? direction === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Sort by operation"
|
||||
onClick={() => changeSort('operationId')}
|
||||
>
|
||||
Operation
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
aria-sort={
|
||||
sort === 'status'
|
||||
? direction === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Sort by status"
|
||||
onClick={() => changeSort('status')}
|
||||
>
|
||||
Status
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col">Root job</th>
|
||||
<th scope="col">Items</th>
|
||||
<th scope="col">Attempts</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.createdAt}</td>
|
||||
<th scope="row">{item.id}</th>
|
||||
<td>{item.operationId}</td>
|
||||
<td>{statusLabel(item.status)}</td>
|
||||
<td>{item.rootJobId}</td>
|
||||
<td>
|
||||
{item.items.length === 0 ? (
|
||||
<span>
|
||||
{item.status === 'queued' ||
|
||||
item.status === 'running' ||
|
||||
item.status === 'cancelling'
|
||||
? 'No terminal item results while this job is active.'
|
||||
: 'No terminal item results.'}
|
||||
</span>
|
||||
) : null}
|
||||
{item.items.map((entry) => (
|
||||
<div key={entry.id}>
|
||||
<span>{entry.targetId}</span> — <span>{statusLabel(entry.state)}</span>
|
||||
{entry.error ? (
|
||||
<>
|
||||
{' '}
|
||||
— <span>{entry.error.code}</span> — <span>{entry.error.title}</span> —{' '}
|
||||
<span>{entry.error.status}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
<td>
|
||||
{item.attempts.map((entry) => (
|
||||
<div key={entry.id}>
|
||||
{entry.id} — {statusLabel(entry.state)}
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav aria-label="Jobs pagination">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous page"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((value) => value - 1)}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span>
|
||||
Page {page} of {pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next page"
|
||||
disabled={page >= pageCount}
|
||||
onClick={() => setPage((value) => value + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<FleetDataSource['load']>(
|
||||
() =>
|
||||
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(<InstanceSettingsPage />);
|
||||
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(<InstanceSettingsPage dataSource={pending.source} />);
|
||||
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<FleetDataSource['load']>()
|
||||
.mockRejectedValueOnce(new Error('password=top-secret'))
|
||||
.mockResolvedValueOnce({ instances: [], statuses: new Map() });
|
||||
render(<InstanceSettingsPage dataSource={{ load }} />);
|
||||
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<FleetDataSource['load']>((signal) =>
|
||||
(calls++ === 0 ? first : second).source.load(signal),
|
||||
);
|
||||
const source = { load };
|
||||
const { rerender } = render(<InstanceSettingsPage dataSource={source} refreshSignal={0} />);
|
||||
const oldSignal = load.mock.calls[0]?.[0];
|
||||
rerender(<InstanceSettingsPage dataSource={source} refreshSignal={1} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, SafeStatus>;
|
||||
}
|
||||
|
||||
export interface InstanceSettingsPageProps {
|
||||
readonly dataSource?: FleetDataSource;
|
||||
readonly initialData?: FleetSnapshot;
|
||||
readonly refreshSignal?: number;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: 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<string>();
|
||||
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<string, SafeStatus>();
|
||||
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<SafeSnapshot | null>(() =>
|
||||
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 (
|
||||
<section aria-labelledby="settings-instances-title">
|
||||
<header>
|
||||
<div>
|
||||
<h1 id="settings-instances-title">Instances</h1>
|
||||
<p>Configure the SimAdmin instances available to this workspace.</p>
|
||||
</div>
|
||||
<a href="/instances/new">Add instance</a>
|
||||
</header>
|
||||
|
||||
{unavailable ? (
|
||||
<p role="status" aria-label="Instances unavailable">
|
||||
Instances are runtime-unavailable because no fleet data source was provided.
|
||||
</p>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<p role="status" aria-label="Instances loading status">
|
||||
Loading instances…
|
||||
</p>
|
||||
) : null}
|
||||
{failed ? (
|
||||
<div role="alert">
|
||||
<p>Instances could not be loaded.</p>
|
||||
{dataSource ? (
|
||||
<button type="button" onClick={() => setAttempt((value) => value + 1)}>
|
||||
Retry loading instances
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !failed && snapshot?.instances.length === 0 ? (
|
||||
<p>No instances are configured.</p>
|
||||
) : null}
|
||||
{!loading && !failed && snapshot && snapshot.instances.length > 0 ? (
|
||||
<ul aria-label="Configured instances">
|
||||
{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 (
|
||||
<li key={instance.id} aria-label={displayName}>
|
||||
<h2>
|
||||
<a href={`/settings/instances/${encodeURIComponent(instance.id)}`}>
|
||||
{displayName}
|
||||
</a>
|
||||
</h2>
|
||||
{instance.name ? <p>{instance.id}</p> : null}
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Origin</dt>
|
||||
<dd>
|
||||
{origin ? (
|
||||
<a
|
||||
href={origin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Open ${displayName} origin`}
|
||||
>
|
||||
{origin}
|
||||
</a>
|
||||
) : (
|
||||
'Invalid origin'
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
{state ? (
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{state}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{authentication ? (
|
||||
<div>
|
||||
<dt>Authentication</dt>
|
||||
<dd>{authentication}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{status?.freshness ? (
|
||||
<div>
|
||||
<dt>Freshness</dt>
|
||||
<dd>{titleCase(status.freshness)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{status?.capabilities?.length ? (
|
||||
<div>
|
||||
<dt>Capabilities</dt>
|
||||
<dd>{status.capabilities.join(', ')}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Compile-time checks that the sanitizer's accepted source fields remain Fleet-owned.
|
||||
void ({} as FleetInstance);
|
||||
void ({} as FleetStatus);
|
||||
Reference in New Issue
Block a user