feat(web): rebuild the console around the fused control plane
Render the Hub feature set in the existing Animal Island styling: fleet messages, notification centre, log centre, organisation panel, per-instance device module panels and the settings backup, connection and maintenance surfaces.
This commit is contained in:
@@ -160,4 +160,48 @@ describe('Jobs API data source', () => {
|
||||
expect(error.message).not.toMatch(/private|title|token|secret/i);
|
||||
expect('detail' in error).toBe(false);
|
||||
});
|
||||
it('POSTs a bounded cancellation and returns the terminal job', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ ...job, status: 'cancelled' }));
|
||||
const result = await createJobsApiDataSource(fetcher).cancel?.(
|
||||
'job-1',
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledWith('/api/v1/jobs/job-1/cancel', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(result).toMatchObject({ id: 'job-1', status: 'cancelled' });
|
||||
});
|
||||
|
||||
it('refuses to build a cancellation URL from an unsafe job id', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>();
|
||||
await expect(
|
||||
createJobsApiDataSource(fetcher).cancel?.('https://evil/..', new AbortController().signal),
|
||||
).rejects.toThrow('Jobs request is invalid.');
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POSTs the reconcile sweep and hands the raw counters back to the page', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ interrupted: 3, pending: 0 }));
|
||||
const result = await createJobsApiDataSource(fetcher).reconcile?.(new AbortController().signal);
|
||||
expect(fetcher).toHaveBeenCalledWith('/api/v1/jobs/reconcile', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(result).toEqual({ interrupted: 3, pending: 0 });
|
||||
});
|
||||
|
||||
it('maps a rejected reconcile to the same redacted error type', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(problem, 502));
|
||||
const error = await createJobsApiDataSource(fetcher)
|
||||
.reconcile?.(new AbortController().signal)
|
||||
.catch((value: unknown) => value as JobsApiError);
|
||||
expect(error).toBeInstanceOf(JobsApiError);
|
||||
expect(error).toMatchObject({ status: 502, code: 'UPSTREAM_FAILURE' });
|
||||
expect(error.message).not.toMatch(/remain|detail/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,6 +292,8 @@ async function responseError(response: Response): Promise<JobsApiError> {
|
||||
return new JobsApiError(response.status, code, requestId);
|
||||
}
|
||||
|
||||
const JOB_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/u;
|
||||
|
||||
export function createJobsApiDataSource(fetcher: typeof fetch = fetch): JobsDataSource {
|
||||
return {
|
||||
async load(query, signal): Promise<JobPage> {
|
||||
@@ -310,5 +312,38 @@ export function createJobsApiDataSource(fetcher: typeof fetch = fetch): JobsData
|
||||
}
|
||||
return parseJobPage(body);
|
||||
},
|
||||
async cancel(jobId, signal): Promise<Job> {
|
||||
if (!JOB_ID_PATTERN.test(jobId)) throw new Error('Jobs request is invalid.');
|
||||
const response = await fetcher(`/api/v1/jobs/${encodeURIComponent(jobId)}/cancel`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw await responseError(response);
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new Error('Jobs response is invalid.');
|
||||
}
|
||||
const job = parseJob(body);
|
||||
if (!job) throw new Error('Jobs response is invalid.');
|
||||
return job;
|
||||
},
|
||||
async reconcile(signal): Promise<unknown> {
|
||||
const response = await fetcher('/api/v1/jobs/reconcile', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw await responseError(response);
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
throw new Error('Jobs response is invalid.');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,3 +203,86 @@ describe('Phase 7 Jobs workspace', () => {
|
||||
expect(screen.queryByRole('button', { name: /retry/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Job control actions', () => {
|
||||
const running: Job = { ...job, id: 'job-live', status: 'running' };
|
||||
const page = (items: readonly Job[]) => ({
|
||||
items,
|
||||
page: { page: 1, pageSize: 25, total: items.length },
|
||||
});
|
||||
|
||||
it('offers cancellation only for jobs that can still be cancelled', async () => {
|
||||
const cancel = vi.fn<NonNullable<JobsDataSource['cancel']>>().mockResolvedValue(running);
|
||||
render(
|
||||
<JobsPage
|
||||
dataSource={{
|
||||
load: vi
|
||||
.fn<JobsDataSource['load']>()
|
||||
.mockResolvedValue(page([running, { ...job, id: 'job-done', status: 'succeeded' }])),
|
||||
cancel,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = await screen.findByRole('button', { name: '取消任务 job-live' });
|
||||
expect(screen.queryByRole('button', { name: '取消任务 job-done' })).toBeNull();
|
||||
expect(screen.getByRole('columnheader', { name: '控制' })).toBeTruthy();
|
||||
|
||||
await userEvent.click(button);
|
||||
expect(cancel).toHaveBeenCalledWith('job-live', expect.any(AbortSignal));
|
||||
expect(await screen.findByRole('button', { name: '取消任务 job-live' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reports a failed cancellation without leaking the rejection payload', async () => {
|
||||
const cancel = vi
|
||||
.fn<NonNullable<JobsDataSource['cancel']>>()
|
||||
.mockRejectedValue(new Error('upstream token=leak'));
|
||||
render(
|
||||
<JobsPage
|
||||
dataSource={{
|
||||
load: vi.fn<JobsDataSource['load']>().mockResolvedValue(page([running])),
|
||||
cancel,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: '取消任务 job-live' }));
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toContain('无法取消任务 job-live');
|
||||
expect(alert.textContent).not.toContain('leak');
|
||||
});
|
||||
|
||||
it('reconciles interrupted jobs and summarizes the result', async () => {
|
||||
const reconcile = vi
|
||||
.fn<NonNullable<JobsDataSource['reconcile']>>()
|
||||
.mockResolvedValue({ interrupted: 2, pending: 0, extra: 'ignored' });
|
||||
render(
|
||||
<JobsPage
|
||||
dataSource={{
|
||||
load: vi.fn<JobsDataSource['load']>().mockResolvedValue(page([job])),
|
||||
reconcile,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '对账中断任务' }));
|
||||
expect(reconcile).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
const status = await screen.findByRole('status', { name: '任务对账结果' });
|
||||
expect(status.textContent).toContain('关闭 2 个中断任务');
|
||||
expect(status.textContent).toContain('仍有 0 个任务在进行中');
|
||||
});
|
||||
|
||||
it('hides every control when the runtime cannot mutate jobs', async () => {
|
||||
render(
|
||||
<JobsPage
|
||||
dataSource={{
|
||||
load: vi.fn<JobsDataSource['load']>().mockResolvedValue(page([running])),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await screen.findByRole('table', { name: '任务' });
|
||||
expect(screen.queryByRole('button', { name: '取消任务 job-live' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '对账中断任务' })).toBeNull();
|
||||
expect(screen.queryByRole('columnheader', { name: '控制' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,8 +30,17 @@ export interface SafeJobPage {
|
||||
readonly page: JobPage['page'];
|
||||
}
|
||||
|
||||
/** Result of an interrupted-job reconciliation, reduced to two counters. */
|
||||
export interface SafeReconcileResult {
|
||||
readonly interrupted: number;
|
||||
readonly pending: number;
|
||||
}
|
||||
|
||||
export interface JobsDataSource {
|
||||
load(query: JobPageQuery, signal: AbortSignal): Promise<unknown>;
|
||||
/** Optional: only present when the runtime can actually close a job out. */
|
||||
cancel?(jobId: string, signal: AbortSignal): Promise<unknown>;
|
||||
reconcile?(signal: AbortSignal): Promise<unknown>;
|
||||
}
|
||||
export interface JobsPageProps {
|
||||
readonly dataSource?: JobsDataSource;
|
||||
@@ -40,6 +49,8 @@ export interface JobsPageProps {
|
||||
|
||||
type SortField = NonNullable<JobPageQuery['sort']>;
|
||||
|
||||
const ACTIVE_JOB_STATUSES = new Set<JobStatus>(['queued', 'running', 'cancelling']);
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
@@ -198,6 +209,20 @@ function safeLoadError(value: unknown): SafeProblem | null {
|
||||
return parseProblem(value) ?? null;
|
||||
}
|
||||
|
||||
/** Keeps only the two integers the reconcile banner needs; anything else is treated as absent. */
|
||||
export function sanitizeReconcileResult(value: unknown): SafeReconcileResult | null {
|
||||
const source = record(value);
|
||||
if (!source) return null;
|
||||
const counter = (candidate: unknown): number | null =>
|
||||
typeof candidate === 'number' && Number.isSafeInteger(candidate) && candidate >= 0
|
||||
? candidate
|
||||
: null;
|
||||
const interrupted = counter(source.interrupted);
|
||||
const pending = counter(source.pending);
|
||||
if (interrupted === null || pending === null) return null;
|
||||
return { interrupted, pending };
|
||||
}
|
||||
|
||||
export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
|
||||
const [result, setResult] = useState<SafeJobPage | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -211,6 +236,18 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
|
||||
const [direction, setDirection] = useState<SortDirection>('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
const request = useRef(0);
|
||||
const [busyJob, setBusyJob] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [reconcileResult, setReconcileResult] = useState<SafeReconcileResult | null>(null);
|
||||
const [reconciling, setReconciling] = useState(false);
|
||||
const action = useRef(0);
|
||||
|
||||
// Bumping the ticket on unmount makes any in-flight action resolve into nothing.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
action.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const query = useMemo<JobPageQuery>(
|
||||
() => ({
|
||||
@@ -280,11 +317,62 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const canCancel = typeof dataSource.cancel === 'function';
|
||||
const canReconcile = typeof dataSource.reconcile === 'function';
|
||||
|
||||
const runCancel = (jobId: string) => {
|
||||
const cancel = dataSource.cancel;
|
||||
if (typeof cancel !== 'function' || busyJob !== null) return;
|
||||
const ticket = ++action.current;
|
||||
setBusyJob(jobId);
|
||||
setActionError(null);
|
||||
const controller = new AbortController();
|
||||
void cancel(jobId, controller.signal).then(
|
||||
() => {
|
||||
if (action.current !== ticket) return;
|
||||
setBusyJob(null);
|
||||
setManualRefresh((value) => value + 1);
|
||||
},
|
||||
() => {
|
||||
if (action.current !== ticket) return;
|
||||
setBusyJob(null);
|
||||
setActionError(`无法取消任务 ${jobId}。`);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const runReconcile = () => {
|
||||
const reconcile = dataSource.reconcile;
|
||||
if (typeof reconcile !== 'function' || reconciling) return;
|
||||
const ticket = ++action.current;
|
||||
setReconciling(true);
|
||||
setActionError(null);
|
||||
setReconcileResult(null);
|
||||
const controller = new AbortController();
|
||||
void reconcile(controller.signal).then(
|
||||
(raw) => {
|
||||
if (action.current !== ticket) return;
|
||||
setReconciling(false);
|
||||
setReconcileResult(sanitizeReconcileResult(raw));
|
||||
setManualRefresh((value) => value + 1);
|
||||
},
|
||||
() => {
|
||||
if (action.current !== ticket) return;
|
||||
setReconciling(false);
|
||||
setActionError('无法执行任务对账。');
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-labelledby="jobs-title">
|
||||
<header>
|
||||
<h1 id="jobs-title">任务</h1>
|
||||
<p>只读操作历史。</p>
|
||||
<p>
|
||||
{canCancel || canReconcile
|
||||
? '操作历史,可取消进行中的任务并对账中断任务。'
|
||||
: '只读操作历史。'}
|
||||
</p>
|
||||
</header>
|
||||
<div className="jobs-toolbar">
|
||||
<label>
|
||||
@@ -336,7 +424,22 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
{canReconcile ? (
|
||||
<Button htmlType="button" size="small" aria-label="对账中断任务" onClick={runReconcile}>
|
||||
{reconciling ? '对账中…' : '对账中断任务'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{actionError ? (
|
||||
<p role="alert" className="jobs-action-alert">
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
{reconcileResult ? (
|
||||
<p role="status" aria-label="任务对账结果" className="jobs-action-status">
|
||||
{`本次对账关闭 ${reconcileResult.interrupted} 个中断任务,仍有 ${reconcileResult.pending} 个任务在进行中。`}
|
||||
</p>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<p role="status" aria-label="任务加载状态">
|
||||
正在加载任务…
|
||||
@@ -418,6 +521,7 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
|
||||
<th scope="col">根任务</th>
|
||||
<th scope="col">项目</th>
|
||||
<th scope="col">尝试</th>
|
||||
{canCancel ? <th scope="col">控制</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -458,6 +562,24 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
{canCancel ? (
|
||||
<td>
|
||||
{ACTIVE_JOB_STATUSES.has(item.status) ? (
|
||||
<Button
|
||||
htmlType="button"
|
||||
size="small"
|
||||
ghost
|
||||
aria-label={`取消任务 ${item.id}`}
|
||||
disabled={busyJob !== null}
|
||||
onClick={() => runCancel(item.id)}
|
||||
>
|
||||
{busyJob === item.id ? '取消中…' : '取消'}
|
||||
</Button>
|
||||
) : (
|
||||
<span>—</span>
|
||||
)}
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user