diff --git a/client/src/api/jobs.ts b/client/src/api/jobs.ts index 692e65a37f32..c4b4762a101c 100644 --- a/client/src/api/jobs.ts +++ b/client/src/api/jobs.ts @@ -1,4 +1,7 @@ import type { components } from "@/api/schema"; +import { rethrowSimple } from "@/utils/simple-error"; + +import { GalaxyApi } from "./client"; export type JobDestinationParams = components["schemas"]["JobDestinationParams"]; export type ShowFullJobResponse = components["schemas"]["ShowFullJobResponse"]; @@ -15,8 +18,8 @@ export type JobMessage = | components["schemas"]["RegexJobMessage"] | components["schemas"]["MaxDiscoveredFilesJobMessage"]; -export const NON_TERMINAL_STATES = ["new", "queued", "running", "waiting", "paused", "resubmitted", "stop"]; -export const ERROR_STATES = ["error", "deleted", "deleting"]; +export const NON_TERMINAL_STATES = ["new", "queued", "running", "waiting", "paused", "resubmitted", "upload"]; +export const ERROR_STATES = ["error", "deleted", "deleting", "failed"]; export const TERMINAL_STATES = ["ok", "skipped", "stop", "stopping"].concat(ERROR_STATES); interface JobDef { @@ -40,3 +43,22 @@ export interface ResponseVal { jobResponse: JobResponse; toolName: string; } + +/** + * Delete/Stop a job. + * @param jobId The ID of the job to delete. + * @param message An optional message to be set on the job and output dataset(s) to explain the reason for stopping. + * @returns A promise that resolves to a boolean indicating whether the job was successfully deleted or job was already in a terminal state. + */ +export async function deleteJob(jobId: string, message?: string): Promise { + const { data, error } = await GalaxyApi().DELETE("/api/jobs/{job_id}", { + params: { path: { job_id: jobId } }, + data: { message }, + }); + + if (error) { + rethrowSimple(error); + } + + return data; +} diff --git a/client/src/components/History/CurrentCollection/CollectionOperations.vue b/client/src/components/History/CurrentCollection/CollectionOperations.vue index ff4c63888f71..e5eaa9fbcb7f 100644 --- a/client/src/components/History/CurrentCollection/CollectionOperations.vue +++ b/client/src/components/History/CurrentCollection/CollectionOperations.vue @@ -1,22 +1,23 @@ + + diff --git a/client/src/components/JobInformation/JobInformation.test.js b/client/src/components/JobInformation/JobInformation.test.js index 8263de604774..e711e89a8056 100644 --- a/client/src/components/JobInformation/JobInformation.test.js +++ b/client/src/components/JobInformation/JobInformation.test.js @@ -1,5 +1,6 @@ import "@tests/vitest/mockHelpPopovers"; +import { createTestingPinia } from "@pinia/testing"; import { getLocalVue } from "@tests/vitest/helpers"; import { mount } from "@vue/test-utils"; import flushPromises from "flush-promises"; @@ -61,6 +62,7 @@ describe("JobInformation/JobInformation.vue", () => { wrapper = mount(JobInformation, { propsData, localVue, + pinia: createTestingPinia({ createSpy: vi.fn }), }); await flushPromises(); jobInfoTable = wrapper.find("#job-information"); diff --git a/client/src/components/JobInformation/JobInformation.vue b/client/src/components/JobInformation/JobInformation.vue index b1177426de53..23385c484916 100644 --- a/client/src/components/JobInformation/JobInformation.vue +++ b/client/src/components/JobInformation/JobInformation.vue @@ -11,7 +11,9 @@ import { getJobDuration } from "./utilities"; import Heading from "../Common/Heading.vue"; import DecodedId from "../DecodedId.vue"; +import JobState from "../JobStates/JobState.vue"; import CodeRow from "./CodeRow.vue"; +import RerunJobButton from "./RerunJobButton.vue"; import CopyToClipboard from "@/components/CopyToClipboard.vue"; import HelpText from "@/components/Help/HelpText.vue"; import UtcDate from "@/components/UtcDate.vue"; @@ -47,6 +49,13 @@ const jobIsTerminal = computed(() => (job.value?.state ? jobStateIsTerminal(job. const jobIsRunning = computed(() => (job.value?.state ? jobStateIsRunning(job.value.state) : false)); const routeToInvocation = computed(() => `/workflows/invocations/${fetchedInvocationId.value}`); +/** Whether the job can be rerun; actually decided based on if the tool `is_workflow_compatible`, + * but that would require an extra fetch. Just going by known non-rerunnable tool ids for now. + */ +const jobIsRerunnable = computed( + () => !!job.value?.tool_id && !job.value.tool_id.startsWith("upload") && job.value.tool_id !== "__DATA_FETCH__", +); + // Curious as to why we're trying to access tool_version and traceback like this, when they don't exist on // `ShowFullJobResponse`? Possibly historical reasons or maybe the `JobProvider` can return different types (doesn't seem like it)? const toolVersion = computed(() => @@ -144,7 +153,15 @@ watch( :stderr_position="stderr_position" :stderr_length="stderr_length" @update:result="updateConsoleOutputs" /> - Job Information +
+ + Job Information + + +
+ +
+
@@ -261,9 +278,15 @@ watch(
- diff --git a/client/src/components/JobInformation/RerunJobButton.vue b/client/src/components/JobInformation/RerunJobButton.vue new file mode 100644 index 000000000000..07e4899531e8 --- /dev/null +++ b/client/src/components/JobInformation/RerunJobButton.vue @@ -0,0 +1,31 @@ + + + diff --git a/client/src/components/JobStates/JobState.test.ts b/client/src/components/JobStates/JobState.test.ts new file mode 100644 index 000000000000..7a22dfb80ef5 --- /dev/null +++ b/client/src/components/JobStates/JobState.test.ts @@ -0,0 +1,147 @@ +import { createTestingPinia } from "@pinia/testing"; +import { getFakeRegisteredUser } from "@tests/test-data"; +import { getLocalVue } from "@tests/vitest/helpers"; +import { mount } from "@vue/test-utils"; +import flushPromises from "flush-promises"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RegisteredUser } from "@/api"; +import { useServerMock } from "@/api/client/__mocks__"; +import type { JobBaseModel, JobState as JobStateType, ShowFullJobResponse } from "@/api/jobs"; +import { useUserStore } from "@/stores/userStore"; + +import JobState from "./JobState.vue"; + +vi.mock("vue-router/composables", () => ({ + useRoute: vi.fn(() => ({})), +})); + +const localVue = getLocalVue(); +const { server, http } = useServerMock(); + +const FAKE_USER = getFakeRegisteredUser({ id: "user-123" }); + +const BASE_JOB: JobBaseModel = { + model_class: "Job", + id: "job-abc", + state: "running", + tool_id: "some_tool", + create_time: "2024-01-01T00:00:00", + update_time: "2024-01-01T00:01:00", +}; + +const FULL_JOB = { + ...BASE_JOB, + inputs: {}, + outputs: {}, + output_collections: {}, + params: {}, + user_id: "user-123", +} as ShowFullJobResponse; + +const SELECTORS = { + STOP_BTN: ".stop-job-btn", + STATE_BADGE: ".job-state-badge", +}; + +function mountJobState(job: JobBaseModel | ShowFullJobResponse, user: RegisteredUser | null = FAKE_USER) { + const pinia = createTestingPinia({ createSpy: vi.fn }); + const userStore = useUserStore(); + userStore.currentUser = user; + return mount(JobState as object, { + propsData: { job }, + localVue, + pinia, + }); +} + +describe("JobState.vue", () => { + beforeEach(() => { + server.use( + http.delete("/api/jobs/{job_id}", ({ response }) => { + return response(200).json(true); + }), + ); + }); + + it("renders the job state badge with the job state text", async () => { + const wrapper = mountJobState(BASE_JOB); + await flushPromises(); + expect(wrapper.find(SELECTORS.STATE_BADGE).text()).toContain("running"); + }); + + it("shows the stop button for a non-terminal state when using JobBaseModel (no user_id)", async () => { + const wrapper = mountJobState({ ...BASE_JOB, state: "running" }); + await flushPromises(); + expect(wrapper.find(SELECTORS.STOP_BTN).exists()).toBe(true); + }); + + it("hides the stop button for terminal states", async () => { + for (const state of ["ok", "error", "deleted", "failed"] as JobStateType[]) { + const wrapper = mountJobState({ ...BASE_JOB, state }); + await flushPromises(); + expect(wrapper.find(SELECTORS.STOP_BTN).exists()).toBe(false); + } + }); + + it("shows the stop button when ShowFullJobResponse user_id matches current user", async () => { + const wrapper = mountJobState({ ...FULL_JOB, state: "running", user_id: FAKE_USER.id }); + await flushPromises(); + expect(wrapper.find(SELECTORS.STOP_BTN).exists()).toBe(true); + }); + + it("hides the stop button when ShowFullJobResponse user_id does not match current user", async () => { + const wrapper = mountJobState({ ...FULL_JOB, state: "running", user_id: "someone-else" }); + await flushPromises(); + expect(wrapper.find(SELECTORS.STOP_BTN).exists()).toBe(false); + }); + + it("hides the stop button when no user is logged in", async () => { + const wrapper = mountJobState(BASE_JOB, null); + await flushPromises(); + expect(wrapper.find(SELECTORS.STOP_BTN).exists()).toBe(false); + }); + + it("calls DELETE /api/jobs/{job_id} when stop button is clicked", async () => { + let deletedJobId: string | undefined; + server.use( + http.delete("/api/jobs/{job_id}", ({ response, params }) => { + deletedJobId = params.job_id; + return response(200).json(true); + }), + ); + + const wrapper = mountJobState({ ...BASE_JOB, state: "running" }); + await flushPromises(); + + await wrapper.find(SELECTORS.STOP_BTN).trigger("click"); + await flushPromises(); + + expect(deletedJobId).toBe(BASE_JOB.id); + }); + + it("disables the stop button while stopping is in progress", async () => { + let resolveDelete!: () => void; + server.use( + http.delete("/api/jobs/{job_id}", ({ response }) => { + return new Promise((resolve) => { + resolveDelete = () => resolve(response(200).json(true)); + }); + }), + ); + + const wrapper = mountJobState({ ...BASE_JOB, state: "running" }); + await flushPromises(); + + const stopBtn = wrapper.find(SELECTORS.STOP_BTN); + stopBtn.trigger("click"); + await flushPromises(); + + expect(stopBtn.classes()).toContain("g-disabled"); + + resolveDelete(); + await flushPromises(); + + expect(stopBtn.classes()).not.toContain("g-disabled"); + }); +}); diff --git a/client/src/components/JobStates/JobState.vue b/client/src/components/JobStates/JobState.vue index 38fe4e961ff8..83d383cbc9e9 100644 --- a/client/src/components/JobStates/JobState.vue +++ b/client/src/components/JobStates/JobState.vue @@ -1,12 +1,20 @@ + + diff --git a/client/src/components/WorkflowInvocationState/JobStep.test.ts b/client/src/components/WorkflowInvocationState/JobStep.test.ts index f755739c4a54..315278c24566 100644 --- a/client/src/components/WorkflowInvocationState/JobStep.test.ts +++ b/client/src/components/WorkflowInvocationState/JobStep.test.ts @@ -1,6 +1,7 @@ +import { createTestingPinia } from "@pinia/testing"; import { mount, shallowMount } from "@vue/test-utils"; import flushPromises from "flush-promises"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { JobBaseModel } from "@/api/jobs"; import { statePlaceholders } from "@/composables/useInvocationGraph"; @@ -27,6 +28,7 @@ describe("Job Step", () => { jobs: TEST_JOBS_JSON, invocationId: TEST_INVOCATION_ID, }, + pinia: createTestingPinia({ createSpy: vi.fn }), }); await flushPromises(); @@ -75,6 +77,7 @@ describe("Job Step", () => { jobs: TEST_JOBS_JSON, invocationId: TEST_INVOCATION_ID, }, + pinia: createTestingPinia({ createSpy: vi.fn }), }); await flushPromises(); @@ -124,6 +127,7 @@ describe("Job Step", () => { jobs: [singleJob], invocationId: TEST_INVOCATION_ID, }, + pinia: createTestingPinia({ createSpy: vi.fn }), }); await flushPromises(); diff --git a/client/src/components/WorkflowInvocationState/JobStepJobs.test.ts b/client/src/components/WorkflowInvocationState/JobStepJobs.test.ts index 4d0df56b0649..fe99a39017c8 100644 --- a/client/src/components/WorkflowInvocationState/JobStepJobs.test.ts +++ b/client/src/components/WorkflowInvocationState/JobStepJobs.test.ts @@ -13,6 +13,10 @@ import TEST_JOBS_JSON from "./test/json/jobs.json"; import JobStepJobs from "./JobStepJobs.vue"; +vi.mock("vue-router/composables", () => ({ + useRoute: vi.fn(() => ({})), +})); + const localVue = getLocalVue(); const { server, http } = useServerMock(); diff --git a/client/src/components/WorkflowInvocationState/JobStepJobs.vue b/client/src/components/WorkflowInvocationState/JobStepJobs.vue index 926361d30e89..3c87993f2a55 100644 --- a/client/src/components/WorkflowInvocationState/JobStepJobs.vue +++ b/client/src/components/WorkflowInvocationState/JobStepJobs.vue @@ -162,16 +162,12 @@ watch(