import React, { useEffect, useRef, useState } from 'react'; 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'; 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; 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; 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 = { queued: 'operationsStatusQueued', running: 'operationsStatusRunning', waiting_for_retry: 'operationsStatusWaitingForRetry', waiting_for_external_fallback: 'operationsStatusWaitingForFallback', succeeded: 'operationsStatusSucceeded', failed: 'operationsStatusFailed', cancelled: 'operationsStatusCancelled' }; const tasks: Record = { 'cv.process': 'operationsTaskCv', 'strategy.snapshot': 'operationsTaskStrategy' }; const stages: Record = { 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 = { 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 = { 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(); const [listing, setListing] = useState(null); const [selected, select] = useState(null); const [detail, setDetail] = useState(null); const [events, setEvents] = useState([]); const [hasOlder, setHasOlder] = useState(false); const [before, setBefore] = useState(null); 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); 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' | 'invalid' | null>(null); const [updated, setUpdated] = useState(null); const [copy, setCopy] = useState<'copied' | 'failed' | null>(null); const detailHeading = useRef(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 | 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, 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); const [listResponse, detailResponse, eventResponse] = await Promise.all([ api.get(`/admin/processing?${query}`, options), selected ? api.get(`/admin/processing/${selected}`, options) : Promise.resolve(null), selected ? api.get(`/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 if (status === 400) { setError('invalid'); 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 {t(error === 'denied' ? 'processingDenied' : error === 'invalid' ? 'processingInvalidFilters' : paused ? 'processingPaused' : error ? 'processingReconnecting' : 'processingConnected')} {updated ? {t('processingLastUpdated', { time: date(updated) })} : null} {error ? setRefresh(value => value + 1)}>{t('retry')} : undefined}>{t(error === 'denied' ? 'processingDenied' : error === 'invalid' ? 'processingInvalidFilters' : 'processingLoadFailed')} : null} {busy && !visibleListing ? {t('loading')} : null} { event.preventDefault(); resetSelection(); setPage(1); setFilters({ ...inputs, owner: inputs.owner.trim() }); }}> setInputs(value => ({ ...value, status: event.target.value }))} slotProps={{ select: { native: true } }}> {Object.keys(statuses).map(value => )} setInputs(value => ({ ...value, taskType: event.target.value }))} slotProps={{ select: { native: true } }}> {Object.keys(tasks).map(value => )} setInputs(value => ({ ...value, owner: event.target.value }))} slotProps={{ htmlInput: { maxLength: 255 } }} sx={{ flex: '1 1 14rem', minWidth: 0 }} /> setInputs(value => ({ ...value, q: event.target.value }))} slotProps={{ htmlInput: { maxLength: 200 } }} /> setInputs(value => ({ ...value, jobId: event.target.value }))} slotProps={{ htmlInput: { min: 1, max: 2147483647 } }} /> setInputs(value => ({ ...value, createdFrom: event.target.value }))} slotProps={{ inputLabel: { shrink: true } }} /> setInputs(value => ({ ...value, createdBefore: event.target.value }))} slotProps={{ inputLabel: { shrink: true } }} /> setInputs(value => ({ ...value, worker: event.target.value }))} slotProps={{ select: { native: true } }}> {['document', 'ai', 'untracked'].map(value => )} setInputs(value => ({ ...value, failureCode: event.target.value }))} slotProps={{ select: { native: true } }}> {failureCodes.map(value => )} {visibleListing ? <> {Object.entries(visibleListing.counts).map(([key, count]) => )} {!visibleListing.items.length ? {t('processingNoJobs')} : {['processingTask', 'processingOwner', 'processingStatus', 'processingWorker', 'processingCreated', 'processingElapsed'].map(key => {t(key as TranslationKey)})}{visibleListing.items.map(item => {item.job?.title ?? item.document?.name ?? item.id.slice(0, 8)} {item.owner.email} {state(item.status)}{stage(item.stage)}{item.failureCode && ['failed', 'waiting_for_retry', 'waiting_for_external_fallback'].includes(item.status) ? : null} {worker(item.worker)}{t(item.workerEnabled ? 'processingEnabled' : 'processingDisabled')} {t('processingDuration', { count: Math.round(item.durationSeconds) })} )}
} {t('processingPage', { page, total: Math.max(1, Math.min(1000, Math.ceil(visibleListing.total / 25))) })}
{t('processingWorkerFlags')} {t('processingWorkerFlagsHelp')}{t('processingAiDescription')} {visibleListing.workers.map(item => )} : null} {detail && visibleListing ? {copy ? {t(copy === 'copied' ? 'processingCopied' : 'processingCopyFailed')} : null} {detail.owner.name ?? detail.owner.id} · {detail.owner.email} · {state(detail.status)} · {stage(detail.stage)} {t('processingAttempts', { count: detail.attemptCount, max: detail.maxAttempts })} · {t('processingDuration', { count: Math.round(detail.durationSeconds) })} {([['processingCreated', detail.createdAtUtc], ['processingStarted', detail.startedAtUtc], ['processingCompleted', detail.completedAtUtc], ['processingLastSeen', detail.lastHeartbeatAtUtc]] as const).map(([key, value]) => {t(key)}{date(value)})} {detail.failureCode ? {!['failed', 'waiting_for_retry', 'waiting_for_external_fallback'].includes(detail.status) ? `${t('processingPreviousFailure')}: ` : ''}{failure(detail.failureCode)}{t('processingFailureCode')}: {detail.failureCode} · {t('processingFailureAt')}: {date(detail.lastFailureAtUtc)} · {stage(detail.lastFailureStage)}{t(detail.status === 'waiting_for_retry' ? 'processingRetryScheduled' : 'processingRetryUnknown')} : null} {detail.job ? {detail.job.canOpen ? {detail.job.title} : detail.job.title} · {detail.job.company} · #{detail.job.id} : {t('processingNoLinkedJob')}} {detail.document ? {t('processingImportId')}: {detail.document.importId}{t('processingSource')}: {detail.document.name}{detail.document.type} · {t('processingBytes', { count: detail.document.bytes })}{detail.document.pages ? ` · ${t('processingPages', { count: detail.document.pages })}` : ''}{detail.document.state === 'pending_review' ? t('cvImportStageReady') : detail.document.state === 'applied' ? t('processingSourceApplied') : detail.document.state === 'expired' ? t('processingSourceExpired') : state(detail.document.state ?? 'unknown')} : null} {t('processingEvents')} {t('processingRetention')} {!events.length ? {t('processingNoEvents')} : {events.map(item => {t(kinds[item.kind] ?? 'processingUnknown')} · {state(item.status)} · {stage(item.stage)} · {t('processingAttempts', { count: item.attempt, max: detail.maxAttempts })}{item.failureCode ? {failure(item.failureCode)} : null})}} : null}
; }