diff --git a/src/LLL.DurableTask.Ui/app/src/components/ReasonDialog.tsx b/src/LLL.DurableTask.Ui/app/src/components/ReasonDialog.tsx new file mode 100644 index 0000000..8c09c26 --- /dev/null +++ b/src/LLL.DurableTask.Ui/app/src/components/ReasonDialog.tsx @@ -0,0 +1,73 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + TextField, +} from "@mui/material"; +import React, { useState } from "react"; + +interface ReasonDialogProps { + open: boolean; + title: string; + description: string; + onClose: () => void; + onConfirm: (reason: string) => void; +} + +export function ReasonDialog({ + open, + title, + description, + onClose, + onConfirm, +}: ReasonDialogProps) { + const [reason, setReason] = useState(""); + + const handleConfirm = () => { + const value = reason; + setReason(""); + onConfirm(value); + }; + + const handleClose = () => { + setReason(""); + onClose(); + }; + + return ( + + {title} + + {description} + setReason(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleConfirm(); + } + }} + /> + + + + + + + ); +} diff --git a/src/LLL.DurableTask.Ui/app/src/views/orchestration/Orchestration.tsx b/src/LLL.DurableTask.Ui/app/src/views/orchestration/Orchestration.tsx index d06ca5f..b36a6b1 100644 --- a/src/LLL.DurableTask.Ui/app/src/views/orchestration/Orchestration.tsx +++ b/src/LLL.DurableTask.Ui/app/src/views/orchestration/Orchestration.tsx @@ -1,4 +1,6 @@ import DeleteIcon from "@mui/icons-material/Delete"; +import ReplayIcon from "@mui/icons-material/Replay"; +import StopIcon from "@mui/icons-material/Stop"; import { LoadingButton } from "@mui/lab"; import { Box, @@ -15,9 +17,10 @@ import { import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; import { useConfirm } from "material-ui-confirm"; import { useSnackbar } from "notistack"; -import React, { useCallback } from "react"; +import React, { useCallback, useState } from "react"; import { Link as RouterLink, useNavigate, useParams } from "react-router"; import { ErrorAlert } from "../../components/ErrorAlert"; +import { ReasonDialog } from "../../components/ReasonDialog"; import { AutoRefreshButton } from "../../components/RefreshButton"; import { useApiClient } from "../../hooks/useApiClient"; import { useQueryState } from "../../hooks/useQueryState"; @@ -25,23 +28,14 @@ import { useRefreshInterval } from "../../hooks/useRefreshInterval"; import { ExecutionsList } from "./ExecutionsList"; import { HistoryTable } from "./HistoryTable"; import { RaiseEvent } from "./RaiseEvent"; -import { Rewind } from "./Rewind"; import { State } from "./State"; -import { Terminate } from "./Terminate"; type RouteParams = { instanceId: string; executionId: string; }; -type TabValue = - | "state" - | "history" - | "executions" - | "raise_event" - | "terminate" - | "rewind" - | "json"; +type TabValue = "state" | "history" | "executions" | "raise_event" | "json"; export function Orchestration() { const [tab, setTab] = useQueryState("tab", "state"); @@ -91,29 +85,29 @@ export function Orchestration() { mutationFn: (args) => apiClient.purgeOrchestration(...args), }); - const handlePurgeClick = useCallback(() => { - confirm({ + const handlePurgeClick = useCallback(async () => { + const { confirmed } = await confirm({ description: "This action is irreversible. Do you confirm the purge of this instance?", - }).then(async () => { - try { - await purgeMutation.mutateAsync([instanceId]); - enqueueSnackbar("Instance purged", { - variant: "success", - }); - navigate(`/orchestrations`); - } catch (error) { - enqueueSnackbar(String(error), { - variant: "error", - persist: true, - action: (key) => ( - - ), - }); - } }); + if (!confirmed) return; + try { + await purgeMutation.mutateAsync([instanceId]); + enqueueSnackbar("Instance purged", { + variant: "success", + }); + navigate(`/orchestrations`); + } catch (error) { + enqueueSnackbar(String(error), { + variant: "error", + persist: true, + action: (key) => ( + + ), + }); + } }, [ closeSnackbar, confirm, @@ -123,6 +117,11 @@ export function Orchestration() { purgeMutation, ]); + const [reasonDialog, setReasonDialog] = useState<{ + action: string; + fn: (reason: string) => Promise; + } | null>(null); + return (
@@ -154,11 +153,59 @@ export function Orchestration() { setRefreshInterval={setRefreshInterval} /> - {apiClient.isAuthorized("OrchestrationsPurgeInstance") && - stateQuery.isSuccess && ( - + {stateQuery.isSuccess && ( + + {apiClient.isAuthorized("OrchestrationsTerminate") && ( + + )} + {apiClient.hasFeature("Rewind") && + apiClient.isAuthorized("OrchestrationsRewind") && ( + + )} + {apiClient.isAuthorized("OrchestrationsPurgeInstance") && ( } loading={purgeMutation.isPending} onClick={handlePurgeClick} @@ -166,8 +213,9 @@ export function Orchestration() { > Purge - - )} + )} + + )} {(stateQuery.isFetching || historyQuery.isFetching) && ( @@ -197,13 +245,6 @@ export function Orchestration() { {apiClient.isAuthorized("OrchestrationsRaiseEvent") && ( )} - {apiClient.isAuthorized("OrchestrationsTerminate") && ( - - )} - {apiClient.hasFeature("Rewind") && - apiClient.isAuthorized("OrchestrationsRewind") && ( - - )} {stateQuery.data ? ( @@ -237,25 +278,6 @@ export function Orchestration() { /> )} - {apiClient.isAuthorized("OrchestrationsTerminate") && - tab === "terminate" && ( - - - - )} - {apiClient.hasFeature("Rewind") && - apiClient.isAuthorized("OrchestrationsRewind") && - tab === "rewind" && ( - - - - )} {tab === "json" && (
@@ -270,6 +292,31 @@ export function Orchestration() {
           
         ) : null}
       
+       setReasonDialog(null)}
+        onConfirm={async (reason) => {
+          const dialog = reasonDialog;
+          setReasonDialog(null);
+          if (dialog) {
+            try {
+              await dialog.fn(reason);
+            } catch (error) {
+              enqueueSnackbar(String(error), {
+                variant: "error",
+                persist: true,
+                action: (key) => (
+                  
+                ),
+              });
+            }
+          }
+        }}
+      />
     
); } diff --git a/src/LLL.DurableTask.Ui/app/src/views/orchestrations/Orchestrations.tsx b/src/LLL.DurableTask.Ui/app/src/views/orchestrations/Orchestrations.tsx index 725af42..92e22f9 100644 --- a/src/LLL.DurableTask.Ui/app/src/views/orchestrations/Orchestrations.tsx +++ b/src/LLL.DurableTask.Ui/app/src/views/orchestrations/Orchestrations.tsx @@ -105,7 +105,7 @@ export function Orchestrations() { ) : null} - + void; } -export function OrchestrationsTable({ result }: Props) { +export function OrchestrationsTable({ result, onAction }: Props) { const apiClient = useApiClient(); + const confirm = useConfirm(); + const { enqueueSnackbar } = useSnackbar(); + const [selected, setSelected] = useState>(new Set()); + + const orchestrations = result?.orchestrationState ?? []; + const instanceIds = orchestrations.map( + (o) => o.orchestrationInstance.instanceId, + ); + const allSelected = + instanceIds.length > 0 && instanceIds.every((id) => selected.has(id)); + const someSelected = instanceIds.some((id) => selected.has(id)); + + const toggleAll = () => { + if (allSelected) { + setSelected(new Set()); + } else { + setSelected(new Set(instanceIds)); + } + }; + + const toggleOne = (instanceId: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(instanceId)) { + next.delete(instanceId); + } else { + next.add(instanceId); + } + return next; + }); + }; + + const selectedIds = instanceIds.filter((id) => selected.has(id)); + + const [reasonDialog, setReasonDialog] = useState<{ + action: string; + fn: (instanceId: string, reason: string) => Promise; + } | null>(null); + const [loading, setLoading] = useState(null); + + const executeBulk = async ( + ids: string[], + action: string, + fn: (instanceId: string) => Promise, + ) => { + setLoading(action); + let succeeded = 0; + let failed = 0; + for (const id of ids) { + try { + await fn(id); + succeeded++; + } catch { + failed++; + } + } + setLoading(null); + + const message = + failed > 0 + ? `${action}: ${succeeded} succeeded, ${failed} failed` + : `${action}: ${succeeded} succeeded`; + enqueueSnackbar(message, { + variant: failed > 0 ? "warning" : "success", + }); + + setSelected(new Set()); + onAction?.(); + }; + + const runBulkAction = async ( + action: string, + fn: (instanceId: string) => Promise, + ) => { + const ids = [...selectedIds]; + const { confirmed } = await confirm({ + description: `This will ${action} ${ids.length} orchestration(s). Continue?`, + }); + if (!confirmed) return; + executeBulk(ids, action, fn); + }; + + const hasActions = + apiClient.isAuthorized("OrchestrationsTerminate") || + apiClient.isAuthorized("OrchestrationsPurgeInstance") || + (apiClient.hasFeature("Rewind") && + apiClient.isAuthorized("OrchestrationsRewind")); return ( + {selectedIds.length > 0 && ( + + + {selectedIds.length} selected + + + {apiClient.isAuthorized("OrchestrationsTerminate") && ( + } + loading={loading === "Terminate"} + loadingPosition="start" + disabled={loading !== null} + onClick={() => + setReasonDialog({ + action: "Terminate", + fn: (id, reason) => + apiClient.terminateOrchestration(id, { reason }), + }) + } + > + Terminate + + )} + {apiClient.hasFeature("Rewind") && + apiClient.isAuthorized("OrchestrationsRewind") && ( + } + loading={loading === "Rewind"} + loadingPosition="start" + disabled={loading !== null} + onClick={() => + setReasonDialog({ + action: "Rewind", + fn: (id, reason) => + apiClient.rewindOrchestration(id, { reason }), + }) + } + > + Rewind + + )} + {apiClient.isAuthorized("OrchestrationsPurgeInstance") && ( + } + loading={loading === "Purge"} + loadingPosition="start" + disabled={loading !== null} + onClick={() => + runBulkAction("Purge", (id) => + apiClient.purgeOrchestration(id), + ) + } + > + Purge + + )} + + + )} + {hasActions && ( + + + + )} InstanceId ExecutionId Name @@ -37,67 +208,98 @@ export function OrchestrationsTable({ result }: Props) { - {result?.orchestrationState.map((orchestration) => ( - - - - {orchestration.orchestrationInstance.instanceId} - - - - {apiClient.hasFeature("StatePerExecution") ? ( + {orchestrations.map((orchestration) => { + const instanceId = orchestration.orchestrationInstance.instanceId; + const isSelected = selected.has(instanceId); + return ( + + {hasActions && ( + + toggleOne(instanceId)} + /> + + )} + - {orchestration.orchestrationInstance.executionId} + {instanceId} - ) : ( - orchestration.orchestrationInstance.executionId - )} - - {orchestration.name} - {orchestration.orchestrationStatus} - {formatDateTime(orchestration.createdTime)} - - {formatDateTime(orchestration.lastUpdatedTime)} - - {apiClient.hasFeature("Tags") && ( - - *": { m: 0.25 }, - }} - > - {orchestration.tags && - Object.entries(orchestration.tags).map(([key, value]) => ( - - ))} - - )} - - ))} + + {apiClient.hasFeature("StatePerExecution") ? ( + + {orchestration.orchestrationInstance.executionId} + + ) : ( + orchestration.orchestrationInstance.executionId + )} + + {orchestration.name} + {orchestration.orchestrationStatus} + + {formatDateTime(orchestration.createdTime)} + + + {formatDateTime(orchestration.lastUpdatedTime)} + + {apiClient.hasFeature("Tags") && ( + + *": { m: 0.25 }, + }} + > + {orchestration.tags && + Object.entries(orchestration.tags).map( + ([key, value]) => ( + + ), + )} + + + )} + + ); + })}
+ setReasonDialog(null)} + onConfirm={(reason) => { + const dialog = reasonDialog; + const ids = [...selectedIds]; + setReasonDialog(null); + if (dialog) { + executeBulk(ids, dialog.action, (id) => dialog.fn(id, reason)); + } + }} + />
); }