483 lines
16 KiB
TypeScript
483 lines
16 KiB
TypeScript
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 ? (
|
|
<>
|
|
<div className="table-scroll" role="region" aria-label="Jobs table" tabIndex={0}>
|
|
<table className="dense-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>
|
|
</div>
|
|
<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>
|
|
);
|
|
}
|