feat(admin): add safe processing monitor and operation correlation

This commit is contained in:
cesnimda
2026-10-28 19:31:32 +01:00
parent 1f87301ee9
commit d52403dba8
19 changed files with 1064 additions and 6 deletions
+112
View File
@@ -0,0 +1,112 @@
import { expect, test, type APIRequestContext, type TestInfo } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { spawnSync } from 'node:child_process';
import { realpathSync } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { translations } from '../src/i18n/translations';
const api = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
async function login(client: APIRequestContext, email: string) {
expect((await client.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await client.get(`${api}/auth/me`)).json();
return { id: me.id as string, email, headers: { 'X-CSRF-TOKEN': (await client.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string } };
}
function seed(info: TestInfo, owner: Awaited<ReturnType<typeof login>>) {
const root = info.config.metadata.isolatedDataRoot;
if (typeof root !== 'string' || process.env.PLAYWRIGHT_REUSE_SERVERS === 'true') throw new Error('Explicit isolated non-reused test data required.');
const dataRoot = realpathSync(root);
expect(path.dirname(dataRoot)).toBe(realpathSync(os.tmpdir()));
const result = spawnSync(process.env.PLAYWRIGHT_PYTHON_PATH || (process.platform === 'win32' ? 'python' : 'python3'), ['-X', 'utf8', path.resolve('e2e/helpers/seed-operation.py')],
{ input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email }), encoding: 'utf8', timeout: 15000, maxBuffer: 1024 * 1024 });
expect(result.status, result.stderr).toBe(0);
return (JSON.parse(result.stdout) as { operationId: string }).operationId;
}
test('[D] Real administrator/owner boundaries, actual cancellation history and responsive EN/NB themes', async ({ page, playwright }, info) => {
test.setTimeout(180000);
const admin = await playwright.request.newContext();
const ownerClient = await playwright.request.newContext();
const otherClient = await playwright.request.newContext();
const anonymous = await playwright.request.newContext();
try {
const administrator = await login(admin, 'e2e@example.test');
const email = `processing-owner-${Date.now()}@example.test`;
const otherEmail = `processing-other-${Date.now()}@example.test`;
for (const userEmail of [email, otherEmail]) expect((await admin.post(`${api}/users`, { headers: administrator.headers, data: { email: userEmail, password, roles: [] } })).status()).toBe(200);
const owner = await login(ownerClient, email);
const other = await login(otherClient, otherEmail);
const id = seed(info, owner);
for (const endpoint of ['/admin/processing', `/admin/processing/${id}`, `/admin/processing/${id}/events`]) {
expect((await anonymous.get(`${api}${endpoint}`)).status()).toBe(401);
expect((await ownerClient.get(`${api}${endpoint}`)).status()).toBe(403);
}
expect((await otherClient.get(`${api}/operations/${id}`)).status()).toBe(404);
expect((await otherClient.post(`${api}/operations/${id}/cancel`, { headers: other.headers })).status()).toBe(404);
const pageAdmin = await login(page.request, 'e2e@example.test');
await page.context().route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
await page.goto('/admin/processing');
await page.getByLabel('Owner ID', { exact: true }).fill(owner.id);
await page.getByRole('button', { name: 'Apply filters', exact: true }).click();
await page.getByRole('button', { name: `View processing details ${id}`, exact: true }).click();
await expect(page.getByLabel('Correlation ID', { exact: true })).toHaveValue(id);
await expect(page.getByText('No retained events. Older jobs may predate event recording.', { exact: true })).toBeVisible();
const cancellation = await ownerClient.post(`${api}/operations/${id}/cancel`, { headers: owner.headers });
expect(cancellation.status()).toBe(200);
expect((await cancellation.json()).status).toBe('cancelled');
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect(page.getByRole('list').filter({ has: page.getByText(/^Processing cancelled/) })).toBeVisible();
const history = await (await admin.get(`${api}/admin/processing/${id}/events`)).json();
expect(history.items.map((item: { kind: string }) => item.kind)).toEqual(['Cancelled']);
expect(history.items[0].atUtc).toMatch(/Z$/);
const detail = await (await admin.get(`${api}/admin/processing/${id}`)).json();
expect(detail.owner.id).toBe(owner.id);
for (const field of ['leaseToken', 'resultReference', 'failureMessage', 'subjectId', 'idempotencyKey']) expect(detail).not.toHaveProperty(field);
expect(detail.worker).toBe('untracked');
for (const language of ['en', 'nb'] as const) for (const mode of ['light', 'dark'] as const) {
const t = translations[language];
expect((await page.request.put(`${api}/auth/preferences/language`, { headers: pageAdmin.headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.evaluate(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
await page.reload();
await page.getByLabel(t.processingOwnerFilter, { exact: true }).fill(owner.id);
await page.getByRole('button', { name: t.processingApplyFilters, exact: true }).click();
await page.getByRole('button', { name: t.processingViewDetails.replace('{id}', id), exact: true }).click();
await expect(page.getByLabel(t.processingCorrelation, { exact: true })).toHaveValue(id);
for (const width of [1440, 768, 390]) {
await page.setViewportSize({ width, height: 900 });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await page.screenshot({ path: info.outputPath(`processing-${language}-${mode}-${width}.png`), fullPage: true });
}
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
await info.attach(`processing-${language}-${mode}-axe`, { body: JSON.stringify({ violations: axe.violations, incomplete: axe.incomplete }), contentType: 'application/json' });
expect(axe.violations).toEqual([]);
}
await info.attach('fixture-evidence-limit', { body: 'Guarded insertion of one inert unregistered task; actual native cancel creates its only event. No historical Created event, model job, inference, human accessibility or physical-device acceptance is fabricated.', contentType: 'text/plain' });
} finally { await Promise.all([admin.dispose(), ownerClient.dispose(), otherClient.dispose(), anonymous.dispose()]); }
});
test('[D] Controlled unavailable/forbidden reads recover without exposing raw failures or cached metadata', async ({ page }) => {
const administrator = await login(page.request, 'e2e@example.test');
expect((await page.request.put(`${api}/auth/preferences/language`, { headers: administrator.headers, data: { language: 'en' } })).status()).toBe(204);
await page.addInitScript(() => localStorage.setItem('uiLanguage', 'en'));
await page.context().route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
let failure: number | null = 503;
await page.route('**/api/admin/processing?**', route => failure ? route.fulfill({ status: failure, json: { message: 'SECRET_EXCEPTION_DOCUMENT_CONTENT' } }) : route.continue());
await page.goto('/admin/processing');
await expect(page.getByRole('alert').filter({ hasText: 'Processing information could not be loaded.' })).toBeVisible();
await expect(page.getByText(/SECRET_EXCEPTION/)).toHaveCount(0);
failure = null;
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.getByRole('region', { name: 'Processing jobs', exact: true })).toBeVisible();
let calls = 0; page.on('request', request => { if (new URL(request.url()).pathname === '/api/admin/processing') calls++; });
await page.getByRole('button', { name: 'Pause updates', exact: true }).click();
const paused = calls;
await page.waitForTimeout(5500); // Negative assertion across one real polling interval.
expect(calls).toBe(paused);
failure = 403;
await page.getByRole('button', { name: 'Resume updates', exact: true }).click();
await expect(page.getByRole('alert').filter({ hasText: 'Administrator access is required.' })).toBeVisible();
await expect(page.getByRole('region', { name: 'Processing jobs', exact: true })).toHaveCount(0);
await expect(page.getByText(/SECRET_EXCEPTION/)).toHaveCount(0);
});
@@ -0,0 +1,47 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
import path from 'node:path';
const api = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
async function login(client: APIRequestContext, email: string) {
expect((await client.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await client.get(`${api}/auth/me`)).json();
return { id: me.id as string, headers: { 'X-CSRF-TOKEN': (await client.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string } };
}
test('real Docling produces owner-scoped administrator history without exposing source text', async ({ page, playwright }, info) => {
await page.context().route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
const admin = await playwright.request.newContext();
try {
const administrator = await login(admin, 'e2e@example.test');
const email = `processing-docling-${Date.now()}@example.test`;
expect((await admin.post(`${api}/users`, { headers: administrator.headers, data: { email, password, roles: ['Premium'] } })).status()).toBe(200);
const owner = await login(page.request, email);
await page.goto('/career?section=import');
await page.locator('input[type=file][accept*=".pdf"]').setInputFiles(path.resolve(process.env.CV_IMPORT_FIXTURE_ROOT || '../.artifacts/cv-import/corpus-02', 'dev-en-simple.pdf'));
await expect.poll(async () => (await (await page.request.get(`${api}/profile-cv/runs`)).json())[0]?.operation?.status, { timeout: 90000 }).toBe('succeeded');
const run = (await (await page.request.get(`${api}/profile-cv/runs`)).json())[0];
const id = run.operation.id;
expect(run.status).toBe('pending_review');
expect((await admin.get(`${api}/profile-cv/runs/${run.id}/source`)).status()).toBe(404);
const history = await (await admin.get(`${api}/admin/processing/${id}/events`)).json();
expect(history.items.map((item: { kind: string }) => item.kind)).toEqual(['Created', 'Claimed', ...Array(7).fill('StageChanged'), 'Completed']);
expect(history.items.filter((item: { kind: string }) => item.kind === 'StageChanged').map((item: { stage: string }) => item.stage)).toEqual(['cv_validating_file', 'cv_extracting', 'cv_identifying_sections', 'cv_analysing', 'cv_validating_details', 'cv_preparing_review', 'cv_review_ready']);
const detail = await (await admin.get(`${api}/admin/processing/${id}`)).json();
expect(detail.owner.id).toBe(owner.id);
expect(detail.document.name).toBe('dev-en-simple.pdf');
expect(detail.document.pages).toBeGreaterThan(0);
expect(detail.worker).toBe('document');
expect(detail.workerEnabled).toBe(true);
for (const field of ['rawExtractedText', 'documentJson', 'structuredProfileJson', 'leaseToken', 'failureMessage', 'resultReference']) expect(detail).not.toHaveProperty(field);
await login(page.request, 'e2e@example.test');
await page.goto('/admin/processing');
await page.getByLabel('Owner ID', { exact: true }).fill(owner.id);
await page.getByRole('button', { name: 'Apply filters', exact: true }).click();
await page.getByRole('button', { name: `View processing details ${id}`, exact: true }).click();
await expect(page.getByText(/^Processing completed/)).toBeVisible();
await expect(page.getByLabel('Correlation ID', { exact: true })).toHaveValue(id);
await page.screenshot({ path: info.outputPath('real-docling-processing-history.png'), fullPage: true });
await info.attach('real-parser-evidence-limit', { body: 'Actual pinned Docling conversion and deterministic interpretation; no generative model inference or model accuracy acceptance. Administrator source-download access remains denied by the ordinary ownership API.', contentType: 'text/plain' });
} finally { await admin.dispose(); }
});
@@ -16,6 +16,7 @@ for (const scenario of [
{ language: 'nb', mode: 'dark', width: 390, file: 'dev-nb-education.docx' },
] as const) {
test(`real Docling ${scenario.language}/${scenario.mode}: upload, refresh, review, correct, durable profile, editor and PDF`, async ({ page, context, playwright }, info) => {
await context.route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
const t = translations[scenario.language];
const admin = await playwright.request.newContext();
const adminHeaders = await login(admin, 'e2e@example.test');
@@ -6,8 +6,8 @@ const servers = Array.isArray(ordinary.webServer) ? ordinary.webServer : [ordina
export default defineConfig({
...ordinary,
testDir: './import-tests',
testMatch: 'cv-import-docling.spec.ts',
testMatch: ['cv-import-docling.spec.ts', 'admin-processing-docling.spec.ts'],
timeout: 180_000,
retries: 0,
webServer: [{ ...servers[0], env: { ...servers[0].env, Workers__AiOperationsEnabled: 'false', Workers__CvDocumentImportsEnabled: 'true', Ai__BaseUrl: process.env.CV_IMPORT_SIDECAR_URL, Ai__ProbeEnabled: 'false', Email__Enabled: 'false' } }, ...servers.slice(1)],
webServer: [{ ...servers[0], env: { ...servers[0].env, Workers__AiOperationsEnabled: 'false', Workers__CvDocumentImportsEnabled: 'true', Ai__BaseUrl: process.env.CV_IMPORT_SIDECAR_URL, Ai__ServiceToken: process.env.CV_IMPORT_SIDECAR_TOKEN ?? '', Ai__ProbeEnabled: 'false', Email__Enabled: 'false' } }, ...servers.slice(1)],
});
+5
View File
@@ -64,6 +64,7 @@ const ConnectedAccountsPage = lazy(() => import("./views/ConnectedAccountsPage")
const AdminAuditPage = lazy(() => import("./views/AdminAuditPage"));
const AdminUsersPage = lazy(() => import("./views/AdminUsersPage"));
const AdminSystemPage = lazy(() => import("./views/AdminSystemPage"));
const AdminProcessingPage = lazy(() => import("./views/AdminProcessingPage"));
const CorrespondenceInboxPage = lazy(() => import("./views/CorrespondenceInboxPage"));
const OperationsPage = lazy(() => import("./views/OperationsPage"));
const OfferComparisonPage = lazy(() => import("./views/OfferComparisonPage"));
@@ -126,6 +127,7 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
if (path.startsWith("/admin/audit")) return [t("home"), t("admin"), t("auditLog")];
if (path.startsWith("/admin/users")) return [t("home"), t("admin"), t("users")];
if (path.startsWith("/admin/system")) return [t("home"), t("admin"), t("system")];
if (path.startsWith("/admin/processing")) return [t("home"), t("admin"), t("processingTitle")];
return [t("home")];
}
@@ -152,6 +154,7 @@ function titleFor(path: string, t: (k: any) => string): string {
if (path.startsWith("/admin/audit")) return t("auditLog");
if (path.startsWith("/admin/users")) return t("users");
if (path.startsWith("/admin/system")) return t("systemStatus");
if (path.startsWith("/admin/processing")) return t("processingTitle");
return t("notFoundTitle");
}
@@ -344,6 +347,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
{ to: "/admin/audit", label: t("auditLog"), icon: <ShieldIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
{ to: "/admin/users", label: t("users"), icon: <AccountCircleIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
{ to: "/admin/system", label: t("system"), icon: <MemoryIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
{ to: "/admin/processing", label: t("processingTitle"), icon: <MemoryIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
{ to: "/settings", label: t("settings"), icon: <SettingsIcon fontSize="small" />, section: t("account"), children: [
{ to: "/profile", label: t("profile"), icon: <AccountCircleIcon fontSize="small" /> },
{ to: "/settings/connected-accounts", label: t("connectedAccounts"), icon: <MailOutlineIcon fontSize="small" /> },
@@ -439,6 +443,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/admin/audit" element={<AdminAuditPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/system" element={<AdminSystemPage />} />
<Route path="/admin/processing" element={<AdminProcessingPage />} />
<Route path="/trash" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="trash" />} />
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />} />
<Route path="/settings/connected-accounts" element={<ConnectedAccountsPage />} />
@@ -0,0 +1,112 @@
import React from 'react';
import '@testing-library/jest-dom';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import AdminProcessingPage from './views/AdminProcessingPage';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
import { setAuthUserKey } from './auth';
const get = api.get as jest.Mock;
const item = { id: '11111111-1111-4111-8111-111111111111', owner: { id: 'owner', name: 'Synthetic owner', email: 'owner@example.test' }, taskType: 'cv.process', status: 'running', stage: 'cv_extracting', attemptCount: 1, maxAttempts: 3, createdAtUtc: '2026-10-28T12:00:00Z', startedAtUtc: '2026-10-28T12:00:00Z', completedAtUtc: null, lastHeartbeatAtUtc: null, durationSeconds: 8, worker: 'document', workerEnabled: true, failureCode: null, job: null, document: { name: 'CV.pdf', type: 'application/pdf', bytes: 500, pages: 2, state: 'running' } };
const listing = { items: [item], total: 1, page: 1, pageSize: 25, counts: { running: 1 }, workers: [{ name: 'AiOperations', flagEnabled: false, individualOperationsRecorded: true }], cvInterpretation: 'deterministic' };
function open() { return render(<MemoryRouter><I18nProvider><AdminProcessingPage /></I18nProvider></MemoryRouter>); }
beforeEach(() => {
localStorage.clear(); setAuthUserKey('admin', false);
get.mockReset();
get.mockImplementation((url: string) => Promise.resolve({ data: url.endsWith('/events') || url.includes('/events?')
? { items: [{ id: '9007199254740993', kind: 'Claimed', status: 'running', stage: null, attempt: 1, failureCode: null, atUtc: '2026-10-28T12:00:00Z' }], hasMore: false, retentionDays: 30 }
: url.includes(item.id) ? item : listing }));
});
afterEach(() => jest.useRealTimers());
test('shows safe job metadata and real events with a copyable correlation ID', async () => {
open();
expect(await screen.findByText('Synthetic owner')).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: /View processing details/ }));
expect(await screen.findByLabelText('Correlation ID')).toHaveValue(item.id);
expect(await screen.findByText(/^Worker claimed job/)).toBeVisible();
expect(screen.getAllByText('CV.pdf').length).toBeGreaterThan(0);
await waitFor(() => expect(screen.getByRole('heading', { name: 'Processing details' })).toHaveFocus());
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
});
test('pause stops polling and resume uses an exact string event cursor', async () => {
jest.useFakeTimers(); open();
await act(async () => { await Promise.resolve(); });
fireEvent.click(screen.getByRole('button', { name: /View processing details/ }));
await act(async () => { await Promise.resolve(); });
await act(async () => { jest.advanceTimersByTime(5100); });
expect(get.mock.calls.some(([url]) => url.includes('after=9007199254740993'))).toBe(true);
fireEvent.click(screen.getByRole('button', { name: 'Pause updates' }));
const count = get.mock.calls.length;
await act(async () => { jest.advanceTimersByTime(15000); });
expect(get).toHaveBeenCalledTimes(count);
fireEvent.click(screen.getByRole('button', { name: 'Resume updates' }));
await act(async () => { await Promise.resolve(); });
expect(get.mock.calls.length).toBeGreaterThan(count);
});
test('forbidden responses clear metadata and do not retry automatically', async () => {
jest.useFakeTimers(); get.mockRejectedValue({ response: { status: 403, data: 'SECRET server error' } });
open(); await act(async () => { await Promise.resolve(); });
expect(screen.getByRole('alert')).toHaveTextContent('Administrator access is required.');
const count = get.mock.calls.length;
await act(async () => { jest.advanceTimersByTime(30000); });
expect(get).toHaveBeenCalledTimes(count);
expect(screen.queryByText('Synthetic owner')).not.toBeInTheDocument();
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
});
test('malformed successful responses produce a recoverable error instead of rendering raw data', async () => {
get.mockResolvedValue({ data: 'SECRET malformed body' });
open();
expect(await screen.findByRole('alert')).toHaveTextContent('Processing information could not be loaded.');
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
});
test('identity changes abort pending reads and clear the previous administrator view', async () => {
let finish!: (value: unknown) => void;
get.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
open(); await waitFor(() => expect(get).toHaveBeenCalled());
const signal = get.mock.calls[0][1].signal;
get.mockRejectedValue({ response: { status: 403 } });
act(() => setAuthUserKey('ordinary'));
await act(async () => { finish({ data: listing }); });
expect(signal.aborted).toBe(true);
expect(screen.queryByText('Synthetic owner')).not.toBeInTheDocument();
});
test('a failed cycle aborts sibling reads and reconnects with bounded backoff', async () => {
jest.useFakeTimers();
get.mockRejectedValueOnce(new Error('SECRET transport failure'));
open(); await act(async () => { await Promise.resolve(); });
expect(screen.getByRole('alert')).toHaveTextContent('Processing information could not be loaded.');
expect(get.mock.calls[0][1].signal.aborted).toBe(true);
const count = get.mock.calls.length;
await act(async () => { jest.advanceTimersByTime(14999); });
expect(get).toHaveBeenCalledTimes(count);
await act(async () => { jest.advanceTimersByTime(1); });
expect(screen.getByText('Synthetic owner')).toBeVisible();
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
});
test('older event pages pause updates and retain exact cursors until returning to latest', async () => {
jest.useFakeTimers();
get.mockImplementation((url: string) => Promise.resolve({ data: url.includes('/events')
? { items: [{ id: url.includes('before=') ? '9007199254740992' : '9007199254740993', kind: url.includes('before=') ? 'Created' : 'Claimed', status: 'running', stage: null, attempt: 1, failureCode: null, atUtc: '2026-10-28T12:00:00Z' }], hasMore: true, retentionDays: 30 }
: url.includes(item.id) ? item : listing }));
open(); await act(async () => { await Promise.resolve(); });
fireEvent.click(screen.getByRole('button', { name: /View processing details/ }));
await act(async () => { await Promise.resolve(); });
fireEvent.click(screen.getByRole('button', { name: 'Older events' }));
await act(async () => { await Promise.resolve(); });
expect(get.mock.calls.some(([url]) => url.includes('before=9007199254740993'))).toBe(true);
expect(screen.getByText(/^Job created/)).toBeVisible();
const count = get.mock.calls.length;
await act(async () => { jest.advanceTimersByTime(30000); });
expect(get).toHaveBeenCalledTimes(count);
fireEvent.click(screen.getByRole('button', { name: 'Latest events' }));
await act(async () => { await Promise.resolve(); });
expect(screen.getByText(/^Worker claimed job/)).toBeVisible();
});
+152
View File
@@ -2,6 +2,82 @@ export type UiLanguage = "en" | "nb";
export const translations = {
en: {
processingTitle: "Processing monitor",
processingSubtitle: "Document and AI operations, their owners and safe processing history.",
processingDetails: "Processing details",
processingJobs: "Processing jobs",
processingEvents: "Processing events",
processingViewDetails: "View processing details {id}",
processingPause: "Pause updates",
processingResume: "Resume updates",
processingConnected: "Automatic updates enabled",
processingPaused: "Updates paused",
processingReconnecting: "Reconnecting",
processingDenied: "Administrator access is required.",
processingLoadFailed: "Processing information could not be loaded. The last displayed information may be out of date. Retry or wait for reconnection.",
processingNoJobs: "No processing jobs match these filters.",
processingNoEvents: "No retained events. Older jobs may predate event recording.",
processingPrevious: "Previous page",
processingNext: "Next page",
processingPage: "Page {page} of {total}",
processingOwner: "Owner",
processingStatus: "Status",
processingTask: "Task",
processingAll: "All",
processingApplyFilters: "Apply filters",
processingOwnerFilter: "Owner ID",
processingWorker: "Worker",
processingWorkerDocument: "Document processing",
processingWorkerAi: "AI operations",
processingWorkerUntracked: "Unregistered worker",
processingWorkerEnrichment: "Job enrichment",
processingWorkerRules: "Rules",
processingWorkerReminders: "Follow-up reminders",
processingWorkerExport: "Daily exports",
processingWorkerSearch: "Saved search alerts",
processingEnabled: "Enabled",
processingDisabled: "Disabled",
processingWorkerFlags: "Configured worker switches",
processingWorkerFlagsHelp: "Additional feature settings may also be required. Only document and AI operations publish individual job history here; enabled does not confirm worker health.",
processingAiDescription: "CV imports use deterministic interpretation. AI worker availability is shown above; model health and extraction quality are not checked by this view.",
processingCorrelation: "Correlation ID",
processingCopy: "Copy ID",
processingCopied: "ID copied",
processingCopyFailed: "Clipboard unavailable. Select and copy the ID field.",
processingCreated: "Created",
processingStarted: "Started",
processingCompleted: "Completed",
processingLastSeen: "Last heartbeat",
processingDuration: "{count} seconds",
processingAttempts: "Attempt {count} of {max}",
processingNotAvailable: "Not recorded",
processingSource: "Source document",
processingBytes: "{count} bytes",
processingPages: "{count} pages",
processingSourceApplied: "Approved changes applied to Career Profile",
processingSourceExpired: "Source document expired",
processingOlder: "Older events",
processingLatest: "Latest events",
processingRetention: "This view keeps up to 200 events. History is retained for 30 days. Loading an older page pauses automatic updates.",
processingUnknown: "Unknown",
processingOther: "Other operation",
processingEventCreated: "Job created",
processingEventClaimed: "Worker claimed job",
processingEventStageChanged: "Processing stage changed",
processingEventCompleted: "Processing completed",
processingEventRetryScheduled: "Retry scheduled",
processingEventFailed: "Processing failed",
processingEventCancellationRequested: "Cancellation requested",
processingEventCancelled: "Processing cancelled",
processingEventRetried: "Job retried",
processingEventLeaseRecovered: "Stopped worker recovered",
processingEventExpired: "Worker lease or deadline expired",
processingFailureGeneric: "Processing failed. Check service configuration and availability.",
processingFailureTimeout: "Processing timed out.",
processingFailureLease: "The worker stopped responding before processing completed.",
processingFailureDeadline: "The processing deadline elapsed.",
processingFailureAccess: "AI access changed before processing began.",
processingLastUpdated: "Updated {time}",
cvImportReviewHelp: "Review every field. Nothing changes in your Career Profile until you apply your choices.",
cvImportPanels: "Import source and review",
cvImportSourcePanel: "Source document",
@@ -3032,6 +3108,82 @@ export const translations = {
coverAiReject: "Reject",
},
nb: {
processingTitle: "Behandlingsovervåking",
processingSubtitle: "Dokument- og KI-oppgaver, eiere og trygg behandlingshistorikk.",
processingDetails: "Behandlingsdetaljer",
processingJobs: "Behandlingsoppgaver",
processingEvents: "Behandlingshendelser",
processingViewDetails: "Vis behandlingsdetaljer {id}",
processingPause: "Sett oppdateringer på pause",
processingResume: "Fortsett oppdateringer",
processingConnected: "Automatiske oppdateringer er aktivert",
processingPaused: "Oppdateringer er satt på pause",
processingReconnecting: "Kobler til på nytt",
processingDenied: "Administratortilgang kreves.",
processingLoadFailed: "Behandlingsinformasjonen kunne ikke lastes. Informasjonen som vises, kan være utdatert. Prøv igjen eller vent på ny tilkobling.",
processingNoJobs: "Ingen behandlingsoppgaver samsvarer med filtrene.",
processingNoEvents: "Ingen lagrede hendelser. Eldre oppgaver kan være fra før hendelsesregistrering ble innført.",
processingPrevious: "Forrige side",
processingNext: "Neste side",
processingPage: "Side {page} av {total}",
processingOwner: "Eier",
processingStatus: "Status",
processingTask: "Oppgave",
processingAll: "Alle",
processingApplyFilters: "Bruk filtre",
processingOwnerFilter: "Eier-ID",
processingWorker: "Bakgrunnsprosess",
processingWorkerDocument: "Dokumentbehandling",
processingWorkerAi: "KI-oppgaver",
processingWorkerUntracked: "Uregistrert bakgrunnsprosess",
processingWorkerEnrichment: "Jobbberikelse",
processingWorkerRules: "Regler",
processingWorkerReminders: "Oppfølgingspåminnelser",
processingWorkerExport: "Daglige eksporter",
processingWorkerSearch: "Varsler for lagrede søk",
processingEnabled: "Aktivert",
processingDisabled: "Deaktivert",
processingWorkerFlags: "Konfigurerte brytere for bakgrunnsprosesser",
processingWorkerFlagsHelp: "Andre funksjonsinnstillinger kan også være nødvendige. Bare dokument- og KI-oppgaver registrerer individuell oppgavehistorikk her. Aktivert bekrefter ikke at prosessen fungerer.",
processingAiDescription: "CV-import bruker deterministisk tolkning. Tilgjengeligheten til KI-prosessen vises ovenfor. Denne visningen kontrollerer ikke modelltilstand eller uttrekkskvalitet.",
processingCorrelation: "Korrelasjons-ID",
processingCopy: "Kopier ID",
processingCopied: "ID kopiert",
processingCopyFailed: "Utklippstavlen er utilgjengelig. Merk og kopier ID-feltet.",
processingCreated: "Opprettet",
processingStarted: "Startet",
processingCompleted: "Fullført",
processingLastSeen: "Siste livstegn",
processingDuration: "{count} sekunder",
processingAttempts: "Forsøk {count} av {max}",
processingNotAvailable: "Ikke registrert",
processingSource: "Kildedokument",
processingBytes: "{count} byte",
processingPages: "{count} sider",
processingSourceApplied: "Godkjente endringer er lagt til i karriereprofilen",
processingSourceExpired: "Kildedokumentet er utløpt",
processingOlder: "Eldre hendelser",
processingLatest: "Nyeste hendelser",
processingRetention: "Visningen beholder opptil 200 hendelser. Historikk lagres i 30 dager. Lasting av en eldre side setter automatiske oppdateringer på pause.",
processingUnknown: "Ukjent",
processingOther: "Annen oppgave",
processingEventCreated: "Oppgave opprettet",
processingEventClaimed: "Bakgrunnsprosessen hentet oppgaven",
processingEventStageChanged: "Behandlingstrinn endret",
processingEventCompleted: "Behandling fullført",
processingEventRetryScheduled: "Nytt forsøk planlagt",
processingEventFailed: "Behandling mislyktes",
processingEventCancellationRequested: "Avbrudd forespurt",
processingEventCancelled: "Behandling avbrutt",
processingEventRetried: "Oppgave startet på nytt",
processingEventLeaseRecovered: "Stanset bakgrunnsprosess gjenopprettet",
processingEventExpired: "Arbeidsleie eller tidsfrist utløpt",
processingFailureGeneric: "Behandlingen mislyktes. Kontroller tjenestekonfigurasjon og tilgjengelighet.",
processingFailureTimeout: "Behandlingen tok for lang tid.",
processingFailureLease: "Bakgrunnsprosessen sluttet å svare før behandlingen var fullført.",
processingFailureDeadline: "Tidsfristen for behandlingen er utløpt.",
processingFailureAccess: "KI-tilgangen ble endret før behandlingen startet.",
processingLastUpdated: "Oppdatert {time}",
cvImportReviewHelp: "Gå gjennom hvert felt. Karriereprofilen endres først når du bruker valgene dine.",
cvImportPanels: "Kilde og gjennomgang av import",
cvImportSourcePanel: "Kildedokument",
+2 -2
View File
@@ -32,7 +32,7 @@ export function PageHeader({ title, description, actions, level = "h1", titleId,
}
// Presentation only: domain components keep their loading, draft and mutation owners.
export function PageSection({ title, description, actions, children, level = "h2", surface = false }: Omit<HeaderProps, "titleId" | "level" | "titleRef"> & {
export function PageSection({ title, titleRef, description, actions, children, level = "h2", surface = false }: Omit<HeaderProps, "titleId" | "level"> & {
children?: React.ReactNode;
level?: "h2" | "h3";
surface?: boolean;
@@ -43,7 +43,7 @@ export function PageSection({ title, description, actions, children, level = "h2
display: "grid", gap: 2, minWidth: 0, alignContent: "start",
...(surface ? { p: "var(--app-card-padding)", bgcolor: "background.paper", border: "1px solid", borderColor: "divider", borderRadius: 1 } : {}),
}}>
<PageHeader title={title} titleId={titleId} description={description} actions={actions} level={level} />
<PageHeader title={title} titleRef={titleRef} titleId={titleId} description={description} actions={actions} level={level} />
{children ? <Box sx={{ minWidth: 0 }}>{children}</Box> : null}
</Box>
);
@@ -0,0 +1,201 @@
import React, { useEffect, useRef, useState } from 'react';
import { Alert, Box, Button, Chip, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography } from '@mui/material';
import { api } from '../api';
import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from '../auth';
import { isSessionInvalidated, SESSION_INVALIDATED_EVENT } from '../sessionBoundary';
import { useI18n } from '../i18n/I18nProvider';
import type { TranslationKey } from '../i18n/translations';
import { PageHeader, PageSection } from '../layout/PageLayout';
type Item = {
id: string; owner: { id: string; name: string | null; email: string | null }; taskType: string; status: string; stage: string | null;
attemptCount: number; maxAttempts: number; createdAtUtc: string; startedAtUtc: string | null; completedAtUtc: string | null;
lastHeartbeatAtUtc: string | null; durationSeconds: number; worker: string; workerEnabled: boolean; failureCode: string | null;
job: { id: number; title: string; company: string | null } | null;
document: { name: string; type: string | null; bytes: number; pages: number | null; state: string | null } | null;
};
type Listing = { items: Item[]; total: number; counts: Record<string, number>; workers: { name: string; flagEnabled: boolean; individualOperationsRecorded: boolean }[] };
type Event = { id: string; atUtc: string; kind: string; status: string; stage: string | null; attempt: number; failureCode: string | null };
type Events = { items: Event[]; hasMore: boolean; retentionDays: number };
const statuses: Record<string, TranslationKey> = { queued: 'operationsStatusQueued', running: 'operationsStatusRunning', waiting_for_retry: 'operationsStatusWaitingForRetry', waiting_for_external_fallback: 'operationsStatusWaitingForFallback', succeeded: 'operationsStatusSucceeded', failed: 'operationsStatusFailed', cancelled: 'operationsStatusCancelled' };
const tasks: Record<string, TranslationKey> = { 'cv.process': 'operationsTaskCv', 'strategy.snapshot': 'operationsTaskStrategy' };
const stages: Record<string, TranslationKey> = { cv_validating_file: 'cvImportStageValidating', cv_extracting: 'cvImportStageExtracting', cv_identifying_sections: 'cvImportStageSections', cv_analysing: 'cvImportStageAnalysing', cv_validating_details: 'cvImportStageDetails', cv_preparing_review: 'cvImportStagePreparing', cv_review_ready: 'cvImportStageReady' };
const kinds: Record<string, TranslationKey> = { Created: 'processingEventCreated', Claimed: 'processingEventClaimed', StageChanged: 'processingEventStageChanged', Completed: 'processingEventCompleted', RetryScheduled: 'processingEventRetryScheduled', Failed: 'processingEventFailed', CancellationRequested: 'processingEventCancellationRequested', Cancelled: 'processingEventCancelled', Retried: 'processingEventRetried', LeaseRecovered: 'processingEventLeaseRecovered', Expired: 'processingEventExpired' };
const workers: Record<string, TranslationKey> = { AiOperations: 'processingWorkerAi', CvDocumentImports: 'processingWorkerDocument', JobEnrichment: 'processingWorkerEnrichment', Rules: 'processingWorkerRules', FollowUpReminders: 'processingWorkerReminders', DailyExport: 'processingWorkerExport', SavedJobSearchAlerts: 'processingWorkerSearch' };
export default function AdminProcessingPage() {
const { t, language } = useI18n();
const [listing, setListing] = useState<Listing | null>(null);
const [selected, select] = useState<string | null>(null);
const [detail, setDetail] = useState<Item | null>(null);
const [events, setEvents] = useState<Event[]>([]);
const [hasOlder, setHasOlder] = useState(false);
const [before, setBefore] = useState<string | null>(null);
const [filters, setFilters] = useState({ status: '', taskType: '', owner: '' });
const [inputs, setInputs] = useState(filters);
const [page, setPage] = useState(1);
const [paused, setPaused] = useState(false);
const pausedRef = useRef(false);
const [refresh, setRefresh] = useState(0);
const [identity, setIdentity] = useState(getAuthUserKey);
const [busy, setBusy] = useState(true);
const [error, setError] = useState<'denied' | 'unavailable' | null>(null);
const [updated, setUpdated] = useState<string | null>(null);
const [copy, setCopy] = useState<'copied' | 'failed' | null>(null);
const detailHeading = useRef<HTMLHeadingElement>(null);
const focusDetail = useRef(false);
useEffect(() => {
const changed = () => {
setListing(null); setDetail(null); setEvents([]); select(null); setBefore(null);
setIdentity(getAuthUserKey()); setRefresh(value => value + 1);
};
window.addEventListener(AUTH_USER_CHANGED_EVENT, changed);
window.addEventListener(SESSION_INVALIDATED_EVENT, changed);
return () => { window.removeEventListener(AUTH_USER_CHANGED_EVENT, changed); window.removeEventListener(SESSION_INVALIDATED_EVENT, changed); };
}, []);
useEffect(() => {
if (isSessionInvalidated()) return;
let controller = new AbortController();
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let cursor: string | undefined;
let eventWindow: Event[] = [];
const current = () => !stopped && identity === getAuthUserKey() && !isSessionInvalidated();
const load = async () => {
if (!current()) return;
const request = new AbortController();
controller = request;
const options = { signal: request.signal, timeout: 15000 };
setBusy(true);
let retry = true;
let delay = 5000;
try {
const query = new URLSearchParams({ page: String(page), pageSize: '25' });
for (const [key, value] of Object.entries(filters)) if (value) query.set(key, value);
const eventQuery = new URLSearchParams({ take: '100' });
if (before) eventQuery.set('before', before);
else if (cursor) eventQuery.set('after', cursor);
const [listResponse, detailResponse, eventResponse] = await Promise.all([
api.get<Listing>(`/admin/processing?${query}`, options),
selected ? api.get<Item>(`/admin/processing/${selected}`, options) : Promise.resolve(null),
selected ? api.get<Events>(`/admin/processing/${selected}/events?${eventQuery}`, options) : Promise.resolve(null),
]);
if (!current() || request.signal.aborted) return;
const list = listResponse.data;
if (!list || !Array.isArray(list.items) || list.items.length > 100 || !Array.isArray(list.workers) || list.workers.length > 32
|| !list.counts || typeof list.counts !== 'object' || !Number.isSafeInteger(list.total) || list.total < 0
|| (detailResponse && (detailResponse.data?.id !== selected || typeof detailResponse.data?.owner?.id !== 'string'))
|| (eventResponse && (!Array.isArray(eventResponse.data?.items) || eventResponse.data.items.length > 100))) throw new Error('Invalid processing response');
setListing(listResponse.data); setDetail(detailResponse?.data ?? null);
if (eventResponse) {
const next = eventResponse.data.items;
const append = Boolean(cursor) && !before;
const combined = append ? [...new Map([...eventWindow, ...next].map(item => [item.id, item])).values()] : next;
eventWindow = combined.slice(-200);
setEvents(eventWindow);
if (!append) setHasOlder(eventResponse.data.hasMore);
else if (combined.length > 200) setHasOlder(true);
if (next.length) cursor = next[next.length - 1].id;
} else setEvents([]);
setUpdated(new Date().toISOString()); setError(null);
if (focusDetail.current && detailResponse) {
focusDetail.current = false;
requestAnimationFrame(() => { if (current()) detailHeading.current?.focus(); });
}
} catch (cause) {
if (!current() || request.signal.aborted) return;
request.abort();
const status = (cause as { response?: { status?: number } }).response?.status;
if (status === 401 || status === 403) {
setListing(null); setDetail(null); setEvents([]); setError('denied'); retry = false;
} else { setError('unavailable'); delay = 15000; }
} finally {
if (current()) {
setBusy(false);
if (retry && !pausedRef.current && !before) timer = setTimeout(() => { if (!pausedRef.current) void load(); }, delay);
}
}
};
void load();
return () => { stopped = true; controller.abort(); clearTimeout(timer); };
}, [before, filters, identity, page, refresh, selected]);
const date = (value: string | null) => value ? new Date(value).toLocaleString(language === 'nb' ? 'nb-NO' : 'en-GB') : t('processingNotAvailable');
const state = (value: string) => t(statuses[value] ?? 'processingUnknown');
const stage = (value: string | null) => value && stages[value] ? t(stages[value]) : t('processingNotAvailable');
const failure = (value: string) => t(value === 'timeout' ? 'processingFailureTimeout' : value === 'lease_expired' ? 'processingFailureLease' : value === 'deadline_exceeded' ? 'processingFailureDeadline' : value === 'entitlement_changed' ? 'processingFailureAccess' : 'processingFailureGeneric');
const worker = (value: string) => t(value === 'document' ? 'processingWorkerDocument' : value === 'ai' ? 'processingWorkerAi' : 'processingWorkerUntracked');
const resetSelection = () => { select(null); setDetail(null); setEvents([]); setBefore(null); setCopy(null); };
const resume = () => { pausedRef.current = false; setPaused(false); setBefore(null); setRefresh(value => value + 1); };
const visibleListing = identity === getAuthUserKey() ? listing : null;
return <Box sx={{ display: 'grid', gap: 3, minWidth: 0, '& p, & td, & th': { overflowWrap: 'anywhere' }, '& button': { minHeight: 44 } }}>
<PageHeader level="h2" title={t('processingTitle')} description={t('processingSubtitle')} />
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1} alignItems="center">
<Button onClick={() => { if (paused) resume(); else { pausedRef.current = true; setPaused(true); } }}>{t(paused ? 'processingResume' : 'processingPause')}</Button>
<Button onClick={() => setRefresh(value => value + 1)} disabled={busy}>{t('refresh')}</Button>
<Typography role="status" aria-live="polite">{t(error === 'denied' ? 'processingDenied' : paused ? 'processingPaused' : error ? 'processingReconnecting' : 'processingConnected')}</Typography>
{updated ? <Typography variant="caption">{t('processingLastUpdated', { time: date(updated) })}</Typography> : null}
</Stack>
{error ? <Alert severity="error" action={error === 'unavailable' ? <Button onClick={() => setRefresh(value => value + 1)}>{t('retry')}</Button> : undefined}>{t(error === 'denied' ? 'processingDenied' : 'processingLoadFailed')}</Alert> : null}
{busy && !visibleListing ? <Typography role="status">{t('loading')}</Typography> : null}
<Box component="form" onSubmit={event => { event.preventDefault(); resetSelection(); setPage(1); setFilters({ ...inputs, owner: inputs.owner.trim() }); }}>
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={2}>
<TextField select label={t('processingStatus')} value={inputs.status} onChange={event => setInputs(value => ({ ...value, status: event.target.value }))} slotProps={{ select: { native: true } }}>
<option value="">{t('processingAll')}</option>{Object.keys(statuses).map(value => <option key={value} value={value}>{state(value)}</option>)}
</TextField>
<TextField select label={t('processingTask')} value={inputs.taskType} onChange={event => setInputs(value => ({ ...value, taskType: event.target.value }))} slotProps={{ select: { native: true } }}>
<option value="">{t('processingAll')}</option>{Object.keys(tasks).map(value => <option key={value} value={value}>{t(tasks[value])}</option>)}<option value="other">{t('processingOther')}</option>
</TextField>
<TextField label={t('processingOwnerFilter')} value={inputs.owner} onChange={event => setInputs(value => ({ ...value, owner: event.target.value }))} slotProps={{ htmlInput: { maxLength: 255 } }} sx={{ flex: '1 1 14rem', minWidth: 0 }} />
<Button type="submit" variant="outlined">{t('processingApplyFilters')}</Button>
</Stack>
</Box>
{visibleListing ? <>
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1}>{Object.entries(visibleListing.counts).map(([key, count]) => <Chip key={key} label={`${state(key)}: ${count}`} />)}</Stack>
<PageSection title={t('processingJobs')} surface>
{!visibleListing.items.length ? <Typography>{t('processingNoJobs')}</Typography> : <TableContainer tabIndex={0} role="group" aria-label={t('processingJobs')} sx={{ overflowX: 'auto', maxWidth: '100%' }}>
<Table aria-label={t('processingJobs')} sx={{ minWidth: 720 }}><TableHead><TableRow>{['processingTask', 'processingOwner', 'processingStatus', 'processingWorker', 'processingCreated'].map(key => <TableCell key={key}>{t(key as TranslationKey)}</TableCell>)}</TableRow></TableHead>
<TableBody>{visibleListing.items.map(item => <TableRow key={item.id} selected={selected === item.id}>
<TableCell component="th" scope="row"><Button aria-label={t('processingViewDetails', { id: item.id })} onClick={() => { setDetail(null); setEvents([]); setBefore(null); setCopy(null); focusDetail.current = true; select(item.id); }}>{t(tasks[item.taskType] ?? 'processingOther')}</Button><Typography variant="caption" display="block">{item.job?.title ?? item.document?.name ?? item.id.slice(0, 8)}</Typography></TableCell>
<TableCell><Button onClick={() => { const next = { ...filters, owner: item.owner.id }; setInputs(next); setFilters(next); setPage(1); resetSelection(); }}>{item.owner.name ?? item.owner.email ?? item.owner.id}</Button><Typography variant="caption" display="block">{item.owner.email}</Typography></TableCell>
<TableCell>{state(item.status)}<Typography variant="caption" display="block">{stage(item.stage)}</Typography></TableCell>
<TableCell>{worker(item.worker)}<Typography variant="caption" display="block">{t(item.workerEnabled ? 'processingEnabled' : 'processingDisabled')}</Typography></TableCell>
<TableCell><time dateTime={item.createdAtUtc}>{date(item.createdAtUtc)}</time></TableCell>
</TableRow>)}</TableBody></Table>
</TableContainer>}
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap" alignItems="center" sx={{ mt: 2 }}>
<Button disabled={page <= 1 || busy} onClick={() => { resetSelection(); setPage(value => value - 1); }}>{t('processingPrevious')}</Button>
<Typography>{t('processingPage', { page, total: Math.max(1, Math.ceil(visibleListing.total / 25)) })}</Typography>
<Button disabled={page * 25 >= visibleListing.total || busy} onClick={() => { resetSelection(); setPage(value => value + 1); }}>{t('processingNext')}</Button>
</Stack>
</PageSection>
<Box component="details"><Box component="summary" sx={{ p: 1, cursor: 'pointer' }}>{t('processingWorkerFlags')}</Box>
<Typography variant="body2">{t('processingWorkerFlagsHelp')}</Typography><Typography variant="body2">{t('processingAiDescription')}</Typography>
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1} sx={{ mt: 1 }}>{visibleListing.workers.map(item => <Chip key={item.name} label={`${t(workers[item.name] ?? 'processingWorkerUntracked')}: ${t(item.flagEnabled ? 'processingEnabled' : 'processingDisabled')}`} />)}</Stack>
</Box>
</> : null}
{detail && visibleListing ? <PageSection surface title={t('processingDetails')} titleRef={detailHeading}>
<Stack spacing={2}>
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1} alignItems="center">
<TextField label={t('processingCorrelation')} value={detail.id} slotProps={{ input: { readOnly: true } }} sx={{ flex: '1 1 20rem', minWidth: 0 }} />
<Button onClick={async () => { try { await navigator.clipboard.writeText(detail.id); setCopy('copied'); } catch { setCopy('failed'); } }}>{t('processingCopy')}</Button>
{copy ? <Typography role="status">{t(copy === 'copied' ? 'processingCopied' : 'processingCopyFailed')}</Typography> : null}
</Stack>
<Typography>{detail.owner.name ?? detail.owner.id} · {detail.owner.email} · {state(detail.status)} · {stage(detail.stage)}</Typography>
<Typography>{t('processingAttempts', { count: detail.attemptCount, max: detail.maxAttempts })} · {t('processingDuration', { count: Math.round(detail.durationSeconds) })}</Typography>
<Box component="dl" sx={{ m: 0, display: 'grid', gridTemplateColumns: { xs: '1fr', sm: 'auto 1fr' }, gap: 1 }}>
{([['processingCreated', detail.createdAtUtc], ['processingStarted', detail.startedAtUtc], ['processingCompleted', detail.completedAtUtc], ['processingLastSeen', detail.lastHeartbeatAtUtc]] as const).map(([key, value]) => <React.Fragment key={key}><Typography component="dt">{t(key)}</Typography><Typography component="dd" sx={{ m: 0 }}>{date(value)}</Typography></React.Fragment>)}
</Box>
{detail.failureCode ? <Alert severity="warning">{failure(detail.failureCode)}</Alert> : null}
{detail.job ? <Typography>{detail.job.title} · {detail.job.company} · #{detail.job.id}</Typography> : null}
{detail.document ? <Box><Typography>{t('processingSource')}: {detail.document.name}</Typography><Typography variant="body2">{detail.document.type} · {t('processingBytes', { count: detail.document.bytes })}{detail.document.pages ? ` · ${t('processingPages', { count: detail.document.pages })}` : ''}</Typography><Typography variant="body2">{detail.document.state === 'pending_review' ? t('cvImportStageReady') : detail.document.state === 'applied' ? t('processingSourceApplied') : detail.document.state === 'expired' ? t('processingSourceExpired') : state(detail.document.state ?? 'unknown')}</Typography></Box> : null}
<Typography component="h3" variant="h6">{t('processingEvents')}</Typography>
<Typography variant="body2">{t('processingRetention')}</Typography>
{!events.length ? <Typography>{t('processingNoEvents')}</Typography> : <Box component="ol" sx={{ m: 0, pl: 3 }}>{events.map(item => <Box component="li" key={item.id} sx={{ mb: 2 }}><Typography>{t(kinds[item.kind] ?? 'processingUnknown')} · {state(item.status)}</Typography><Typography variant="body2"><time dateTime={item.atUtc}>{date(item.atUtc)}</time> · {stage(item.stage)} · {t('processingAttempts', { count: item.attempt, max: detail.maxAttempts })}</Typography>{item.failureCode ? <Typography variant="body2">{failure(item.failureCode)}</Typography> : null}</Box>)}</Box>}
<Stack direction="row" spacing={1}><Button disabled={!hasOlder || busy || !events.length} onClick={() => { pausedRef.current = true; setPaused(true); setBefore(events[0].id); }}>{t('processingOlder')}</Button><Button disabled={busy} onClick={resume}>{t('processingLatest')}</Button></Stack>
</Stack>
</PageSection> : null}
</Box>;
}