fix(operations): preserve task state and accessible recovery
This commit is contained in:
@@ -1,147 +1,172 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Alert, Box, Button, Chip, LinearProgress, Paper, Stack, Typography } from "@mui/material";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { notificationDateLabel, UserNotification } from "../notifications";
|
||||
import type { TranslationKey } from "../i18n/translations";
|
||||
import { PageHeader, PageSection } from "../layout/PageLayout";
|
||||
import { notificationDateLabel, type UserNotification } from "../notifications";
|
||||
import type { UserOperation } from "../types";
|
||||
|
||||
type Operation = {
|
||||
id: string;
|
||||
taskType: string;
|
||||
status: string;
|
||||
subjectType?: string | null;
|
||||
createdAtUtc: string;
|
||||
completedAtUtc?: string | null;
|
||||
cancellationRequestedAtUtc?: string | null;
|
||||
progressStage?: string | null;
|
||||
progressPercent?: number | null;
|
||||
failureCategory?: string | null;
|
||||
canCancel: boolean;
|
||||
canRetry: boolean;
|
||||
const statusKeys: Record<string, TranslationKey> = {
|
||||
queued: "operationsStatusQueued", running: "operationsStatusRunning",
|
||||
waiting_for_retry: "operationsStatusWaitingForRetry", waiting_for_external_fallback: "operationsStatusWaitingForFallback",
|
||||
succeeded: "operationsStatusSucceeded", failed: "operationsStatusFailed", cancelled: "operationsStatusCancelled",
|
||||
};
|
||||
|
||||
const statusLabel = (value: string) => value.replaceAll("_", " ");
|
||||
const taskKeys: Record<string, TranslationKey> = { "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<Operation[]>([]);
|
||||
const [operations, setOperations] = useState<UserOperation[]>([]);
|
||||
const [notifications, setNotifications] = useState<UserNotification[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [error, setError] = useState<Failure | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const mounted = useRef(false);
|
||||
const sequence = useRef(0);
|
||||
const activeLoad = useRef<AbortController | null>(null);
|
||||
const pendingAction = useRef(false);
|
||||
const refreshRequired = useRef(false);
|
||||
const activityRef = useRef<HTMLDivElement>(null);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
const actionFocus = useRef<HTMLElement | null>(null);
|
||||
const readFocus = useRef<HTMLElement | null>(null);
|
||||
|
||||
const load = useCallback(async (showLoading = false) => {
|
||||
if (showLoading) setLoading(true);
|
||||
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<Operation[]>("/operations?limit=50"),
|
||||
api.get<UserNotification[]>("/notifications?limit=50"),
|
||||
api.get<UserOperation[]>("/operations?limit=50", { signal: controller.signal }),
|
||||
api.get<UserNotification[]>("/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 (requestError) {
|
||||
setError(getApiErrorMessage(requestError, t("operationsLoadFailed")));
|
||||
} catch (cause) {
|
||||
if (mounted.current && !controller.signal.aborted && request === sequence.current) {
|
||||
setError({ cause, kind: refreshRequired.current ? "committed" : "load" });
|
||||
controller.abort();
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (mounted.current && request === sequence.current) { activeLoad.current = null; setLoading(false); }
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void load();
|
||||
const interval = window.setInterval(() => void load(), 15000);
|
||||
return () => window.clearInterval(interval);
|
||||
return () => { mounted.current = false; sequence.current += 1; activeLoad.current?.abort(); activeLoad.current = null; window.clearInterval(interval); };
|
||||
}, [load]);
|
||||
|
||||
const runAction = async (key: string, action: () => Promise<unknown>, notificationsChanged = false) => {
|
||||
if (busyKey) return;
|
||||
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<ActionUpdate>) => {
|
||||
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 {
|
||||
await action();
|
||||
await load();
|
||||
if (notificationsChanged) window.dispatchEvent(new Event("notifications-changed"));
|
||||
} catch (requestError) {
|
||||
setError(getApiErrorMessage(requestError, t("operationsActionFailed")));
|
||||
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 {
|
||||
setBusyKey(null);
|
||||
if (mounted.current) { pendingAction.current = false; setBusyKey(null); }
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
<Typography color="text.secondary">{t("operationsSubtitle")}</Typography>
|
||||
<Button variant="outlined" onClick={() => void load(true)} disabled={loading}>{t("refresh")}</Button>
|
||||
</Box>
|
||||
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) : "";
|
||||
|
||||
{error ? <Alert severity="error" aria-live="polite">{error}</Alert> : null}
|
||||
{loading ? <LinearProgress aria-label={t("operationsLoading")} /> : null}
|
||||
|
||||
<Paper component="section" aria-labelledby="notifications-heading" sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Typography id="notifications-heading" variant="h6" sx={{ mb: 2 }}>{t("notifications")}</Typography>
|
||||
{notifications.length === 0 && !loading ? <Typography color="text.secondary">{t("operationsNoNotifications")}</Typography> : null}
|
||||
<Stack spacing={1.5}>
|
||||
{notifications.map((notification) => (
|
||||
<Box key={notification.id} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2, opacity: notification.readAtUtc ? 0.75 : 1 }}>
|
||||
<Typography sx={{ fontWeight: notification.readAtUtc ? 600 : 800 }}>{notification.title}</Typography>
|
||||
<Typography color="text.secondary">{notification.message}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{notificationDateLabel(notification.createdAtUtc, language === "nb" ? "nb-NO" : "en")}</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||
{!notification.readAtUtc ? (
|
||||
<Button size="small" disabled={busyKey !== null} onClick={() => void runAction(`read-${notification.id}`, () => api.post(`/notifications/${notification.id}/read`), true)}>
|
||||
{t("notificationsMarkRead")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="small" color="inherit" disabled={busyKey !== null} onClick={() => void runAction(`dismiss-${notification.id}`, () => api.delete(`/notifications/${notification.id}`), true)}>
|
||||
{t("notificationsDismiss")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
return <Stack ref={activityRef} role="region" aria-label={t("operationsActivity")} aria-busy={loading || busyKey !== null} tabIndex={-1} spacing={2} sx={{ minWidth: 0 }}>
|
||||
<PageHeader level="h2" title={t("operationsActivity")} titleRef={headingRef} description={t("operationsSubtitle")} actions={<Button variant="outlined" onClick={refresh} disabled={loading || busyKey !== null} sx={buttonSx}>{t("refresh")}</Button>} />
|
||||
<Typography variant="body2" color="text.secondary">{t("operationsRecentHelp")}</Typography>
|
||||
{error ? <Alert severity="error" sx={{ overflowWrap: "anywhere", display: "grid", gridTemplateColumns: "auto minmax(0, 1fr)", "& .MuiAlert-message": { minWidth: 0 }, "& .MuiAlert-action": { gridColumn: 2, m: 0, p: 0, justifySelf: "start" } }} action={<Button color="inherit" size="small" onClick={refresh} disabled={loading || busyKey !== null} sx={buttonSx}>{t("retry")}</Button>}>
|
||||
<Typography variant="body2">{errorTitle}</Typography>
|
||||
{errorDetail !== errorTitle ? <Typography variant="body2">{errorDetail}</Typography> : null}
|
||||
</Alert> : null}
|
||||
{loading ? <LinearProgress aria-label={t("operationsLoading")} /> : null}
|
||||
<PageSection surface title={t("notifications")}>
|
||||
{hasLoaded && notifications.length === 0 ? <Typography color="text.secondary">{t("operationsNoNotifications")}</Typography> : null}
|
||||
<Stack spacing={1.5}>{notifications.map(notification => <Paper key={notification.id} component="article" aria-label={notification.title} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, overflowWrap: "anywhere" }}>
|
||||
<Typography component="h3" variant="sectionTitle">{notification.title}</Typography>
|
||||
{!notification.readAtUtc ? <Typography variant="caption" color="text.secondary">{t("notificationsUnread")}</Typography> : null}
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{notification.message}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{dateLabel(notification.createdAtUtc)}</Typography>
|
||||
<Stack direction="row" useFlexGap spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||
{!notification.readAtUtc ? <Button size="small" disabled={locked} sx={buttonSx} onClick={() => void runAction("read-" + notification.id, async () => { await api.post("/notifications/" + notification.id + "/read"); return { readId: notification.id }; })}>{t("notificationsMarkRead")}</Button> : null}
|
||||
<Button size="small" color="inherit" disabled={locked} sx={buttonSx} onClick={() => void runAction("dismiss-" + notification.id, async () => { await api.delete("/notifications/" + notification.id); return { dismissId: notification.id }; })}>{t("notificationsDismiss")}</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper component="section" aria-labelledby="operations-heading" sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Typography id="operations-heading" variant="h6" sx={{ mb: 2 }}>{t("operations")}</Typography>
|
||||
{operations.length === 0 && !loading ? <Typography color="text.secondary">{t("operationsEmpty")}</Typography> : null}
|
||||
<Stack spacing={1.5}>
|
||||
{operations.map((operation) => (
|
||||
<Box key={operation.id} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{operation.taskType}</Typography>
|
||||
<Chip size="small" label={statusLabel(operation.status)} />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">{t("operationsStarted", { date: notificationDateLabel(operation.createdAtUtc, language === "nb" ? "nb-NO" : "en") })}</Typography>
|
||||
{operation.progressStage ? <Typography sx={{ mt: 1 }}>{operation.progressStage}</Typography> : null}
|
||||
{operation.progressPercent != null ? <LinearProgress variant="determinate" value={operation.progressPercent} aria-label={t("operationsProgress", { task: operation.taskType })} sx={{ mt: 1 }} /> : null}
|
||||
{operation.cancellationRequestedAtUtc ? <Typography color="text.secondary" sx={{ mt: 1 }}>{t("operationsCancellationRequested")}</Typography> : null}
|
||||
{operation.failureCategory ? <Alert severity="error" sx={{ mt: 1 }}>{t("operationsFailed", { category: statusLabel(operation.failureCategory) })}</Alert> : null}
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||
{operation.canCancel ? (
|
||||
<Button size="small" color="error" disabled={busyKey !== null} onClick={() => void runAction(`cancel-${operation.id}`, () => api.post(`/operations/${operation.id}/cancel`))}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
{operation.canRetry ? (
|
||||
<Button size="small" variant="outlined" disabled={busyKey !== null} onClick={() => void runAction(`retry-${operation.id}`, () => api.post(`/operations/${operation.id}/retry`), true)}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Paper>)}</Stack>
|
||||
</PageSection>
|
||||
<PageSection surface title={t("operations")}>
|
||||
{hasLoaded && operations.length === 0 ? <Typography color="text.secondary">{t("operationsEmpty")}</Typography> : null}
|
||||
<Stack spacing={1.5}>{operations.map(operation => <Paper key={operation.id} component="article" aria-label={taskLabel(operation.taskType)} data-operation-id={operation.id} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, overflowWrap: "anywhere" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", minWidth: 0 }}>
|
||||
<Typography component="h3" variant="sectionTitle" sx={{ overflowWrap: "anywhere", minWidth: 0 }}>{taskLabel(operation.taskType)}</Typography>
|
||||
<Chip size="small" label={Object.hasOwn(statusKeys, operation.status) ? t(statusKeys[operation.status]) : operation.status.replaceAll("_", " ")} sx={{ maxWidth: "100%", height: "auto", "& .MuiChip-label": { whiteSpace: "normal", py: 0.5, overflowWrap: "anywhere" } }} />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">{t("operationsCreated", { date: dateLabel(operation.createdAtUtc) })}</Typography>
|
||||
{operation.progressStage ? <Typography variant="body2" sx={{ mt: 1 }}>{operation.progressStage}</Typography> : null}
|
||||
{operation.progressPercent != null ? <LinearProgress variant="determinate" value={operation.progressPercent} aria-label={t("operationsProgress", { task: taskLabel(operation.taskType) })} sx={{ mt: 1 }} /> : null}
|
||||
{operation.cancellationRequestedAtUtc ? <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("operationsCancellationRequested")}</Typography> : null}
|
||||
{operation.failureCategory ? <Alert severity="error" sx={{ mt: 1, overflowWrap: "anywhere" }}>{t("operationsFailed", { category: operation.failureCategory.replaceAll("_", " ") })}</Alert> : null}
|
||||
<Stack direction="row" useFlexGap spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||
{operation.canCancel ? <Button size="small" color="error" disabled={locked} sx={buttonSx} onClick={() => void runAction("cancel-" + operation.id, async () => ({ operation: (await api.post<UserOperation>("/operations/" + operation.id + "/cancel")).data }))}>{t("cancel")}</Button> : null}
|
||||
{operation.canRetry ? <Button size="small" variant="outlined" disabled={locked} sx={buttonSx} onClick={() => void runAction("retry-" + operation.id, async () => ({ operation: (await api.post<UserOperation>("/operations/" + operation.id + "/retry")).data }))}>{t("retry")}</Button> : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
</Paper>)}</Stack>
|
||||
</PageSection>
|
||||
</Stack>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user