Skip to content

Commit 167b54c

Browse files
committed
feat: display workflows in result page
1 parent a745a42 commit 167b54c

File tree

12 files changed

+527
-99
lines changed

12 files changed

+527
-99
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@
5252
"@rjsf/utils": "5.24.10",
5353
"@rjsf/validator-ajv8": "5.24.10",
5454
"@sentry/nextjs": "8.55.0",
55-
"@squonk/account-server-client": "4.2.1",
56-
"@squonk/data-manager-client": "4.1.5",
55+
"@squonk/account-server-client": "4.2.5",
56+
"@squonk/data-manager-client": "4.2.0",
5757
"@squonk/mui-theme": "5.0.0",
5858
"@squonk/sdf-parser": "1.3.1",
5959
"@tanstack/match-sorter-utils": "8.19.4",

pnpm-lock.yaml

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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+
};

src/components/runCards/InstancesList.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ export const InstancesList = ({ predicate }: InstancesListProps) => {
3737
if (instances.length === 0) {
3838
return (
3939
<Box sx={{ p: 2 }}>
40-
<Typography variant="body2">No instances of this type currently exist</Typography>
40+
<Typography color="text.secondary" variant="body2">
41+
No instances currently exist
42+
</Typography>
4143
</Box>
4244
);
4345
}
@@ -59,7 +61,11 @@ export const InstancesList = ({ predicate }: InstancesListProps) => {
5961
>
6062
<ListItemText
6163
primary={instance.name}
62-
secondary={<><LocalTime utcTimestamp={instance.launched} /> - version: {instance.job_version}</>}
64+
secondary={
65+
<>
66+
<LocalTime utcTimestamp={instance.launched} /> - version: {instance.job_version}
67+
</>
68+
}
6369
slotProps={{ primary: { variant: "body1" } }}
6470
/>
6571
</ListItemButton>

src/components/runCards/JobCard/JobCard.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@ export const JobCard = ({ projectId, job: jobs, disabled = false }: ApplicationC
6969
)}
7070
collapsed={
7171
<InstancesList
72-
predicate={(instance) => instance.job_collection === job.collection && instance.job_job === job.job}
72+
predicate={(instance) =>
73+
instance.job_collection === job.collection && instance.job_job === job.job
74+
}
7375
/>
7476
}
7577
header={{ color: "primary.main", subtitle: job.name, avatar: job.job[0], title: job.job }}

src/components/runCards/WorkflowCard/WorkflowCard.tsx

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,47 @@
11
import { type WorkflowSummary } from "@squonk/data-manager-client";
22

3-
import { Chip, Typography } from "@mui/material";
3+
import { Box, Chip, List, ListItemButton, ListItemText, Typography } from "@mui/material";
4+
import A from "next/link";
45

56
import { useCurrentProjectId } from "../../../hooks/projectHooks";
67
import { BaseCard } from "../../BaseCard";
8+
import { LocalTime } from "../../LocalTime";
79
import { RunWorkflowButton } from "./RunWorkflowButton";
810

11+
export interface WorkflowRunListItem {
12+
id: string;
13+
name: string;
14+
version?: string;
15+
started?: string;
16+
}
17+
918
export interface WorkflowCardProps {
1019
workflow: WorkflowSummary;
20+
runningWorkflows?: WorkflowRunListItem[];
1121
}
1222

1323
/**
14-
* MuiCard that displays a summary of a workflow definition.
24+
* MuiCard that displays a summary of a workflow definition, with running workflows listed in a MUI List.
25+
* The list matches the style of InstancesList, showing version and start time for each run.
1526
*/
16-
export const WorkflowCard = ({ workflow }: WorkflowCardProps) => {
27+
export const WorkflowCard = ({ workflow, runningWorkflows = [] }: WorkflowCardProps) => {
1728
const { projectId } = useCurrentProjectId();
29+
const hasRunning = runningWorkflows.length > 0;
30+
31+
// Sort by started descending (most recent first)
32+
const sortedRuns = [...runningWorkflows].sort((a, b) => {
33+
if (a.started && b.started) {
34+
return new Date(b.started).getTime() - new Date(a.started).getTime();
35+
}
36+
if (a.started) {
37+
return -1;
38+
}
39+
if (b.started) {
40+
return 1;
41+
}
42+
return 0;
43+
});
44+
1845
return (
1946
<BaseCard
2047
actions={() => (
@@ -25,6 +52,36 @@ export const WorkflowCard = ({ workflow }: WorkflowCardProps) => {
2552
workflowId={workflow.id}
2653
/>
2754
)}
55+
collapsed={
56+
hasRunning ? (
57+
<List dense component="ul">
58+
{sortedRuns.map((rw) => (
59+
<ListItemButton
60+
component={A}
61+
href={{ pathname: "/results/workflow/[workflowId]", query: { workflowId: rw.id } }}
62+
key={rw.id}
63+
>
64+
<ListItemText
65+
primary={rw.name}
66+
secondary={
67+
<>
68+
<span style={{ marginRight: 8 }}>version: {rw.version ?? "n/a"}</span>
69+
{!!rw.started && <LocalTime utcTimestamp={rw.started} />}
70+
</>
71+
}
72+
slotProps={{ primary: { variant: "body1" } }}
73+
/>
74+
</ListItemButton>
75+
))}
76+
</List>
77+
) : (
78+
<Box sx={{ p: 2 }}>
79+
<Typography color="text.secondary" variant="body2">
80+
No workflows currently exist
81+
</Typography>
82+
</Box>
83+
)
84+
}
2885
header={{
2986
color: "#f1c40f",
3087
subtitle: workflow.name,

0 commit comments

Comments
 (0)