diff --git a/client/src/api/invocations.ts b/client/src/api/invocations.ts index 88c34695fb8c..2196f9a9254e 100644 --- a/client/src/api/invocations.ts +++ b/client/src/api/invocations.ts @@ -9,8 +9,18 @@ export type WorkflowInvocationCollectionView = components["schemas"]["WorkflowIn export type InvocationJobsSummary = components["schemas"]["InvocationJobsResponse"]; export type InvocationStep = components["schemas"]["InvocationStep"]; +export type StepJobSummary = + | components["schemas"]["InvocationStepJobsResponseStepModel"] + | components["schemas"]["InvocationStepJobsResponseJobModel"] + | components["schemas"]["InvocationStepJobsResponseCollectionJobsModel"]; + export const invocationsFetcher = fetcher.path("/api/invocations").method("get").create(); +export const stepJobsSummaryFetcher = fetcher + .path("/api/invocations/{invocation_id}/step_jobs_summary") + .method("get") + .create(); + export type WorkflowInvocation = WorkflowInvocationElementView | WorkflowInvocationCollectionView; export interface WorkflowInvocationJobsSummary { diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 482ec3912ddb..e1cbfe76f583 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -7963,6 +7963,11 @@ export interface components { * @example 0123456789ABCDEF */ id: string; + /** + * Implicit Collection Jobs ID + * @description The implicit collection job ID associated with the workflow invocation step. + */ + implicit_collection_jobs_id?: string | null; /** Job Id */ job_id: string | null; /** @@ -8049,7 +8054,7 @@ export interface components { InvocationStepJobsResponseCollectionJobsModel: { /** * ID - * @description The encoded ID of the workflow invocation. + * @description The encoded ID of the collection job. * @example 0123456789ABCDEF */ id: string; @@ -8076,7 +8081,7 @@ export interface components { InvocationStepJobsResponseJobModel: { /** * ID - * @description The encoded ID of the workflow invocation. + * @description The encoded ID of the job. * @example 0123456789ABCDEF */ id: string; diff --git a/client/src/api/workflows.ts b/client/src/api/workflows.ts index 0082f1db63f9..e532b4499c1b 100644 --- a/client/src/api/workflows.ts +++ b/client/src/api/workflows.ts @@ -1,4 +1,6 @@ -import { fetcher } from "@/api/schema"; +import { components, fetcher } from "@/api/schema"; + +export type StoredWorkflowDetailed = components["schemas"]["StoredWorkflowDetailed"]; export const workflowsFetcher = fetcher.path("/api/workflows").method("get").create(); diff --git a/client/src/components/Common/Heading.vue b/client/src/components/Common/Heading.vue index dbbc5565ce63..7e7ccc8bf024 100644 --- a/client/src/components/Common/Heading.vue +++ b/client/src/components/Common/Heading.vue @@ -1,7 +1,11 @@ + + + + + + + {{ value }} job{{ value > 1 ? "s" : "" }} {{ statePlaceholders[key] || key }}. + + + + + + This is an input + + This is a subworkflow. + This step has no jobs as of yet. + + diff --git a/client/src/components/Workflow/Editor/NodeOutput.vue b/client/src/components/Workflow/Editor/NodeOutput.vue index cac8dba4a5ac..641c8d8c38f7 100644 --- a/client/src/components/Workflow/Editor/NodeOutput.vue +++ b/client/src/components/Workflow/Editor/NodeOutput.vue @@ -51,6 +51,7 @@ const props = defineProps<{ datatypesMapper: DatatypesMapperModel; parentNode: HTMLElement | null; readonly: boolean; + blank: boolean; }>(); const emit = defineEmits(["pan-by", "stopDragging", "onDragConnector"]); @@ -341,7 +342,7 @@ const removeTagsAction = computed(() => { - + { { .output-terminal { @include node-terminal-style(right); - &:hover { - color: $brand-success; - } + &:not(.blank-output) { + &:hover { + color: $brand-success; + } - button:focus + .terminal-icon { - color: $brand-success; + button:focus + .terminal-icon { + color: $brand-success; + } } } diff --git a/client/src/components/Workflow/Editor/WorkflowGraph.vue b/client/src/components/Workflow/Editor/WorkflowGraph.vue index 467ad460dd29..44cdcabb733e 100644 --- a/client/src/components/Workflow/Editor/WorkflowGraph.vue +++ b/client/src/components/Workflow/Editor/WorkflowGraph.vue @@ -7,7 +7,13 @@ @onZoom="onZoom" @update:pan="panBy" /> - + , default: null }, readonly: { type: Boolean, default: false }, initialPosition: { type: Object as PropType<{ x: number; y: number }>, default: () => ({ x: 50, y: 20 }) }, + isInvocation: { type: Boolean, default: false }, showMinimap: { type: Boolean, default: true }, showZoomControls: { type: Boolean, default: true }, }); @@ -198,11 +206,15 @@ const { comments } = storeToRefs(commentStore); .canvas-content { width: 100%; - height: 100%; position: relative; left: 0px; top: 0px; overflow: hidden; + + /* TODO: w/out this, canvas height = 0 when width goes beyond a point (invocation graph) */ + &.fixed-window-height { + height: 60vh; + } } .node-area { diff --git a/client/src/components/Workflow/Invocation/Graph/InvocationGraph.vue b/client/src/components/Workflow/Invocation/Graph/InvocationGraph.vue new file mode 100644 index 000000000000..987782991e20 --- /dev/null +++ b/client/src/components/Workflow/Invocation/Graph/InvocationGraph.vue @@ -0,0 +1,349 @@ + + + + + + + + + + {{ errorMessage }} + + Unknown Error + + + + + + + + + + Hide Graph + + + + + Show Graph + + + (showingJobId = jobId)" + @focus-on-step="toggleActiveStep" /> + + + + + + + Showing Job Details for + + {{ showingJobId }} + + + No Job Selected + + + + + + + + + + + + + + + + + + + + + + Select a job from a step in the invocation to view its details here. + + + + + + + diff --git a/client/src/components/Workflow/Invocation/Graph/WorkflowInvocationSteps.vue b/client/src/components/Workflow/Invocation/Graph/WorkflowInvocationSteps.vue new file mode 100644 index 000000000000..febb69008e06 --- /dev/null +++ b/client/src/components/Workflow/Invocation/Graph/WorkflowInvocationSteps.vue @@ -0,0 +1,131 @@ + + + + + + + + + + + + Workflow Inputs + + + + + + + + + + + + diff --git a/client/src/components/Workflow/InvocationsList.vue b/client/src/components/Workflow/InvocationsList.vue index ce962d036388..6de58e97143b 100644 --- a/client/src/components/Workflow/InvocationsList.vue +++ b/client/src/components/Workflow/InvocationsList.vue @@ -1,8 +1,19 @@ - - {{ title }} - + + + + {{ title }} + + + {{ headerMessage }} @@ -21,12 +32,17 @@ - + Last updated: ; - Invocation ID: - {{ row.item.id }} + + Invocation ID: + openInvocation(e, row)">{{ + row.item.id + }} + @@ -46,13 +62,14 @@ @click.stop="swapRowDetails(data)" /> - - + + openInvocation(e, data)"> {{ getStoredWorkflowNameByInstanceId(data.item.workflow_id) }} - + @@ -67,6 +84,18 @@ + + + + + { + this.$router.push(this.invocationLink(item)); + }); + } else { + this.$router.push(this.invocationLink(item)); + } + }, }, }; diff --git a/client/src/components/Workflow/Run/WorkflowRunSuccess.vue b/client/src/components/Workflow/Run/WorkflowRunSuccess.vue index f7bc8cecb1b9..4beabd2a4f04 100644 --- a/client/src/components/Workflow/Run/WorkflowRunSuccess.vue +++ b/client/src/components/Workflow/Run/WorkflowRunSuccess.vue @@ -8,19 +8,26 @@ This workflow will generate results in multiple histories. You can observe progress in the - history multi-view. + history multi-view. This workflow will generate results in a new history. - Switch to that history now. + Switch to that history now. You can check the status of queued jobs and view the resulting data the History panel. + + View all of your workflow invocations in the + Invocations List. + + :invocation-id="invocation.id" + full-page /> @@ -28,7 +35,7 @@ diff --git a/client/src/components/Workflow/constants.js b/client/src/components/Workflow/constants.ts similarity index 68% rename from client/src/components/Workflow/constants.js rename to client/src/components/Workflow/constants.ts index 71dda07248c3..75cbb7a469f3 100644 --- a/client/src/components/Workflow/constants.js +++ b/client/src/components/Workflow/constants.ts @@ -1,5 +1,5 @@ const WorkflowInputs = ["data_input", "data_collection_input", "parameter_input"]; -export function isWorkflowInput(stepType) { +export function isWorkflowInput(stepType: string): boolean { return WorkflowInputs.includes(stepType); } diff --git a/client/src/components/Workflow/workflows.services.ts b/client/src/components/Workflow/workflows.services.ts index 775d6d193d59..2eb5b113ad47 100644 --- a/client/src/components/Workflow/workflows.services.ts +++ b/client/src/components/Workflow/workflows.services.ts @@ -77,8 +77,12 @@ export async function createWorkflow(workflowName: string, workflowAnnotation: s return data; } -export async function getWorkflowFull(workflowId: string) { - const { data } = await axios.get(withPrefix(`/workflow/load_workflow?_=true&id=${workflowId}`)); +export async function getWorkflowFull(workflowId: string, version?: number) { + let url = `/workflow/load_workflow?_=true&id=${workflowId}`; + if (version !== undefined) { + url += `&version=${version}`; + } + const { data } = await axios.get(withPrefix(url)); return data; } diff --git a/client/src/components/WorkflowInvocationState/JobStep.test.js b/client/src/components/WorkflowInvocationState/JobStep.test.js index a22ad555d243..b11420459940 100644 --- a/client/src/components/WorkflowInvocationState/JobStep.test.js +++ b/client/src/components/WorkflowInvocationState/JobStep.test.js @@ -51,14 +51,29 @@ describe("DatasetUIWrapper.vue with Dataset", () => { expect(wrapper.vm.toggledItems["1"]).toBeTruthy(); expect(wrapper.find(".expanded").exists()).toBeTruthy(); // 2 collapsed rows, plus 1 expanded row - expect(wrapper.find("tbody").findAll("tr").length).toBe(3); + expect(countVisibleRows(wrapper)).toBe(3); // update data const additionalJob = { ...jobs[0], id: 3 }; await wrapper.setProps({ jobs: [...jobs, additionalJob] }); // verify new data is displayed - expect(wrapper.find("tbody").findAll("tr").length).toBe(4); + expect(countVisibleRows(wrapper)).toBe(4); // verify first row is still expanded expect(wrapper.vm.toggledItems["1"]).toBeTruthy(); expect(wrapper.find(".expanded").exists()).toBeTruthy(); }); }); + +/** When expanding a row, a hidden `` may be added like: + * ``` + * + * ``` + * and this function will count rows other than these hidden ones. + */ +function countVisibleRows(wrapper) { + const rows = wrapper.find("tbody").find("tbody").findAll("tr"); + const visibleRows = rows.filter((row) => { + // only count rows that are not hidden + return !(row.attributes("aria-hidden") && row.attributes("aria-hidden") === "true"); + }); + return visibleRows.length; +} diff --git a/client/src/components/WorkflowInvocationState/JobStep.vue b/client/src/components/WorkflowInvocationState/JobStep.vue index 2dfa002231ea..694d6304e2ab 100644 --- a/client/src/components/WorkflowInvocationState/JobStep.vue +++ b/client/src/components/WorkflowInvocationState/JobStep.vue @@ -1,13 +1,37 @@ - + + + + + + + + + + + + + + + - - - + + + + + @@ -21,6 +45,9 @@ + + diff --git a/client/src/components/WorkflowInvocationState/WorkflowInvocationDetails.vue b/client/src/components/WorkflowInvocationState/WorkflowInvocationDetails.vue deleted file mode 100644 index d53391b83d21..000000000000 --- a/client/src/components/WorkflowInvocationState/WorkflowInvocationDetails.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - {{ dataInputStepLabel(key, input) }} - - - - - - {{ key }}: - - - - - - {{ key }}: - - - - - - - - - diff --git a/client/src/components/WorkflowInvocationState/WorkflowInvocationInputOutputTabs.vue b/client/src/components/WorkflowInvocationState/WorkflowInvocationInputOutputTabs.vue new file mode 100644 index 000000000000..82901ff11a69 --- /dev/null +++ b/client/src/components/WorkflowInvocationState/WorkflowInvocationInputOutputTabs.vue @@ -0,0 +1,55 @@ + + + + + + + + + {{ dataInputStepLabel(key, input) }} + + + + + + {{ key }}: + + + + + + {{ key }}: + + + + + diff --git a/client/src/components/WorkflowInvocationState/WorkflowInvocationSummary.test.js b/client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.test.js similarity index 67% rename from client/src/components/WorkflowInvocationState/WorkflowInvocationSummary.test.js rename to client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.test.js index eafa5a84b1a0..f4e069fb3cc7 100644 --- a/client/src/components/WorkflowInvocationState/WorkflowInvocationSummary.test.js +++ b/client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.test.js @@ -1,12 +1,13 @@ +import { createTestingPinia } from "@pinia/testing"; import { shallowMount } from "@vue/test-utils"; import { getLocalVue } from "tests/jest/helpers"; import invocationData from "../Workflow/test/json/invocation.json"; -import WorkflowInvocationSummary from "./WorkflowInvocationSummary"; +import WorkflowInvocationOverview from "./WorkflowInvocationOverview"; const localVue = getLocalVue(); -describe("WorkflowInvocationSummary.vue with terminal invocation", () => { +describe("WorkflowInvocationOverview.vue with terminal invocation", () => { let wrapper; let propsData; @@ -15,16 +16,17 @@ describe("WorkflowInvocationSummary.vue with terminal invocation", () => { invocation: invocationData, invocationAndJobTerminal: true, invocationSchedulingTerminal: true, + jobStatesSummary: {}, }; - wrapper = shallowMount(WorkflowInvocationSummary, { + wrapper = shallowMount(WorkflowInvocationOverview, { propsData, localVue, + pinia: createTestingPinia(), }); }); - it("displays report links", async () => { + it("displays pdf report links", async () => { expect(wrapper.find(".invocation-pdf-link").exists()).toBeTruthy(); - expect(wrapper.find(".invocation-report-link").exists()).toBeTruthy(); }); it("doesn't show cancel invocation button", async () => { @@ -32,7 +34,7 @@ describe("WorkflowInvocationSummary.vue with terminal invocation", () => { }); }); -describe("WorkflowInvocationSummary.vue with invocation scheduling running", () => { +describe("WorkflowInvocationOverview.vue with invocation scheduling running", () => { let wrapper; let propsData; let store; @@ -42,17 +44,17 @@ describe("WorkflowInvocationSummary.vue with invocation scheduling running", () invocation: invocationData, invocationAndJobTerminal: false, invocationSchedulingTerminal: false, + jobStatesSummary: {}, }; - wrapper = shallowMount(WorkflowInvocationSummary, { + wrapper = shallowMount(WorkflowInvocationOverview, { store, propsData, localVue, }); }); - it("does not display report links", async () => { + it("does not display pdf report links", async () => { expect(wrapper.find(".invocation-pdf-link").exists()).toBeFalsy(); - expect(wrapper.find(".invocation-report-link").exists()).toBeFalsy(); }); it("shows cancel invocation button", async () => { diff --git a/client/src/components/WorkflowInvocationState/WorkflowInvocationSummary.vue b/client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.vue similarity index 54% rename from client/src/components/WorkflowInvocationState/WorkflowInvocationSummary.vue rename to client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.vue index 28ddeaa0eae1..f327d03a6ed1 100644 --- a/client/src/components/WorkflowInvocationState/WorkflowInvocationSummary.vue +++ b/client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.vue @@ -1,8 +1,11 @@ + + + + + + Invoked Workflow: "{{ getWorkflowName() }}" + + + + + + Invocations List + + + + + + + invoked + + + + History: + + + + + + + + Workflow Version: {{ workflowVersion + 1 }} + + + + + + + Edit + + + + Run + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/components/WorkflowInvocationState/WorkflowInvocationStep.vue b/client/src/components/WorkflowInvocationState/WorkflowInvocationStep.vue index bab804b2d879..4678418e6f5d 100644 --- a/client/src/components/WorkflowInvocationState/WorkflowInvocationStep.vue +++ b/client/src/components/WorkflowInvocationState/WorkflowInvocationStep.vue @@ -1,7 +1,13 @@ - + @@ -11,9 +17,16 @@ - + + + + - + + class="invocation-step-output-details" + :open="!isDataStep && inGraphView"> Output Datasets {{ name }} @@ -33,7 +47,8 @@ + class="invocation-step-output-collection-details" + :open="!isDataStep && inGraphView"> Output Dataset Collections {{ name }} @@ -41,9 +56,21 @@ - - Jobs - + + + Jobs (Click on any job to view its details) + + + This step has no jobs @@ -125,11 +153,15 @@ export default { invocation: Object, workflowStep: Object, workflow: Object, + graphStep: { type: Object, default: undefined }, + expanded: { type: Boolean, default: undefined }, + showingJobId: { type: String, default: null }, + inGraphView: { type: Boolean, default: false }, }, data() { return { - expanded: false, polling: null, + localExpanded: this.expanded === undefined ? false : this.expanded, }; }, computed: { @@ -138,6 +170,19 @@ export default { isReady() { return this.invocation.steps.length > 0; }, + // a computed property that assesses whether we have an expanded prop + computedExpanded: { + get() { + return this.expanded === undefined ? this.localExpanded : this.expanded; + }, + set(value) { + if (this.expanded === undefined) { + this.localExpanded = value; + } else { + this.$emit("update:expanded", value); + } + }, + }, invocationStepId() { return this.step?.id; }, @@ -164,8 +209,11 @@ export default { this.fetchWorkflowForInstanceId(this.workflowStep.workflow_id); } }, + showJob(id) { + this.$emit("show-job", id); + }, toggleStep() { - this.expanded = !this.expanded; + this.computedExpanded = !this.computedExpanded; }, toolProps(stepIndex) { const workflowStep = this.workflow.steps[stepIndex]; @@ -189,3 +237,11 @@ export default { }, }; + + diff --git a/client/src/composables/useInvocationGraph.ts b/client/src/composables/useInvocationGraph.ts new file mode 100644 index 000000000000..f9416c471119 --- /dev/null +++ b/client/src/composables/useInvocationGraph.ts @@ -0,0 +1,267 @@ +import { type IconDefinition, library } from "@fortawesome/fontawesome-svg-core"; +import { + faCheckCircle, + faClock, + faExclamationTriangle, + faForward, + faPause, + faSpinner, + faTrash, +} from "@fortawesome/free-solid-svg-icons"; +import Vue, { computed, type Ref, ref } from "vue"; + +import { stepJobsSummaryFetcher, type StepJobSummary, type WorkflowInvocationElementView } from "@/api/invocations"; +import { isWorkflowInput } from "@/components/Workflow/constants"; +import { fromSimple } from "@/components/Workflow/Editor/modules/model"; +import { getWorkflowFull } from "@/components/Workflow/workflows.services"; +import type { Step } from "@/stores/workflowStepStore"; +import type { Workflow } from "@/stores/workflowStore"; +import { rethrowSimple } from "@/utils/simple-error"; + +import { provideScopedWorkflowStores } from "./workflowStores"; + +export interface GraphStep extends Step { + state?: + | "new" + | "upload" + | "waiting" + | "queued" + | "running" + | "ok" + | "error" + | "deleted" + | "hidden" + | "setting_metadata" + | "paused" + | "skipped"; + jobs: StepJobSummary["states"]; + headerClass?: Record; + headerIcon?: IconDefinition; + headerIconSpin?: boolean; +} +interface InvocationGraph extends Workflow { + steps: { [index: number]: GraphStep }; +} + +/** Classes for states' icons */ +export const iconClasses: Record = { + ok: { icon: faCheckCircle, class: "text-success" }, + error: { icon: faExclamationTriangle, class: "text-danger" }, + paused: { icon: faPause, class: "text-primary" }, + running: { icon: faSpinner, spin: true }, + new: { icon: faClock }, + waiting: { icon: faClock }, + queued: { icon: faClock }, + deleted: { icon: faTrash, class: "text-danger" }, + skipped: { icon: faForward, class: "text-warning" }, +}; + +/** Only one job needs to be in one of these states for the graph step to be in that state */ +const SINGLE_INSTANCE_STATES = ["error", "running", "paused"]; +/** All jobs need to be in one of these states for the graph step to be in that state */ +const ALL_INSTANCES_STATES = ["deleted", "skipped", "new", "queued"]; + +/** Composable that creates a readonly invocation graph and loads it onto a workflow editor canvas for display. + * @param invocation - The invocation to display in graph view + * @param workflowId - The id of the workflow that was invoked + */ +export function useInvocationGraph( + invocation: Ref, + workflowId: string | undefined, + workflowVersion: number | undefined +) { + library.add(faCheckCircle, faClock, faExclamationTriangle, faForward, faPause, faSpinner, faTrash); + + const steps = ref<{ [index: string]: GraphStep }>({}); + const storeId = computed(() => `invocation-${invocation.value.id}`); + + /** The full invocation mapped onto the original workflow */ + const invocationGraph = ref(null); + + /** The workflow that was invoked */ + const loadedWorkflow = ref(null); + + provideScopedWorkflowStores(storeId); + + async function loadInvocationGraph() { + try { + if (!workflowId) { + throw new Error("Workflow Id is not defined"); + } + if (workflowVersion === undefined) { + throw new Error("Workflow Version is not defined"); + } + + // initialize the original full workflow and invocation graph refs (only on the first load) + if (!loadedWorkflow.value) { + loadedWorkflow.value = await getWorkflowFull(workflowId, workflowVersion); + } + if (!invocationGraph.value) { + invocationGraph.value = { + ...loadedWorkflow.value, + id: storeId.value, + steps: null, + }; + } + + // get the job summary for each step in the invocation + const { data: stepJobsSummary } = await stepJobsSummaryFetcher({ invocation_id: invocation.value.id }); + + /** The original steps of the workflow */ + const originalSteps: Record = { ...loadedWorkflow.value.steps }; + + // for each step in the workflow, store the state and status of jobs + for (let i = 0; i < Object.keys(originalSteps).length; i++) { + /** An invocation graph step */ + const graphStepFromWfStep = { ...originalSteps[i] } as GraphStep; + + /** The type of the step (subworkflow, input, tool, etc.) */ + let type; + if (graphStepFromWfStep.type === "subworkflow") { + type = "subworkflow"; + } else if (isWorkflowInput(graphStepFromWfStep.type)) { + type = "input"; + } + + /** The raw invocation step */ + const invocationStep = invocation.value.steps[i]; + + if (type !== "input") { + // there is an invocation step for this workflow step + if (invocationStep) { + /** The `populated_state` for this graph step. (This may or may not be used to + * derive the `state` for this invocation graph step) */ + let populatedState; + + if (type === "subworkflow") { + // if the step is a subworkflow, get the populated state from the invocation step + populatedState = invocationStep.state || undefined; + + /* TODO: + Note that subworkflows are often in the `scheduled` state regardless of whether + their output is successful or not. One good way to visually show if a subworkflow was + successful is to set `graphStepFromWfStep.state = subworkflow.output?.state`. + */ + } + + // First, try setting the state of the graph step based on its jobs' states or the populated state + else { + /** The step job summary for the invocation step (based on its job id) */ + const invocationStepSummary = stepJobsSummary.find((stepJobSummary: StepJobSummary) => { + if (stepJobSummary.model === "ImplicitCollectionJobs") { + return stepJobSummary.id === invocationStep.implicit_collection_jobs_id; + } else { + return stepJobSummary.id === invocationStep.job_id; + } + }); + + if (invocationStepSummary) { + // the step is not a subworkflow, get the populated state from the invocation step summary + populatedState = invocationStepSummary.populated_state; + + if (invocationStepSummary.states) { + const statesForThisStep = Object.keys(invocationStepSummary.states); + // set the state of the graph step based on the job states for this step + graphStepFromWfStep.state = getStepStateFromJobStates(statesForThisStep); + } + // now store the job states for this step in the graph step + graphStepFromWfStep.jobs = invocationStepSummary.states; + } else { + // TODO: There is no summary for this step's `job_id`; what does this mean? + graphStepFromWfStep.state = "waiting"; + } + } + + // If the state still hasn't been set, set it based on the populated state + if (!graphStepFromWfStep.state) { + if (populatedState === "scheduled" || populatedState === "ready") { + graphStepFromWfStep.state = "queued"; + } else if (populatedState === "resubmitted") { + graphStepFromWfStep.state = "new"; + } else if (populatedState === "failed") { + graphStepFromWfStep.state = "error"; + } else if (populatedState === "deleting") { + graphStepFromWfStep.state = "deleted"; + } else if (populatedState && !["stop", "stopped"].includes(populatedState)) { + graphStepFromWfStep.state = populatedState as GraphStep["state"]; + } + } + } + + // there is no invocation step for this workflow step, it is probably queued + else { + graphStepFromWfStep.state = "queued"; + } + + /** Setting the header class for the graph step */ + graphStepFromWfStep.headerClass = { + "node-header-invocation": true, + [`header-${graphStepFromWfStep.state}`]: !!graphStepFromWfStep.state, + }; + // TODO: maybe a different one for inputs? Currently they have no state either. + + /** Setting the header icon for the graph step */ + if (graphStepFromWfStep.state) { + graphStepFromWfStep.headerIcon = iconClasses[graphStepFromWfStep.state]?.icon; + graphStepFromWfStep.headerIconSpin = iconClasses[graphStepFromWfStep.state]?.spin; + } + } + + // update the invocation graph steps object + Vue.set(steps.value, i, graphStepFromWfStep); + } + + invocationGraph.value!.steps = { ...steps.value }; + + // Load the invocation graph into the editor every time + await fromSimple(storeId.value, invocationGraph.value as any); + } catch (e) { + rethrowSimple(e); + } + } + + /** Given the job states for a step, if the states fall into a single instance state + * or all instances state, return the state of the step. + * @param jobStates - The job states for a step + * @returns The state for the graph step or `undefined` if the states don't match any + * single instance state or all instances state + * */ + function getStepStateFromJobStates(jobStates: string[]): GraphStep["state"] | undefined { + for (const state of SINGLE_INSTANCE_STATES) { + if (jobStates.includes(state)) { + return state as GraphStep["state"]; + } + } + for (const state of ALL_INSTANCES_STATES) { + if (jobStates.every((jobState) => jobState === state)) { + return state as GraphStep["state"]; + } + } + return undefined; + } + + // TODO: Maybe we can use this to layout the graph after the steps are loaded (for neatness)? + // async function layoutGraph() { + // const newSteps = await autoLayout(storeId.value, steps.value); + // if (newSteps) { + // newSteps?.map((step: any) => stepStore.updateStep(step)); + // // Object.assign(steps.value, {...steps.value, ...stepStore.steps}); + // Object.keys(steps.value).forEach((key) => { + // steps.value[key] = { ...steps.value[key], ...(stepStore.steps[key] as GraphStep) }; + // }); + // } + // invocationGraph.value!.steps = steps.value; + // await fromSimple(storeId.value, invocationGraph.value as any); + // } + + return { + /** An id used to scope the store to the invocation's id */ + storeId, + /** The steps of the invocation graph */ + steps, + /** Fetches the original workflow structure (once) and the step job summaries for each step in the invocation, + * and displays the job states on the workflow graph steps. + */ + loadInvocationGraph, + }; +} diff --git a/client/src/entry/analysis/router.js b/client/src/entry/analysis/router.js index dec661825916..0f5d17685574 100644 --- a/client/src/entry/analysis/router.js +++ b/client/src/entry/analysis/router.js @@ -545,7 +545,10 @@ export function getRouter(Galaxy) { { path: "workflows/invocations/:invocationId", component: WorkflowInvocationState, - props: true, + props: (route) => ({ + invocationId: route.params.invocationId, + isFullPage: true, + }), }, { path: "workflows/list", diff --git a/client/src/stores/workflowStore.ts b/client/src/stores/workflowStore.ts index aca2e887f5e7..b757fe868b20 100644 --- a/client/src/stores/workflowStore.ts +++ b/client/src/stores/workflowStore.ts @@ -11,6 +11,7 @@ export interface Workflow { steps: Steps; step_count?: number; latest_id?: string; + version: number; } export const useWorkflowStore = defineStore("workflowStore", () => { diff --git a/client/src/style/scss/base.scss b/client/src/style/scss/base.scss index 6532c64b2c8d..4cfacb2b380c 100644 --- a/client/src/style/scss/base.scss +++ b/client/src/style/scss/base.scss @@ -189,6 +189,21 @@ $galaxy-state-bg: ( border-style: dotted; } +.node-header-invocation { + color: $text-color; + @each $state in map-keys($galaxy-state-bg) { + &.header-#{$state} { + background-color: map-get($galaxy-state-bg, $state) !important; + } + } + &.header-paused { + background-color: $state-info-bg !important; + } + &.header-skipped { + background-color: map-get($galaxy-state-bg, "hidden") !important; + } +} + // Extra label colors .badge-beta { @extend .badge-warning; diff --git a/client/src/utils/navigation/navigation.yml b/client/src/utils/navigation/navigation.yml index bbd7621b5450..28aee0709b20 100644 --- a/client/src/utils/navigation/navigation.yml +++ b/client/src/utils/navigation/navigation.yml @@ -844,6 +844,7 @@ invocations: toggle_invocation_details: '.toggle-invocation-details' progress_steps_note: '.workflow-invocation-state-component .steps-progress .progressNote' progress_jobs_note: '.workflow-invocation-state-component .jobs-progress .progressNote' + hide_invocation_graph: '.workflow-invocation-state-component [data-description="hide invocation graph"]' invocation_tab: type: xpath selector: '//a[text()="${label}"]' diff --git a/client/src/utils/navigation/schema.ts b/client/src/utils/navigation/schema.ts index 8fe3a0b8a4df..649462897c25 100644 --- a/client/src/utils/navigation/schema.ts +++ b/client/src/utils/navigation/schema.ts @@ -499,6 +499,7 @@ interface Rootinvocations extends Component { toggle_invocation_details: SelectorTemplate; progress_steps_note: SelectorTemplate; progress_jobs_note: SelectorTemplate; + hide_invocation_graph: SelectorTemplate; invocation_tab: SelectorTemplate; invocation_details_tab: SelectorTemplate; input_details_title: SelectorTemplate; diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index ded3c626d14d..0afef8cf20f4 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -8779,6 +8779,7 @@ def to_dict(self, view="collection", value_mapper=None, step_details=False, lega v["state"] = None steps.append(v) else: + v["implicit_collection_jobs_id"] = step.implicit_collection_jobs_id steps.append(v) rval["steps"] = steps diff --git a/lib/galaxy/schema/invocation.py b/lib/galaxy/schema/invocation.py index 44741831c8f1..55bb438ae531 100644 --- a/lib/galaxy/schema/invocation.py +++ b/lib/galaxy/schema/invocation.py @@ -379,6 +379,11 @@ class InvocationStep(Model, WithModelClass): title="Jobs", description="Jobs associated with the workflow invocation step.", ) + implicit_collection_jobs_id: Optional[EncodedDatabaseIdField] = Field( + None, + title="Implicit Collection Jobs ID", + description="The implicit collection job ID associated with the workflow invocation step.", + ) class InvocationReport(Model, WithModelClass): @@ -581,10 +586,20 @@ class InvocationStepJobsResponseStepModel(InvocationJobsSummaryBaseModel): class InvocationStepJobsResponseJobModel(InvocationJobsSummaryBaseModel): model: JOB_MODEL_CLASS + id: EncodedDatabaseIdField = Field( + default=..., + title="ID", + description="The encoded ID of the job.", + ) class InvocationStepJobsResponseCollectionJobsModel(InvocationJobsSummaryBaseModel): model: IMPLICIT_COLLECTION_JOBS_MODEL_CLASS + id: EncodedDatabaseIdField = Field( + default=..., + title="ID", + description="The encoded ID of the collection job.", + ) class CreateInvocationFromStore(StoreContentSource): diff --git a/lib/galaxy_test/selenium/test_workflow_invocation_details.py b/lib/galaxy_test/selenium/test_workflow_invocation_details.py index 8062bfcba49c..6d98905b8528 100644 --- a/lib/galaxy_test/selenium/test_workflow_invocation_details.py +++ b/lib/galaxy_test/selenium/test_workflow_invocation_details.py @@ -42,12 +42,12 @@ def assert_progress_steps_note_contains(text): assert_progress_steps_note_contains("3 of 3 steps successfully scheduled.") assert "2 of 2 jobs complete." in invocations.progress_jobs_note.wait_for_visible().text - invocations.invocation_tab(label="Details").wait_for_and_click() - invocations.invocation_details_tab(label="Inputs").wait_for_and_click() + invocations.invocation_tab(label="Inputs").wait_for_and_click() invocations.input_details_title(label="text_input").wait_for_visible() assert "Test_Dataset" in invocations.input_details_name(label="text_input").wait_for_visible().text - invocations.invocation_details_tab(label="Steps").wait_for_and_click() + invocations.invocation_tab(label="Overview").wait_for_and_click() + invocations.hide_invocation_graph.wait_for_and_click() assert "Step 1: text_input" in invocations.step_title(order_index="0").wait_for_visible().text assert "Step 2: split_up" in invocations.step_title(order_index="1").wait_for_visible().text assert "Step 3: paired" in invocations.step_title(order_index="2").wait_for_visible().text
{{ showingJobId }}
This workflow will generate results in multiple histories. You can observe progress in the - history multi-view. + history multi-view.
This workflow will generate results in a new history. - Switch to that history now. + Switch to that history now.
You can check the status of queued jobs and view the resulting data the History panel.
+ View all of your workflow invocations in the + Invocations List. +