Files
jobtrackingapp/job-tracker-ui/src/views/OperationsPage.tsx

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>;
}