|
| 1 | +import { type RunningWorkflowSummary } from "@squonk/data-manager-client"; |
| 2 | +import { |
| 3 | + useGetRunningWorkflow, |
| 4 | + useGetRunningWorkflowSteps, |
| 5 | +} from "@squonk/data-manager-client/workflow"; |
| 6 | + |
| 7 | +import { |
| 8 | + AccountTreeRounded as AccountTreeRoundedIcon, |
| 9 | + Person as PersonIcon, |
| 10 | +} from "@mui/icons-material"; |
| 11 | +import { |
| 12 | + Timeline, |
| 13 | + TimelineConnector, |
| 14 | + TimelineContent, |
| 15 | + TimelineDot, |
| 16 | + TimelineItem, |
| 17 | + TimelineOppositeContent, |
| 18 | + TimelineSeparator, |
| 19 | +} from "@mui/lab"; |
| 20 | +import { |
| 21 | + Alert, |
| 22 | + Box, |
| 23 | + Divider, |
| 24 | + ListItem, |
| 25 | + ListItemIcon, |
| 26 | + ListItemText, |
| 27 | + Typography, |
| 28 | +} from "@mui/material"; |
| 29 | + |
| 30 | +import { getErrorMessage } from "../../utils/next/orvalError"; |
| 31 | +import { CenterLoader } from "../CenterLoader"; |
| 32 | +import { HorizontalList } from "../HorizontalList"; |
| 33 | +import { LocalTime } from "../LocalTime"; |
| 34 | +import { NextLink } from "../NextLink"; |
| 35 | +import { ResultCard } from "../results/ResultCard"; |
| 36 | +import { type StatusIconProps } from "../results/StatusIcon"; |
| 37 | + |
| 38 | +export interface RunningWorkflowCardProps { |
| 39 | + /** |
| 40 | + * The ID of the running workflow to display |
| 41 | + */ |
| 42 | + runningWorkflowId: string; |
| 43 | + /** |
| 44 | + * Optionally, a summary object for the workflow |
| 45 | + */ |
| 46 | + workflowSummary?: RunningWorkflowSummary; |
| 47 | + /** |
| 48 | + * Whether the card should have its collapsed content visible immediately. Defaults to true. |
| 49 | + */ |
| 50 | + collapsedByDefault?: boolean; |
| 51 | +} |
| 52 | + |
| 53 | +// Map workflow status to StatusIcon-compatible state |
| 54 | +function mapWorkflowStatusToState(status?: string): StatusIconProps["state"] { |
| 55 | + switch (status) { |
| 56 | + case "RUNNING": |
| 57 | + return "RUNNING"; |
| 58 | + case "SUCCESS": |
| 59 | + return "COMPLETED"; |
| 60 | + case "FAILURE": |
| 61 | + case "USER_STOPPED": |
| 62 | + return "FAILED"; |
| 63 | + default: |
| 64 | + return undefined; |
| 65 | + } |
| 66 | +} |
| 67 | + |
| 68 | +/** |
| 69 | + * Expandable card that displays details about a running workflow. |
| 70 | + * Fetches details and steps using the workflow ID. |
| 71 | + */ |
| 72 | +export const RunningWorkflowCard = ({ |
| 73 | + runningWorkflowId, |
| 74 | + workflowSummary, |
| 75 | + collapsedByDefault = true, |
| 76 | +}: RunningWorkflowCardProps) => { |
| 77 | + const { |
| 78 | + data: workflow, |
| 79 | + isLoading: isWorkflowLoading, |
| 80 | + error: workflowError, |
| 81 | + } = useGetRunningWorkflow(runningWorkflowId); |
| 82 | + const { |
| 83 | + data: stepsData, |
| 84 | + isLoading: isStepsLoading, |
| 85 | + error: stepsError, |
| 86 | + } = useGetRunningWorkflowSteps(runningWorkflowId); |
| 87 | + |
| 88 | + // stepsData?.running_workflow_steps is the array of steps |
| 89 | + const steps = stepsData?.running_workflow_steps; |
| 90 | + |
| 91 | + if (isWorkflowLoading || isStepsLoading) { |
| 92 | + return <CenterLoader />; |
| 93 | + } |
| 94 | + |
| 95 | + if (workflowError) { |
| 96 | + return ( |
| 97 | + <Alert severity="error">Failed to load workflow: {getErrorMessage(workflowError)}</Alert> |
| 98 | + ); |
| 99 | + } |
| 100 | + if (stepsError) { |
| 101 | + return ( |
| 102 | + <Alert severity="error">Failed to load workflow steps: {getErrorMessage(stepsError)}</Alert> |
| 103 | + ); |
| 104 | + } |
| 105 | + |
| 106 | + // Expanded content: Timeline of steps |
| 107 | + const timeline = |
| 108 | + steps && steps.length > 0 ? ( |
| 109 | + <Timeline sx={{ p: 0, m: 0 }}> |
| 110 | + {steps.map((step, idx) => { |
| 111 | + const showStopped = step.stopped && step.stopped !== step.started; |
| 112 | + return ( |
| 113 | + <TimelineItem key={step.id}> |
| 114 | + <TimelineOppositeContent sx={{ flex: "unset" }}> |
| 115 | + <Typography variant="caption"> |
| 116 | + {!!step.started && ( |
| 117 | + <LocalTime showTime showDate={false} utcTimestamp={step.started} /> |
| 118 | + )} |
| 119 | + {!!showStopped && ( |
| 120 | + <> |
| 121 | + {" "} |
| 122 | + <span style={{ fontStyle: "italic" }}>to </span> |
| 123 | + {!!step.stopped && ( |
| 124 | + <LocalTime showTime showDate={false} utcTimestamp={step.stopped} /> |
| 125 | + )} |
| 126 | + </> |
| 127 | + )} |
| 128 | + </Typography> |
| 129 | + </TimelineOppositeContent> |
| 130 | + <TimelineSeparator> |
| 131 | + <TimelineDot |
| 132 | + color={ |
| 133 | + step.status === "SUCCESS" |
| 134 | + ? "success" |
| 135 | + : step.status === "FAILURE" |
| 136 | + ? "error" |
| 137 | + : "info" |
| 138 | + } |
| 139 | + /> |
| 140 | + {idx < steps.length - 1 && <TimelineConnector />} |
| 141 | + </TimelineSeparator> |
| 142 | + <TimelineContent> |
| 143 | + <Typography variant="subtitle2"> |
| 144 | + {step.instance_id ? ( |
| 145 | + <NextLink |
| 146 | + component="a" |
| 147 | + href={{ |
| 148 | + pathname: "/results/instance/[instanceId]", |
| 149 | + query: { instanceId: step.instance_id }, |
| 150 | + }} |
| 151 | + > |
| 152 | + {step.name} |
| 153 | + </NextLink> |
| 154 | + ) : ( |
| 155 | + step.name |
| 156 | + )} |
| 157 | + </Typography> |
| 158 | + <Typography variant="body2">Status: {step.status}</Typography> |
| 159 | + {!!step.error_msg && ( |
| 160 | + <Typography color="error" variant="body2"> |
| 161 | + Error: {step.error_msg} |
| 162 | + </Typography> |
| 163 | + )} |
| 164 | + </TimelineContent> |
| 165 | + </TimelineItem> |
| 166 | + ); |
| 167 | + })} |
| 168 | + </Timeline> |
| 169 | + ) : ( |
| 170 | + <Typography variant="body2">No steps found for this workflow.</Typography> |
| 171 | + ); |
| 172 | + |
| 173 | + // Collapsed content: key workflow details and timeline (expanded content) |
| 174 | + const collapsed = ( |
| 175 | + <Box> |
| 176 | + <HorizontalList> |
| 177 | + <ListItem> |
| 178 | + <ListItemIcon sx={{ minWidth: "40px" }}> |
| 179 | + <PersonIcon /> |
| 180 | + </ListItemIcon> |
| 181 | + <ListItemText primary={workflow?.running_user} secondary="User" /> |
| 182 | + </ListItem> |
| 183 | + {!!workflow?.project.name && ( |
| 184 | + <ListItem> |
| 185 | + <ListItemIcon sx={{ minWidth: "40px" }}> |
| 186 | + <AccountTreeRoundedIcon /> |
| 187 | + </ListItemIcon> |
| 188 | + <ListItemText primary={workflow.project.name} secondary="Project" /> |
| 189 | + </ListItem> |
| 190 | + )} |
| 191 | + </HorizontalList> |
| 192 | + <Divider sx={{ my: 2 }} /> |
| 193 | + <Typography gutterBottom variant="h6"> |
| 194 | + Workflow Steps |
| 195 | + </Typography> |
| 196 | + {timeline} |
| 197 | + </Box> |
| 198 | + ); |
| 199 | + |
| 200 | + return ( |
| 201 | + <ResultCard |
| 202 | + actions={() => null} |
| 203 | + collapsed={collapsed} |
| 204 | + collapsedByDefault={collapsedByDefault} |
| 205 | + createdDateTime={workflow?.started ?? ""} |
| 206 | + finishedDateTime={workflow?.stopped ?? ""} |
| 207 | + href={{ |
| 208 | + pathname: "/results/workflow/[workflowId]", |
| 209 | + query: { workflowId: workflow?.id ?? workflowSummary?.id ?? "" }, |
| 210 | + }} |
| 211 | + linkTitle={workflow?.name ?? workflowSummary?.name ?? "Workflow"} |
| 212 | + state={mapWorkflowStatusToState(workflow?.status)} |
| 213 | + /> |
| 214 | + ); |
| 215 | +}; |
0 commit comments