feat(web): add jobs audit and settings workspaces

This commit is contained in:
chick
2026-07-17 23:08:03 +08:00
parent 5eb8680393
commit e46e81ea77
9 changed files with 1834 additions and 1 deletions
+205
View File
@@ -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();
});
});
+480
View File
@@ -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>
);
}