feat(admin): add safe processing monitor and operation correlation
This commit is contained in:
@@ -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)],
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user