import React, { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Box, Button, Chip, LinearProgress, Paper, Stack, Typography } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; import { useI18n } from "../i18n/I18nProvider"; import type { TranslationKey } from "../i18n/translations"; import { PageHeader, PageSection } from "../layout/PageLayout"; import { notificationDateLabel, type UserNotification } from "../notifications"; import type { UserOperation } from "../types"; const statusKeys: Record = { queued: "operationsStatusQueued", running: "operationsStatusRunning", waiting_for_retry: "operationsStatusWaitingForRetry", waiting_for_external_fallback: "operationsStatusWaitingForFallback", succeeded: "operationsStatusSucceeded", failed: "operationsStatusFailed", cancelled: "operationsStatusCancelled", }; const taskKeys: Record = { "cv.process": "operationsTaskCv", "strategy.snapshot": "operationsTaskStrategy" }; const buttonSx = { minHeight: { xs: 44, sm: 36 }, whiteSpace: "normal", overflowWrap: "anywhere", "@media (pointer: coarse)": { minHeight: 44 } } as const; type Failure = { cause: unknown; kind: "load" | "action" | "committed" }; type ActionUpdate = { operation?: UserOperation; readId?: string; dismissId?: string }; export default function OperationsPage() { const { language, t } = useI18n(); const [operations, setOperations] = useState([]); const [notifications, setNotifications] = useState([]); const [loading, setLoading] = useState(true); const [hasLoaded, setHasLoaded] = useState(false); const [error, setError] = useState(null); const [busyKey, setBusyKey] = useState(null); const mounted = useRef(false); const sequence = useRef(0); const activeLoad = useRef(null); const pendingAction = useRef(false); const refreshRequired = useRef(false); const activityRef = useRef(null); const headingRef = useRef(null); const actionFocus = useRef(null); const readFocus = useRef(null); const load = useCallback(async (afterWrite = false) => { if (!mounted.current || activeLoad.current || pendingAction.current && !afterWrite) return; const request = ++sequence.current; const controller = new AbortController(); activeLoad.current = controller; setLoading(true); try { const [operationResponse, notificationResponse] = await Promise.all([ api.get("/operations?limit=50", { signal: controller.signal }), api.get("/notifications?limit=50", { signal: controller.signal }), ]); if (!mounted.current || controller.signal.aborted || request !== sequence.current) return; const active = document.activeElement; readFocus.current = active instanceof HTMLElement && activityRef.current?.contains(active) ? active : null; setOperations(operationResponse.data ?? []); setNotifications(notificationResponse.data ?? []); setHasLoaded(true); refreshRequired.current = false; setError(null); } catch (cause) { if (mounted.current && !controller.signal.aborted && request === sequence.current) { setError({ cause, kind: refreshRequired.current ? "committed" : "load" }); controller.abort(); } } finally { if (mounted.current && request === sequence.current) { activeLoad.current = null; setLoading(false); } } }, []); useEffect(() => { mounted.current = true; void load(); const interval = window.setInterval(() => void load(), 15000); return () => { mounted.current = false; sequence.current += 1; activeLoad.current?.abort(); activeLoad.current = null; window.clearInterval(interval); }; }, [load]); useEffect(() => { if (loading || busyKey || !actionFocus.current) return; const target = actionFocus.current; actionFocus.current = null; if (document.activeElement !== activityRef.current) return; if (target.isConnected && !target.matches(":disabled")) target.focus(); else headingRef.current?.focus(); }, [busyKey, error, loading, notifications, operations]); useEffect(() => { const target = readFocus.current; readFocus.current = null; if (target && !target.isConnected && document.activeElement === document.body) headingRef.current?.focus(); }, [loading, notifications, operations]); const rememberFocus = () => { const active = document.activeElement; if (active instanceof HTMLElement && active !== activityRef.current && activityRef.current?.contains(active)) { actionFocus.current = active; activityRef.current.focus(); } }; const refresh = () => { rememberFocus(); void load(); }; const runAction = async (key: string, action: () => Promise) => { if (!mounted.current || pendingAction.current || refreshRequired.current) return; pendingAction.current = true; sequence.current += 1; activeLoad.current?.abort(); activeLoad.current = null; setLoading(false); rememberFocus(); setBusyKey(key); setError(null); try { const update = await action(); if (!mounted.current) return; if (update.operation) setOperations(items => items.map(item => item.id === update.operation!.id ? update.operation! : item)); if (update.readId) setNotifications(items => items.map(item => item.id === update.readId ? { ...item, readAtUtc: new Date().toISOString() } : item)); if (update.dismissId) setNotifications(items => items.filter(item => item.id !== update.dismissId)); // A failed follow-up read must never invite replay of a committed write. refreshRequired.current = true; window.dispatchEvent(new Event("notifications-changed")); await load(true); } catch (cause) { if (mounted.current) setError({ cause, kind: "action" }); } finally { if (mounted.current) { pendingAction.current = false; setBusyKey(null); } } }; const locked = busyKey !== null || error?.kind === "committed"; const taskLabel = (task: string) => Object.hasOwn(taskKeys, task) ? t(taskKeys[task]) : task; const dateLabel = (value: string) => notificationDateLabel(value, language === "nb" ? "nb-NO" : "en-GB"); const errorTitle = error ? t(error.kind === "committed" ? "operationsCommittedRefreshFailed" : error.kind === "action" ? "operationsActionFailed" : "operationsLoadFailed") : ""; const errorDetail = error ? getApiErrorMessage(error.cause, errorTitle) : ""; return {t("refresh")}} /> {t("operationsRecentHelp")} {error ? {t("retry")}}> {errorTitle} {errorDetail !== errorTitle ? {errorDetail} : null} : null} {loading ? : null} {hasLoaded && notifications.length === 0 ? {t("operationsNoNotifications")} : null} {notifications.map(notification => {notification.title} {!notification.readAtUtc ? {t("notificationsUnread")} : null} {notification.message} {dateLabel(notification.createdAtUtc)} {!notification.readAtUtc ? : null} )} {hasLoaded && operations.length === 0 ? {t("operationsEmpty")} : null} {operations.map(operation => {taskLabel(operation.taskType)} {t("operationsCreated", { date: dateLabel(operation.createdAtUtc) })} {operation.progressStage ? {operation.progressStage} : null} {operation.progressPercent != null ? : null} {operation.cancellationRequestedAtUtc ? {t("operationsCancellationRequested")} : null} {operation.failureCategory ? {t("operationsFailed", { category: operation.failureCategory.replaceAll("_", " ") })} : null} {operation.canCancel ? : null} {operation.canRetry ? : null} )} ; }