fix(admin): complete processing diagnostics and failure evidence
This commit is contained in:
@@ -48,9 +48,19 @@ test('[D] Real administrator/owner boundaries, actual cancellation history and r
|
||||
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.getByLabel('Search users, jobs or task ID', { exact: true }).fill(id);
|
||||
await page.getByLabel('Worker filter', { exact: true }).selectOption('untracked');
|
||||
await page.getByLabel('Submitted from (UTC)', { exact: true }).fill('2020-01-01');
|
||||
await page.getByLabel('Submitted before (UTC)', { exact: true }).fill('2100-01-01');
|
||||
await page.getByLabel('Application ID', { exact: true }).fill('2147483647');
|
||||
await page.getByRole('button', { name: 'Apply filters', exact: true }).click();
|
||||
await expect(page.getByText('No processing jobs match these filters.', { exact: true })).toBeVisible();
|
||||
await page.getByLabel('Application ID', { exact: true }).fill('');
|
||||
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.getByLabel('Submitting user ID', { exact: true })).toHaveValue(owner.id);
|
||||
await expect(page.getByText('No linked job', { exact: true })).toBeVisible();
|
||||
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);
|
||||
|
||||
@@ -58,6 +58,8 @@ for (const [language, mode] of [['en', 'light'], ['nb', 'dark']] as const) {
|
||||
await fs.writeFile(`${extension}/background.js`, 'globalThis.auditZoomReady = true;');
|
||||
const context = await playwright.chromium.launchPersistentContext(info.outputPath('local-browser-profile'), {
|
||||
channel: 'chromium', headless: true, viewport: null, deviceScaleFactor: undefined,
|
||||
// The bundled browser otherwise disables the extension that owns real tab zoom.
|
||||
ignoreDefaultArgs: ['--disable-extensions'],
|
||||
args: ['--window-size=1280,900', `--disable-extensions-except=${extension}`, `--load-extension=${extension}`],
|
||||
});
|
||||
const admin = await playwright.request.newContext();
|
||||
@@ -65,6 +67,7 @@ for (const [language, mode] of [['en', 'light'], ['nb', 'dark']] as const) {
|
||||
let createdAdminId: string | undefined;
|
||||
const records: unknown[] = [];
|
||||
try {
|
||||
const worker = context.serviceWorkers()[0] || await context.waitForEvent('serviceworker');
|
||||
await context.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort();
|
||||
@@ -77,7 +80,6 @@ for (const [language, mode] of [['en', 'light'], ['nb', 'dark']] as const) {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto('http://localhost:3300/login');
|
||||
await expect(page.getByRole('heading', { name: translations[language].signInTitle, exact: true })).toBeVisible();
|
||||
const worker = context.serviceWorkers()[0] || await context.waitForEvent('serviceworker');
|
||||
const zoom = async (factor: number) => {
|
||||
const result = await worker.evaluate(async factor => {
|
||||
// The extension changes real tab zoom, rather than emulating a narrow viewport or pinch.
|
||||
|
||||
@@ -30,6 +30,7 @@ test('real Docling produces owner-scoped administrator history without exposing
|
||||
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.importId).toBe(run.id);
|
||||
expect(detail.document.pages).toBeGreaterThan(0);
|
||||
expect(detail.worker).toBe('document');
|
||||
expect(detail.workerEnabled).toBe(true);
|
||||
@@ -41,6 +42,9 @@ test('real Docling produces owner-scoped administrator history without exposing
|
||||
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 expect(page.getByLabel('Submitting user ID', { exact: true })).toHaveValue(owner.id);
|
||||
await expect(page.getByText(`Import ID: ${run.id}`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('No linked job', { exact: true })).toBeVisible();
|
||||
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(); }
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 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: { importId: 7, 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(() => {
|
||||
@@ -31,6 +31,57 @@ test('shows safe job metadata and real events with a copyable correlation ID', a
|
||||
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('submits diagnostic filters and displays explicit owner, import and unrelated-job metadata', async () => {
|
||||
open();
|
||||
await screen.findByText('Synthetic owner');
|
||||
fireEvent.change(screen.getByLabelText('Search users, jobs or task ID'), { target: { value: 'Owned job' } });
|
||||
fireEvent.change(screen.getByLabelText('Application ID'), { target: { value: '2' } });
|
||||
fireEvent.change(screen.getByLabelText('Submitted from (UTC)'), { target: { value: '2026-10-01' } });
|
||||
fireEvent.change(screen.getByLabelText('Submitted before (UTC)'), { target: { value: '2026-11-01' } });
|
||||
fireEvent.change(screen.getByLabelText('Worker filter'), { target: { value: 'document' } });
|
||||
fireEvent.change(screen.getByLabelText('Failure code'), { target: { value: 'timeout' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply filters' }));
|
||||
await waitFor(() => expect(get.mock.calls.some(([url]) => {
|
||||
const query = new URLSearchParams(url.split('?')[1]);
|
||||
return query.get('q') === 'Owned job' && query.get('jobId') === '2' && query.get('worker') === 'document'
|
||||
&& query.get('failureCode') === 'timeout' && query.get('createdFrom') === '2026-10-01T00:00:00Z'
|
||||
&& query.get('createdBefore') === '2026-11-01T00:00:00Z';
|
||||
})).toBe(true));
|
||||
fireEvent.click(screen.getByRole('button', { name: /View processing details/ }));
|
||||
expect(await screen.findByLabelText('Submitting user ID')).toHaveValue('owner');
|
||||
expect(screen.getByText('No linked job')).toBeVisible();
|
||||
expect(screen.getByText('Import ID: 7')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows stable failure evidence and links an application only when the server permits it', async () => {
|
||||
const selected = { ...item, status: 'failed', failureCode: 'timeout', completedAtUtc: '2026-10-28T12:00:08Z', job: { id: 2, title: 'Owned job', company: 'Synthetic company', canOpen: false } };
|
||||
get.mockImplementation((url: string) => Promise.resolve({ data: url.includes('/events') ? { items: [], hasMore: false, retentionDays: 30 }
|
||||
: url.includes(item.id) ? selected : listing }));
|
||||
open();
|
||||
await screen.findByText('Synthetic owner');
|
||||
fireEvent.click(screen.getByRole('button', { name: /View processing details/ }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Failure code: timeout');
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Whether this failure is transient or permanent has not been established.');
|
||||
expect(screen.queryByRole('link', { name: 'Owned job' })).not.toBeInTheDocument();
|
||||
selected.job.canOpen = true;
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||
expect(await screen.findByRole('link', { name: 'Owned job' })).toHaveAttribute('href', '/jobs/2');
|
||||
});
|
||||
|
||||
test('a successful retry keeps prior failure evidence separate from its success timestamp', async () => {
|
||||
const failedAt = '2026-10-28T12:01:00Z';
|
||||
const completedAt = '2026-10-28T12:05:00Z';
|
||||
const selected = { ...item, status: 'succeeded', failureCode: 'timeout', completedAtUtc: completedAt, lastFailureAtUtc: failedAt, lastFailureStage: 'cv_extracting' };
|
||||
get.mockImplementation((url: string) => Promise.resolve({ data: url.includes('/events') ? { items: [], hasMore: false, retentionDays: 30 }
|
||||
: url.includes(item.id) ? selected : listing }));
|
||||
open(); await screen.findByText('Synthetic owner');
|
||||
fireEvent.click(screen.getByRole('button', { name: /View processing details/ }));
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert).toHaveTextContent('Previous failure: Processing timed out.');
|
||||
expect(alert).toHaveTextContent(new Date(failedAt).toLocaleString('en-GB'));
|
||||
expect(alert).not.toHaveTextContent(new Date(completedAt).toLocaleString('en-GB'));
|
||||
});
|
||||
|
||||
test('pause stops polling and resume uses an exact string event cursor', async () => {
|
||||
jest.useFakeTimers(); open();
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
@@ -65,6 +116,16 @@ test('malformed successful responses produce a recoverable error instead of rend
|
||||
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('invalid filters explain the rejected request and stop automatic retries', async () => {
|
||||
jest.useFakeTimers();
|
||||
get.mockRejectedValue({ response: { status: 400 } });
|
||||
open(); await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Check the filter values');
|
||||
const count = get.mock.calls.length;
|
||||
await act(async () => { jest.advanceTimersByTime(30000); });
|
||||
expect(get).toHaveBeenCalledTimes(count);
|
||||
});
|
||||
|
||||
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; }));
|
||||
|
||||
@@ -21,6 +21,21 @@ export const translations = {
|
||||
processingNext: "Next page",
|
||||
processingPage: "Page {page} of {total}",
|
||||
processingOwner: "Owner",
|
||||
processingSearch: "Search users, jobs or task ID",
|
||||
processingInvalidFilters: "Check the filter values and ensure the before date follows the from date.",
|
||||
processingApplicationId: "Application ID",
|
||||
processingCreatedFrom: "Submitted from (UTC)",
|
||||
processingCreatedBefore: "Submitted before (UTC)",
|
||||
processingWorkerFilter: "Worker filter",
|
||||
processingFailureCode: "Failure code",
|
||||
processingElapsed: "Duration",
|
||||
processingSubmittingUserId: "Submitting user ID",
|
||||
processingNoLinkedJob: "No linked job",
|
||||
processingImportId: "Import ID",
|
||||
processingFailureAt: "Failure timestamp",
|
||||
processingPreviousFailure: "Previous failure",
|
||||
processingRetryScheduled: "A retry is scheduled; see the status and timeline.",
|
||||
processingRetryUnknown: "Whether this failure is transient or permanent has not been established.",
|
||||
processingStatus: "Status",
|
||||
processingTask: "Task",
|
||||
processingAll: "All",
|
||||
@@ -3127,6 +3142,21 @@ export const translations = {
|
||||
processingNext: "Neste side",
|
||||
processingPage: "Side {page} av {total}",
|
||||
processingOwner: "Eier",
|
||||
processingSearch: "Søk etter bruker, jobb eller oppgave-ID",
|
||||
processingInvalidFilters: "Kontroller filterverdiene. Før-datoen må være etter fra-datoen.",
|
||||
processingApplicationId: "Søknads-ID",
|
||||
processingCreatedFrom: "Sendt inn fra (UTC)",
|
||||
processingCreatedBefore: "Sendt inn før (UTC)",
|
||||
processingWorkerFilter: "Prosessfilter",
|
||||
processingFailureCode: "Feilkode",
|
||||
processingElapsed: "Varighet",
|
||||
processingSubmittingUserId: "Innsenderens bruker-ID",
|
||||
processingNoLinkedJob: "Ingen tilknyttet jobb",
|
||||
processingImportId: "Import-ID",
|
||||
processingFailureAt: "Tidspunkt for feil",
|
||||
processingPreviousFailure: "Tidligere feil",
|
||||
processingRetryScheduled: "Et nytt forsøk er planlagt. Se status og tidslinje.",
|
||||
processingRetryUnknown: "Det er ikke fastslått om feilen er midlertidig eller varig.",
|
||||
processingStatus: "Status",
|
||||
processingTask: "Oppgave",
|
||||
processingAll: "Alle",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { Alert, Box, Button, Chip, Link, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography } from '@mui/material';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from '../auth';
|
||||
import { isSessionInvalidated, SESSION_INVALIDATED_EVENT } from '../sessionBoundary';
|
||||
@@ -11,8 +12,9 @@ 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;
|
||||
lastFailureAtUtc: string | null; lastFailureStage: string | null;
|
||||
job: { id: number; title: string; company: string | null; canOpen: boolean } | null;
|
||||
document: { importId: number; 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 };
|
||||
@@ -22,6 +24,7 @@ const tasks: Record<string, TranslationKey> = { 'cv.process': 'operationsTaskCv'
|
||||
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' };
|
||||
const failureCodes = ['timeout', 'lease_expired', 'deadline_exceeded', 'entitlement_changed', 'document_unavailable', 'sidecar_unavailable', 'upstream_unavailable', 'invalid_response', 'circuit_open', 'rate_limited', 'permission_denied', 'cancelled', 'upload_rejected', 'parser_failed', 'malformed_output', 'provider_unavailable', 'processing_failed'];
|
||||
|
||||
export default function AdminProcessingPage() {
|
||||
const { t, language } = useI18n();
|
||||
@@ -31,7 +34,7 @@ export default function AdminProcessingPage() {
|
||||
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 [filters, setFilters] = useState({ status: '', taskType: '', owner: '', q: '', jobId: '', worker: '', failureCode: '', createdFrom: '', createdBefore: '' });
|
||||
const [inputs, setInputs] = useState(filters);
|
||||
const [page, setPage] = useState(1);
|
||||
const [paused, setPaused] = useState(false);
|
||||
@@ -39,7 +42,7 @@ export default function AdminProcessingPage() {
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [identity, setIdentity] = useState(getAuthUserKey);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [error, setError] = useState<'denied' | 'unavailable' | null>(null);
|
||||
const [error, setError] = useState<'denied' | 'unavailable' | 'invalid' | null>(null);
|
||||
const [updated, setUpdated] = useState<string | null>(null);
|
||||
const [copy, setCopy] = useState<'copied' | 'failed' | null>(null);
|
||||
const detailHeading = useRef<HTMLHeadingElement>(null);
|
||||
@@ -73,7 +76,7 @@ export default function AdminProcessingPage() {
|
||||
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);
|
||||
for (const [key, value] of Object.entries(filters)) if (value) query.set(key, key === 'createdFrom' || key === 'createdBefore' ? `${value}T00:00:00Z` : value);
|
||||
const eventQuery = new URLSearchParams({ take: '100' });
|
||||
if (before) eventQuery.set('before', before);
|
||||
else if (cursor) eventQuery.set('after', cursor);
|
||||
@@ -110,7 +113,8 @@ export default function AdminProcessingPage() {
|
||||
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; }
|
||||
} else if (status === 400) { setError('invalid'); retry = false; }
|
||||
else { setError('unavailable'); delay = 15000; }
|
||||
} finally {
|
||||
if (current()) {
|
||||
setBusy(false);
|
||||
@@ -135,10 +139,10 @@ export default function AdminProcessingPage() {
|
||||
<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>
|
||||
<Typography role="status" aria-live="polite">{t(error === 'denied' ? 'processingDenied' : error === 'invalid' ? 'processingInvalidFilters' : 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}
|
||||
{error ? <Alert severity="error" action={error === 'unavailable' ? <Button onClick={() => setRefresh(value => value + 1)}>{t('retry')}</Button> : undefined}>{t(error === 'denied' ? 'processingDenied' : error === 'invalid' ? 'processingInvalidFilters' : '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}>
|
||||
@@ -149,6 +153,16 @@ export default function AdminProcessingPage() {
|
||||
<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 }} />
|
||||
<TextField label={t('processingSearch')} value={inputs.q} onChange={event => setInputs(value => ({ ...value, q: event.target.value }))} slotProps={{ htmlInput: { maxLength: 200 } }} />
|
||||
<TextField type="number" label={t('processingApplicationId')} value={inputs.jobId} onChange={event => setInputs(value => ({ ...value, jobId: event.target.value }))} slotProps={{ htmlInput: { min: 1, max: 2147483647 } }} />
|
||||
<TextField type="date" label={t('processingCreatedFrom')} value={inputs.createdFrom} onChange={event => setInputs(value => ({ ...value, createdFrom: event.target.value }))} slotProps={{ inputLabel: { shrink: true } }} />
|
||||
<TextField type="date" label={t('processingCreatedBefore')} value={inputs.createdBefore} onChange={event => setInputs(value => ({ ...value, createdBefore: event.target.value }))} slotProps={{ inputLabel: { shrink: true } }} />
|
||||
<TextField select label={t('processingWorkerFilter')} value={inputs.worker} onChange={event => setInputs(value => ({ ...value, worker: event.target.value }))} slotProps={{ select: { native: true } }}>
|
||||
<option value="">{t('processingAll')}</option>{['document', 'ai', 'untracked'].map(value => <option key={value} value={value}>{worker(value)}</option>)}
|
||||
</TextField>
|
||||
<TextField select label={t('processingFailureCode')} value={inputs.failureCode} onChange={event => setInputs(value => ({ ...value, failureCode: event.target.value }))} slotProps={{ select: { native: true } }}>
|
||||
<option value="">{t('processingAll')}</option>{failureCodes.map(value => <option key={value} value={value}>{value}</option>)}
|
||||
</TextField>
|
||||
<Button type="submit" variant="outlined">{t('processingApplyFilters')}</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -156,19 +170,20 @@ export default function AdminProcessingPage() {
|
||||
<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>
|
||||
<Table aria-label={t('processingJobs')} sx={{ minWidth: 800 }}><TableHead><TableRow>{['processingTask', 'processingOwner', 'processingStatus', 'processingWorker', 'processingCreated', 'processingElapsed'].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>{state(item.status)}<Typography variant="caption" display="block">{stage(item.stage)}</Typography>{item.failureCode && ['failed', 'waiting_for_retry', 'waiting_for_external_fallback'].includes(item.status) ? <Chip size="small" color="warning" label={failure(item.failureCode)} /> : null}</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>
|
||||
<TableCell>{t('processingDuration', { count: Math.round(item.durationSeconds) })}</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>
|
||||
<Typography>{t('processingPage', { page, total: Math.max(1, Math.min(1000, Math.ceil(visibleListing.total / 25))) })}</Typography>
|
||||
<Button disabled={page >= 1000 || 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>
|
||||
@@ -184,13 +199,14 @@ export default function AdminProcessingPage() {
|
||||
{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>
|
||||
<TextField label={t('processingSubmittingUserId')} value={detail.owner.id} slotProps={{ input: { readOnly: true } }} />
|
||||
<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}
|
||||
{detail.failureCode ? <Alert severity="warning"><Typography>{!['failed', 'waiting_for_retry', 'waiting_for_external_fallback'].includes(detail.status) ? `${t('processingPreviousFailure')}: ` : ''}{failure(detail.failureCode)}</Typography><Typography variant="body2">{t('processingFailureCode')}: {detail.failureCode} · {t('processingFailureAt')}: {date(detail.lastFailureAtUtc)} · {stage(detail.lastFailureStage)}</Typography><Typography variant="body2">{t(detail.status === 'waiting_for_retry' ? 'processingRetryScheduled' : 'processingRetryUnknown')}</Typography></Alert> : null}
|
||||
{detail.job ? <Typography>{detail.job.canOpen ? <Link component={RouterLink} to={`/jobs/${detail.job.id}`}>{detail.job.title}</Link> : detail.job.title} · {detail.job.company} · #{detail.job.id}</Typography> : <Typography>{t('processingNoLinkedJob')}</Typography>}
|
||||
{detail.document ? <Box><Typography>{t('processingImportId')}: {detail.document.importId}</Typography><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>}
|
||||
|
||||
Reference in New Issue
Block a user