Complete provisioning job detail pages and lifecycle evidence
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
export type ProvisioningMutation = 'resume' | 'cancel';
|
||||
|
||||
export interface ProvisioningRefreshState<T> {
|
||||
id: string;
|
||||
detail: T | null;
|
||||
refreshing: boolean;
|
||||
busy: ProvisioningMutation | null;
|
||||
loadError: string;
|
||||
actionError: string;
|
||||
notice: string;
|
||||
refreshedAt: string | null;
|
||||
}
|
||||
|
||||
interface Options<T> {
|
||||
onChange: (state: ProvisioningRefreshState<T>) => void;
|
||||
validate: (value: unknown, id: string) => value is T;
|
||||
fetcher?: typeof fetch;
|
||||
now?: () => string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Reads and action feedback belong to a particular visit, even when its ID is revisited. */
|
||||
export function createProvisioningRefresh<T>(options: Options<T>) {
|
||||
const request = options.fetcher ?? fetch;
|
||||
const now = options.now ?? (() => new Date().toISOString());
|
||||
let visit = 0;
|
||||
let read = 0;
|
||||
let disposed = false;
|
||||
let pending: { controller: AbortController; promise: Promise<void>; timer?: ReturnType<typeof setTimeout> } | null = null;
|
||||
let mutation: AbortController | null = null;
|
||||
let state: ProvisioningRefreshState<T> = {
|
||||
id: '', detail: null, refreshing: false, busy: null, loadError: '', actionError: '', notice: '', refreshedAt: null
|
||||
};
|
||||
const publish = (patch: Partial<ProvisioningRefreshState<T>>) => {
|
||||
state = { ...state, ...patch };
|
||||
if (!disposed) options.onChange(state);
|
||||
};
|
||||
function invalidateRead() {
|
||||
read++;
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.controller.abort();
|
||||
pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(force = false, reconcile = false): Promise<void> {
|
||||
if (disposed || !state.id || (state.busy && !reconcile)) return;
|
||||
if (pending && !force) return pending.promise;
|
||||
invalidateRead();
|
||||
const currentVisit = visit;
|
||||
const currentRead = read;
|
||||
const id = state.id;
|
||||
const current = () => !disposed && currentVisit === visit && currentRead === read && id === state.id;
|
||||
const controller = new AbortController();
|
||||
const entry = { controller, promise: Promise.resolve(), timer: undefined as ReturnType<typeof setTimeout> | undefined };
|
||||
let timedOut = false;
|
||||
pending = entry;
|
||||
publish({ refreshing: true });
|
||||
entry.timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.timeoutMs ?? 15_000);
|
||||
entry.promise = (async () => {
|
||||
try {
|
||||
const response = await request(`/api/provisioning/${encodeURIComponent(id)}?events=recent`, { signal: controller.signal, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(response.status === 404 ? 'Provisioning operation not found.' : 'Could not refresh this operation.');
|
||||
const detail: unknown = await response.json();
|
||||
if (!options.validate(detail, id)) throw new Error('The operation response was incomplete. Try refreshing again.');
|
||||
if (current()) publish({ detail, loadError: '', refreshedAt: now() });
|
||||
} catch (error) {
|
||||
if (current() && (!controller.signal.aborted || timedOut)) publish({
|
||||
loadError: timedOut ? 'Operation refresh timed out. Try again.' : error instanceof Error ? error.message : 'Could not refresh this operation.'
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(entry.timer);
|
||||
if (pending === entry) pending = null;
|
||||
if (current()) publish({ refreshing: false });
|
||||
}
|
||||
})();
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
function setOperation(id: string, detail: T | null = null, refreshedAt: string | null = null) {
|
||||
if (disposed) return;
|
||||
visit++;
|
||||
invalidateRead();
|
||||
mutation?.abort();
|
||||
mutation = null;
|
||||
publish({ id, detail, refreshedAt, refreshing: false, busy: null, loadError: '', actionError: '', notice: '' });
|
||||
if (!detail) void load();
|
||||
}
|
||||
|
||||
async function act(action: ProvisioningMutation, successMessage: string): Promise<void> {
|
||||
if (disposed || !state.id || state.busy) return;
|
||||
const currentVisit = visit;
|
||||
const id = state.id;
|
||||
const current = () => !disposed && currentVisit === visit && id === state.id;
|
||||
invalidateRead();
|
||||
const controller = new AbortController();
|
||||
mutation = controller;
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.timeoutMs ?? 15_000);
|
||||
publish({ busy: action, refreshing: false, actionError: '', notice: '' });
|
||||
try {
|
||||
const response = await request(`/api/provisioning/${encodeURIComponent(id)}/${action}`, { method: 'POST', signal: controller.signal });
|
||||
const body = await response.json() as { error?: string };
|
||||
if (!response.ok) throw new Error(body.error ?? 'The operation changed before this request could be accepted.');
|
||||
if (current()) publish({ notice: successMessage });
|
||||
} catch (error) {
|
||||
if (current() && (!controller.signal.aborted || timedOut)) publish({ actionError: timedOut
|
||||
? 'The request timed out and may have reached the server. Review the refreshed operation before trying again.'
|
||||
: error instanceof Error ? error.message : 'Could not confirm the request. Review the operation before trying again.' });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (mutation === controller) mutation = null;
|
||||
// A failed reconciliation is a read error, never a failed accepted mutation.
|
||||
if (current()) {
|
||||
await load(true, true);
|
||||
if (current()) publish({ busy: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
disposed = true;
|
||||
visit++;
|
||||
invalidateRead();
|
||||
mutation?.abort();
|
||||
mutation = null;
|
||||
}
|
||||
|
||||
return { setOperation, refresh: () => load(), forceRefresh: () => load(true), act, dispose };
|
||||
}
|
||||
Reference in New Issue
Block a user