173 lines
12 KiB
TypeScript
173 lines
12 KiB
TypeScript
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<string, TranslationKey> = {
|
|
queued: "operationsStatusQueued", running: "operationsStatusRunning",
|
|
waiting_for_retry: "operationsStatusWaitingForRetry", waiting_for_external_fallback: "operationsStatusWaitingForFallback",
|
|
succeeded: "operationsStatusSucceeded", failed: "operationsStatusFailed", cancelled: "operationsStatusCancelled",
|
|
};
|
|
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<UserOperation[]>([]);
|
|
const [notifications, setNotifications] = useState<UserNotification[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
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 (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<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 (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<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 {
|
|
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 <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>)}</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>
|
|
</PageSection>
|
|
</Stack>;
|
|
}
|