feat/Update_Controllers_to_Allow_for_Premium_Membership
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
type Notification = {
|
||||
id: string;
|
||||
operationId?: string | null;
|
||||
kind: string;
|
||||
title: string;
|
||||
message: string;
|
||||
createdAtUtc: string;
|
||||
readAtUtc?: string | null;
|
||||
};
|
||||
|
||||
const statusLabel = (value: string) => value.replaceAll("_", " ");
|
||||
const dateLabel = (value: string) => {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
|
||||
};
|
||||
|
||||
export default function OperationsPage() {
|
||||
const [operations, setOperations] = useState<Operation[]>([]);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (showLoading = false) => {
|
||||
if (showLoading) setLoading(true);
|
||||
try {
|
||||
const [operationResponse, notificationResponse] = await Promise.all([
|
||||
api.get<Operation[]>("/operations?limit=50"),
|
||||
api.get<Notification[]>("/notifications?limit=50"),
|
||||
]);
|
||||
setOperations(operationResponse.data ?? []);
|
||||
setNotifications(notificationResponse.data ?? []);
|
||||
setError(null);
|
||||
} catch (requestError) {
|
||||
setError(getApiErrorMessage(requestError, "Operations could not be loaded."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const interval = window.setInterval(() => void load(), 15000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [load]);
|
||||
|
||||
const runAction = async (key: string, action: () => Promise<unknown>, notificationsChanged = false) => {
|
||||
if (busyKey) return;
|
||||
setBusyKey(key);
|
||||
try {
|
||||
await action();
|
||||
await load();
|
||||
if (notificationsChanged) window.dispatchEvent(new Event("notifications-changed"));
|
||||
} catch (requestError) {
|
||||
setError(getApiErrorMessage(requestError, "The action could not be completed."));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
<Typography color="text.secondary">Background work survives navigation and refresh.</Typography>
|
||||
<Button variant="outlined" onClick={() => void load(true)} disabled={loading}>Refresh</Button>
|
||||
</Box>
|
||||
|
||||
{error ? <Alert severity="error" aria-live="polite">{error}</Alert> : null}
|
||||
{loading ? <LinearProgress aria-label="Loading operations" /> : null}
|
||||
|
||||
<Paper component="section" aria-labelledby="notifications-heading" sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Typography id="notifications-heading" variant="h6" sx={{ mb: 2 }}>Notifications</Typography>
|
||||
{notifications.length === 0 && !loading ? <Typography color="text.secondary">No notifications.</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">{dateLabel(notification.createdAtUtc)}</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)}>
|
||||
Mark read
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="small" color="inherit" disabled={busyKey !== null} onClick={() => void runAction(`dismiss-${notification.id}`, () => api.delete(`/notifications/${notification.id}`), true)}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper component="section" aria-labelledby="operations-heading" sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Typography id="operations-heading" variant="h6" sx={{ mb: 2 }}>Operations</Typography>
|
||||
{operations.length === 0 && !loading ? <Typography color="text.secondary">No background operations yet.</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">Started {dateLabel(operation.createdAtUtc)}</Typography>
|
||||
{operation.progressStage ? <Typography sx={{ mt: 1 }}>{operation.progressStage}</Typography> : null}
|
||||
{operation.progressPercent != null ? <LinearProgress variant="determinate" value={operation.progressPercent} aria-label={`${operation.taskType} progress`} sx={{ mt: 1 }} /> : null}
|
||||
{operation.cancellationRequestedAtUtc ? <Typography color="text.secondary" sx={{ mt: 1 }}>Cancellation requested.</Typography> : null}
|
||||
{operation.failureCategory ? <Alert severity="error" sx={{ mt: 1 }}>Failed: {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`))}>
|
||||
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)}>
|
||||
Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user