Merge branch 'release_24.2' into dev

This commit is contained in:
Nicola Soranzo
2024-12-10 21:17:36 +00:00
30 changed files with 388 additions and 184 deletions
+10 -8
View File
@@ -2,26 +2,28 @@ name: Update PR title
on:
pull_request_target:
types: [opened, edited]
branches:
- "release_**"
types: [opened, edited, reopened]
jobs:
update-title:
if: github.event.action != 'edited' || github.event.changes.base.ref.from != ''
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v4
- name: Update PR title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
TARGET_BRANCH: "${{ github.base_ref }}"
PR_TITLE: "${{ github.event.pull_request.title }}"
REPO: "${{ github.repository }}"
run: |
VERSION=$(echo $TARGET_BRANCH | grep -oP '\d+\.\d+')
if [[ -n "$VERSION" && ! "$PR_TITLE" =~ ^\[$VERSION\] ]]; then
NEW_TITLE="[$VERSION] $PR_TITLE"
gh pr edit $PR_NUMBER --title "$NEW_TITLE"
VERSION=$(echo $TARGET_BRANCH | grep -oP '^release_\K\d+.\d+$' || true)
NEW_TITLE=$(echo "$PR_TITLE" | sed -E "s/\[[0-9]+\.[0-9]+\] //")
if [[ -n "$VERSION" ]]; then
NEW_TITLE="[$VERSION] $NEW_TITLE"
fi
if [[ "$NEW_TITLE" != "$PR_TITLE" ]]; then
gh pr edit $PR_NUMBER --repo "$REPO" --title "$NEW_TITLE"
fi
+2 -2
View File
@@ -4,9 +4,9 @@
import { BProgress, BProgressBar } from "bootstrap-vue";
interface Props {
total: number;
total?: number;
note: string;
loading: boolean;
loading?: boolean;
okCount?: number;
runningCount?: number;
newCount?: number;
+1 -14
View File
@@ -118,7 +118,7 @@ const showHelpForum = computed(() => isConfigLoaded.value && config.value.enable
<template>
<div class="position-relative">
<div class="underlay sticky-top" />
<div class="ui-form-header-underlay sticky-top" />
<div class="tool-header sticky-top bg-secondary px-2 py-1 rounded">
<div class="d-flex justify-content-between">
<div class="py-1 d-flex flex-wrap flex-gapx-1">
@@ -209,19 +209,6 @@ const showHelpForum = computed(() => isConfigLoaded.value && config.value.enable
</template>
<style lang="scss" scoped>
@import "scss/theme/blue.scss";
.underlay::after {
content: "";
display: block;
position: absolute;
top: -$margin-h;
left: -0.5rem;
right: -0.5rem;
height: 50px;
background: linear-gradient($white 75%, change-color($white, $alpha: 0));
}
.fa-wrench {
cursor: unset;
}
@@ -308,7 +308,7 @@ export default {
ensureParametersSet();
stateStore.activeNodeId = null;
activityBar.value?.setActiveSideBar("workflow-editor-attributes");
if (args.highlight) {
if (args?.highlight) {
this.highlightAttribute = args.highlight;
}
}
@@ -327,6 +327,7 @@ Examples of RDM repositories include [Zenodo](https://zenodo.org/), [Invenio RDM
<BCard
v-for="plugin in exportPlugins"
:key="plugin.id"
:data-invocation-export-type="plugin.id"
class="wizard-selection-card"
:border-variant="exportData.exportPluginFormat === plugin.id ? 'primary' : 'default'"
@click="exportData.exportPluginFormat = plugin.id">
@@ -350,6 +351,7 @@ Examples of RDM repositories include [Zenodo](https://zenodo.org/), [Invenio RDM
<BCard
v-for="target in exportDestinationTargets"
:key="target.destination"
:data-invocation-export-destination="target.destination"
:border-variant="exportData.destination === target.destination ? 'primary' : 'default'"
:header-bg-variant="exportData.destination === target.destination ? 'primary' : 'default'"
:header-text-variant="exportData.destination === target.destination ? 'white' : 'default'"
@@ -34,8 +34,6 @@ interface Props {
workflow: Workflow;
/** Whether the invocation is terminal */
isTerminal: boolean;
/** Whether the invocation is scheduled */
isScheduled: boolean;
/** The zoom level for the graph */
zoom?: number;
/** Whether to show the minimap */
@@ -1,6 +1,7 @@
<template>
<div>
<div v-if="isConfigLoaded">
<div class="ui-form-header-underlay sticky-top" />
<div v-if="isConfigLoaded" class="sticky-top">
<BAlert v-if="!canRunOnHistory" variant="warning" show>
<span v-localize>
The workflow cannot run because the current history is immutable. Please select a different history
@@ -66,17 +66,13 @@ const localVue = getLocalVue();
* @param version The version of the component to mount (`run_form` or `invocation` view)
* @param ownsWorkflow Whether the user owns the workflow associated with the invocation
* @param unimportableWorkflow Whether the workflow import should fail
* @returns The wrapper object, and the mockRouter object
* @returns The wrapper object
*/
async function mountWorkflowNavigationTitle(
version: "run_form" | "invocation",
ownsWorkflow = true,
unimportableWorkflow = false
) {
const mockRouter = {
push: jest.fn(),
};
let workflowId: string;
let invocation;
if (version === "invocation") {
@@ -96,9 +92,6 @@ async function mountWorkflowNavigationTitle(
workflowId,
},
localVue,
mocks: {
$router: mockRouter,
},
pinia: createTestingPinia(),
});
@@ -107,7 +100,7 @@ async function mountWorkflowNavigationTitle(
username: ownsWorkflow ? WORKFLOW_OWNER : OTHER_USER,
});
return { wrapper, mockRouter };
return { wrapper };
}
describe("WorkflowNavigationTitle renders", () => {
@@ -116,10 +109,9 @@ describe("WorkflowNavigationTitle renders", () => {
const heading = wrapper.find(SELECTORS.WORKFLOW_HEADING);
expect(heading.text()).toContain(`Invoked Workflow: ${SAMPLE_WORKFLOW.name}`);
expect(heading.text()).toContain(`(version: ${SAMPLE_WORKFLOW.version + 1})`);
expect(heading.text()).toContain(`(Version: ${SAMPLE_WORKFLOW.version + 1})`);
const actionsGroup = wrapper.find(SELECTORS.ACTIONS_BUTTON_GROUP);
const runButton = actionsGroup.find(SELECTORS.ROUTE_TO_RUN_BUTTON);
const runButton = wrapper.find(SELECTORS.ROUTE_TO_RUN_BUTTON);
expect(runButton.attributes("title")).toContain("Rerun");
expect(runButton.attributes("title")).toContain(SAMPLE_WORKFLOW.name);
});
@@ -129,24 +121,19 @@ describe("WorkflowNavigationTitle renders", () => {
const heading = wrapper.find(SELECTORS.WORKFLOW_HEADING);
expect(heading.text()).toContain(`Workflow: ${SAMPLE_WORKFLOW.name}`);
expect(heading.text()).toContain(`(version: ${SAMPLE_WORKFLOW.version + 1})`);
expect(heading.text()).toContain(`(Version: ${SAMPLE_WORKFLOW.version + 1})`);
const actionsGroup = wrapper.find(SELECTORS.ACTIONS_BUTTON_GROUP);
const runButton = actionsGroup.find(SELECTORS.EXECUTE_WORKFLOW_BUTTON);
const runButton = wrapper.find(SELECTORS.EXECUTE_WORKFLOW_BUTTON);
expect(runButton.attributes("title")).toContain("Run");
});
it("edit button if user owns the workflow", async () => {
async function findAndClickEditButton(version: "invocation" | "run_form") {
const { wrapper, mockRouter } = await mountWorkflowNavigationTitle(version);
const { wrapper } = await mountWorkflowNavigationTitle(version);
const actionsGroup = wrapper.find(SELECTORS.ACTIONS_BUTTON_GROUP);
const editButton = actionsGroup.find(SELECTORS.EDIT_WORKFLOW_BUTTON);
await editButton.trigger("click");
await flushPromises();
expect(mockRouter.push).toHaveBeenCalledTimes(1);
expect(mockRouter.push).toHaveBeenCalledWith(
expect(editButton.attributes("to")).toBe(
`/workflows/edit?id=${SAMPLE_WORKFLOW.id}&version=${SAMPLE_WORKFLOW.version}`
);
}
@@ -5,7 +5,6 @@ import { BAlert, BButton, BButtonGroup } from "bootstrap-vue";
import { storeToRefs } from "pinia";
import { computed, ref } from "vue";
import { RouterLink } from "vue-router";
import { useRouter } from "vue-router/composables";
import { isRegisteredUser } from "@/api";
import type { WorkflowInvocationElementView } from "@/api/invocations";
@@ -21,8 +20,6 @@ import AsyncButton from "../Common/AsyncButton.vue";
import ButtonSpinner from "../Common/ButtonSpinner.vue";
import WorkflowRunButton from "./WorkflowRunButton.vue";
const router = useRouter();
interface Props {
invocation?: WorkflowInvocationElementView;
workflowId: string;
@@ -98,16 +95,14 @@ const workflowImportTitle = computed(() => {
<BAlert v-if="error" variant="danger" show>{{ error }}</BAlert>
<div class="position-relative">
<div v-if="workflow" class="ui-portlet-section">
<div class="d-flex portlet-header align-items-center">
<div class="position-relative mb-2">
<div v-if="workflow" class="bg-secondary px-2 py-1 rounded">
<div class="d-flex align-items-center flex-gapx-1">
<div class="flex-grow-1" data-description="workflow heading">
<div class="px-1">
<FontAwesomeIcon :icon="faSitemap" />
<b class="mx-1">
{{ props.invocation ? "Invoked " : "" }}Workflow: {{ getWorkflowName() }}
</b>
<i>(version: {{ workflow.version + 1 }})</i>
<div>
<FontAwesomeIcon :icon="faSitemap" fixed-width />
<b> {{ props.invocation ? "Invoked " : "" }}Workflow: {{ getWorkflowName() }} </b>
<span>(Version: {{ workflow.version + 1 }})</span>
</div>
</div>
<BButtonGroup>
@@ -122,7 +117,7 @@ const workflowImportTitle = computed(() => {
"
variant="link"
:disabled="workflow.deleted"
@click="router.push(`/workflows/edit?id=${workflow.id}&version=${workflow.version}`)">
:to="`/workflows/edit?id=${workflow.id}&version=${workflow.version}`">
<FontAwesomeIcon :icon="faEdit" fixed-width />
</BButton>
<AsyncButton
@@ -138,30 +133,30 @@ const workflowImportTitle = computed(() => {
</AsyncButton>
<slot name="workflow-title-actions" />
<ButtonSpinner
v-if="!props.invocation"
id="run-workflow"
data-description="execute workflow button"
:wait="runWaiting"
:disabled="runDisabled"
size="sm"
title="Run"
@onClick="emit('on-execute')" />
<WorkflowRunButton
v-else
:id="workflow.id"
data-description="route to workflow run button"
variant="link"
:title="
!workflow.deleted
? `<b>Rerun</b><br>${getWorkflowName()}`
: 'This workflow has been deleted.'
"
:disabled="workflow.deleted"
full
:version="workflow.version" />
</BButtonGroup>
<ButtonSpinner
v-if="!props.invocation"
id="run-workflow"
data-description="execute workflow button"
:wait="runWaiting"
:disabled="runDisabled"
size="sm"
title="Run Workflow"
@onClick="emit('on-execute')" />
<WorkflowRunButton
v-else
:id="workflow.id"
data-description="route to workflow run button"
variant="link"
:title="
!workflow.deleted
? `<b>Rerun</b><br>${getWorkflowName()}`
: 'This workflow has been deleted.'
"
:disabled="workflow.deleted"
force
full
:version="workflow.version" />
</div>
</div>
<div v-if="props.success" class="donemessagelarge">
@@ -1,4 +1,5 @@
import { mount } from "@vue/test-utils";
import { mount, type Wrapper } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { getLocalVue } from "tests/jest/helpers";
import { generateRandomString } from "./testUtils";
@@ -11,22 +12,69 @@ const WORKFLOW_ID = generateRandomString();
const WORKFLOW_VERSION = 1;
const WORKFLOW_RUN_BUTTON_SELECTOR = `[data-workflow-run="${WORKFLOW_ID}"]`;
async function mountWorkflowRunButton(props?: { id: string; version: number; full?: boolean }) {
function getPath() {
return `/workflows/run?id=${WORKFLOW_ID}&version=${WORKFLOW_VERSION}`;
}
async function mountWorkflowRunButton(
props?: { id: string; version: number; full?: boolean; force?: boolean },
currentPath?: string
) {
const mockRouter = {
push: jest.fn(),
afterEach: jest.fn(),
currentRoute: {
fullPath: currentPath || getPath(),
},
};
const wrapper = mount(WorkflowRunButton as object, {
propsData: { ...props },
localVue,
mocks: {
$router: mockRouter,
},
});
return { wrapper };
return { wrapper, mockRouter };
}
async function clickButton(button: Wrapper<Vue>) {
// Remove the href and target attributes to prevent navigation error
// This is done because the `BButton` has a `:to` prop as well as an `@click` event
button.element.setAttribute("href", "javascript:void(0)");
button.element.setAttribute("target", "");
await button.trigger("click");
await flushPromises();
}
describe("WorkflowRunButton.vue", () => {
it("should render button with icon and route", async () => {
const { wrapper } = await mountWorkflowRunButton({ id: WORKFLOW_ID, version: WORKFLOW_VERSION });
it("should render button with icon and route to it", async () => {
const { wrapper, mockRouter } = await mountWorkflowRunButton({ id: WORKFLOW_ID, version: WORKFLOW_VERSION });
const button = wrapper.find(WORKFLOW_RUN_BUTTON_SELECTOR);
expect(button.attributes("title")).toBe("Run workflow");
expect(button.text()).toBe("");
expect(button.attributes("href")).toBe(`/workflows/run?id=${WORKFLOW_ID}&version=${WORKFLOW_VERSION}`);
expect(button.attributes("href")).toBe(getPath());
await clickButton(button);
// Check that router.push was called with the correct arguments
expect(mockRouter.push).toHaveBeenCalledWith(getPath());
});
it("should force route if on the same path", async () => {
const { wrapper, mockRouter } = await mountWorkflowRunButton(
{ id: WORKFLOW_ID, version: WORKFLOW_VERSION, force: true },
getPath()
);
const button = wrapper.find(WORKFLOW_RUN_BUTTON_SELECTOR);
await clickButton(button);
// Check that router.push was called with the correct arguments
expect(mockRouter.push).toHaveBeenCalledWith(getPath(), { force: true });
});
});
@@ -3,6 +3,7 @@ import { faPlay } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BButton } from "bootstrap-vue";
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router/composables";
interface Props {
id: string;
@@ -20,13 +21,22 @@ const props = withDefaults(defineProps<Props>(), {
variant: "primary",
});
const route = useRoute();
const router = useRouter();
const runPath = computed(
() => `/workflows/run?id=${props.id}${props.version !== undefined ? `&version=${props.version}` : ""}`
);
function forceRunPath() {
if (props.force) {
window.open(runPath.value);
function routeToPath() {
if (props.force && route.fullPath === runPath.value) {
// vue-router 4 supports a native force push with clean URLs,
// but we're using a __vkey__ bit as a workaround
// Only conditionally force to keep urls clean most of the time.
// @ts-ignore - monkeypatched router, drop with migration.
router.push(runPath.value, { force: true });
} else {
router.push(runPath.value);
}
}
</script>
@@ -42,7 +52,7 @@ function forceRunPath() {
class="text-decoration-none"
:disabled="disabled"
:to="runPath"
@click="forceRunPath">
@click="routeToPath">
<FontAwesomeIcon :icon="faPlay" fixed-width />
<span v-if="full" v-localize>Run</span>
@@ -1,5 +1,5 @@
<template>
<div ref="chartContainer" class="chart" :style="style"></div>
<div ref="chartContainer" :style="style"></div>
</template>
<script setup lang="ts">
@@ -9,15 +9,18 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
export interface VisSpec {
spec: VisualizationSpec;
width: string;
fillWidth?: boolean;
}
const props = withDefaults(defineProps<VisSpec>(), {
width: "100%",
fillWidth: true,
});
const style = computed(() => {
return { width: props.width };
if (props.fillWidth) {
return { width: "100%" };
}
return {};
});
const chartContainer = ref<HTMLDivElement | null>(null);
@@ -49,8 +52,3 @@ onBeforeUnmount(() => {
}
});
</script>
<style scoped>
.chart {
}
</style>
@@ -275,18 +275,9 @@ function itemToPieChartSpec(item: piechartData) {
type: "nominal",
legend: {
type: "symbol",
title: "", // should be item.category_title but it doesn't align right so just hide it
direction: "vertical",
titleAlign: "right",
padding: 10,
// rowPadding: 3,
labelOffset: 40,
// symbolOffset: 50,
labelLimit: 120,
// labelAlign: 'center',
columnPadding: 5,
// clipHeight: 20,
titleOrient: "top",
title: item.category_title,
titleFontSize: 16,
labelFontSize: 14,
},
},
tooltip: [
@@ -381,6 +372,10 @@ function getTimingInTitle(timing: string): string {
const timingInTitles = computed(() => {
return getTimingInTitle(timing.value);
});
const groupByInTitles = computed(() => {
return attributeToLabel[groupBy.value];
});
</script>
<template>
@@ -393,22 +388,22 @@ const timingInTitles = computed(() => {
<b-dropdown-item @click="timing = 'minutes'">{{ getTimingInTitle("minutes") }}</b-dropdown-item>
<b-dropdown-item @click="timing = 'hours'">{{ getTimingInTitle("hours") }}</b-dropdown-item>
</b-dropdown>
<b-dropdown right text="Group By: Tool">
<b-dropdown right :text="'Group By: ' + groupByInTitles">
<b-dropdown-item @click="groupBy = 'tool_id'">Tool</b-dropdown-item>
<b-dropdown-item @click="groupBy = 'step_id'">Workflow Step</b-dropdown-item>
</b-dropdown>
</BButtonGroup>
</BRow>
<BRow>
<BCol v-if="wallclockAggregate && wallclockAggregate.values">
<BCol v-if="wallclockAggregate && wallclockAggregate.values" class="text-center">
<h2 class="h-l truncate text-center">
Aggregate
<HelpText :for-title="true" uri="galaxy.jobs.metrics.walltime" text="Runtime Time" /> (in
{{ timingInTitles }})
</h2>
<VegaWrapper :spec="itemToPieChartSpec(wallclockAggregate)" />
<VegaWrapper :spec="itemToPieChartSpec(wallclockAggregate)" :fill-width="false" />
</BCol>
<BCol v-if="allocatedCoreTimeAggregate && allocatedCoreTimeAggregate.values">
<BCol v-if="allocatedCoreTimeAggregate && allocatedCoreTimeAggregate.values" class="text-center">
<h2 class="h-l truncate text-center">
Aggregate
<HelpText
@@ -417,14 +412,14 @@ const timingInTitles = computed(() => {
text="Allocated Core Time" />
(in {{ timingInTitles }})
</h2>
<VegaWrapper :spec="itemToPieChartSpec(allocatedCoreTimeAggregate)" />
<VegaWrapper :spec="itemToPieChartSpec(allocatedCoreTimeAggregate)" :fill-width="false" />
</BCol>
</BRow>
<BRow v-for="({ spec, item }, key) in metrics" :key="key">
<BCol>
<h2 class="h-l truncate text-center">
<span v-if="item.helpTerm">
<HelpText :for-title="true" :uri="item.helpTerm" :text="key" />
<HelpText :for-title="true" :uri="item.helpTerm" :text="`${key}`" />
</span>
<span v-else>
{{ key }}
@@ -15,7 +15,6 @@ import InvocationMessage from "@/components/WorkflowInvocationState/InvocationMe
interface Props {
invocation: WorkflowInvocationElementView;
invocationAndJobTerminal: boolean;
invocationSchedulingTerminal: boolean;
isFullPage?: boolean;
isSubworkflow?: boolean;
}
@@ -62,7 +61,6 @@ const uniqueMessages = computed(() => {
:invocation="invocation"
:workflow="workflow"
:is-terminal="invocationAndJobTerminal"
:is-scheduled="invocationSchedulingTerminal"
:is-full-page="isFullPage"
:show-minimap="isFullPage" />
</div>
@@ -10,7 +10,6 @@ import { useInvocationStore } from "@/stores/invocationStore";
import { useWorkflowStore } from "@/stores/workflowStore";
import { errorMessageAsString } from "@/utils/simple-error";
import { cancelWorkflowScheduling } from "./services";
import {
errorCount as jobStatesSummaryErrorCount,
isTerminal,
@@ -53,6 +52,7 @@ const stepStatesInterval = ref<any>(undefined);
const jobStatesInterval = ref<any>(undefined);
const invocationLoaded = ref(false);
const errorMessage = ref<string | null>(null);
const cancellingInvocation = ref(false);
// after the report tab is first activated, no longer lazy-render it from then on
const reportActive = ref(false);
@@ -199,6 +199,16 @@ watch(
const storeId = computed(() => (invocation.value ? `invocation-${invocation.value.id}` : undefined));
watch(
() => invocationSchedulingTerminal.value,
async (newVal, oldVal) => {
if (oldVal && !newVal) {
// If the invocation was terminal and now is not, start polling again
await pollStepStatesUntilTerminal();
}
}
);
onUnmounted(() => {
clearTimeout(stepStatesInterval.value);
clearTimeout(jobStatesInterval.value);
@@ -219,11 +229,16 @@ async function pollJobStatesUntilTerminal() {
function onError(e: any) {
console.error(e);
}
function onCancel() {
emit("invocation-cancelled");
}
function cancelWorkflowSchedulingLocal() {
cancelWorkflowScheduling(props.invocationId).then(onCancel).catch(onError);
async function onCancel() {
try {
cancellingInvocation.value = true;
await invocationStore.cancelWorkflowScheduling(props.invocationId);
} catch (e) {
onError(e);
} finally {
emit("invocation-cancelled");
cancellingInvocation.value = false;
}
}
</script>
@@ -243,6 +258,7 @@ function cancelWorkflowSchedulingLocal() {
size="sm"
class="text-decoration-none"
variant="link"
:disabled="cancellingInvocation || invocationState == 'cancelling'"
@click="onCancel">
<FontAwesomeIcon :icon="faSquare" fixed-width />
Cancel
@@ -301,9 +317,7 @@ function cancelWorkflowSchedulingLocal() {
:invocation="invocation"
:is-full-page="props.isFullPage"
:invocation-and-job-terminal="invocationAndJobTerminal"
:invocation-scheduling-terminal="invocationSchedulingTerminal"
:is-subworkflow="isSubworkflow"
@invocation-cancelled="cancelWorkflowSchedulingLocal" />
:is-subworkflow="isSubworkflow" />
</BTab>
<BTab v-if="!isSubworkflow" title="Steps" lazy>
<WorkflowInvocationSteps
@@ -334,7 +348,7 @@ function cancelWorkflowSchedulingLocal() {
</template>
<InvocationReport v-if="invocationStateSuccess" :invocation-id="invocation.id" />
</BTab>
<BTab title="Export" lazy>
<BTab title="Export" title-item-class="invocation-export-tab" lazy>
<div v-if="invocationAndJobTerminal">
<WorkflowInvocationExportOptions :invocation-id="invocation.id" />
</div>
@@ -1,14 +0,0 @@
import axios from "axios";
import { getRootFromIndexLink } from "onload";
const getUrl = (path) => getRootFromIndexLink() + path;
export function getInvocationJobsSummary(invocationId) {
const url = getUrl(`api/invocations/${invocationId}/jobs_summary`);
return axios.get(url);
}
export function cancelWorkflowScheduling(invocationId) {
const url = getUrl(`api/invocations/${invocationId}`);
return axios.delete(url);
}
+4 -1
View File
@@ -69,7 +69,7 @@ export const statePlaceholders: Record<string, string> = {
};
/** 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"];
const SINGLE_INSTANCE_STATES = ["error", "running", "paused", "deleting"];
/** 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"];
@@ -293,6 +293,9 @@ export function useInvocationGraph(
function getStepStateFromJobStates(jobStates: string[]): GraphStep["state"] | undefined {
for (const state of SINGLE_INSTANCE_STATES) {
if (jobStates.includes(state)) {
if (state === "deleting") {
return "deleted";
}
return state as GraphStep["state"];
}
}
+19 -2
View File
@@ -42,8 +42,24 @@ export const useInvocationStore = defineStore("invocationStore", () => {
return data;
}
const { getItemById: getInvocationById, fetchItemById: fetchInvocationForId } =
useKeyedCache<WorkflowInvocation>(fetchInvocationDetails);
async function cancelWorkflowScheduling(invocationId: string) {
const { data, error } = await GalaxyApi().DELETE("/api/invocations/{invocation_id}", {
params: {
path: { invocation_id: invocationId },
},
});
if (error) {
rethrowSimple(error);
}
storedInvocations.value[invocationId] = data;
return data;
}
const {
getItemById: getInvocationById,
fetchItemById: fetchInvocationForId,
storedItems: storedInvocations,
} = useKeyedCache<WorkflowInvocation>(fetchInvocationDetails);
const { getItemById: getInvocationJobsSummaryById, fetchItemById: fetchInvocationJobsSummaryForId } =
useKeyedCache<InvocationJobsSummary>(fetchInvocationJobsSummary);
@@ -58,6 +74,7 @@ export const useInvocationStore = defineStore("invocationStore", () => {
fetchInvocationJobsSummaryForId,
getInvocationStepById,
fetchInvocationStepById,
cancelWorkflowScheduling,
graphStepsByStoreId,
};
});
+11
View File
@@ -195,6 +195,17 @@ $ui-margin-horizontal-large: $margin-v * 2;
}
}
.ui-form-header-underlay::after {
content: "";
display: block;
position: absolute;
top: -$margin-h;
left: -0.5rem;
right: -0.5rem;
height: 50px;
background: linear-gradient($white 75%, change-color($white, $alpha: 0));
}
// popovers
.popover {
max-width: 70%;
@@ -891,6 +891,12 @@ invocations:
step_job_details: '[data-step="${order_index}"] .invocation-step-job-details'
step_job_information: '[data-step="${order_index}"] .invocation-step-job-details .info_data_table'
step_job_information_tool_id: '[data-step="${order_index}"] .invocation-step-job-details .info_data_table #galaxy-tool-id'
export_tab: '.invocation-export-tab'
export_output_format: '[data-invocation-export-type="${type}"] .card-body'
export_destination: '[data-invocation-export-destination="${destination}"] .card-body'
wizard_next_button: '.wizard-actions .go-next-btn'
wizard_export_button: '.wizard-actions .go-next-btn.btn-primary'
export_download_link: '.download-link'
tour:
popover:
+4 -3
View File
@@ -620,7 +620,7 @@ class WorkflowContentsManager(UsesAnnotations):
import_options = ImportOptions()
import_options.deduplicate_subworkflows = True
as_dict = python_to_workflow(as_dict, galaxy_interface, workflow_directory=None, import_options=import_options)
raw_description = RawWorkflowDescription(as_dict, path)
raw_description = RawWorkflowDescription(as_dict)
created_workflow = self.build_workflow_from_raw_description(trans, raw_description, WorkflowCreateOptions())
return created_workflow.workflow
@@ -959,8 +959,9 @@ class WorkflowContentsManager(UsesAnnotations):
return wf_dict
def _sync_stored_workflow(self, trans, stored_workflow):
workflow_path = stored_workflow.from_path
self.store_workflow_to_path(workflow_path, stored_workflow, stored_workflow.latest_workflow, trans=trans)
if trans.user_is_admin:
workflow_path = stored_workflow.from_path
self.store_workflow_to_path(workflow_path, stored_workflow, stored_workflow.latest_workflow, trans=trans)
def store_workflow_artifacts(self, directory, filename_base, workflow, **kwd):
modern_workflow_path = os.path.join(directory, f"{filename_base}.gxwf.yml")
+13 -6
View File
@@ -2655,7 +2655,7 @@ class PostJobAction(Base, RepresentById):
workflow_step_id: Mapped[Optional[int]] = mapped_column(ForeignKey("workflow_step.id"), index=True)
action_type: Mapped[str] = mapped_column(String(255))
output_name: Mapped[Optional[str]] = mapped_column(String(255))
action_arguments: Mapped[Optional[bytes]] = mapped_column(MutableJSONType)
_action_arguments: Mapped[Optional[Dict[str, Any]]] = mapped_column("action_arguments", MutableJSONType)
workflow_step: Mapped[Optional["WorkflowStep"]] = relationship(
back_populates="post_job_actions",
primaryjoin=(lambda: WorkflowStep.id == PostJobAction.workflow_step_id),
@@ -2668,6 +2668,18 @@ class PostJobAction(Base, RepresentById):
self.workflow_step = workflow_step
ensure_object_added_to_session(self, object_in_session=workflow_step)
@property
def action_arguments(self):
if self.action_type in ("HideDatasetAction", "DeleteIntermediatesAction") and self._action_arguments is True:
# Fix up broken workflows resulting from imports with gxformat2 <= 0.20.0
return {}
else:
return self._action_arguments
@action_arguments.setter
def action_arguments(self, value: Dict[str, Any]):
self._action_arguments = value
class PostJobActionAssociation(Base, RepresentById):
__tablename__ = "post_job_action_association"
@@ -7016,11 +7028,6 @@ class HistoryDatasetCollectionAssociation(
primaryjoin=copied_from_history_dataset_collection_association_id == id,
remote_side=[id],
uselist=False,
back_populates="copied_to_history_dataset_collection_association",
)
copied_to_history_dataset_collection_association = relationship(
"HistoryDatasetCollectionAssociation",
back_populates="copied_from_history_dataset_collection_association",
)
implicit_input_collections: Mapped[List["ImplicitlyCreatedDatasetCollectionInput"]] = relationship(
primaryjoin=(
+22 -18
View File
@@ -999,14 +999,16 @@ class ModelImportStore(metaclass=abc.ABCMeta):
# sense.
hdca_copied_from_sinks = object_import_tracker.hdca_copied_from_sinks
if copied_from_object_key in object_import_tracker.hdcas_by_key:
hdca.copied_from_history_dataset_collection_association = object_import_tracker.hdcas_by_key[
copied_from_object_key
]
source_hdca = object_import_tracker.hdcas_by_key[copied_from_object_key]
if source_hdca is not hdca:
# We may not have the copied source, in which case the first included HDCA in the chain
# acts as the source, so here we make sure we don't create a cycle.
hdca.copied_from_history_dataset_collection_association = source_hdca
else:
if copied_from_object_key in hdca_copied_from_sinks:
hdca.copied_from_history_dataset_collection_association = object_import_tracker.hdcas_by_key[
hdca_copied_from_sinks[copied_from_object_key]
]
source_hdca = object_import_tracker.hdcas_by_key[hdca_copied_from_sinks[copied_from_object_key]]
if source_hdca is not hdca:
hdca.copied_from_history_dataset_collection_association = source_hdca
else:
hdca_copied_from_sinks[copied_from_object_key] = dataset_collection_key
@@ -1072,7 +1074,7 @@ class ModelImportStore(metaclass=abc.ABCMeta):
for step_attrs in invocation_attrs["steps"]:
imported_invocation_step = model.WorkflowInvocationStep()
imported_invocation_step.workflow_invocation = imported_invocation
ensure_object_added_to_session(imported_invocation, session=self.sa_session)
ensure_object_added_to_session(imported_invocation_step, session=self.sa_session)
attach_workflow_step(imported_invocation_step, step_attrs)
restore_times(imported_invocation_step, step_attrs)
imported_invocation_step.action = step_attrs["action"]
@@ -1926,12 +1928,14 @@ class DirectoryModelExportStore(ModelExportStore):
self.export_files = export_files
self.included_datasets: Dict[model.DatasetInstance, Tuple[model.DatasetInstance, bool]] = {}
self.dataset_implicit_conversions: Dict[model.DatasetInstance, model.ImplicitlyConvertedDatasetAssociation] = {}
self.included_collections: List[Union[model.DatasetCollection, model.HistoryDatasetCollectionAssociation]] = []
self.included_collections: Dict[
Union[model.DatasetCollection, model.HistoryDatasetCollectionAssociation],
Union[model.DatasetCollection, model.HistoryDatasetCollectionAssociation],
] = {}
self.included_libraries: List[model.Library] = []
self.included_library_folders: List[model.LibraryFolder] = []
self.included_invocations: List[model.WorkflowInvocation] = []
self.collection_datasets: Set[int] = set()
self.collections_attrs: List[Union[model.DatasetCollection, model.HistoryDatasetCollectionAssociation]] = []
self.dataset_id_to_path: Dict[int, Tuple[Optional[str], Optional[str]]] = {}
self.job_output_dataset_associations: Dict[int, Dict[str, model.DatasetInstance]] = {}
@@ -2298,8 +2302,7 @@ class DirectoryModelExportStore(ModelExportStore):
def add_dataset_collection(
self, collection: Union[model.DatasetCollection, model.HistoryDatasetCollectionAssociation]
) -> None:
self.collections_attrs.append(collection)
self.included_collections.append(collection)
self.included_collections[collection] = collection
def add_implicit_conversion_dataset(
self,
@@ -2362,7 +2365,7 @@ class DirectoryModelExportStore(ModelExportStore):
collections_attrs_filename = os.path.join(export_directory, ATTRS_FILENAME_COLLECTIONS)
with open(collections_attrs_filename, "w") as collections_attrs_out:
collections_attrs_out.write(to_json(self.collections_attrs))
collections_attrs_out.write(to_json(self.included_collections.values()))
conversions_attrs_filename = os.path.join(export_directory, ATTRS_FILENAME_CONVERSIONS)
with open(conversions_attrs_filename, "w") as conversions_attrs_out:
@@ -2383,12 +2386,12 @@ class DirectoryModelExportStore(ModelExportStore):
#
# Get all jobs associated with included HDAs.
jobs_dict: Dict[str, model.Job] = {}
jobs_dict: Dict[int, model.Job] = {}
implicit_collection_jobs_dict = {}
def record_job(job):
if not job:
# No viable job.
if not job or job.id in jobs_dict:
# No viable job or job already recorded.
return
jobs_dict[job.id] = job
@@ -2414,10 +2417,11 @@ class DirectoryModelExportStore(ModelExportStore):
)
job_hda = hda
while job_hda.copied_from_history_dataset_association: # should this check library datasets as well?
# record job (if one exists) even if dataset was copied
# copy could have been created manually through UI/API or using database operation tool,
# in which case we have a relevant job to export.
record_associated_jobs(job_hda)
job_hda = job_hda.copied_from_history_dataset_association
if not job_hda.creating_job_associations:
# No viable HDA found.
continue
record_associated_jobs(job_hda)
@@ -738,7 +738,7 @@ class BuildMulledDockerContainerResolver(CliContainerResolver):
"namespace": namespace,
"hash_func": self.hash_func,
"command": "build-and-test",
"use_mamba": True,
"use_mamba": False,
}
self._mulled_kwds["channels"] = default_mulled_conda_channels_from_env() or self._get_config_option(
"mulled_channels", DEFAULT_CHANNELS
@@ -789,7 +789,7 @@ class BuildMulledSingularityContainerResolver(SingularityCliContainerResolver):
"namespace": "local",
"singularity": True,
"singularity_image_dir": self.cache_directory.path,
"use_mamba": True,
"use_mamba": False,
}
self.involucro_context = InvolucroContext(**self._involucro_context_kwds)
auto_init = self._get_config_option("involucro_auto_init", True)
+2
View File
@@ -894,12 +894,14 @@ class InputsSelectOptionsDefinesOptions(Linter):
# TODO check if input param is present for from_dataset
from_dataset = options.get("from_dataset", None)
from_data_table = options.get("from_data_table", None)
from_url = options.get("from_url", None)
if (
from_file is None
and from_parameter is None
and from_dataset is None
and from_data_table is None
and from_url is None
and not filter_adds_options
):
lint_ctx.error(
+3 -4
View File
@@ -3875,14 +3875,13 @@ class HarmonizeTool(DatabaseOperationTool):
final_sorted_identifiers = [
element.element_identifier for element in elements1 if element.element_identifier in old_elements2_dict
]
# Raise Exception if it is empty
if len(final_sorted_identifiers) == 0:
# Create empty collections:
output_collections.create_collection(
next(iter(self.outputs.values())), "output1", elements={}, propagate_hda_tags=False
self.outputs["output1"], "output1", elements={}, propagate_hda_tags=False
)
output_collections.create_collection(
next(iter(self.outputs.values())), "output2", elements={}, propagate_hda_tags=False
self.outputs["output2"], "output2", elements={}, propagate_hda_tags=False
)
return
@@ -3900,7 +3899,7 @@ class HarmonizeTool(DatabaseOperationTool):
self._add_datasets_to_history(history, new_elements.values())
# Create collections:
output_collections.create_collection(
next(iter(self.outputs.values())), output_label, elements=new_elements, propagate_hda_tags=False
self.outputs[output_label], output_label, elements=new_elements, propagate_hda_tags=False
)
# Create outputs:
@@ -171,6 +171,37 @@
<output_collection name="output1" type="list" count="0"/>
<output_collection name="output2" type="list" count="0"/>
</test>
<!-- test that collection types get propagated to outputs correctly -->
<test>
<param name="input1">
<collection type="list">
<element name="element_1" value="simple_line.txt" />
</collection>
</param>
<param name="input2">
<collection type="list:paired">
<element name="element_1">
<collection type="paired">
<element name="forward" value="1.fastqsanger" ftype="fastqsanger" />
<element name="reverse" value="1.fastqsanger" ftype="fastqsanger" />
</collection>
</element>
</collection>
</param>
<output_collection name="output1" type="list" count="1">
<element name="element_1">
<assert_contents>
<has_text_matching expression="^This is a line of text.\n$" />
</assert_contents>
</element>
</output_collection>
<output_collection name="output2" type="list:paired" count="1">
<element name="element_1">
<element name="forward" file="1.fastqsanger" ftype="fastqsanger" />
<element name="reverse" file="1.fastqsanger" ftype="fastqsanger" />
</element>
</output_collection>
</test>
</tests>
<help><![CDATA[
+2
View File
@@ -1911,6 +1911,8 @@ class BaseWorkflowPopulator(BasePopulator):
invocations = self.history_invocations(history_id)
if len(invocations) == expected_invocation_count:
return True
elif len(invocations) > expected_invocation_count:
raise AssertionError("More than the expect number of invocations found in workflow")
wait_on(invocation_count, f"{expected_invocation_count} history invocations")
for invocation in self.history_invocations(history_id):
@@ -32,6 +32,52 @@ from .framework import (
class TestWorkflowRun(SeleniumTestCase, UsesHistoryItemAssertions, RunsWorkflows):
ensure_registered = True
@selenium_test
@managed_history
def test_workflow_export_file_rocrate(self):
self._setup_simple_invocation_for_export_testing()
invocations = self.components.invocations
invocations.export_tab.wait_for_and_click()
self.screenshot("invocation_export_formats")
invocations.export_output_format(type="ro-crate").wait_for_and_click()
invocations.wizard_next_button.wait_for_and_click()
download_option = invocations.export_destination(destination="download")
download_option.wait_for_present()
self.screenshot("invocation_export_rocrate_destinations")
download_option.wait_for_and_click()
invocations.wizard_next_button.wait_for_and_click()
export_button = invocations.wizard_export_button
export_button.wait_for_present()
self.screenshot("invocation_export_rocrate_download_options")
export_button.wait_for_and_click()
self.sleep_for(self.wait_types.UX_TRANSITION)
self.screenshot("invocation_export_crate_preparing_download")
invocations.export_download_link.wait_for_present()
self.screenshot("invocation_export_crate_download_ready")
@selenium_test
@managed_history
def test_workflow_export_file_native(self):
self._setup_simple_invocation_for_export_testing()
invocations = self.components.invocations
invocations.export_tab.wait_for_and_click()
self.screenshot("invocation_export_formats")
invocations.export_output_format(type="default-file").wait_for_and_click()
invocations.wizard_next_button.wait_for_and_click()
download_option = invocations.export_destination(destination="download")
download_option.wait_for_present()
self.screenshot("invocation_export_native_destinations")
download_option.wait_for_and_click()
invocations.wizard_next_button.wait_for_and_click()
export_button = invocations.wizard_export_button
export_button.wait_for_present()
self.screenshot("invocation_export_native_download_options")
export_button.wait_for_and_click()
self.sleep_for(self.wait_types.UX_TRANSITION)
self.screenshot("invocation_export_native_preparing_download")
invocations.export_download_link.wait_for_present()
self.screenshot("invocation_export_native_download_ready")
@selenium_test
@managed_history
def test_simple_execution(self):
@@ -376,3 +422,13 @@ steps: {}
assert initial_value == "", initial_value
input_element.clear()
input_element.send_keys(value)
def _setup_simple_invocation_for_export_testing(self):
# precondition: refresh history
self.perform_upload(self.get_filename("1.fasta"))
self.wait_for_history()
self.workflow_run_open_workflow(WORKFLOW_SIMPLE_CAT_TWICE)
self.workflow_run_submit()
history_id = self.current_history_id()
self.workflow_populator.wait_for_history_workflows(history_id, expected_invocation_count=1)
return self.workflow_populator.history_invocations(history_id)[0]
+44
View File
@@ -17,6 +17,7 @@ from galaxy_test.api.test_workflows import RunsWorkflowFixtures
from galaxy_test.base import api_asserts
from galaxy_test.base.api import UsesCeleryTasks
from galaxy_test.base.populators import (
DatasetCollectionPopulator,
DatasetPopulator,
RunJobsSummary,
WorkflowPopulator,
@@ -27,6 +28,7 @@ from galaxy_test.driver.integration_util import IntegrationTestCase
class TestWorkflowTasksIntegration(PosixFileSourceSetup, IntegrationTestCase, UsesCeleryTasks, RunsWorkflowFixtures):
dataset_populator: DatasetPopulator
dataset_collection_populator: DatasetCollectionPopulator
framework_tool_and_types = True
@classmethod
@@ -37,6 +39,7 @@ class TestWorkflowTasksIntegration(PosixFileSourceSetup, IntegrationTestCase, Us
def setUp(self):
super().setUp()
self.dataset_populator = DatasetPopulator(self.galaxy_interactor)
self.dataset_collection_populator = DatasetCollectionPopulator(self.galaxy_interactor)
self.workflow_populator = WorkflowPopulator(self.galaxy_interactor)
self._write_file_fixtures()
@@ -124,6 +127,47 @@ class TestWorkflowTasksIntegration(PosixFileSourceSetup, IntegrationTestCase, Us
invocation_details = self._export_and_import_workflow_invocation(summary, use_uris)
self._rerun_imported_workflow(summary, invocation_details)
def test_export_import_invocation_with_copied_hdca_and_database_operation_tool(self):
with self.dataset_populator.test_history() as history_id:
self.dataset_collection_populator.create_list_in_history(history_id=history_id, wait=True).json()
new_history = self.dataset_populator.copy_history(history_id=history_id).json()
copied_collection = self.dataset_populator.get_history_collection_details(new_history["id"])
workflow_id = self.workflow_populator.upload_yaml_workflow(
"""class: GalaxyWorkflow
inputs:
input:
type: collection
collection_type: list
steps:
extract_dataset:
tool_id: __EXTRACT_DATASET__
in:
input:
source: input
"""
)
inputs = {"input": {"src": "hdca", "id": copied_collection["id"]}}
workflow_request = {"history": f"hist_id={new_history['id']}", "inputs_by": "name", "inputs": inputs}
invocation = self.workflow_populator.invoke_workflow_raw(
workflow_id, workflow_request, assert_ok=True
).json()
invocation_id = invocation["id"]
self.workflow_populator.wait_for_invocation_and_jobs(history_id, workflow_id, invocation_id)
jobs = self.workflow_populator.get_invocation_jobs(invocation_id)
summary = RunJobsSummary(
history_id=history_id,
workflow_id=workflow_id,
invocation_id=invocation["id"],
inputs=inputs,
jobs=jobs,
invocation=invocation,
workflow_request=workflow_request,
)
imported_invocation_details = self._export_and_import_workflow_invocation(summary)
original_contents = self.dataset_populator.get_history_contents(new_history["id"])
contents = self.dataset_populator.get_history_contents(imported_invocation_details["history_id"])
assert len(contents) == len(original_contents) == 5
def _export_and_import_workflow_invocation(
self, summary: RunJobsSummary, use_uris: bool = True, model_store_format="tgz"
) -> Dict[str, Any]: