feat(admin): add safe processing monitor and operation correlation
This commit is contained in:
@@ -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