297 lines
9.7 KiB
TypeScript
297 lines
9.7 KiB
TypeScript
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
|
|
import type { InstanceContext } from '../app-shell.js';
|
|
|
|
/** Aggregate task counts only; per-task records and configuration are intentionally unsupported. */
|
|
export interface AutomationTaskCounts {
|
|
readonly total?: number | null;
|
|
readonly enabled?: number | null;
|
|
readonly disabled?: number | null;
|
|
readonly running?: number | null;
|
|
readonly queued?: number | null;
|
|
readonly succeeded?: number | null;
|
|
readonly failed?: number | null;
|
|
}
|
|
|
|
/** Bounded high-level status labels only, never status payloads or raw logs. */
|
|
export type AutomationState = 'healthy' | 'degraded' | 'failed' | 'disabled' | 'unknown';
|
|
export type AutomationScheduler = 'active' | 'idle' | 'paused' | 'disabled' | 'unavailable';
|
|
export type AutomationWorkers = 'available' | 'busy' | 'degraded' | 'disabled' | 'unavailable';
|
|
export interface AutomationStatus {
|
|
readonly state?: AutomationState | null;
|
|
readonly scheduler?: AutomationScheduler | null;
|
|
readonly workers?: AutomationWorkers | null;
|
|
}
|
|
|
|
export interface AutomationSnapshot {
|
|
readonly observedAt?: string;
|
|
readonly status: AutomationStatus;
|
|
readonly tasks: AutomationTaskCounts;
|
|
}
|
|
|
|
export interface AutomationDataSource {
|
|
/** Injected by the authenticated owner; this isolated module defines no production endpoint. */
|
|
load(instanceId: string, signal: AbortSignal): Promise<unknown>;
|
|
}
|
|
|
|
export interface AutomationModuleProps {
|
|
readonly instance: InstanceContext;
|
|
readonly dataSource?: AutomationDataSource;
|
|
readonly refreshSignal?: unknown;
|
|
}
|
|
|
|
type ReadState =
|
|
| { kind: 'idle'; ownerId: string }
|
|
| { kind: 'loading'; ownerId: string; snapshot?: AutomationSnapshot }
|
|
| { kind: 'ready'; ownerId: string; snapshot: AutomationSnapshot }
|
|
| { kind: 'error'; ownerId: string; snapshot?: AutomationSnapshot };
|
|
|
|
const SAFE_LOAD_ERROR = 'Automation data could not be loaded.';
|
|
const STATES = new Set<AutomationState>(['healthy', 'degraded', 'failed', 'disabled', 'unknown']);
|
|
const SCHEDULERS = new Set<AutomationScheduler>([
|
|
'active',
|
|
'idle',
|
|
'paused',
|
|
'disabled',
|
|
'unavailable',
|
|
]);
|
|
const WORKERS = new Set<AutomationWorkers>([
|
|
'available',
|
|
'busy',
|
|
'degraded',
|
|
'disabled',
|
|
'unavailable',
|
|
]);
|
|
const TASK_COUNT_KEYS = [
|
|
'total',
|
|
'enabled',
|
|
'disabled',
|
|
'running',
|
|
'queued',
|
|
'succeeded',
|
|
'failed',
|
|
] as const;
|
|
|
|
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function safeTimestamp(value: unknown): string | undefined {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)
|
|
)
|
|
return undefined;
|
|
const parsed = new Date(value);
|
|
if (!Number.isFinite(parsed.getTime())) return undefined;
|
|
const canonical = parsed.toISOString();
|
|
return canonical === value || canonical === value.replace(/Z$/, '.000Z') ? value : undefined;
|
|
}
|
|
|
|
function boundedEnum<T extends string>(
|
|
value: unknown,
|
|
allowed: ReadonlySet<T>,
|
|
): T | null | undefined {
|
|
if (value === null) return null;
|
|
return typeof value === 'string' && allowed.has(value as T) ? (value as T) : undefined;
|
|
}
|
|
|
|
/** Copy and validate untrusted source data before it can enter React state. */
|
|
export function sanitizeAutomationSnapshot(value: unknown): AutomationSnapshot {
|
|
const source = isRecord(value) ? value : {};
|
|
const rawStatus = isRecord(source.status) ? source.status : {};
|
|
const state = boundedEnum(rawStatus.state, STATES);
|
|
const scheduler = boundedEnum(rawStatus.scheduler, SCHEDULERS);
|
|
const workers = boundedEnum(rawStatus.workers, WORKERS);
|
|
const status: AutomationStatus = {
|
|
...(state !== undefined ? { state } : {}),
|
|
...(scheduler !== undefined ? { scheduler } : {}),
|
|
...(workers !== undefined ? { workers } : {}),
|
|
};
|
|
const rawTasks = isRecord(source.tasks) ? source.tasks : {};
|
|
const tasks: Record<string, number | null> = {};
|
|
for (const key of TASK_COUNT_KEYS) {
|
|
const count = rawTasks[key];
|
|
if (count === null || (Number.isSafeInteger(count) && (count as number) >= 0))
|
|
tasks[key] = count as number | null;
|
|
}
|
|
const observedAt = safeTimestamp(source.observedAt);
|
|
return {
|
|
...(observedAt ? { observedAt } : {}),
|
|
status,
|
|
tasks,
|
|
};
|
|
}
|
|
|
|
function Section({ label, children }: { label: string; children: ReactNode }) {
|
|
return (
|
|
<section className="automation-card" aria-label={label}>
|
|
<h2>{label}</h2>
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Fields({
|
|
values,
|
|
}: {
|
|
values: readonly (readonly [string, string | number | null | undefined])[];
|
|
}) {
|
|
const supplied = values.filter(([, value]) => value !== undefined);
|
|
if (!supplied.length) return <p>No aggregate status was supplied.</p>;
|
|
return (
|
|
<dl>
|
|
{supplied.map(([label, value]) => (
|
|
<div key={label}>
|
|
<dt>{label}</dt>
|
|
<dd>{value == null ? 'Unavailable' : String(value)}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
);
|
|
}
|
|
|
|
function SnapshotView({ snapshot }: { snapshot: AutomationSnapshot }) {
|
|
const status = snapshot.status;
|
|
const tasks = snapshot.tasks;
|
|
return (
|
|
<>
|
|
{snapshot.observedAt ? <p>Observed {snapshot.observedAt}</p> : null}
|
|
<p>Read-only aggregate view; task details and operational controls are excluded.</p>
|
|
<div className="automation-grid">
|
|
<Section label="Automation status">
|
|
<Fields
|
|
values={[
|
|
['State', status.state],
|
|
['Scheduler', status.scheduler],
|
|
['Workers', status.workers],
|
|
]}
|
|
/>
|
|
</Section>
|
|
<Section label="Task counts">
|
|
<Fields
|
|
values={[
|
|
['Total', tasks.total],
|
|
['Enabled', tasks.enabled],
|
|
['Disabled', tasks.disabled],
|
|
['Running', tasks.running],
|
|
['Queued', tasks.queued],
|
|
['Succeeded', tasks.succeeded],
|
|
['Failed', tasks.failed],
|
|
]}
|
|
/>
|
|
</Section>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function AutomationModule({ instance, dataSource, refreshSignal }: AutomationModuleProps) {
|
|
const requestFence = useRef(0);
|
|
const [retry, setRetry] = useState(0);
|
|
const [state, setState] = useState<ReadState>({ kind: 'idle', ownerId: instance.id });
|
|
|
|
useEffect(() => {
|
|
const request = ++requestFence.current;
|
|
const ownerId = instance.id;
|
|
const controller = new AbortController();
|
|
|
|
if (instance.authentication !== 'authenticated' || !dataSource) {
|
|
setState({ kind: 'idle', ownerId });
|
|
return () => controller.abort();
|
|
}
|
|
|
|
setState((current) => ({
|
|
kind: 'loading',
|
|
ownerId,
|
|
...(current.ownerId === ownerId && current.kind !== 'idle' && current.snapshot
|
|
? { snapshot: current.snapshot }
|
|
: {}),
|
|
}));
|
|
|
|
let read: Promise<unknown>;
|
|
try {
|
|
read = dataSource.load(ownerId, controller.signal);
|
|
} catch (reason: unknown) {
|
|
read = Promise.reject(reason);
|
|
}
|
|
void read.then(
|
|
(snapshot) => {
|
|
if (request === requestFence.current && !controller.signal.aborted)
|
|
setState({ kind: 'ready', ownerId, snapshot: sanitizeAutomationSnapshot(snapshot) });
|
|
},
|
|
(_reason: unknown) => {
|
|
void _reason;
|
|
if (request === requestFence.current && !controller.signal.aborted)
|
|
setState((current) => ({
|
|
kind: 'error',
|
|
ownerId,
|
|
...(current.ownerId === ownerId && current.kind === 'loading' && current.snapshot
|
|
? { snapshot: current.snapshot }
|
|
: {}),
|
|
}));
|
|
},
|
|
);
|
|
return () => controller.abort();
|
|
}, [dataSource, instance.authentication, instance.id, refreshSignal, retry]);
|
|
|
|
if (instance.authentication !== 'authenticated')
|
|
return (
|
|
<div className="state-panel state-error" role="alert">
|
|
Authentication is required before Automation data can be read for this instance.
|
|
</div>
|
|
);
|
|
|
|
if (!dataSource)
|
|
return (
|
|
<div className="state-panel" role="status" aria-label="Automation unavailable">
|
|
No safe Automation read data source is available. This console will not invent or call an
|
|
uncontracted production endpoint.
|
|
</div>
|
|
);
|
|
|
|
const retained =
|
|
state.ownerId === instance.id && (state.kind === 'loading' || state.kind === 'error')
|
|
? state.snapshot
|
|
: undefined;
|
|
if ((state.kind === 'idle' || state.kind === 'loading') && !retained)
|
|
return (
|
|
<p role="status" aria-label="Automation loading status">
|
|
Loading Automation…
|
|
</p>
|
|
);
|
|
|
|
if (state.kind === 'error' && !retained)
|
|
return (
|
|
<div className="state-panel state-error" role="alert">
|
|
<p>Unable to load Automation: {SAFE_LOAD_ERROR}</p>
|
|
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
|
Retry loading Automation
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
const snapshot =
|
|
state.kind === 'ready' && state.ownerId === instance.id ? state.snapshot : retained;
|
|
if (!snapshot) return null;
|
|
return (
|
|
<div className="automation-module">
|
|
{state.kind === 'loading' ? <p role="status">Refreshing Automation…</p> : null}
|
|
{state.kind === 'error' ? (
|
|
<div className="state-panel state-error" role="alert">
|
|
<p>Refresh failed; showing the last known Automation data.</p>
|
|
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
|
Retry loading Automation
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
{instance.freshness !== 'fresh' ? (
|
|
<p className="state-panel" role="status" aria-label="Automation freshness">
|
|
Automation data is {instance.freshness}; verify freshness before relying on these values.
|
|
</p>
|
|
) : null}
|
|
<SnapshotView snapshot={snapshot} />
|
|
</div>
|
|
);
|
|
}
|