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:
chick
2026-09-05 18:53:04 +08:00
parent f9186bd851
commit a75badeb58
77 changed files with 20578 additions and 435 deletions
@@ -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);
});
});
+35
View File
@@ -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.');
}
},
};
}
+83
View File
@@ -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();
});
});
+123 -1
View File
@@ -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>