Merge pull request #23463 from ahmedhamidawan/route_to_jobs_on_tool_run

This commit is contained in:
Marius van den Beek
2026-10-15 18:01:24 +03:00
committed by GitHub
64 changed files with 4265 additions and 849 deletions
@@ -133,7 +133,7 @@ onUpdated(() => nextTick(checkClamped));
<FontAwesomeIcon v-if="collapsed" fixed-width :icon="faAngleDoubleDown" />
<FontAwesomeIcon v-else fixed-width :icon="faAngleDoubleUp" />
</GButton>
<FontAwesomeIcon v-if="props.icon" :icon="props.icon" />
<FontAwesomeIcon v-if="props.icon" :icon="props.icon" fixed-width />
<slot />
</component>
</template>
@@ -146,6 +146,7 @@ const isActiveHost = computed(() => props.host === activeToastHost.value);
.g-toast-body {
margin-top: 0.25rem;
word-break: break-all;
}
}
+24
View File
@@ -52,3 +52,27 @@ export async function fetchInvocationReport(invocationId: string): Promise<Invoc
return data as InvocationReport;
}
/**
* Fetches the workflow invocation associated with a given job ID
* @param {string} jobId The ID of the job to fetch the invocation for
* @returns {Promise<WorkflowInvocation | null>} A promise that resolves to the workflow invocation
* or `null` if not found (could mean that the job is not part of any workflow invocation)
*/
export async function fetchInvocationForJob(jobId: string): Promise<WorkflowInvocation | null> {
const { data: invocations, error } = await GalaxyApi().GET("/api/invocations", {
params: {
query: { job_id: jobId },
},
});
if (error) {
rethrowSimple(error);
}
if (invocations.length && invocations[0]) {
return invocations[0];
}
return null;
}
+4 -1
View File
@@ -45,10 +45,14 @@ export interface JobResponse {
outputs: {
hid: number;
name: string;
history_content_type: "dataset";
id: string;
}[]; // TODO: This is temporary, adjust when API response is typed
output_collections: {
hid: number;
name: string;
history_content_type: "dataset_collection";
id: string;
}[]; // TODO: This is temporary, adjust when API response is typed
// implicit_collections // TODO: Add when API response is typed
errors?: any;
@@ -56,7 +60,6 @@ export interface JobResponse {
export interface ResponseVal {
jobDef: JobRequest;
jobResponse: JobResponse;
toolName: string;
}
export interface SubmitToolJobParams {
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { faCheck, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { nextTick, onUnmounted, ref, watch } from "vue";
import localize from "@/utils/localization";
import CheckDraw from "@/components/Common/SuccessIndicator/CheckDraw.vue";
interface Props {
/** If the object has unsaved changes */
hasChanges: boolean;
@@ -22,29 +24,31 @@ const props = withDefaults(defineProps<Props>(), {
/** Briefly shown after a successful save when the exposed `flashSavedIndicator` method is called. */
const showSavedIndicator = ref(false);
/** Used to key each individual flash so `CheckDraw` replays its draw-in animation each time. */
const flashKey = ref(0);
let savedIndicatorTimeout: ReturnType<typeof setTimeout> | undefined;
let flashGeneration = 0;
let unmounted = false;
onUnmounted(() => {
unmounted = true;
flashGeneration++;
flashKey.value++;
clearTimeout(savedIndicatorTimeout);
});
/** Flashes a "Saved" indicator when called, given `props.hasChanges` is `false`. */
async function flashSavedIndicator() {
const generation = ++flashGeneration;
const generation = ++flashKey.value;
clearTimeout(savedIndicatorTimeout);
// Let a simultaneous `hasChanges` update settle before deciding whether the feedback is still valid.
await nextTick();
if (unmounted || generation !== flashGeneration || props.hasChanges) {
if (unmounted || generation !== flashKey.value || props.hasChanges) {
return;
}
showSavedIndicator.value = true;
savedIndicatorTimeout = setTimeout(() => {
if (!unmounted && generation === flashGeneration) {
if (!unmounted && generation === flashKey.value) {
showSavedIndicator.value = false;
}
}, 1500);
@@ -56,7 +60,7 @@ watch(
() => props.hasChanges,
(hasChanges) => {
if (hasChanges) {
flashGeneration++;
flashKey.value++;
clearTimeout(savedIndicatorTimeout);
showSavedIndicator.value = false;
}
@@ -83,7 +87,7 @@ defineExpose({
v-show="showSavedIndicator"
class="changes-saved-indicator"
:data-description="`${props.objectNamespace} saved indicator`">
<FontAwesomeIcon :icon="faCheck" fixed-width aria-hidden="true" />
<CheckDraw v-if="showSavedIndicator" :key="flashKey" />
{{ localize("Saved") }}
</span>
</transition>
@@ -102,6 +106,9 @@ defineExpose({
.changes-saved-indicator {
color: var(--color-green-700);
display: flex;
gap: 0.25rem;
align-items: center;
}
.saved-indicator-enter-active,
@@ -0,0 +1,46 @@
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import DetailBlock from "./DetailBlock.vue";
const localVue = getLocalVue();
function mountDetailBlock(props: Record<string, unknown> = {}) {
return mount(DetailBlock as object, {
props: { headerIcon: faInfoCircle, title: "Details", collapsible: true, ...props },
slots: { default: "<p>content</p>" },
global: localVue,
});
}
function isContentVisible(wrapper: ReturnType<typeof mountDetailBlock>) {
return wrapper.findComponent({ name: "GCollapse" }).props("visible");
}
async function toggle(wrapper: ReturnType<typeof mountDetailBlock>) {
wrapper.findComponent({ name: "GHeading" }).vm.$emit("click");
await wrapper.vm.$nextTick();
}
describe("DetailBlock", () => {
it("toggles its own collapse state when `collapsed` is omitted", async () => {
const wrapper = mountDetailBlock();
expect(isContentVisible(wrapper)).toBe(true);
await toggle(wrapper);
expect(isContentVisible(wrapper)).toBe(false);
expect(wrapper.emitted("update:collapsed")).toBeUndefined();
});
it("only emits `update:collapsed` when `collapsed` is controlled by the parent", async () => {
const wrapper = mountDetailBlock({ collapsed: false });
await toggle(wrapper);
expect(wrapper.emitted("update:collapsed")).toEqual([[true]]);
expect(isContentVisible(wrapper)).toBe(true);
});
});
@@ -0,0 +1,142 @@
<script setup lang="ts">
/**
* Styled component for displaying an icon alongside a title and optional collapsible content,
* with the icon and title placed on one line and the content placed in the same column as
* the title. (A two-row, two-column layout with the icon and title in the first row and the
* content in the second row.)
*
* The content is optionally collapsible.
* The content by default is placed in a `GCard` but can be overloaded by providing a
* `custom-content` slot.
*
* @component DetailBlock
* @example
* <DetailBlock :header-icon="faInfoCircle" title="Job Information" :collapsible="true">
* <template v-slot:custom-content>
* <p>This is the custom content for the detail block.</p>
* </template>
* </DetailBlock>
*/
import type { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { computed, ref } from "vue";
import GCollapse from "@/components/BaseComponents/GCollapse.vue";
import GCard from "@/components/Common/GCard.vue";
import Heading from "@/components/Common/Heading.vue";
const props = withDefaults(
defineProps<{
/** The icon to display in the detail block header */
headerIcon: IconDefinition;
/** The title to display in the detail block header */
title: string;
/** If `true`, the content is collapsible */
collapsible?: boolean;
/** Controls collapse state externally (e.g. to persist it). Omit to manage it internally. */
collapsed?: boolean;
}>(),
// `collapsed` must default to `undefined` (not Vue's implicit `false` for booleans) so that
// omitting it actually leaves the collapse state uncontrolled.
{ collapsed: undefined },
);
const emit = defineEmits<{
(e: "update:collapsed", value: boolean): void;
}>();
const internalCollapsed = ref(false);
const isControlled = computed(() => props.collapsed !== undefined);
const collapsed = computed(() => (isControlled.value ? props.collapsed! : internalCollapsed.value));
const collapseState = computed<"open" | "closed" | "none">(() => {
if (!props.collapsible) {
return "none";
}
return collapsed.value ? "closed" : "open";
});
function toggleCollapse() {
if (!props.collapsible) {
return;
}
const next = !collapsed.value;
if (isControlled.value) {
emit("update:collapsed", next);
} else {
internalCollapsed.value = next;
}
}
</script>
<template>
<div>
<div class="detail-block">
<div class="detail-block-icon">
<FontAwesomeIcon :icon="props.headerIcon" />
</div>
<div class="d-flex flex-gapx-1">
<Heading
class="flex-grow-1"
inline
size="sm"
bold
separator
:collapse="collapseState"
@click="toggleCollapse">
{{ props.title }}
</Heading>
<slot name="header-indicators" />
</div>
<div class="detail-block-spacer" />
<GCollapse :visible="collapseState !== 'closed'">
<slot name="custom-content">
<GCard>
<slot />
</GCard>
</slot>
</GCollapse>
</div>
</div>
</template>
<style scoped lang="scss">
// 2x2 grid: icon | heading (row 1), empty spacer | content (row 2)
.detail-block {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
row-gap: 0.5rem;
column-gap: 0.5rem;
.detail-block-icon {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: var(--color-blue-100);
border: 1px solid var(--color-grey-200);
border-radius: 8px;
color: var(--color-blue-600);
}
.detail-block-spacer {
align-self: stretch;
}
.heading {
margin-bottom: 0;
}
// TODO: Maybe we need to max-width all poppers to 70vw globally?
:deep(.popper-element) {
max-width: 70vw;
text-align: left;
white-space: normal !important;
}
}
</style>
@@ -0,0 +1,49 @@
<template>
<svg class="check-draw" viewBox="0 0 36 36" fill="none" aria-hidden="true">
<circle class="check-draw-circle" cx="18" cy="18" r="16" pathLength="100" />
<path class="check-draw-check" d="M10.5 18.5L15.5 23.5L25.5 12.5" pathLength="100" />
</svg>
</template>
<style lang="scss" scoped>
.check-draw {
width: 1.1em;
height: 1.1em;
}
// Both shapes declare pathLength="100" so this dash math is exact (100 == the whole path),
// instead of a guessed/measured length that can undershoot and leave a stray segment visible
// before the animation starts.
.check-draw-circle {
stroke: var(--color-green-700);
stroke-width: 2.5;
stroke-linecap: round;
fill: none;
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation: check-draw-circle 0.4s ease-out forwards;
}
.check-draw-check {
stroke: var(--color-green-700);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation: check-draw-check 0.3s ease-out 0.35s forwards;
}
@keyframes check-draw-circle {
to {
stroke-dashoffset: 0;
}
}
@keyframes check-draw-check {
to {
stroke-dashoffset: 0;
}
}
</style>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import type { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { onMounted, ref } from "vue";
import CheckDraw from "./CheckDraw.vue";
const props = defineProps<{
coveredIcon: IconDefinition;
}>();
const showSuccessBadge = ref(false);
onMounted(() => {
showSuccessBadge.value = true;
setTimeout(() => {
showSuccessBadge.value = false;
}, 1900);
});
</script>
<template>
<span class="success-icon-overlay">
<transition name="success-icon-overlay-fade">
<CheckDraw v-if="showSuccessBadge" class="success-icon-overlay-check-draw" />
</transition>
<FontAwesomeIcon
v-if="!showSuccessBadge && props.coveredIcon"
:icon="props.coveredIcon"
fixed-width
class="success-icon-overlay-covered" />
</span>
</template>
<style lang="scss" scoped>
.success-icon-overlay {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25em;
height: 1.25em;
}
.success-icon-overlay-check-draw {
position: absolute;
inset: 0;
margin: auto;
}
// Pixel-locked to the same box .success-icon-overlay reserves, so removing this element (when the
// success badge fades in) never changes the wrapper's flex-content width and pushes the tool
// name.
.success-icon-overlay-covered {
position: absolute;
inset: 0;
width: 0.85em;
height: 0.85em;
margin: auto;
}
.success-icon-overlay-fade-leave-active {
transition: opacity 0.4s ease;
}
.success-icon-overlay-fade-leave-to {
opacity: 0;
}
</style>
+2
View File
@@ -45,6 +45,8 @@ onMounted(async () => {
appendScriptStyle(model);
}
});
defineExpose({ webhookId });
</script>
<template>
@@ -120,7 +120,7 @@ onUnmounted(() => {
<JobParameters dataset_type="hda" :dataset-id="datasetId" />
<JobInformation :job-id="dataset.creating_job" />
<JobInformation :job-id="dataset.creating_job" include-rerun-indicator />
<DatasetStorage :dataset-id="datasetId" />
+14 -42
View File
@@ -1,45 +1,17 @@
<template>
<span v-if="decoded_id">({{ decoded_id }})</span>
</template>
<script>
import axios from "axios";
<script setup lang="ts">
import { computed } from "vue";
import { getGalaxyInstance } from "@/app";
import { getAppRoot } from "@/onload/loadConfig";
import { rethrowSimple } from "@/utils/simple-error";
import { useUserStore } from "@/stores/userStore";
export default {
props: {
id: {
type: String,
required: true,
},
},
data() {
return { decoded_id: null };
},
computed: {
isAdmin() {
// window.parent.Galaxy is needed when instance is mounted in mako
const Galaxy = getGalaxyInstance() || window.parent.Galaxy;
return Galaxy?.user?.isAdmin() || false;
},
},
created: function () {
this.decodeId(this.id);
},
methods: {
decodeId: async function (id) {
if (this.isAdmin) {
const url = `${getAppRoot()}api/configuration/decode/${id}`;
try {
const response = await axios.get(url);
this.decoded_id = response.data.decoded_id;
} catch (e) {
rethrowSimple(e);
}
}
},
},
};
const props = defineProps<{
id: string;
}>();
const userStore = useUserStore();
const decodedId = computed<number | null>(() => userStore.getDecodedId(props.id));
</script>
<template>
<span v-if="decodedId">({{ decodedId }})</span>
</template>
@@ -2,7 +2,6 @@
import { computed } from "vue";
import { useCreatingJob } from "@/composables/useCreatingJob";
import { useJobBasic } from "@/composables/useJobBasic";
import type { HistoryGraphNode } from "./historyGraphMapper";
@@ -10,8 +9,7 @@ import JobDetailsTabs from "./JobDetailsTabs.vue";
import ToolExecutionJobs from "./ToolExecutionJobs.vue";
import GAlert from "@/components/BaseComponents/GAlert.vue";
import GTabs from "@/components/BaseComponents/GTabs.vue";
import RerunJobButton from "@/components/JobInformation/RerunJobButton.vue";
import JobState from "@/components/JobStates/JobState.vue";
import JobHeader from "@/components/JobInformation/JobHeader.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
interface Props {
@@ -32,7 +30,6 @@ const infoIcon = computed(() => props.node?.icon);
// For dataset/collection nodes, resolve the creating job and fetch its basic
// details for the JobState badge / RerunJobButton in the GTabs nav-end.
const { jobId: creatingJobId, loading: lookupLoading, error: lookupError } = useCreatingJob(itemId, nodeSrc);
const { job } = useJobBasic(creatingJobId);
</script>
<template>
@@ -46,8 +43,7 @@ const { job } = useJobBasic(creatingJobId);
<GAlert v-else-if="isDatasetLike && lookupError" variant="info" show class="mb-0">{{ lookupError }}</GAlert>
<GTabs v-else-if="isDatasetLike && creatingJobId">
<template v-slot:nav-end>
<JobState v-if="job" :job="job" class="mr-2" />
<RerunJobButton v-if="job" :job-id="creatingJobId" outline />
<JobHeader v-if="creatingJobId" :job-id="creatingJobId" no-tool-name no-hr />
</template>
<JobDetailsTabs
:key="creatingJobId"
@@ -53,7 +53,7 @@ watch(
<FontAwesomeIcon :icon="informationIcon" />
<span class="font-weight-bold text-break">{{ informationTitle }}</span>
</template>
<JobInformation :key="jobId" :job-id="jobId" :include-title="false" :include-times="true" />
<JobInformation :key="jobId" :job-id="jobId" :include-times="true" />
</GTab>
<GTab lazy>
<template v-slot:title>
@@ -3,15 +3,12 @@ import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { BPagination } from "bootstrap-vue";
import { computed, ref, toRef } from "vue";
import { useJobBasic } from "@/composables/useJobBasic";
import { useToolExecutionJobs } from "./useToolExecutionJobs";
import JobDetailsTabs from "./JobDetailsTabs.vue";
import GAlert from "@/components/BaseComponents/GAlert.vue";
import GTabs from "@/components/BaseComponents/GTabs.vue";
import RerunJobButton from "@/components/JobInformation/RerunJobButton.vue";
import JobState from "@/components/JobStates/JobState.vue";
import JobHeader from "@/components/JobInformation/JobHeader.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
interface Props {
@@ -30,11 +27,6 @@ const { jobs, loading, error } = useToolExecutionJobs(toRef(props, "toolExecutio
const currentIndex = ref(0);
const currentJob = computed(() => jobs.value[currentIndex.value] ?? null);
// Job details for the state badge in the tab nav-end, via the shared
// jobStore cache. The Information / Parameters / Outputs tabs each fetch
// their own data internally via JobDetailsTabs.
const { job } = useJobBasic(computed(() => currentJob.value?.id ?? null));
const hasMany = computed(() => jobs.value.length > 1);
// BPagination is 1-indexed; bridge to the 0-indexed currentIndex.
@@ -53,19 +45,21 @@ const paginationPage = computed<number>({
<template v-else-if="currentJob">
<GTabs>
<template v-slot:nav-end>
<JobState v-if="job" :job="job" class="mr-2" />
<BPagination
v-if="hasMany"
v-model="paginationPage"
:total-rows="jobs.length"
:per-page="1"
size="sm"
:limit="3"
first-number
last-number
hide-goto-end-buttons
class="mb-0 mr-2" />
<RerunJobButton v-if="job" :job-id="currentJob.id" outline />
<JobHeader v-if="currentJob" :job-id="currentJob.id" no-tool-name no-hr>
<template v-slot:pagination>
<BPagination
v-if="hasMany"
v-model="paginationPage"
:total-rows="jobs.length"
:per-page="1"
size="sm"
:limit="3"
first-number
last-number
hide-goto-end-buttons
class="mb-0 mr-2" />
</template>
</JobHeader>
</template>
<JobDetailsTabs :job-id="currentJob.id" :info-title="props.infoTitle" :info-icon="props.infoIcon" />
</GTabs>
+4 -13
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, watch } from "vue";
import { computed, toRef } from "vue";
import { useJobStore } from "@/stores/jobStore";
import { useJobDetails } from "@/composables/jobDetails";
import GAlert from "@/components/BaseComponents/GAlert.vue";
import SwitchToHistoryLink from "@/components/History/SwitchToHistoryLink.vue";
@@ -19,17 +19,8 @@ const emit = defineEmits<{
(e: "dismissed"): void;
}>();
const jobStore = useJobStore();
const historyId = computed(() => {
return jobStore.getJob(props.jobId)?.history_id;
});
async function init() {
jobStore.fetchJob({ id: props.jobId });
}
watch(() => props.jobId, init, { immediate: true });
const { job } = useJobDetails(toRef(props, "jobId"), { poll: false });
const historyId = computed(() => job.value?.history_id);
</script>
<template>
@@ -0,0 +1,33 @@
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import CodeRow from "./CodeRow.vue";
const localVue = getLocalVue();
function mountCodeRow(codeItem: string) {
return mount(CodeRow as object, {
props: { codeLabel: "Tool Standard Output", codeItem },
global: { ...localVue, stubs: { HelpText: true } },
});
}
describe("CodeRow", () => {
it("offers expanding short multi-line content, which the one-line preview folds together", async () => {
const wrapper = mountCodeRow("first line\nsecond line\n");
await wrapper.vm.$nextTick();
const toggle = wrapper.find(".code-row-toggle");
expect(toggle.exists()).toBe(true);
await toggle.trigger("click");
expect(wrapper.find("pre").classes()).not.toContain("preview");
});
it("does not offer expanding a single line that fits", async () => {
const wrapper = mountCodeRow("one line\n");
await wrapper.vm.$nextTick();
expect(wrapper.find(".code-row-toggle").exists()).toBe(false);
});
});
+113 -34
View File
@@ -1,43 +1,35 @@
<template>
<tr>
<td>
<div class="code-row">
<div class="code-row-label">
<span v-if="helpUri">
<HelpText :uri="helpUri" :text="codeLabel" />
</span>
<span v-else>
{{ codeLabel }}
</span>
</td>
<td v-if="codeItem">
<BRow align-v="center">
<BCol cols="11">
<pre :class="codeClass">{{ codeItem }}</pre>
</BCol>
<BCol
v-g-tooltip.hover
class="nopadding pointer"
:title="`click to ${action}`"
@mousedown="mouseIsDown = true"
@mousemove="mouseIsDown ? (mouseMoved = true) : (mouseMoved = false)"
@mouseup="toggleExpanded()">
<FontAwesomeIcon :icon="iconClass" />
</BCol>
</BRow>
</td>
<td v-else><i>empty</i></td>
</tr>
</div>
<div v-if="codeItem" class="code-row-body">
<pre ref="pre" :class="codeClass">{{ codeItem }}</pre>
<button
v-if="needsToggle"
type="button"
class="code-row-toggle"
:title="`click to ${action}`"
@click="toggleExpanded">
<FontAwesomeIcon :icon="iconClass" fixed-width />
</button>
</div>
<i v-else class="code-row-empty">empty</i>
</div>
</template>
<script>
import { faCompressAlt, faExpandAlt } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BCol, BRow } from "bootstrap-vue";
import HelpText from "@/components/Help/HelpText.vue";
export default {
components: {
BCol,
BRow,
FontAwesomeIcon,
HelpText,
},
@@ -48,10 +40,9 @@ export default {
},
data() {
return {
mouseIsDown: false,
mouseMoved: false,
expanded: false,
lastPos: 0,
needsToggle: false,
faCompressAlt,
faExpandAlt,
};
@@ -78,28 +69,116 @@ export default {
} catch {
console.debug("Code div is not present");
}
this.checkNeedsToggle();
this.observePre();
},
mounted() {
this.checkNeedsToggle();
this.resizeObserver = new ResizeObserver(() => this.checkNeedsToggle());
this.observePre();
},
beforeUnmount() {
this.resizeObserver?.disconnect();
},
methods: {
toggleExpanded() {
this.mouseIsDown = false;
if (this.codeItem && !this.mouseMoved) {
if (this.codeItem) {
this.expanded = !this.expanded;
}
},
checkNeedsToggle() {
// Only the collapsed (single-line, ellipsized) box can be reliably measured for
// overflow — once expanded it wraps/scrolls, so skip re-checking in that state.
if (this.expanded) {
return;
}
const pre = this.$refs.pre;
// The collapsed preview is `nowrap`, which folds line breaks into spaces, so short
// multi-line content fits without overflowing but still needs expanding to read it.
const isMultiline = !!this.codeItem && this.codeItem.trimEnd().includes("\n");
this.needsToggle = !!pre && (isMultiline || pre.scrollWidth > pre.clientWidth);
},
observePre() {
// .observe() on an already-observed element is a no-op, so it's safe to call this
// on every update — it just picks up the <pre> once codeItem first becomes truthy.
if (this.$refs.pre) {
this.resizeObserver?.observe(this.$refs.pre);
}
},
},
};
</script>
<style scoped>
.pointer {
cursor: pointer;
<style scoped lang="scss">
.code-row {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
.nopadding {
padding: 0;
margin: 0;
.code-row-label {
font-size: 0.78rem;
font-weight: 700;
color: var(--color-grey-500);
margin-bottom: 0.35rem;
}
.code-row-body {
display: flex;
align-items: flex-start;
background: var(--color-blue-700);
border-radius: 0.375rem;
overflow: hidden;
.code {
word-wrap: break-word;
margin: 0;
background: transparent;
border: none;
}
}
.code-row-toggle {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
margin-right: 0.5rem;
margin-top: 0.25rem;
border: none;
background: transparent;
color: var(--color-grey-100);
transition: background-color 0.15s ease;
&:hover,
&:focus-visible {
background-color: var(--color-grey-600);
}
}
.code-row-empty {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 0.85rem;
background: var(--color-grey-100);
border: 1px dashed var(--color-grey-300);
border-radius: 0.375rem;
color: var(--color-grey-400);
font-size: 0.85rem;
}
.code {
max-height: 50em;
overflow: auto;
}
pre {
flex: 1;
min-width: 0;
}
</style>
@@ -0,0 +1,29 @@
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import type { JobBaseModel } from "@/api/jobs";
import JobDetails from "./JobDetails.vue";
const localVue = getLocalVue();
const JobInformationStub = {
name: "JobInformation",
template: "<div><slot name='extra-code-rows' /></div>",
};
describe("JobDetails", () => {
it("shows the traceback of a job passed in from the admin job list", () => {
const adminListJob = { id: "job1", state: "error", traceback: "Traceback (most recent call last): ..." };
const wrapper = mount(JobDetails as object, {
props: { job: adminListJob as unknown as JobBaseModel },
global: {
...localVue,
stubs: { JobInformation: JobInformationStub, JobParameters: true, JobMetrics: true, HelpText: true },
},
});
expect(wrapper.text()).toContain("Traceback (most recent call last): ...");
});
});
@@ -6,6 +6,7 @@ import type { JobBaseModel } from "@/api/jobs";
import Heading from "../Common/Heading.vue";
import CodeRow from "./CodeRow.vue";
import JobInformation from "./JobInformation.vue";
import JobHeader from "@/components/JobInformation/JobHeader.vue";
import JobMetrics from "@/components/JobMetrics/JobMetrics.vue";
import JobParameters from "@/components/JobParameters/JobParameters.vue";
@@ -13,39 +14,29 @@ const props = defineProps<{
job?: JobBaseModel;
jobId?: string;
invocationId?: string;
showHeader?: boolean;
}>();
const id = computed(() => props.job?.id || props.jobId);
// Curious as to why we're trying to access traceback, info and remote_host like this, when they don't exist on
// `JobBaseModel`? Possibly historical reasons? (leaving as is for now)
const traceback = computed(() => (props.job && "traceback" in props.job ? (props.job?.traceback as string) : null));
// `traceback`, `info` and `remote_host` aren't on `JobBaseModel`: they're only present on the
// `admin_job_list` job objects passed in by the admin jobs table. `JobInformation` can't show the
// traceback itself since the `/api/jobs/{job_id}` response model doesn't include it.
const traceback = computed(() => (props.job && "traceback" in props.job ? (props.job.traceback as string) : null));
const info = computed(() => (props.job && "info" in props.job ? (props.job?.info as string) : null));
const remoteHost = computed(() => (props.job && "remote_host" in props.job ? (props.job.remote_host as string) : null));
</script>
<template>
<div v-if="id">
<JobInformation :job-id="id" :include-times="true" :invocation-id="invocationId">
<!-- only needed for admin job component -->
<tr v-if="traceback">
<td>Traceback</td>
<td>
<CodeRow :code-label="'Traceback'" :code-item="traceback" />
</td>
</tr>
<tr v-if="info">
<td>Info</td>
<td>
<CodeRow :code-label="'Info'" :code-item="info" />
</td>
</tr>
<tr v-if="remoteHost">
<td>Remote Host</td>
<td>
{{ remoteHost }}
</td>
</tr>
<JobHeader v-if="props.showHeader" :job-id="id" />
<JobInformation :job-id="id" include-times :invocation-id="invocationId">
<template v-slot:extra-code-rows>
<!-- only needed for admin job component -->
<CodeRow v-if="traceback" :code-label="'Traceback'" :code-item="traceback" />
<CodeRow v-if="info" :code-label="'Info'" :code-item="info" />
<CodeRow v-if="remoteHost" :code-label="'Remote Host'" :code-item="remoteHost" />
</template>
</JobInformation>
<br />
<Heading id="job-parameters-heading" h1 separator inline size="md"> Job Parameters </Heading>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { faWrench } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
import type { JobBaseModel, ShowFullJobResponse } from "@/api/jobs";
import { useJobDetails } from "@/composables/jobDetails";
import { useToolStore } from "@/stores/toolStore";
import Heading from "@/components/Common/Heading.vue";
import SuccessIconOverlay from "@/components/Common/SuccessIndicator/SuccessIconOverlay.vue";
import RerunJobButton from "@/components/JobInformation/RerunJobButton.vue";
import JobState from "@/components/JobStates/JobState.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
const props = defineProps<{
jobId: string;
/** A job object. `undefined` fetches/polls it here instead; `null` means
* the parent confirms there's deliberately no job yet (also skips fetching). */
job?: JobBaseModel | ShowFullJobResponse | null;
noToolName?: boolean;
noHr?: boolean;
animateSuccess?: boolean;
}>();
const toolStore = useToolStore();
const fetchJobId = computed(() => (props.job === undefined ? props.jobId : undefined));
const { job: fetchedJob } = useJobDetails(fetchJobId, { full: false });
const job = computed(() => (props.job !== undefined ? props.job : fetchedJob.value));
</script>
<template>
<div>
<div class="d-flex justify-content-between">
<Heading
v-if="!props.noToolName"
class="job-header-title"
:icon="!props.animateSuccess ? faWrench : undefined"
inline
size="md">
<SuccessIconOverlay v-if="props.animateSuccess" :covered-icon="faWrench" />
<LoadingSpan v-if="!job" message="" />
<span v-else>{{ toolStore.getToolNameById(job.tool_id, "Job Details") }}</span>
</Heading>
<div class="job-header-end">
<JobState class="job-information-state-badge" :job-id="props.jobId" :job="job" />
<slot name="pagination" />
<RerunJobButton :job-id="props.jobId" :job="job" outline />
</div>
</div>
<hr v-if="!props.noHr" />
</div>
</template>
<style lang="scss" scoped>
.job-header-title {
min-width: 0;
flex-shrink: 1;
// To make it equally sized as the `covered-icon` in `SuccessIconOverlay`
:deep(svg) {
height: 0.85em;
}
}
.job-header-end {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
flex-wrap: wrap;
flex-shrink: 0;
margin-left: auto;
}
</style>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import CopyToClipboard from "@/components/CopyToClipboard.vue";
import DecodedId from "@/components/DecodedId.vue";
const props = defineProps<{
/** The encoded job ID to display. */
id: string;
}>();
/** Last 8 characters of the encoded job ID. Jobs submitted together often share a leading
* prefix, so a trailing slice is more likely to actually distinguish them at a glance. */
const shortId = computed(() => props.id.slice(-8));
/** Briefly true right after `id` changes (e.g. paginating between jobs), to replay the reveal
* animation on the short id text so the change is noticeable even if the text looks similar. */
const justChanged = ref(false);
let changeTimeout: ReturnType<typeof setTimeout> | undefined;
watch(
() => props.id,
(id, previousId) => {
if (!id || !previousId || id === previousId) {
return;
}
clearTimeout(changeTimeout);
justChanged.value = true;
changeTimeout = setTimeout(() => {
justChanged.value = false;
}, 900);
},
);
</script>
<template>
<span id="encoded-job-id" v-g-tooltip.hover class="job-id-chip" :title="`Job API ID: ${props.id}`">
<span class="job-id-chip-value" :class="{ 'job-id-chip-value-reveal': justChanged }">{{ shortId }}</span>
<DecodedId :id="props.id" />
<CopyToClipboard message="Job API ID was copied to your clipboard" :text="props.id" title="Copy Job API ID" />
</span>
</template>
<style scoped lang="scss">
.job-id-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-blue-600);
background: var(--color-blue-100);
border: 1px solid var(--color-grey-200);
border-radius: 999px;
padding: 0.15rem 0.6rem;
white-space: nowrap;
:deep(svg) {
font-size: 0.7rem;
color: var(--color-grey-500);
&:hover {
color: var(--color-blue-600);
}
}
}
.job-id-chip-value-reveal {
display: inline-block;
animation: job-id-chip-reveal 0.3s ease;
}
@keyframes job-id-chip-reveal {
0% {
opacity: 0;
transform: translateY(-0.15em);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
</style>
@@ -15,6 +15,10 @@ import JobInformation from "./JobInformation.vue";
vi.mock("app");
vi.mock("vue-router/composables", () => ({
useRoute: vi.fn(() => ({})),
}));
const JOB_ID = "test_id";
const localVue = getLocalVue();
@@ -75,13 +79,16 @@ describe("JobInformation/JobInformation.vue", () => {
// table should exist
expect(jobInfoTable).toBeTruthy();
const rows = jobInfoTable.findAll("tr");
// should contain 10 rows
expect(rows.length).toBe(10);
// should contain 7 rows
expect(rows.length).toBe(6);
const codeRows = wrapper.findAll(".code-row");
// should contain 3 code rows
expect(codeRows.length).toBe(3);
});
it("stdout and stderr should be rendered", async () => {
["stdout", "stderr"].forEach((std) => {
const label = jobInfoTable.find("#" + std);
const label = wrapper.find("#" + std);
const value = label.find(".code");
expect(value.text()).toBe(std);
});
@@ -116,7 +123,7 @@ describe("JobInformation/JobInformation.vue", () => {
wrapper = mount(JobInformation, {
propsData: { jobId: JOB_ID },
localVue,
pinia: createTestingPinia({ createSpy: vi.fn }),
pinia: createTestingPinia({ createSpy: vi.fn, stubActions: false }),
});
await flushPromises();
@@ -131,11 +138,11 @@ describe("JobInformation/JobInformation.vue", () => {
const rendered_entries = [
{ id: "galaxy-tool-id", backend_key: "tool_id" },
{ id: "galaxy-tool-version", backend_key: "tool_version" },
{ id: "encoded-job-id", backend_key: "id" },
{ id: "encoded-copied-from-job-id", backend_key: "copied_from_job_id" },
];
verifyValues(rendered_entries, jobInfoTable, jobResponse);
expect(wrapper.find('td[data-description="galaxy-job-state"]').exists()).toBe(true);
expect(wrapper.find("#encoded-job-id").text()).toContain(JOB_ID);
});
});
@@ -156,7 +163,7 @@ describe("JobInformation/JobInformation.vue invocation lookup", () => {
const wrapper = mount(JobInformation, {
propsData: { jobId: JOB_ID },
localVue,
pinia: createTestingPinia({ createSpy: vi.fn }),
pinia: createTestingPinia({ createSpy: vi.fn, stubActions: false }),
});
await flushPromises();
@@ -165,4 +172,119 @@ describe("JobInformation/JobInformation.vue invocation lookup", () => {
"Could not determine the workflow invocation",
);
});
it("ignores a lookup that resolves after the job changed", async () => {
let releaseFirstLookup = () => {};
server.use(
http.get("/api/configuration/decode/{encoded_id}", ({ response }) => {
return response(200).json({ decoded_id: 123 });
}),
http.get("/api/jobs/{job_id}", ({ response, params }) => {
return response(200).json({ ...jobResponse, id: params.job_id });
}),
mswHttp.get("/api/invocations", async ({ request }) => {
if (new URL(request.url).searchParams.get("job_id") === "first_job") {
await new Promise((resolve) => (releaseFirstLookup = resolve));
return HttpResponse.json([{ id: "first_job_invocation" }]);
}
return HttpResponse.json([]);
}),
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
return response(200).json({ stdout: "stdout", stderr: "stderr" });
}),
);
const wrapper = mount(JobInformation, {
propsData: { jobId: "first_job" },
localVue,
pinia: createTestingPinia({ createSpy: vi.fn, stubActions: false }),
});
await flushPromises();
await wrapper.setProps({ jobId: "second_job" });
await flushPromises();
releaseFirstLookup();
await flushPromises();
expect(wrapper.text()).not.toContain("first_job_invocation");
expect(wrapper.find("#invocation-lookup-error").exists()).toBe(false);
});
});
describe("JobInformation/JobInformation.vue invocation lookup when returning to a job", () => {
it("ignores the original lookup's late failure after switching away and back", async () => {
let firstJobLookups = 0;
let failFirstLookup = () => {};
server.use(
http.get("/api/configuration/decode/{encoded_id}", ({ response }) => {
return response(200).json({ decoded_id: 123 });
}),
http.get("/api/jobs/{job_id}", ({ response, params }) => {
return response(200).json({ ...jobResponse, id: params.job_id });
}),
mswHttp.get("/api/invocations", async ({ request }) => {
if (new URL(request.url).searchParams.get("job_id") !== "first_job") {
return HttpResponse.json([]);
}
firstJobLookups++;
if (firstJobLookups === 1) {
await new Promise((resolve) => (failFirstLookup = resolve));
return HttpResponse.error();
}
return HttpResponse.json([{ id: "first_job_invocation" }]);
}),
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
return response(200).json({ stdout: "stdout", stderr: "stderr" });
}),
);
const wrapper = mount(JobInformation, {
propsData: { jobId: "first_job" },
localVue,
pinia: createTestingPinia({ createSpy: vi.fn, stubActions: false }),
});
await flushPromises();
await wrapper.setProps({ jobId: "second_job" });
await flushPromises();
await wrapper.setProps({ jobId: "first_job" });
await flushPromises();
expect(wrapper.text()).toContain("first_job_invocation");
failFirstLookup();
await flushPromises();
expect(wrapper.text()).toContain("first_job_invocation");
expect(wrapper.find("#invocation-lookup-error").exists()).toBe(false);
});
});
describe("JobInformation/JobInformation.vue rerun indicator", () => {
it("fetches the job's tool to decide whether to offer a rerun", async () => {
server.use(
http.get("/api/configuration/decode/{encoded_id}", ({ response }) => {
return response(200).json({ decoded_id: 123 });
}),
http.get("/api/jobs/{job_id}", ({ response }) => {
return response(200).json(jobResponse);
}),
http.get("/api/invocations", ({ response }) => {
return response(200).json([]);
}),
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
return response(200).json({ stdout: "stdout", stderr: "stderr" });
}),
mswHttp.get("/api/tools/:tool_id", () =>
HttpResponse.json({ id: jobResponse.tool_id, is_workflow_compatible: true }),
),
);
const wrapper = mount(JobInformation, {
propsData: { jobId: JOB_ID, includeRerunIndicator: true },
localVue,
pinia: createTestingPinia({ createSpy: vi.fn, stubActions: false }),
});
// The tool store fetches via axios, whose response needs more than a microtask flush
await flushPromises();
await new Promise((resolve) => setTimeout(resolve, 100));
await flushPromises();
expect(wrapper.find("[title='Rerun this job']").exists()).toBe(true);
});
});
@@ -1,18 +1,24 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { faArrowRight, faInfoCircle, faRedo } from "@fortawesome/free-solid-svg-icons";
import { BSkeleton } from "bootstrap-vue";
import { computed, ref, toRef, watch } from "vue";
import { useRoute } from "vue-router";
import { GalaxyApi } from "@/api";
import { type JobConsoleOutput, NON_TERMINAL_STATES, type ShowFullJobResponse } from "@/api/jobs";
import { JobConsoleOutputProvider, JobDetailsProvider } from "@/components/providers/JobProvider";
import { errorMessageAsString, rethrowSimple } from "@/utils/simple-error";
import { fetchInvocationForJob } from "@/api/invocations";
import type { CardAction } from "@/components/Common/GCard.types";
import { useJobConsoleOutput, useJobDetails } from "@/composables/jobDetails";
import { useRerunnableTool } from "@/composables/rerunnableTool";
import { errorMessageAsString } from "@/utils/simple-error";
import { stateIsTerminal } from "@/utils/utils";
import type { JobMessage } from "../../api/jobs";
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 JobIdChip from "./JobIdChip.vue";
import GAlert from "@/components/BaseComponents/GAlert.vue";
import DetailBlock from "@/components/Common/DetailBlock.vue";
import GCard from "@/components/Common/GCard.vue";
import CopyToClipboard from "@/components/CopyToClipboard.vue";
import HelpText from "@/components/Help/HelpText.vue";
import UtcDate from "@/components/UtcDate.vue";
@@ -22,43 +28,67 @@ const props = withDefaults(
jobId: string;
/** If `true`, the job's update and create times, as well as time to finish are shown. */
includeTimes?: boolean;
/** If `true`, the title is shown. */
includeTitle?: boolean;
/** If `true`, the rerun indicator is shown. */
includeRerunIndicator?: boolean;
/** If `true`, we render a "View full details" button */
includeViewFullDetailsButton?: boolean;
/** If provided, this component will skip fetching the invocation ID for the job. */
invocationId?: string;
/** If `true`, the section is collapsible */
collapsible?: boolean;
/** Controls collapse state externally (e.g. a parent that also needs to persist or force
* it open). Omit to toggle it locally, uncontrolled and unpersisted. */
collapsed?: boolean;
}>(),
{ includeTitle: true },
// `collapsed` must default to `undefined` (not Vue's implicit `false` for booleans), otherwise
// the component always considers itself controlled and can't toggle on its own.
{ invocationId: undefined, collapsed: undefined },
);
const job = ref<ShowFullJobResponse | null>(null);
const emit = defineEmits<{
(e: "update:collapsed", value: boolean): void;
}>();
const route = useRoute();
/** Whether the collapsible job information card is collapsed. */
const internalCollapsed = ref(false);
const collapsed = computed({
get: () => (props.collapsed !== undefined ? props.collapsed : internalCollapsed.value),
set: (value) => {
if (props.collapsed !== undefined) {
emit("update:collapsed", value);
} else {
internalCollapsed.value = value;
}
},
});
/** Invocation ID for the run that the job might have come from. It is `null` if the job has no associated invocation,
* or `undefined` if it has not been fetched yet.
*/
const fetchedInvocationId = ref<string | null | undefined>(props.invocationId);
const invocationLookupError = ref<string | null>(null);
const stdout_length = ref(50000);
const stdout_text = ref("");
const stderr_length = ref(50000);
const stderr_text = ref("");
const { job, error } = useJobDetails(toRef(props, "jobId"));
const stdout_position = computed(() => stdout_text.value.length);
const stderr_position = computed(() => stderr_text.value.length);
// Only resolve the tool (possibly fetching it) when the rerun indicator is actually requested
const { canRerun } = useRerunnableTool(computed(() => (props.includeRerunIndicator ? job.value?.tool_id : undefined)));
function jobStateIsTerminal(jobState: string) {
return jobState && !NON_TERMINAL_STATES.includes(jobState);
}
const jobIsRunning = computed(() => job.value?.state === "running");
function jobStateIsRunning(jobState: string) {
return jobState == "running";
}
// Console output is only polled while the job is actively running
const consoleOutputJobId = computed(() => (jobIsRunning.value ? props.jobId : undefined));
const { stdout: polledStdout, stderr: polledStderr } = useJobConsoleOutput(consoleOutputJobId);
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__",
const stdout_text = computed(() =>
job.value && stateIsTerminal({ state: job.value.state }) ? (job.value.tool_stdout ?? "") : polledStdout.value,
);
const stderr_text = computed(() =>
job.value && stateIsTerminal({ state: job.value.state }) ? (job.value.tool_stderr ?? "") : polledStderr.value,
);
const routeToInvocation = computed(() => `/workflows/invocations/${fetchedInvocationId.value}`);
// 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)?
@@ -67,35 +97,40 @@ const toolVersion = computed(() =>
);
const traceback = computed(() => (job.value && "traceback" in job.value ? (job.value?.traceback as string) : null));
const primaryActions = computed<CardAction[]>(() => {
const actions: CardAction[] = [];
if (props.includeViewFullDetailsButton && route.path !== `/jobs/${props.jobId}/view`) {
actions.push({
id: "go-to-job-details",
class: "text-decoration-none",
label: "View full details",
icon: faArrowRight,
title: "View full job details",
to: `/jobs/${props.jobId}/view`,
size: "md",
variant: "link",
});
}
if (props.includeRerunIndicator && job.value && canRerun.value) {
actions.push({
id: "job-info-card-rerun",
class: "text-decoration-none",
label: "Rerun",
icon: faRedo,
title: "Rerun this job",
to: `/?job_id=${props.jobId}`,
size: "md",
variant: "link",
});
}
return actions;
});
const metadataDetail = ref<Record<string, string>>({
exit_code: `Tools may use exit codes to indicate specific execution errors. Many programs use 0 to indicate success and non-zero exit codes to indicate errors. Galaxy allows each tool to specify exit codes that indicate errors. https://docs.galaxyproject.org/en/master/dev/schema.html#tool-stdio-exit-code`,
error_level: `NO_ERROR = 0<br>LOG = 1<br>QC = 1.1<br>WARNING = 2<br>FATAL = 3<br>FATAL_OOM = 4<br>MAX = 4`,
});
function updateJob(newJob: ShowFullJobResponse) {
job.value = newJob;
if (jobStateIsTerminal(newJob?.state)) {
if (newJob.tool_stdout) {
stdout_text.value = newJob.tool_stdout;
}
if (newJob.tool_stderr) {
stderr_text.value = newJob.tool_stderr;
}
}
}
function updateConsoleOutputs(output: JobConsoleOutput) {
// Keep stdout in memory and only fetch new text via JobProvider
if (output) {
if (output.stdout != null) {
stdout_text.value += output.stdout;
}
if (output.stderr != null) {
stderr_text.value += output.stderr;
}
}
}
function filterMetadata(jobMessages: JobMessage[]): Partial<JobMessage>[] {
return jobMessages.map((item) => {
return Object.entries(item).reduce((acc: Record<string, unknown>, [key, value]) => {
@@ -107,197 +142,279 @@ function filterMetadata(jobMessages: JobMessage[]): Partial<JobMessage>[] {
});
}
async function fetchInvocationForJob(jobId: string) {
const { data: invocations, error } = await GalaxyApi().GET("/api/invocations", {
params: {
query: { job_id: jobId },
},
});
if (error) {
rethrowSimple(error);
}
if (invocations.length) {
return invocations[0];
}
return null;
}
// Fetches the invocation for the given job id to get the associated invocation id
// Reset the fetched invocation ID as soon as the job ID changes and no invocation ID is provided
watch(
() => props.jobId,
async (newId, oldId) => {
if (
newId &&
(fetchedInvocationId.value === undefined || (fetchedInvocationId.value === null && newId !== oldId))
) {
let invocation;
try {
invocation = await fetchInvocationForJob(newId);
invocationLookupError.value = null;
} catch (e) {
invocationLookupError.value = errorMessageAsString(e);
(newId, oldId) => {
if (!props.invocationId && newId !== oldId) {
fetchedInvocationId.value = undefined;
invocationLookupError.value = null;
}
},
);
// Fetches the invocation for the given job id to get the associated invocation id. Watches whether
// the job has loaded rather than the job object itself, which changes on every poll.
watch(
[() => props.jobId, () => !!job.value],
async ([jobId, jobLoaded], _old, onCleanup) => {
// A lookup still in flight when the job changes belongs to the old job. Comparing ids isn't
// enough: switching away and back to the same job would accept the original lookup.
let cancelled = false;
onCleanup(() => (cancelled = true));
if (!jobLoaded || !jobId || fetchedInvocationId.value !== undefined) {
return;
}
let invocation;
try {
invocation = await fetchInvocationForJob(jobId);
if (cancelled) {
return;
}
invocationLookupError.value = null;
if (invocation) {
fetchedInvocationId.value = invocation.id;
} else {
// The job does not have an associated invocation
fetchedInvocationId.value = null;
}
} catch (e) {
if (cancelled) {
return;
}
invocationLookupError.value = errorMessageAsString(e);
fetchedInvocationId.value = null;
}
},
{ immediate: true },
);
// Animate the skeleton -> real content swap by expanding to the content's natural height
// (rather than popping in at full height instantly)
function onJobInfoEnter(el: Element, done: () => void) {
const target = el as HTMLElement;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
done();
return;
}
target.style.overflow = "hidden";
target.style.height = "0px";
target.style.opacity = "0";
requestAnimationFrame(() => {
target.style.transition = "height 0.3s ease, opacity 0.3s ease";
target.style.height = `${target.scrollHeight}px`;
target.style.opacity = "1";
});
setTimeout(done, 300);
}
function onJobInfoAfterEnter(el: Element) {
const target = el as HTMLElement;
target.style.height = "";
target.style.overflow = "";
target.style.transition = "";
}
// The skeleton itself never needs an animated exit as it's replaced instantly so the
// entering content's height animation is the only motion the user sees.
function onJobInfoLeave(el: Element, done: () => void) {
done();
}
</script>
<template>
<div>
<JobDetailsProvider auto-refresh :job-id="props.jobId" @update:result="updateJob" />
<JobConsoleOutputProvider
v-if="jobIsRunning"
auto-refresh
:job-id="props.jobId"
:stdout_position="stdout_position"
:stdout_length="stdout_length"
:stderr_position="stderr_position"
:stderr_length="stderr_length"
@update:result="updateConsoleOutputs" />
<div v-if="props.includeTitle" class="d-flex justify-content-between flex-gapx-1">
<Heading id="job-information-heading" class="flex-grow-1" h1 separator inline size="md">
Job Information
<JobState v-if="job" class="job-information-state-badge" :job="job" />
</Heading>
<div v-if="job && jobIsRerunnable">
<RerunJobButton :job-id="props.jobId" outline />
</div>
</div>
<table id="job-information" class="tabletip info_data_table">
<tbody>
<tr v-if="job && job.tool_id">
<td>Galaxy Tool ID</td>
<td id="galaxy-tool-id">
{{ job.tool_id }}
<CopyToClipboard
message="Tool ID was copied to your clipboard"
:text="job.tool_id"
title="Copy Tool ID" />
</td>
</tr>
<tr v-if="job && job.state">
<td>Job State</td>
<td data-description="galaxy-job-state">
<HelpText :uri="`galaxy.jobs.states.${job.state}`" :text="job.state" />
</td>
</tr>
<tr v-if="toolVersion">
<td>Galaxy Tool Version</td>
<td id="galaxy-tool-version">{{ toolVersion }}</td>
</tr>
<tr v-if="job && props.includeTimes">
<td>Created</td>
<td v-if="job.create_time" id="created">
<UtcDate :date="job.create_time" mode="pretty" />
</td>
</tr>
<tr v-if="job && props.includeTimes">
<td>Updated</td>
<td v-if="job.update_time" id="updated">
<UtcDate :date="job.update_time" mode="pretty" />
</td>
</tr>
<CodeRow
v-if="job && job.command_line"
id="command-line"
help-uri="unix.commandLine"
:code-label="'Command Line'"
:code-item="job.command_line" />
<CodeRow
v-if="job"
id="stdout"
help-uri="unix.stdout"
:code-label="'Tool Standard Output'"
:code-item="stdout_text" />
<CodeRow
v-if="job"
id="stderr"
help-uri="unix.stderr"
:code-label="'Tool Standard Error'"
:code-item="stderr_text" />
<CodeRow
v-if="traceback"
id="traceback"
help-uri="unix.traceback"
:code-label="'Unexpected Job Errors'"
:code-item="traceback" />
<tr v-if="job">
<td>Tool <HelpText uri="unix.exitCode" text="Exit Code" /></td>
<td id="exit-code">{{ job.exit_code }}</td>
</tr>
<tr v-if="job && job.job_messages && job.job_messages.length > 0" id="job-messages">
<td>Job Messages</td>
<td>
<ul v-if="Array.isArray(job.job_messages)" class="pl-2 mb-0">
<div v-for="(message, m) in filterMetadata(job.job_messages)" :key="m" class="job-message">
<div v-if="job.job_messages.length > 1">
<u>Job Message {{ m + 1 }}:</u>
</div>
<li v-for="(value, name, i) in message" :key="i">
<span
v-if="metadataDetail[name]"
v-g-tooltip.html
class="tooltipJobInfo"
:title="metadataDetail[name]">
<strong>{{ name }}:</strong>
</span>
<strong v-else>{{ name }}:</strong>
{{ value }}
</li>
<hr v-if="m + 1 < job.job_messages.length" />
</div>
</ul>
<div v-else>
{{ job.job_messages }}
<DetailBlock
v-model:collapsed="collapsed"
:header-icon="faInfoCircle"
title="Job Execution Details"
:collapsible="props.collapsible">
<template v-slot:header-indicators>
<JobIdChip v-if="job" :id="job.id" />
</template>
<template v-slot:custom-content>
<GCard :primary-actions="primaryActions">
<template v-slot:description>
<transition
name="job-info-reveal"
@enter="onJobInfoEnter"
@after-enter="onJobInfoAfterEnter"
@leave="onJobInfoLeave">
<GAlert v-if="error" variant="danger">{{ errorMessageAsString(error) }}</GAlert>
<div
v-else-if="!job"
key="skeleton"
class="job-info-skeleton px-2 py-2"
data-description="job information loading">
<BSkeleton animation="wave" width="40%" />
<BSkeleton animation="wave" width="25%" />
<BSkeleton animation="wave" width="60%" />
<BSkeleton animation="wave" width="90%" />
<BSkeleton animation="wave" width="75%" />
</div>
</td>
</tr>
<slot></slot>
<tr v-if="job && job.id">
<td>Job API ID</td>
<td id="encoded-job-id">{{ job.id }} <DecodedId :id="job.id" /></td>
</tr>
<tr v-if="job && job.copied_from_job_id">
<td>Copied from Job API ID</td>
<td id="encoded-copied-from-job-id">
{{ job.copied_from_job_id }} <DecodedId :id="job.copied_from_job_id" />
</td>
</tr>
<tr v-if="fetchedInvocationId">
<td>Workflow Invocation</td>
<td>
<router-link :to="routeToInvocation">{{ fetchedInvocationId }}</router-link>
</td>
</tr>
<tr v-else-if="invocationLookupError">
<td>Workflow Invocation</td>
<td id="invocation-lookup-error" class="text-danger">
Could not determine the workflow invocation: {{ invocationLookupError }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-else key="content">
<table id="job-information" class="tabletip info_data_table job-info-table">
<tbody>
<tr v-if="job.tool_id">
<td>Galaxy Tool ID</td>
<td id="galaxy-tool-id">
{{ job.tool_id }}
<CopyToClipboard
message="Tool ID was copied to your clipboard"
:text="job.tool_id"
title="Copy Tool ID" />
</td>
</tr>
<tr v-if="job.state">
<td>Job State</td>
<td data-description="galaxy-job-state">
<HelpText :uri="`galaxy.jobs.states.${job.state}`" :text="job.state" />
</td>
</tr>
<tr v-if="job.exit_code !== null && job.exit_code !== undefined">
<td>Tool <HelpText uri="unix.exitCode" text="Exit Code" /></td>
<td id="exit-code">{{ job.exit_code }}</td>
</tr>
<tr v-if="toolVersion">
<td>Galaxy Tool Version</td>
<td id="galaxy-tool-version">{{ toolVersion }}</td>
</tr>
<tr v-if="props.includeTimes">
<td>Created</td>
<td v-if="job.create_time" id="created">
<UtcDate :date="job.create_time" mode="pretty" />
</td>
</tr>
<tr v-if="props.includeTimes">
<td>Updated</td>
<td v-if="job.update_time" id="updated">
<UtcDate :date="job.update_time" mode="pretty" />
</td>
</tr>
<tr v-if="job.job_messages && job.job_messages.length > 0" id="job-messages">
<td>Job Messages</td>
<td>
<ul v-if="Array.isArray(job.job_messages)" class="pl-2 mb-0">
<div
v-for="(message, m) in filterMetadata(job.job_messages)"
:key="m"
class="job-message">
<div v-if="job.job_messages.length > 1">
<u>Job Message {{ m + 1 }}:</u>
</div>
<li v-for="(value, name, i) in message" :key="i">
<span
v-if="metadataDetail[name]"
v-g-tooltip.html
class="tooltipJobInfo"
:title="metadataDetail[name]">
<strong>{{ name }}:</strong>
</span>
<strong v-else>{{ name }}:</strong>
{{ value }}
</li>
<hr v-if="m + 1 < job.job_messages.length" />
</div>
</ul>
<div v-else>
{{ job.job_messages }}
</div>
</td>
</tr>
<slot name="extra-table-rows" />
<tr v-if="job.copied_from_job_id">
<td>Copied from Job API ID</td>
<td id="encoded-copied-from-job-id">
{{ job.copied_from_job_id }} <DecodedId :id="job.copied_from_job_id" />
</td>
</tr>
<tr v-if="fetchedInvocationId">
<td>Workflow Invocation</td>
<td>
<router-link :to="routeToInvocation">{{ fetchedInvocationId }}</router-link>
</td>
</tr>
<tr v-else-if="invocationLookupError">
<td>Workflow Invocation</td>
<td id="invocation-lookup-error" class="text-danger">
Could not determine the workflow invocation: {{ invocationLookupError }}
</td>
</tr>
</tbody>
</table>
<div class="px-2 py-2">
<CodeRow
v-if="job.command_line"
id="command-line"
help-uri="unix.commandLine"
:code-label="'Command Line'"
:code-item="job.command_line" />
<CodeRow
id="stdout"
help-uri="unix.stdout"
:code-label="'Tool Standard Output'"
:code-item="stdout_text" />
<CodeRow
id="stderr"
help-uri="unix.stderr"
:code-label="'Tool Standard Error'"
:code-item="stderr_text" />
<CodeRow
v-if="traceback"
id="traceback"
help-uri="unix.traceback"
:code-label="'Unexpected Job Errors'"
:code-item="traceback" />
<slot name="extra-code-rows" />
</div>
</div>
</transition>
</template>
<template v-slot:update-time>
<i v-if="props.includeViewFullDetailsButton" class="mb-0">
View more information on the details page.
</i>
</template>
</GCard>
</template>
</DetailBlock>
</template>
<style scoped lang="scss">
@import "@/style/scss/theme/blue.scss";
.tooltipJobInfo {
text-decoration-line: underline;
text-decoration-style: dashed;
}
.job-information-state-badge {
font-size: $h5-font-size;
.job-info-skeleton {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.job-info-table {
:deep(tr) {
td {
padding: 0.25rem 0.5rem;
vertical-align: top;
border-bottom: none;
}
&:hover td {
background-color: var(--color-blue-100);
}
td:first-child {
font-size: 0.78rem;
font-weight: 700;
color: var(--color-grey-600);
}
td:last-child {
color: var(--color-blue-700);
}
}
}
</style>
@@ -0,0 +1,130 @@
import { createTestingPinia } from "@pinia/testing";
import { getLocalVue, injectTestRouter } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { http, HttpResponse } from "msw";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useServerMock } from "@/api/client/__mocks__";
import type { ShowFullJobResponse } from "@/api/jobs";
import { type Tool, useToolStore } from "@/stores/toolStore";
import TEST_JOBS_JSON from "../WorkflowInvocationState/test/json/jobs.json";
import RerunJobButton from "./RerunJobButton.vue";
const localVue = getLocalVue();
const router = injectTestRouter(localVue);
const { server } = useServerMock();
// "sample-job-2" is terminal, so useJobDetails's polling doesn't keep a timer running past the test.
const BASE_JOB = TEST_JOBS_JSON.find((job) => job.id === "sample-job-2") as unknown as ShowFullJobResponse;
const JOB_ID = BASE_JOB.id;
const TOOL_ID = BASE_JOB.tool_id;
const RERUNNABLE_TOOL = { id: TOOL_ID, is_workflow_compatible: true } as Tool;
const NON_RERUNNABLE_TOOL = { id: TOOL_ID, is_workflow_compatible: false } as Tool;
const SELECTORS = {
BUTTON: ".g-button",
SPINNER: "[data-icon='spinner']",
};
function mountRerunJobButton() {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false });
return mount(RerunJobButton as object, {
propsData: { jobId: JOB_ID },
localVue,
pinia,
router,
});
}
describe("RerunJobButton.vue", () => {
beforeEach(() => {
server.use(http.get("/api/jobs/:job_id", () => HttpResponse.json(BASE_JOB)));
});
afterEach(() => {
vi.clearAllTimers();
});
it("renders disabled with a spinner while the tool is loading", async () => {
server.use(http.get("/api/tools/:tool_id", () => new Promise(() => {})));
const wrapper = mountRerunJobButton();
await flushPromises();
const button = wrapper.find(SELECTORS.BUTTON);
expect(button.exists()).toBe(true);
expect(button.classes()).toContain("g-disabled");
expect(wrapper.find(SELECTORS.SPINNER).exists()).toBe(true);
});
it("enables the button once the tool is loaded and is workflow-compatible", async () => {
server.use(http.get("/api/tools/:tool_id", () => HttpResponse.json(RERUNNABLE_TOOL)));
const wrapper = mountRerunJobButton();
await flushPromises();
expect(wrapper.find(SELECTORS.BUTTON).classes()).not.toContain("g-disabled");
});
it("stays disabled once loaded if the tool is not workflow-compatible", async () => {
server.use(http.get("/api/tools/:tool_id", () => HttpResponse.json(NON_RERUNNABLE_TOOL)));
const wrapper = mountRerunJobButton();
await flushPromises();
expect(wrapper.find(SELECTORS.BUTTON).classes()).toContain("g-disabled");
});
it("settles to disabled with an explanation if the tool can't be fetched", async () => {
server.use(
http.get("/api/tools/:tool_id", () =>
HttpResponse.json({ err_msg: "Tool not found", err_code: 404 }, { status: 404 }),
),
);
const wrapper = mountRerunJobButton();
await flushPromises();
expect(wrapper.find(SELECTORS.BUTTON).exists()).toBe(true);
// The tool store fetches via axios, whose rejection needs more than a microtask flush
await new Promise((resolve) => setTimeout(resolve, 100));
await flushPromises();
expect(wrapper.find(SELECTORS.SPINNER).exists()).toBe(false);
const button = wrapper.find(SELECTORS.BUTTON);
expect(button.classes()).toContain("g-disabled");
expect(button.attributes("title")).toContain("Could not check if this job can be rerun");
});
it("does not re-fetch the tool if it's already cached in the store", async () => {
let toolFetchCount = 0;
server.use(
http.get("/api/tools/:tool_id", () => {
toolFetchCount++;
return HttpResponse.json(RERUNNABLE_TOOL);
}),
);
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false });
const toolStore = useToolStore();
toolStore.fetchToolForId(TOOL_ID);
await flushPromises();
expect(toolFetchCount).toBe(1);
const wrapper = mount(RerunJobButton as object, {
propsData: { jobId: JOB_ID },
localVue,
pinia,
router,
});
await flushPromises();
expect(toolFetchCount).toBe(1);
expect(wrapper.find(SELECTORS.BUTTON).classes()).not.toContain("g-disabled");
});
});
@@ -1,31 +1,64 @@
<script setup lang="ts">
import { faRedo } from "@fortawesome/free-solid-svg-icons";
import { faRedo, faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { computed } from "vue";
import { useRoute } from "vue-router";
import type { JobBaseModel, ShowFullJobResponse } from "@/api/jobs";
import { useJobDetails } from "@/composables/jobDetails.js";
import { useRerunnableTool } from "@/composables/rerunnableTool";
import GButton from "../BaseComponents/GButton.vue";
const route = useRoute();
const props = defineProps<{
jobId: string;
/** A job object. `undefined` fetches/polls it here instead; `null` means
* the parent confirms there's deliberately no job yet (also skips fetching). */
job?: JobBaseModel | ShowFullJobResponse | null;
outline?: boolean;
}>();
const fetchJobId = computed(() => (props.job === undefined ? props.jobId : undefined));
const { job: fetchedJob } = useJobDetails(fetchJobId, { full: false });
const job = computed(() => (props.job !== undefined ? props.job : fetchedJob.value));
const rerunUrl = computed(() => `/?job_id=${props.jobId}`);
const {
canRerun: canRerunJob,
loading: toolLoading,
error: toolError,
} = useRerunnableTool(computed(() => job.value?.tool_id));
const disabledTitle = computed(() => {
if (toolLoading.value) {
return "Checking if this job can be rerun...";
}
if (toolError.value) {
return `Could not check if this job can be rerun: ${toolError.value}`;
}
return "This job cannot be rerun";
});
</script>
<template>
<GButton
title="Rerun job"
v-if="job"
title="Run Job Again"
:disabled-title="disabledTitle"
:disabled="toolLoading || !canRerunJob"
size="small"
color="blue"
:outline="props.outline"
:transparent="!props.outline"
:pressed="route.fullPath === rerunUrl"
:to="rerunUrl">
<FontAwesomeIcon fixed-width :icon="faRedo" />
<span>Run Job Again</span>
<!-- Separate icons rather than a dynamic `icon` prop: an in-place patch of `FontAwesomeIcon`'s
icon doesn't always re-render, leaving the spinner after the check settles while disabled. -->
<FontAwesomeIcon v-if="toolLoading" fixed-width :icon="faSpinner" spin />
<FontAwesomeIcon v-else fixed-width :icon="faRedo" />
<span class="text-nowrap">Run again</span>
</GButton>
</template>
@@ -3,7 +3,7 @@ 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RegisteredUser } from "@/api";
import { useServerMock } from "@/api/client/__mocks__";
@@ -16,6 +16,8 @@ vi.mock("vue-router", () => ({
useRoute: vi.fn(() => ({})),
}));
vi.useFakeTimers();
const localVue = getLocalVue();
const { server, http } = useServerMock();
@@ -45,11 +47,17 @@ const SELECTORS = {
};
function mountJobState(job: JobBaseModel | ShowFullJobResponse, user: RegisteredUser | null = FAKE_USER) {
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
return response(200).json(job as ShowFullJobResponse);
}),
);
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false });
const userStore = useUserStore();
userStore.currentUser = user;
return mount(JobState as object, {
propsData: { job },
propsData: { jobId: job.id },
localVue,
pinia,
});
@@ -64,6 +72,10 @@ describe("JobState.vue", () => {
);
});
afterEach(() => {
vi.clearAllTimers();
});
it("renders the job state badge with the job state text", async () => {
const wrapper = mountJobState(BASE_JOB);
await flushPromises();
@@ -78,7 +90,7 @@ describe("JobState.vue", () => {
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 });
const wrapper = mountJobState({ ...BASE_JOB, id: `job-${state}`, state });
await flushPromises();
expect(wrapper.find(SELECTORS.STOP_BTN).exists()).toBe(false);
}
+26 -11
View File
@@ -5,7 +5,9 @@ import { storeToRefs } from "pinia";
import { computed, ref } from "vue";
import { isRegisteredUser } from "@/api";
import { deleteJob, type JobBaseModel, NON_TERMINAL_STATES, type ShowFullJobResponse } from "@/api/jobs";
import type { JobBaseModel, ShowFullJobResponse } from "@/api/jobs";
import { deleteJob, NON_TERMINAL_STATES } from "@/api/jobs";
import { useJobDetails } from "@/composables/jobDetails.js";
import { useToast } from "@/composables/toast";
import { getHeaderClass, iconClasses } from "@/composables/useInvocationGraph";
import { useUserStore } from "@/stores/userStore";
@@ -14,17 +16,27 @@ import { errorMessageAsString } from "@/utils/simple-error";
import GButton from "../BaseComponents/GButton.vue";
const props = defineProps<{
job: JobBaseModel | ShowFullJobResponse;
jobId: string;
/** A job object. `undefined` fetches/polls it here instead; `null` means
* the parent confirms there's deliberately no job yet (also skips fetching). */
job?: JobBaseModel | ShowFullJobResponse | null;
}>();
const badgeClass = computed(() => {
if (!job.value) {
return {};
}
return {
...getHeaderClass(props.job.state),
...getHeaderClass(job.value.state),
"text-center": true,
};
});
const stateIcon = computed(() => iconClasses[props.job.state] || null);
const fetchJobId = computed(() => (props.job === undefined ? props.jobId : undefined));
const { job: fetchedJob } = useJobDetails(fetchJobId, { full: false });
const job = computed(() => (props.job !== undefined ? props.job : fetchedJob.value));
const stateIcon = computed(() => (job.value ? iconClasses[job.value.state] : null));
const Toast = useToast();
@@ -32,11 +44,11 @@ const { currentUser } = storeToRefs(useUserStore());
/** Whether the current user owns the job (can stop it) */
const userOwnsJob = computed(() => {
if (!currentUser.value || !isRegisteredUser(currentUser.value)) {
if (!job.value || !currentUser.value || !isRegisteredUser(currentUser.value)) {
return false;
}
if ("user_id" in props.job && props.job.user_id) {
return props.job.user_id === currentUser.value.id;
if ("user_id" in job.value && job.value.user_id) {
return job.value.user_id === currentUser.value.id;
}
// `user_id` not available on `JobBaseModel` — caller context implies ownership
return true;
@@ -46,15 +58,18 @@ const userOwnsJob = computed(() => {
* 1. If the job is not the user's own
* 2. Job is not in a terminal state
*/
const canStopJob = computed(() => userOwnsJob.value && NON_TERMINAL_STATES.includes(props.job.state));
const canStopJob = computed(() => job.value && userOwnsJob.value && NON_TERMINAL_STATES.includes(job.value.state));
/** Whether the stop job action is currently being performed */
const stopping = ref(false);
async function stopJob() {
if (!job.value || stopping.value) {
return;
}
stopping.value = true;
try {
await deleteJob(props.job.id);
await deleteJob(job.value.id);
Toast.success("Job scheduled to be stopped.");
} catch (error) {
@@ -66,7 +81,7 @@ async function stopJob() {
</script>
<template>
<span class="job-state-badge rounded px-2 py-1 text-nowrap" :class="badgeClass">
<span v-if="job" class="job-state-badge rounded px-2 py-1 text-nowrap" :class="badgeClass">
<FontAwesomeIcon
v-if="stateIcon"
:icon="stateIcon.icon"
@@ -86,7 +101,7 @@ async function stopJob() {
@click.stop.prevent="stopJob">
<FontAwesomeIcon :icon="faSquare" />
</GButton>
{{ props.job.state }}
{{ job.state }}
</span>
</template>
@@ -39,8 +39,10 @@ describe.each([
async function mountElement(toolId?: string) {
const pinia = createTestingPinia({ createSpy: vi.fn });
// The testing store stubs fetchJob, so stand in for the job it would have loaded.
// `useJobDetails` reads the passive `getCachedJob` getter, not `getJob` (which also
// triggers a fetch on a cache miss).
// @ts-expect-error: getters are only writable on a testing store
useJobStore(pinia).getJob = () => (toolId ? ({ tool_id: toolId } as ShowFullJobResponse) : null);
useJobStore(pinia).getCachedJob = () => (toolId ? ({ tool_id: toolId } as ShowFullJobResponse) : null);
wrapper = mount(component as object, {
attachTo: document.body,
props: { jobId: "job_id", title: "Job" },
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { BCard, BCardFooter, BCardTitle } from "bootstrap-vue";
import { computed, toRef, watch } from "vue";
import { computed, toRef } from "vue";
import { useJobStore } from "@/stores/jobStore";
import { useJobDetails } from "@/composables/jobDetails";
import { useMappingJobs } from "./handlesMappingJobs";
@@ -24,18 +24,16 @@ const props = withDefaults(defineProps<JobMetricsProps>(), {
footer: undefined,
});
const jobStore = useJobStore();
const toolId = computed(() => {
if (targetJobId.value) {
return jobStore.getJob(targetJobId.value)?.tool_id;
return job.value?.tool_id;
}
return undefined;
});
const toolVersion = computed(() => {
if (targetJobId.value) {
// TODO: `ShowFullJobResponse` does not have a `tool_version` property
return (jobStore.getJob(targetJobId.value) as any)?.tool_version;
return (job.value as any)?.tool_version;
}
return undefined;
});
@@ -44,19 +42,7 @@ const implicitCollectionJobsIdRef = toRef(props, "implicitCollectionJobsId");
const { selectJobOptions, selectedJob, targetJobId } = useMappingJobs(jobIdRef, implicitCollectionJobsIdRef);
async function init() {
if (targetJobId.value) {
jobStore.fetchJob({ id: targetJobId.value });
}
}
watch(
targetJobId,
() => {
init();
},
{ immediate: true },
);
const { job } = useJobDetails(targetJobId, { poll: false });
</script>
<template>
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { BCard, BCardFooter, BCardTitle } from "bootstrap-vue";
import { computed, toRef, watch } from "vue";
import { computed, toRef } from "vue";
import { useJobStore } from "@/stores/jobStore";
import { useJobDetails } from "@/composables/jobDetails";
import { useMappingJobs } from "./handlesMappingJobs";
@@ -26,18 +26,16 @@ const props = withDefaults(defineProps<JobParametersProps>(), {
footer: undefined,
});
const jobStore = useJobStore();
const toolId = computed(() => {
if (targetJobId.value) {
return jobStore.getJob(targetJobId.value)?.tool_id;
return job.value?.tool_id;
}
return undefined;
});
const toolVersion = computed(() => {
if (targetJobId.value) {
// TODO: `ShowFullJobResponse` does not have a `tool_version` property
return (jobStore.getJob(targetJobId.value) as any)?.tool_version;
return (job.value as any)?.tool_version;
}
return undefined;
});
@@ -46,19 +44,7 @@ const implicitCollectionJobsIdRef = toRef(props, "implicitCollectionJobsId");
const { selectJobOptions, selectedJob, targetJobId } = useMappingJobs(jobIdRef, implicitCollectionJobsIdRef);
async function init() {
if (targetJobId.value) {
jobStore.fetchJob({ id: targetJobId.value });
}
}
watch(
targetJobId,
() => {
init();
},
{ immediate: true },
);
const { job } = useJobDetails(targetJobId, { poll: false });
</script>
<template>
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { BCard } from "bootstrap-vue";
import { computed, toRef, watch } from "vue";
import { computed, toRef } from "vue";
import { useJobStore } from "@/stores/jobStore";
import { useJobDetails } from "@/composables/jobDetails";
import { useMappingJobs } from "./handlesMappingJobs";
@@ -19,32 +19,17 @@ const props = withDefaults(defineProps<ToolStdProps>(), {
implicitCollectionJobsId: undefined,
});
const jobStore = useJobStore();
const jobIdRef = toRef(props, "jobId");
const implicitCollectionJobsIdRef = toRef(props, "implicitCollectionJobsId");
const { selectJobOptions, selectedJob, targetJobId } = useMappingJobs(jobIdRef, implicitCollectionJobsIdRef);
async function init() {
if (targetJobId.value) {
jobStore.fetchJob({ id: targetJobId.value });
}
}
watch(
targetJobId,
() => {
init();
},
{ immediate: true },
);
const { job } = useJobDetails(targetJobId, { poll: false });
const jobContent = computed(() => {
let content: string | null | undefined;
if (targetJobId.value) {
const job = jobStore.getJob(targetJobId.value);
content = job && job[props.name];
content = job.value?.[props.name];
}
if (!content && props.name == "tool_stdout") {
content = "*No Standard Output Available*";
-1
View File
@@ -454,7 +454,6 @@ async function onExecute() {
jobStore.saveLatestResponse({
jobDef,
jobResponse,
toolName: toolName.value,
});
} else if (nErrors > 0) {
showError.value = true;
+250 -28
View File
@@ -1,45 +1,267 @@
import { createTestingPinia } from "@pinia/testing";
import { getLocalVue, injectTestRouter } from "@tests/vitest/helpers";
import { shallowMount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import { advanceTimersAndFlush, getLocalVue, injectTestRouter } from "@tests/vitest/helpers";
import { enableAutoUnmount, mount, type VueWrapper } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { computed } from "vue";
import { useServerMock } from "@/api/client/__mocks__";
import type { JobRequest, JobResponse, ResponseVal } from "@/api/jobs";
import jobInformationResponse from "@/components/JobInformation/testData/jobInformationResponse.json";
import { useConfig } from "@/composables/config";
import ToolSuccess from "./ToolSuccess.vue";
import ToolSuccessMessage from "./ToolSuccessMessage.vue";
import ToolSuccessOutputs from "./ToolSuccessOutputs.vue";
import JobHeader from "@/components/JobInformation/JobHeader.vue";
import JobInformation from "@/components/JobInformation/JobInformation.vue";
import JobState from "@/components/JobStates/JobState.vue";
import ToolRecommendation from "@/components/ToolRecommendation.vue";
vi.mock("@/composables/config", () => ({ useConfig: vi.fn() }));
vi.mock("@/composables/config", () => ({
useConfig: vi.fn(),
}));
vi.mock("@/api/invocations", () => ({
fetchInvocationForJob: vi.fn(),
}));
vi.mock("axios", () => ({
default: {
get: vi.fn().mockResolvedValue({ data: [] }),
},
}));
vi.mock("@/stores/toolStore", () => ({
useToolStore: () => ({
getToolNameById: () => TEST_TOOL_NAME,
getToolForId: () => ({ is_workflow_compatible: true }),
}),
}));
const { server, http } = useServerMock();
enableAutoUnmount(afterEach);
beforeEach(() => {
server.use(
http.get("/api/jobs/{job_id}", ({ response, params }) =>
response(200).json({ ...jobInformationResponse, id: params.job_id, state: "ok" } as never),
),
http.get("/api/jobs/{job_id}/console_output", ({ response }) =>
response(200).json({ stdout: "", stderr: "", state: "ok" }),
),
);
});
const localVue = getLocalVue();
const router = injectTestRouter(localVue);
describe("ToolSuccess recommendations", () => {
it.each([
["anonymous", { isAnonymous: true }, true, true],
["unloaded", null, true, true],
["registered", { id: "user-id", email: "user@example.org" }, true, true],
["disabled", { id: "user-id", email: "user@example.org" }, false, false],
])("handles %s users/configuration", (_label, currentUser, enabled, expected) => {
vi.mocked(useConfig).mockReturnValue({
config: { value: { enable_tool_recommendations: enabled } },
} as ReturnType<typeof useConfig>);
const pinia = createTestingPinia({
createSpy: vi.fn,
initialState: {
userStore: { currentUser },
jobStore: {
latestResponse: {
jobDef: { tool_id: "cat1" },
jobResponse: { jobs: [], outputs: [], output_collections: [] },
toolName: "Concatenate",
},
vi.mocked(useConfig).mockReturnValue({
config: computed(() => ({ enable_tool_recommendations: false })),
isConfigLoaded: computed(() => true),
});
const TEST_TOOL_NAME = "Test Tool";
const TEST_JOB_DEF = {
tool_id: "test_tool",
} as JobRequest;
const TEST_JOB_RESPONSE = {
produces_entry_points: false,
jobs: [jobInformationResponse],
outputs: [],
output_collections: [],
} as unknown as JobResponse;
// Selectors
const SELECTORS = {
PAGINATION_ITEM: ".page-item .page-link",
};
async function mountToolSuccess(latestResponse: ResponseVal | null, currentUser: unknown = null) {
const testPinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false,
initialState: {
jobStore: {
latestResponse,
},
userStore: {
currentUser,
},
},
});
setActivePinia(testPinia);
const wrapper = mount(ToolSuccess as object, {
localVue,
router,
pinia: testPinia,
stubs: {
FontAwesomeIcon: true,
},
}) as VueWrapper;
await flushPromises();
return wrapper;
}
describe("ToolSuccess", () => {
it("redirects home when there is no response in the store", async () => {
const routerPush = vi.spyOn(router, "push");
await mountToolSuccess(null);
expect(routerPush).toHaveBeenCalledWith("/");
});
describe("ToolSuccess recommendations", () => {
it.each([
["anonymous", { isAnonymous: true }, true, true],
["unloaded", null, true, true],
["registered", { id: "user-id", email: "user@example.org" }, true, true],
["disabled", { id: "user-id", email: "user@example.org" }, false, false],
])("handles %s users/configuration", async (_label, currentUser, enabled, expected) => {
vi.mocked(useConfig).mockReturnValue({
config: { value: { enable_tool_recommendations: enabled } },
} as ReturnType<typeof useConfig>);
const wrapper = await mountToolSuccess(
{
jobDef: TEST_JOB_DEF,
jobResponse: TEST_JOB_RESPONSE,
},
currentUser,
);
expect(wrapper.findComponent(ToolRecommendation).exists()).toBe(expected);
expect(wrapper.findComponent(JobInformation).exists()).toBe(true);
wrapper.unmount();
});
});
describe("with a single job", () => {
let wrapper: VueWrapper;
beforeEach(async () => {
wrapper = await mountToolSuccess({
jobDef: TEST_JOB_DEF,
jobResponse: TEST_JOB_RESPONSE,
});
});
it("renders JobHeader for the job with tool name", () => {
const jobHeader = wrapper.findComponent(JobHeader);
expect(jobHeader.text()).toContain(TEST_TOOL_NAME);
expect(jobHeader.props("jobId")).toEqual(jobInformationResponse.id);
});
it("does not show the multi-job pagination header", () => {
expect(wrapper.findAll(SELECTORS.PAGINATION_ITEM).length).toBe(0);
});
});
describe("with outputs", () => {
const TEST_OUTPUT = { id: "output_id", hid: 1, name: "output1", history_content_type: "dataset" };
const TEST_OUTPUT_COLLECTION = {
id: "collection_id",
hid: 2,
name: "collection1",
history_content_type: "dataset_collection",
};
it("passes both dataset and collection outputs through to ToolSuccessOutputs", async () => {
const wrapper = await mountToolSuccess({
jobDef: TEST_JOB_DEF,
jobResponse: {
...TEST_JOB_RESPONSE,
outputs: [TEST_OUTPUT],
output_collections: [TEST_OUTPUT_COLLECTION],
} as unknown as JobResponse,
});
const outputs = wrapper.findComponent(ToolSuccessOutputs);
expect(outputs.exists()).toBe(true);
expect(outputs.props("jobResponse").outputs).toEqual([TEST_OUTPUT]);
expect(outputs.props("jobResponse").output_collections).toEqual([TEST_OUTPUT_COLLECTION]);
});
});
describe("with multiple jobs", () => {
const SECOND_JOB = { ...jobInformationResponse, id: "test_id_2" };
let wrapper: VueWrapper;
beforeEach(async () => {
wrapper = await mountToolSuccess({
jobDef: TEST_JOB_DEF,
jobResponse: {
...TEST_JOB_RESPONSE,
jobs: [jobInformationResponse, SECOND_JOB] as unknown as JobResponse["jobs"],
},
});
});
it("shows a pagination control for each job", () => {
expect(wrapper.findAll(SELECTORS.PAGINATION_ITEM).length).toBeGreaterThan(0);
});
it("shows JobState and RerunJobButton for the currently viewed job", () => {
const jobState = wrapper.findComponent(JobState);
expect(jobState.exists()).toBe(true);
expect(jobState.props("jobId")).toEqual(jobInformationResponse.id);
});
it("switches the viewed job when navigating to the next pagination page", async () => {
const pageTwo = wrapper
.findAll(SELECTORS.PAGINATION_ITEM)
.filter((link) => link.text() === "2")
.at(0);
await pageTwo?.trigger("click");
const jobState = wrapper.findComponent(JobState);
expect(jobState.props("jobId")).toEqual(SECOND_JOB.id);
});
});
});
describe("ToolSuccess live job state", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
it("shares one poll between the header, details and entry points, and stops polling the previous page", async () => {
const calls: string[] = [];
let state = "running";
server.use(
http.get("/api/jobs/{job_id}", ({ response, params }) => {
calls.push(params.job_id as string);
return response(200).json({ ...jobInformationResponse, id: params.job_id, state } as never);
}),
);
const firstId = jobInformationResponse.id;
const secondId = "second-job";
const wrapper = await mountToolSuccess({
jobDef: TEST_JOB_DEF,
jobResponse: {
...TEST_JOB_RESPONSE,
produces_entry_points: true,
jobs: [TEST_JOB_RESPONSE.jobs[0]!, { ...TEST_JOB_RESPONSE.jobs[0]!, id: secondId }],
},
});
const wrapper = shallowMount(ToolSuccess, { localVue, router, pinia });
expect(wrapper.findComponent(ToolRecommendation).exists()).toBe(expected);
expect(wrapper.findComponent(ToolSuccessMessage).exists()).toBe(true);
expect(wrapper.findComponent(JobState).text()).toContain("running");
expect(calls).toEqual([firstId]);
const pageTwo = wrapper.findAll(SELECTORS.PAGINATION_ITEM).find((link) => link.text() === "2");
expect(pageTwo).toBeDefined();
await pageTwo!.trigger("click");
await flushPromises();
expect(calls).toEqual([firstId, secondId]);
state = "ok";
await advanceTimersAndFlush(1000);
expect(calls).toEqual([firstId, secondId, secondId]);
expect(wrapper.findComponent(JobState).text()).toContain("ok");
expect(wrapper.find('[data-description="galaxy-job-state"]').text()).toContain("ok");
expect(wrapper.text()).toContain("No Interactive Tool sessions are currently available");
await advanceTimersAndFlush(3000);
expect(calls).toEqual([firstId, secondId, secondId]);
wrapper.unmount();
});
});
+89 -14
View File
@@ -1,16 +1,24 @@
<script setup lang="ts">
import { faLightbulb } from "@fortawesome/free-solid-svg-icons";
import { BPagination } from "bootstrap-vue";
import { storeToRefs } from "pinia";
import { computed } from "vue";
import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useConfig } from "@/composables/config";
import { useJobDetails } from "@/composables/jobDetails";
import { useUserLocalStorage } from "@/composables/userLocalStorage";
import { useJobStore } from "@/stores/jobStore";
import LoadingSpan from "../LoadingSpan.vue";
import ToolRecommendation from "../ToolRecommendation.vue";
import ToolSuccessMessage from "./ToolSuccessMessage.vue";
import GAlert from "@/components/BaseComponents/GAlert.vue";
import DetailBlock from "@/components/Common/DetailBlock.vue";
import Webhook from "@/components/Common/Webhook.vue";
import JobHeader from "@/components/JobInformation/JobHeader.vue";
import JobInformation from "@/components/JobInformation/JobInformation.vue";
import ToolSuccessOutputs from "@/components/Tool/ToolSuccessOutputs.vue";
import ToolSuccessToc from "@/components/Tool/ToolSuccessToc.vue";
import ToolEntryPoints from "@/components/ToolEntryPoints/ToolEntryPoints.vue";
const { config } = useConfig();
@@ -21,26 +29,93 @@ const router = useRouter();
const jobDef = computed(() => latestResponse.value?.jobDef);
const jobResponse = computed(() => latestResponse.value?.jobResponse);
const showRecommendation = computed(() => config.value.enable_tool_recommendations);
const toolName = computed(() => latestResponse.value?.toolName);
const nJobs = computed(() => (jobResponse.value && jobResponse.value.jobs ? jobResponse.value.jobs.length : 0));
// no data means that no tool was run in this session i.e. no data in the store
if (!latestResponse.value || Object.keys(latestResponse.value).length === 0) {
router.push(`/`);
}
const currentIndex = ref(0);
// BPagination is 1-indexed; bridge to the 0-indexed currentIndex.
const paginationPage = computed<number>({
get: () => currentIndex.value + 1,
set: (val: number) => {
currentIndex.value = val - 1;
},
});
watch(jobResponse, () => {
currentIndex.value = 0;
});
const viewedJobId = computed(
() => jobResponse.value?.jobs?.[currentIndex.value]?.id ?? jobResponse.value?.jobs?.[0]?.id,
);
const { job: viewedJob } = useJobDetails(viewedJobId);
const webhook = ref<InstanceType<typeof Webhook> | null>(null);
const webhookId = computed(() => webhook.value?.webhookId ?? null);
const jobInformationCollapsed = useUserLocalStorage("job-information-collapsed", false);
const toolSuccessOutputsCollapsed = useUserLocalStorage("tool-success-outputs-collapsed", false);
</script>
<template>
<section>
<GAlert v-if="!jobResponse" variant="info" show>
<LoadingSpan message="Waiting on data" />
</GAlert>
<div v-else>
<div v-if="jobResponse?.produces_entry_points">
<ToolEntryPoints v-for="job in jobResponse.jobs" :key="job.id" :job-id="job.id" />
<GAlert v-if="!jobResponse">
<LoadingSpan message="Waiting on data" />
</GAlert>
<div v-else>
<template v-if="viewedJobId">
<JobHeader :job-id="viewedJobId" :job="viewedJob" animate-success>
<template v-slot:pagination>
<BPagination
v-if="nJobs > 1"
v-model="paginationPage"
:total-rows="nJobs"
:per-page="1"
size="sm"
:limit="3"
first-number
last-number
hide-goto-end-buttons
class="mb-0 unselectable" />
</template>
</JobHeader>
<ToolSuccessToc
v-model:job-information-collapsed="jobInformationCollapsed"
v-model:tool-success-outputs-collapsed="toolSuccessOutputsCollapsed"
:job-response="jobResponse"
:job-def="jobDef"
:webhook-id="webhookId"
:show-recommendation="showRecommendation" />
<div v-if="jobResponse.produces_entry_points" id="tool-entry-points">
<ToolEntryPoints :job-id="viewedJobId" :job="viewedJob" :other-jobs-count="nJobs > 1 ? nJobs - 1 : 0" />
</div>
<ToolSuccessMessage :job-response="jobResponse" :tool-name="toolName || '...'" />
<Webhook v-if="jobDef" type="tool" :tool-id="jobDef.tool_id || undefined" />
<ToolRecommendation v-if="showRecommendation && jobDef?.tool_id" :tool-id="jobDef.tool_id" />
<div id="job-execution-details">
<JobInformation
v-model:collapsed="jobInformationCollapsed"
:job-id="viewedJobId"
collapsible
include-view-full-details-button />
</div>
</template>
<div id="tool-run-outputs">
<ToolSuccessOutputs v-model:collapsed="toolSuccessOutputsCollapsed" :job-response="jobResponse" />
</div>
</section>
<div id="tool-success-webhook">
<DetailBlock v-if="jobDef" v-show="webhookId" :header-icon="faLightbulb" title="Before You Go">
<Webhook ref="webhook" type="tool" :tool-id="jobDef.tool_id || undefined" />
</DetailBlock>
</div>
<div v-if="showRecommendation && jobDef?.tool_id" id="tool-recommendation">
<ToolRecommendation :tool-id="jobDef.tool_id" />
</div>
</div>
</template>
@@ -1,99 +0,0 @@
import { getLocalVue, injectTestRouter } from "@tests/vitest/helpers";
import { mount, type VueWrapper } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { beforeEach, describe, expect, it } from "vitest";
import jobInformationResponse from "@/components/JobInformation/testData/jobInformationResponse.json";
import ToolSuccessMessage from "./ToolSuccessMessage.vue";
const localVue = getLocalVue();
const router = injectTestRouter(localVue);
// Prop constants
const TEST_JOB_RESPONSE = {
produces_entry_points: false,
jobs: [jobInformationResponse],
outputs: [
{
hid: 0,
name: "output1",
},
],
output_collections: [
{
hid: 1,
name: "collection1",
},
],
};
const TEST_TOOL_NAME = "Test Tool";
// Selectors
const SELECTORS = {
SINGULAR_JOB_LINK: "[data-description='singular job link']",
JOB_LINK: "[data-description='job link']",
OUTPUTS_LIST: "[data-description='list of outputs']",
};
describe("ToolSuccessMessage", () => {
let wrapper: VueWrapper;
beforeEach(async () => {
wrapper = mount(ToolSuccessMessage as object, {
global: localVue,
router,
props: {
jobResponse: TEST_JOB_RESPONSE,
toolName: TEST_TOOL_NAME,
},
stubs: {
FontAwesomeIcon: true,
},
});
});
it("shows both dataset and collection outputs correctly", async () => {
expect(wrapper.find(SELECTORS.OUTPUTS_LIST).text()).toContain(
`${TEST_JOB_RESPONSE.outputs[0]?.hid}: ${TEST_JOB_RESPONSE.outputs[0]?.name}`,
);
expect(wrapper.find(SELECTORS.OUTPUTS_LIST).text()).toContain(
`${TEST_JOB_RESPONSE.output_collections[0]?.hid}: ${TEST_JOB_RESPONSE.output_collections[0]?.name}`,
);
});
it("has a link to the singular job", () => {
expect(wrapper.find(SELECTORS.SINGULAR_JOB_LINK).exists()).toBe(true);
});
it("shows the tool name", () => {
expect(wrapper.text()).toContain(`Started tool ${TEST_TOOL_NAME}`);
});
it("has a link to each job when multiple jobs are present", async () => {
// Remount with two-job props instead of setProps -- VTU v2 + Vue 3 doesn't
// reliably re-render when a top-level prop object is replaced wholesale.
wrapper = mount(ToolSuccessMessage as object, {
global: localVue,
router,
props: {
jobResponse: {
...TEST_JOB_RESPONSE,
jobs: [
jobInformationResponse,
{
...jobInformationResponse,
id: "test_id_2",
},
],
},
toolName: TEST_TOOL_NAME,
},
stubs: {
FontAwesomeIcon: true,
},
});
await flushPromises();
expect(wrapper.findAll(SELECTORS.JOB_LINK).length).toBe(2);
});
});
@@ -1,56 +0,0 @@
<script setup lang="ts">
import { computed } from "vue";
import { RouterLink } from "vue-router";
import type { JobResponse } from "@/api/jobs";
import ExternalLink from "../ExternalLink.vue";
const props = defineProps<{
jobResponse: JobResponse;
toolName: string;
}>();
const outputs = computed(() => props.jobResponse.outputs.concat(props.jobResponse.output_collections));
const nJobs = computed(() => (props.jobResponse && props.jobResponse.jobs ? props.jobResponse.jobs.length : 0));
const nJobsText = computed(() => (nJobs.value > 1 ? `${nJobs.value} jobs` : `1 job`));
const nOutputsText = computed(() => (outputs.value.length > 1 ? `${outputs.value.length} outputs` : `this output`));
</script>
<template>
<div class="donemessagelarge">
<p>
Started tool <b>{{ props.toolName }}</b> and successfully added {{ nJobsText }} to the queue.
</p>
<p>It produces {{ nOutputsText }}:</p>
<ul data-description="list of outputs">
<li v-for="item of outputs" :key="item.hid">
<b>{{ item.hid }}: {{ item.name }}</b>
</li>
</ul>
<p>
You can check the status of queued jobs and view the resulting data by refreshing the History panel. When
the job has been run the status will change from 'running' to 'finished' if completed successfully or
'error' if problems were encountered.
</p>
<p v-if="nJobs > 1">
Here is a link to each job:
<ExternalLink
v-for="job in jobResponse.jobs"
:key="job.id"
:href="`/jobs/${job.id}/view`"
data-description="job link">
{{ job.id }}
</ExternalLink>
</p>
<p v-else-if="jobResponse.jobs[0]" data-description="singular job link">
Here is a link to the job:
<RouterLink :to="`/jobs/${jobResponse.jobs[0].id}/view`">
{{ jobResponse.jobs[0]?.id }}
</RouterLink>
</p>
</div>
</template>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { faSignOutAlt } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
import type { JobResponse } from "@/api/jobs";
import DetailBlock from "@/components/Common/DetailBlock.vue";
import GenericItem from "@/components/History/Content/GenericItem.vue";
const props = defineProps<{
jobResponse: JobResponse;
collapsed: boolean;
}>();
const emit = defineEmits<{
(e: "update:collapsed", value: boolean): void;
}>();
const outputs = computed(() => [
...(props.jobResponse?.outputs || []),
...(props.jobResponse?.output_collections || []),
]);
</script>
<template>
<DetailBlock
v-if="outputs.length"
:collapsed="props.collapsed"
:header-icon="faSignOutAlt"
title="Tool Run Outputs"
collapsible
@update:collapsed="emit('update:collapsed', $event)">
<div class="outputs-grid">
<GenericItem
v-for="output in outputs"
:key="output.id"
:item-id="output.id"
:item-src="output.history_content_type === 'dataset' ? 'hda' : 'hdca'" />
</div>
</DetailBlock>
</template>
<style scoped lang="scss">
.outputs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.5rem;
padding: 0.5rem 0.25rem;
}
</style>
@@ -0,0 +1,108 @@
<script setup lang="ts">
/**
* Table Of Contents for the tool success page, allowing quick navigation to different sections.
*/
import { computed } from "vue";
import type { JobRequest, JobResponse } from "@/api/jobs";
interface TocEntry {
id: string;
label: string;
/** Set when this section can be force-expanded (i.e. it's one of the collapsible ones). */
collapsed?: boolean;
}
const props = defineProps<{
jobResponse?: JobResponse;
jobDef?: JobRequest;
webhookId?: string | null;
showRecommendation: boolean;
/** Controls `JobInformation`'s collapse state, so its TOC entry can force it open. */
jobInformationCollapsed: boolean;
/** Controls `ToolSuccessOutputs`' collapse state, so its TOC entry can force it open. */
toolSuccessOutputsCollapsed: boolean;
}>();
const emit = defineEmits<{
(e: "update:jobInformationCollapsed", value: boolean): void;
(e: "update:toolSuccessOutputsCollapsed", value: boolean): void;
}>();
const entries = computed<TocEntry[]>(() => {
const list: TocEntry[] = [];
if (props.jobResponse?.produces_entry_points) {
list.push({ id: "tool-entry-points", label: "Interactive Tools" });
}
list.push({
id: "job-execution-details",
label: "Job Execution Details",
collapsed: props.jobInformationCollapsed,
});
if (props.jobResponse && (props.jobResponse.outputs?.length || props.jobResponse.output_collections?.length)) {
list.push({ id: "tool-run-outputs", label: "Tool Run Outputs", collapsed: props.toolSuccessOutputsCollapsed });
}
if (props.jobDef && props.webhookId) {
list.push({ id: "tool-success-webhook", label: "Before You Go" });
}
if (props.showRecommendation && props.jobDef?.tool_id) {
list.push({ id: "tool-recommendation", label: "Tool Recommendation" });
}
return list;
});
function goTo(entry: TocEntry) {
if (entry.collapsed) {
if (entry.id === "job-execution-details") {
emit("update:jobInformationCollapsed", false);
} else if (entry.id === "tool-run-outputs") {
emit("update:toolSuccessOutputsCollapsed", false);
}
}
document.getElementById(entry.id)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
</script>
<template>
<nav v-if="entries.length > 1" class="toc-strip" aria-label="Jump to section">
<button v-for="entry in entries" :key="entry.id" type="button" class="toc-link" @click="goTo(entry)">
{{ entry.label }}
</button>
</nav>
</template>
<style scoped lang="scss">
.toc-strip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.toc-link {
appearance: none;
border: 1px solid var(--color-grey-200);
background: var(--color-blue-100);
color: var(--color-blue-600);
font-size: var(--font-size-small);
font-weight: 600;
border-radius: 999px;
padding: 0.2rem 0.75rem;
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease;
&:hover,
&:focus-visible {
background: var(--color-yellow-100);
color: var(--color-grey-700);
}
&:active {
background: var(--color-yellow-200);
}
}
</style>
@@ -67,10 +67,17 @@ async function buildJobResponse(toolRequestDetail: ToolRequestDetailedModel) {
rethrowSimple(error);
}
// TODO: The dataset response is not modeled yet in the FastAPI route for /api/datasets/{dataset_id}
const dataset = data as { hid: number; name: string };
const dataset = data as {
hid: number;
name: string;
history_content_type: "dataset";
id: string;
};
return {
hid: dataset.hid,
name: dataset.name,
history_content_type: "dataset",
id: dataset.id,
};
}),
);
@@ -85,7 +92,12 @@ async function buildJobResponse(toolRequestDetail: ToolRequestDetailedModel) {
if (error) {
rethrowSimple(error);
}
return { hid: data.hid as number, name: data.name as string };
return {
hid: data.hid as number,
name: data.name as string,
history_content_type: "dataset_collection",
id: data.id as string,
};
}),
);
}
@@ -100,7 +112,7 @@ async function buildJobResponse(toolRequestDetail: ToolRequestDetailedModel) {
return {
// TODO: Once/If legacy job submission is typed, we won't need this cast to generic JobResponse
jobs: jobs as JobResponse["jobs"],
outputs,
output_collections,
outputs: outputs as JobResponse["outputs"],
output_collections: output_collections as JobResponse["output_collections"],
};
}
@@ -120,4 +120,45 @@ describe("ToolEntryPoints/ToolEntryPoints.vue", () => {
expect(link.exists()).toBe(true);
expect(link.attributes("target")).toBe("_blank");
});
it.each([
[undefined, null, null],
[0, null, null],
[
1,
"+1 job, paginate to view",
"This run has 1 other job. Use the pagination above to view their Interactive Tools.",
],
[
3,
"+3 jobs, paginate to view",
"This run has 3 other jobs. Use the pagination above to view their Interactive Tools.",
],
])("shows the other jobs hint for otherJobsCount=%s", async (otherJobsCount, expectedLabel, expectedTitle) => {
testPinia = createTestingPinia({
createSpy: vi.fn,
initialState: {
entryPointStore: {
entryPoints: ACTIVE_ITS,
},
},
});
setActivePinia(testPinia);
wrapper = mount(ToolEntryPoints, {
props: {
jobId: "b887d74393f85b6d",
otherJobsCount,
},
global: localVue,
pinia: testPinia,
router,
});
const hint = wrapper.find(".other-jobs-hint");
if (expectedLabel === null) {
expect(hint.exists()).toBe(false);
} else {
expect(hint.text()).toBe(expectedLabel);
expect(hint.attributes("title")).toBe(expectedTitle);
}
});
});
@@ -12,18 +12,30 @@ import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { storeToRefs } from "pinia";
import { computed } from "vue";
import type { JobBaseModel, ShowFullJobResponse } from "@/api/jobs";
import type { CardAction, CardBadge } from "@/components/Common/GCard.types";
import { useJobDetails } from "@/composables/jobDetails";
import { useEntryPointStore } from "@/stores/entryPointStore";
import { stateIsTerminal } from "@/utils/utils";
import GButton from "@/components/BaseComponents/GButton.vue";
import DetailBlock from "@/components/Common/DetailBlock.vue";
import GCard from "@/components/Common/GCard.vue";
import Heading from "@/components/Common/Heading.vue";
const props = defineProps<{
jobId: string;
/** A job object. `undefined` fetches/polls it here instead; `null` means
* the parent confirms there's deliberately no job yet (also skips fetching). */
job?: JobBaseModel | ShowFullJobResponse | null;
/** How many other jobs the same run submitted. When > 0, a header hint points to the
* pagination so the Interactive Tools of a batch run's other jobs are not missed. */
otherJobsCount?: number;
}>();
const { entryPointsForJob } = storeToRefs(useEntryPointStore());
const fetchJobId = computed(() => (props.job === undefined ? props.jobId : undefined));
const { job: fetchedJob } = useJobDetails(fetchJobId, { full: false });
const job = computed(() => (props.job !== undefined ? props.job : fetchedJob.value));
const badges = computed<CardBadge[]>(() => {
const total = entryPointsForJob.value(props.jobId).length;
@@ -33,6 +45,17 @@ const badges = computed<CardBadge[]>(() => {
return [{ id: "entry-points-counter", label, variant: "outline-info", title: "" }];
});
const otherJobsHint = computed(() => {
if (!props.otherJobsCount) {
return null;
}
const jobs = props.otherJobsCount > 1 ? "jobs" : "job";
return {
label: `+${props.otherJobsCount} ${jobs}, paginate to view`,
title: `This run has ${props.otherJobsCount} other ${jobs}. Use the pagination above to view their Interactive Tools.`,
};
});
const primaryActions = computed(() => {
const actions: CardAction[] = [];
if (entryPointsForJob.value(props.jobId).length === 1 && entryPointsForJob.value(props.jobId)[0]?.active) {
@@ -61,10 +84,18 @@ const secondaryActions: CardAction[] = [
const currentStatus = computed<{ title: string; icon: IconDefinition; class?: string }>(() => {
if (entryPointsForJob.value(props.jobId).length === 0) {
return {
title: "No Interactive Tool sessions are currently available",
icon: faMinus,
};
if (!job.value || !stateIsTerminal(job.value)) {
return {
title: "Waiting for Interactive Tool session(s) to become available",
icon: faSpinner,
class: "fa-spin",
};
} else {
return {
title: "No Interactive Tool sessions are currently available",
icon: faMinus,
};
}
} else if (entryPointsForJob.value(props.jobId).length === 1) {
if (entryPointsForJob.value(props.jobId)[0]?.active) {
return {
@@ -86,58 +117,64 @@ const currentStatus = computed<{ title: string; icon: IconDefinition; class?: st
</script>
<template>
<div>
<Heading inline size="sm" bold separator>Interactive Tools</Heading>
<GCard
:content-class="currentStatus.class === 'fa-spin' ? 'entry-points-card-loading' : undefined"
:badges="badges"
:primary-actions="primaryActions"
:secondary-actions="secondaryActions"
:title="currentStatus.title"
:title-icon="currentStatus"
title-size="text">
<template v-slot:description>
<div v-if="entryPointsForJob(props.jobId).length > 1" class="entry-points-grid">
<GButton
v-for="entryPoint of entryPointsForJob(props.jobId)"
:key="entryPoint.id"
data-description="entry point button"
:disabled="!entryPoint.active"
:disabled-title="`${entryPoint.name} is waiting to become active...`"
:href="entryPoint.active ? entryPoint.target : undefined"
target="_blank"
rel="noopener"
color="blue"
outline
size="small"
title="Open in a new tab">
<div class="d-flex justify-content-between align-items-center flex-gapx-1 w-100">
<div class="d-flex align-items-center flex-gapx-1">
<FontAwesomeIcon
fixed-width
:class="{ 'status-dot': entryPoint.active }"
:icon="entryPoint.active ? faCircle : faSpinner"
:spin="!entryPoint.active" />
{{ entryPoint.name }}
<DetailBlock :header-icon="faLaptop" title="Interactive Tools">
<template v-slot:header-indicators>
<span v-if="otherJobsHint" class="other-jobs-hint" :title="otherJobsHint.title">
<FontAwesomeIcon :icon="faLayerGroup" />
{{ otherJobsHint.label }}
</span>
</template>
<template v-slot:custom-content>
<GCard
:content-class="currentStatus.class === 'fa-spin' ? 'entry-points-card-loading' : undefined"
:badges="badges"
:primary-actions="primaryActions"
:secondary-actions="secondaryActions"
:title="currentStatus.title"
:title-icon="currentStatus"
title-size="text">
<template v-slot:description>
<div v-if="entryPointsForJob(props.jobId).length > 1" class="entry-points-grid">
<GButton
v-for="entryPoint of entryPointsForJob(props.jobId)"
:key="entryPoint.id"
data-description="entry point button"
:disabled="!entryPoint.active"
:disabled-title="`${entryPoint.name} is waiting to become active...`"
:href="entryPoint.active ? entryPoint.target : undefined"
target="_blank"
rel="noopener"
color="blue"
outline
size="small"
title="Open in a new tab">
<div class="d-flex justify-content-between align-items-center flex-gapx-1 w-100">
<div class="d-flex align-items-center flex-gapx-1">
<FontAwesomeIcon
fixed-width
:class="{ 'status-dot': entryPoint.active }"
:icon="entryPoint.active ? faCircle : faSpinner"
:spin="!entryPoint.active" />
{{ entryPoint.name }}
</div>
<FontAwesomeIcon :icon="faExternalLinkAlt" />
</div>
<FontAwesomeIcon :icon="faExternalLinkAlt" />
</div>
</GButton>
</div>
</template>
</GButton>
</div>
</template>
<template v-slot:update-time>
<i
v-if="
entryPointsForJob(props.jobId).length > 1 &&
entryPointsForJob(props.jobId).some((ep) => !ep.active)
">
Some sessions are not active yet
</i>
</template>
</GCard>
</div>
<template v-slot:update-time>
<i
v-if="
entryPointsForJob(props.jobId).length > 1 &&
entryPointsForJob(props.jobId).some((ep) => !ep.active)
">
Some sessions are not active yet
</i>
</template>
</GCard>
</template>
</DetailBlock>
</template>
<style scoped lang="scss">
@@ -147,6 +184,16 @@ const currentStatus = computed<{ title: string; icon: IconDefinition; class?: st
}
}
.other-jobs-hint {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-blue-600);
white-space: nowrap;
}
.entry-points-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+23 -19
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { faForward } from "@fortawesome/free-solid-svg-icons";
import * as d3 from "d3";
import { nextTick, onMounted, ref } from "vue";
@@ -10,6 +11,7 @@ import { errorMessageAsString } from "@/utils/simple-error";
import { getShortToolId } from "@/utils/tool";
import GAlert from "@/components/BaseComponents/GAlert.vue";
import DetailBlock from "@/components/Common/DetailBlock.vue";
/** Vertical space to give each leaf row, so the tree stays readable regardless of the
* container's measured height (which may be 0 before the SVG has any content). */
@@ -238,24 +240,26 @@ onMounted(() => {
</script>
<template>
<GAlert v-if="errorMessage" variant="warning" show>
Tool recommendations could not be loaded: {{ errorMessage }}
</GAlert>
<div v-else aria-labelledby="tool-recommendation-heading">
<div v-if="deprecated" class="warningmessagelarge">
<h2 id="tool-recommendation-heading" class="h-sm">Tool deprecated</h2>
You have used {{ getShortToolId(props.toolId) }} tool. {{ deprecatedMessage }}
</div>
<template v-else-if="showMessage">
<h2 id="tool-recommendation-heading" class="h-sm">Tool recommendation</h2>
<div>
You have used {{ getShortToolId(props.toolId) }} tool. For further analysis, you could try using the
following/recommended tools. The recommended tools are shown in the decreasing order of their scores
predicted using machine learning analysis on workflows. Therefore, tools at the top may be more useful
than the ones at the bottom. Please click on one of the following/recommended tools to open its
definition.
<DetailBlock :header-icon="faForward" title="Tool Recommendation">
<GAlert v-if="errorMessage" variant="warning" show>
Tool recommendations could not be loaded: {{ errorMessage }}
</GAlert>
<div v-else aria-labelledby="tool-recommendation-heading">
<div v-if="deprecated" class="warningmessagelarge">
<h2 id="tool-recommendation-heading" class="h-sm">Tool deprecated</h2>
You have used {{ getShortToolId(props.toolId) }} tool. {{ deprecatedMessage }}
</div>
<div ref="toolRecommendation" class="ui-tool-recommendation"></div>
</template>
</div>
<template v-else-if="showMessage">
<h2 id="tool-recommendation-heading" class="h-sm">Try one of these recommended next steps</h2>
<p>
You have used <code>{{ getShortToolId(props.toolId) }}</code> tool. For further analysis, you could
try using the following/recommended tools. The recommended tools are shown in the decreasing order
of their scores predicted using machine learning analysis on workflows. Therefore, tools at the top
may be more useful than the ones at the bottom. Please click on one of the following/recommended
tools to open its definition.
</p>
<div ref="toolRecommendation" class="ui-tool-recommendation"></div>
</template>
</div>
</DetailBlock>
</template>
@@ -2,8 +2,10 @@ import { createTestingPinia } from "@pinia/testing";
import { nth } from "@tests/vitest/helpers";
import { mount, shallowMount } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { computed, type Ref, ref } from "vue";
import { useServerMock } from "@/api/client/__mocks__";
import type { JobBaseModel } from "@/api/jobs";
import { statePlaceholders } from "@/composables/useInvocationGraph";
@@ -17,6 +19,28 @@ const TEST_JOBS_JSON = TEST_JOBS_JSON_RAW as JobBaseModel[];
const TEST_INVOCATION_ID = "test-invocation-id";
const mockedJobsById = ref<Record<string, JobBaseModel>>({});
function mockJobs(jobs: JobBaseModel[]) {
mockedJobsById.value = Object.fromEntries(jobs.map((job) => [job.id, job]));
}
// Since `JobState` fetches its job via `useJobDetails`, we need to mock that composable to return
// jobs reactively, as the table changes values as the states change.
vi.mock("@/composables/jobDetails", () => ({
useJobDetails: (jobId: Ref<string | undefined>) => ({
job: computed(() => (jobId.value ? (mockedJobsById.value[jobId.value] ?? null) : null)),
error: computed(() => null),
loading: computed(() => false),
}),
}));
const { server, http } = useServerMock();
beforeEach(() => {
server.use(http.get("/api/invocations/{invocation_id}/metrics", ({ response }) => response(200).json([])));
});
const SELECTORS = {
JOB_STATE_BUTTON_NAV: "nav",
JOB_STATE_BUTTON: ".g-button",
@@ -29,6 +53,8 @@ const SELECTORS = {
describe("Job Step", () => {
it("shows jobs grouped by state in tables when multiple jobs", async () => {
mockJobs(TEST_JOBS_JSON as JobBaseModel[]);
const wrapper = mount(JobStep, {
props: {
jobs: TEST_JOBS_JSON,
@@ -78,6 +104,8 @@ describe("Job Step", () => {
});
it("reacts to job states changing when multiple jobs", async () => {
mockJobs(TEST_JOBS_JSON as JobBaseModel[]);
const wrapper = mount(JobStep, {
props: {
jobs: TEST_JOBS_JSON,
@@ -108,6 +136,7 @@ describe("Job Step", () => {
// we trigger the state change by updating the first job's state from 'new' to 'running'
const updatedJob: JobBaseModel = { ...nth(TEST_JOBS_JSON, 0), state: "running" };
mockJobs([updatedJob, ...(TEST_JOBS_JSON.slice(1) as JobBaseModel[])]);
await wrapper.setProps({ jobs: [updatedJob, ...TEST_JOBS_JSON.slice(1)] });
await flushPromises();
@@ -5,7 +5,7 @@ import flushPromises from "flush-promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { HttpResponse, useServerMock } from "@/api/client/__mocks__";
import type { JobDisplayParametersSummary, ShowFullJobResponse } from "@/api/jobs";
import type { JobBaseModel, JobDisplayParametersSummary, ShowFullJobResponse } from "@/api/jobs";
import paramResponse from "@/components/JobParameters/parameters-response.json";
import { TEST_JOBS_BY_STATES } from "./test/jobStepUtils";
@@ -120,3 +120,28 @@ describe("JobStepJobs", () => {
expect(wrapper.find("#galaxy-tool-id").text()).toBe(TEST_JOBS_BY_STATES["ok"]?.[0]?.tool_id);
});
});
describe("JobStepJobs state updates", () => {
it("uses the parent jobs without fetching each visible row", async () => {
let jobRequests = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
jobRequests++;
return response(200).json(TEST_JOBS_JSON[0] as ShowFullJobResponse);
}),
);
const job = { ...TEST_JOBS_JSON[0]!, state: "new" } as JobBaseModel;
const wrapper = mount(JobStepJobs, {
props: { jobs: [job], invocationId: "test-invocation-id", currentPage: 1, sortDesc: true, perPage: 10 },
global: localVue,
pinia: createTestingPinia({ createSpy: vi.fn, stubActions: false }),
});
await flushPromises();
expect(wrapper.find(".job-state-badge").text()).toContain("new");
await wrapper.setProps({ jobs: [{ ...job, state: "running" }] });
await flushPromises();
expect(wrapper.find(".job-state-badge").text()).toContain("running");
expect(jobRequests).toBe(0);
wrapper.unmount();
});
});
@@ -156,7 +156,7 @@ watch(
</template>
<template v-slot:cell(state)="data">
<JobState :job="data.item" />
<JobState :job-id="data.item.id" :job="data.item" />
</template>
<template v-slot:cell(duration)="data">
+1 -47
View File
@@ -1,52 +1,6 @@
import axios from "axios";
import { SingleQueryProvider } from "@/components/providers/SingleQueryProvider";
import { getAppRoot } from "@/onload/loadConfig";
import { rethrowSimple } from "@/utils/simple-error";
import { cleanPaginationParameters, stateIsTerminal } from "./utils";
async function jobDetails({ jobId }) {
const url = `${getAppRoot()}api/jobs/${jobId}?full=True`;
try {
const { data } = await axios.get(url);
return data;
} catch (e) {
rethrowSimple(e);
}
}
async function jobConsoleOutput({
jobId,
stdout_position = 0,
stdout_length = 0,
stderr_position = 0,
stderr_length = 0,
}) {
const url =
`${getAppRoot()}api/jobs/${jobId}/console_output?stdout_position=${stdout_position}&stdout_length=${stdout_length}` +
`&stderr_position=${stderr_position}&stderr_length=${stderr_length}`;
try {
const { status, data } = await axios.get(url, {
validateStatus: function (status) {
return status == 200 || status == 403;
},
});
if (status == 403) {
if (data.err_code == 403004) {
console.log("This job destination does not support console output");
return { state: "ok" };
}
throw Error("Problem fetching state");
}
return data;
} catch (e) {
rethrowSimple(e);
}
}
export const JobDetailsProvider = SingleQueryProvider(jobDetails, stateIsTerminal);
export const JobConsoleOutputProvider = SingleQueryProvider(jobConsoleOutput, stateIsTerminal);
import { cleanPaginationParameters } from "./utils";
export function jobsProvider(ctx, callback, extraParams = {}) {
const { root, ...requestParams } = ctx;
+3 -12
View File
@@ -2,12 +2,14 @@ import { computed, type Ref, ref, watch } from "vue";
import { ERROR_STATES, TERMINAL_STATES } from "@/api/jobs";
import { type FetchDataPayload, fetchDatasetsToJobId, fetchJobErrorMessage } from "@/api/tools";
import { useJobDetails } from "@/composables/jobDetails";
import { useResourceWatcher } from "@/composables/resourceWatcher";
import { useJobStore } from "@/stores/jobStore";
import { errorMessageAsString } from "@/utils/simple-error";
export function useJobWatcher(jobId: Ref<string | undefined>) {
const { fetchJob, getJob } = useJobStore();
const { fetchJob } = useJobStore();
const { job } = useJobDetails(jobId, { poll: false });
const jobRequestError = ref<string | undefined>(undefined);
async function jobResourceHandler() {
@@ -22,17 +24,6 @@ export function useJobWatcher(jobId: Ref<string | undefined>) {
}
}
const job = computed(() => {
if (jobId.value) {
const jobIdVal = jobId.value;
const job = getJob(jobIdVal);
if (job) {
return job;
}
}
return undefined;
});
const { startWatchingResourceIfNeeded, stopWatchingResourceIfNeeded } = useResourceWatcher(jobResourceHandler);
watch(
@@ -0,0 +1,265 @@
import { createTestingPinia } from "@pinia/testing";
import { advanceTimersAndFlush } from "@tests/vitest/helpers";
import { enableAutoUnmount, mount } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent, type Ref, ref } from "vue";
import { useServerMock } from "@/api/client/__mocks__";
import type { JobConsoleOutput } from "@/api/jobs";
import { useJobConsoleOutput } from "./jobDetails";
const { server, http } = useServerMock();
vi.useFakeTimers();
enableAutoUnmount(afterEach);
function buildConsoleOutput(overrides: Partial<JobConsoleOutput> = {}): JobConsoleOutput {
return { state: "running", stdout: "", stderr: "", ...overrides };
}
function mountJobConsoleOutput(jobId: Ref<string | undefined>) {
const mounted = {
stdout: ref(""),
stderr: ref(""),
error: ref<unknown>(null),
};
const TestComponent = defineComponent({
setup() {
Object.assign(mounted, useJobConsoleOutput(jobId));
return () => null;
},
});
const wrapper = mount(TestComponent);
return { ...mounted, wrapper };
}
describe("useJobConsoleOutput", () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }));
});
afterEach(() => {
vi.clearAllTimers();
});
it("accumulates stdout/stderr across polls", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
callCount++;
if (callCount === 1) {
return response(200).json(buildConsoleOutput({ stdout: "line1\n", stderr: "" }));
}
return response(200).json(buildConsoleOutput({ stdout: "line2\n", stderr: "warn\n" }));
}),
);
const jobId = ref<string | undefined>("job1");
const { stdout, stderr } = mountJobConsoleOutput(jobId);
await flushPromises();
expect(stdout.value).toBe("line1\n");
expect(stderr.value).toBe("");
await advanceTimersAndFlush(3000);
expect(stdout.value).toBe("line1\nline2\n");
expect(stderr.value).toBe("warn\n");
});
it("stops polling once the job's console state is terminal", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
callCount++;
return response(200).json(buildConsoleOutput({ state: "ok", stdout: "done\n" }));
}),
);
const jobId = ref<string | undefined>("job1");
const { stdout } = mountJobConsoleOutput(jobId);
await flushPromises();
expect(stdout.value).toBe("done\n");
expect(callCount).toBe(1);
await advanceTimersAndFlush(5000);
expect(callCount).toBe(1);
});
it("does not keep a timer running when the tab regains focus after the output is complete", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
callCount++;
return response(200).json(buildConsoleOutput({ state: "ok", stdout: "done\n" }));
}),
);
mountJobConsoleOutput(ref<string | undefined>("job1"));
await flushPromises();
expect(vi.getTimerCount()).toBe(0);
// `useResourceWatcher` restarts a stopped watcher (without its job id) on refocus
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
document.dispatchEvent(new Event("visibilitychange"));
await advanceTimersAndFlush(10000);
expect(callCount).toBe(1);
expect(vi.getTimerCount()).toBe(0);
});
it("keeps polling after a failed request instead of treating it as terminal", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
callCount++;
if (callCount === 1) {
return response("5XX").json({ err_msg: "Server error", err_code: 500 }, { status: 500 });
}
return response(200).json(buildConsoleOutput({ stdout: "still running\n" }));
}),
);
const jobId = ref<string | undefined>("job1");
const { stdout, error } = mountJobConsoleOutput(jobId);
await flushPromises();
// The first request failed, and nothing is known about the job's actual state yet
// (state defaults to undefined). That must not be read as "terminal" and stop polling.
expect(error.value).toBeTruthy();
expect(stdout.value).toBe("");
await advanceTimersAndFlush(3000);
expect(callCount).toBe(2);
expect(stdout.value).toBe("still running\n");
});
it("resets accumulated output when jobId changes", async () => {
server.use(
http.get("/api/jobs/{job_id}/console_output", ({ params, response }) => {
const id = params.job_id;
return response(200).json(buildConsoleOutput({ stdout: `output for ${id}\n` }));
}),
);
const jobId = ref<string | undefined>("job1");
const { stdout } = mountJobConsoleOutput(jobId);
await flushPromises();
expect(stdout.value).toBe("output for job1\n");
jobId.value = "job2";
await flushPromises();
expect(stdout.value).toBe("output for job2\n");
});
it("does not fetch when jobId is undefined", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
callCount++;
return response(200).json(buildConsoleOutput());
}),
);
const jobId = ref<string | undefined>(undefined);
const { stdout } = mountJobConsoleOutput(jobId);
await flushPromises();
expect(stdout.value).toBe("");
expect(callCount).toBe(0);
});
it("stops polling once the consuming component unmounts", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}/console_output", ({ response }) => {
callCount++;
return response(200).json(buildConsoleOutput({ stdout: "line\n" }));
}),
);
const jobId = ref<string | undefined>("job1");
const { wrapper } = mountJobConsoleOutput(jobId);
await flushPromises();
expect(callCount).toBe(1);
wrapper.unmount();
await advanceTimersAndFlush(5000);
expect(callCount).toBe(1);
});
it("ignores a stale response from a previous visit to the same job id", async () => {
// job1's first fetch is held open so it resolves only after we've switched away and back
// to job1, by which point it's a leftover from an earlier visit, not the current one.
let resolveFirstFetchForJob1: (output: JobConsoleOutput) => void;
let sawFirstFetchForJob1 = false;
server.use(
http.get("/api/jobs/{job_id}/console_output", ({ response, params }) => {
if (params.job_id === "job1" && !sawFirstFetchForJob1) {
sawFirstFetchForJob1 = true;
return new Promise((resolve) => {
resolveFirstFetchForJob1 = (output) => resolve(response(200).json(output));
});
}
if (params.job_id === "job1") {
return response(200).json(buildConsoleOutput({ stdout: "second visit to job1\n" }));
}
return response(200).json(buildConsoleOutput({ stdout: "job2 output\n" }));
}),
);
const jobId = ref<string | undefined>("job1");
const { stdout, wrapper } = mountJobConsoleOutput(jobId);
await flushPromises();
// Switch away to job2 (its own fetch resolves normally) and then back to job1, all while
// job1's first fetch above is still pending.
jobId.value = "job2";
await flushPromises();
jobId.value = "job1";
await flushPromises();
// The stale response from job1's first visit arrives now. It must not be appended.
resolveFirstFetchForJob1!(buildConsoleOutput({ stdout: "stale, from the first visit to job1\n" }));
await flushPromises();
expect(stdout.value).toBe("second visit to job1\n");
wrapper.unmount();
});
});
describe("unsupported live console output", () => {
afterEach(() => vi.clearAllTimers());
it("stops on the configuration refusal and can fetch a different job", async () => {
let unsupportedCalls = 0;
server.use(
http.get("/api/jobs/{job_id}/console_output", ({ response, params }) => {
if (params.job_id === "unsupported") {
unsupportedCalls++;
return response("4XX").json({ err_code: 403004, err_msg: "Not enabled" }, { status: 403 });
}
return response(200).json(buildConsoleOutput({ stdout: "live output" }));
}),
);
const jobId = ref<string | undefined>("unsupported");
const { stdout, error } = mountJobConsoleOutput(jobId);
await flushPromises();
await advanceTimersAndFlush(3000);
await advanceTimersAndFlush(3000);
expect(unsupportedCalls).toBe(1);
expect(error.value).toBeNull();
jobId.value = "supported";
await flushPromises();
expect(stdout.value).toBe("live output");
await advanceTimersAndFlush(3000);
expect(stdout.value).toBe("live outputlive output");
});
});
+325
View File
@@ -0,0 +1,325 @@
import { createTestingPinia } from "@pinia/testing";
import { advanceTimersAndFlush } from "@tests/vitest/helpers";
import { enableAutoUnmount, mount } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent, h, type Ref, ref } from "vue";
import { useServerMock } from "@/api/client/__mocks__";
import type { JobState, ShowFullJobResponse } from "@/api/jobs";
import { MAX_CACHED_JOBS, useJobStore } from "@/stores/jobStore";
import { useJobDetails } from "./jobDetails";
const { server, http } = useServerMock();
vi.useFakeTimers();
enableAutoUnmount(afterEach);
function buildJob(id: string, state: JobState, overrides: Partial<ShowFullJobResponse> = {}): ShowFullJobResponse {
return {
id,
state,
model_class: "Job",
create_time: "2024-01-01T00:00:00",
update_time: "2024-01-01T00:00:00",
inputs: {},
outputs: {},
output_collections: {},
params: {},
tool_id: "cat1",
...overrides,
} as ShowFullJobResponse;
}
function mountJobDetails(jobId: Ref<string | undefined>, options?: { full?: boolean; poll?: boolean }) {
const mounted = {
job: ref<ShowFullJobResponse | null>(null),
error: ref<unknown>(null),
loading: ref(false),
};
const TestComponent = defineComponent({
setup() {
Object.assign(mounted, useJobDetails(jobId, options));
return () => null;
},
});
const wrapper = mount(TestComponent);
return { ...mounted, wrapper };
}
describe("useJobDetails", () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }));
});
afterEach(() => {
vi.clearAllTimers();
});
it("fetches the job on mount and exposes it reactively", async () => {
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
return response(200).json(buildJob("job1", "running"));
}),
);
const jobId = ref<string | undefined>("job1");
const { job } = mountJobDetails(jobId);
expect(job.value).toBeNull();
await flushPromises();
expect(job.value?.id).toBe("job1");
expect(job.value?.state).toBe("running");
});
it("keeps polling while the job is in a non-terminal state", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
callCount++;
return response(200).json(buildJob("job1", "running"));
}),
);
const jobId = ref<string | undefined>("job1");
mountJobDetails(jobId);
await flushPromises();
expect(callCount).toBe(1);
await advanceTimersAndFlush(1000);
expect(callCount).toBe(2);
await advanceTimersAndFlush(1000);
expect(callCount).toBe(3);
});
it("stops polling once the job reaches a terminal state", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
callCount++;
// Terminal on the very first response.
return response(200).json(buildJob("job1", "ok"));
}),
);
const jobId = ref<string | undefined>("job1");
const { job } = mountJobDetails(jobId);
await flushPromises();
expect(job.value?.state).toBe("ok");
expect(callCount).toBe(1);
await advanceTimersAndFlush(5000);
expect(callCount).toBe(1);
});
it("does not fetch or poll when jobId is undefined", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
callCount++;
return response(200).json(buildJob("job1", "running"));
}),
);
const jobId = ref<string | undefined>(undefined);
const { job } = mountJobDetails(jobId);
await flushPromises();
expect(job.value).toBeNull();
expect(callCount).toBe(0);
});
it("surfaces a load error", async () => {
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
return response("4XX").json({ err_msg: "not found", err_code: 404 }, { status: 404 });
}),
);
const jobId = ref<string | undefined>("job1");
const { job, error } = mountJobDetails(jobId);
await flushPromises();
expect(job.value).toBeNull();
expect(error.value).toBeTruthy();
});
it("stops polling once the consuming component unmounts", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
callCount++;
return response(200).json(buildJob("job1", "running"));
}),
);
const jobId = ref<string | undefined>("job1");
const { wrapper } = mountJobDetails(jobId);
await flushPromises();
expect(callCount).toBe(1);
await advanceTimersAndFlush(1000);
expect(callCount).toBe(2);
wrapper.unmount();
// With the poller stopped, further time passing must not produce any more requests,
// otherwise it would keep hitting the server for the rest of the session even though
// nothing is displaying this job anymore.
await advanceTimersAndFlush(5000);
expect(callCount).toBe(2);
});
it("stops the previous job's poll when jobId changes to a different job", async () => {
let job1Calls = 0;
let job2Calls = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response, params }) => {
if (params.job_id === "job1") {
job1Calls++;
return response(200).json(buildJob("job1", "running"));
}
job2Calls++;
return response(200).json(buildJob("job2", "running"));
}),
);
const jobId = ref<string | undefined>("job1");
mountJobDetails(jobId);
await flushPromises();
expect(job1Calls).toBe(1);
jobId.value = "job2";
await flushPromises();
expect(job2Calls).toBe(1);
// job1's poll must have been released when we switched away from it, not left running
// alongside job2's.
await advanceTimersAndFlush(1000);
expect(job1Calls).toBe(1);
expect(job2Calls).toBe(2);
});
it("keeps polling a shared job alive until every consumer has unmounted", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
callCount++;
return response(200).json(buildJob("job1", "running"));
}),
);
const jobId = ref<string | undefined>("job1");
const first = mountJobDetails(jobId);
const second = mountJobDetails(jobId);
await flushPromises();
expect(callCount).toBe(1); // jobStore dedupes the actual request across both consumers
first.wrapper.unmount();
// The second consumer is still mounted and interested, so the poll must keep going.
await advanceTimersAndFlush(1000);
expect(callCount).toBe(2);
second.wrapper.unmount();
// Now that both consumers are gone, the poll must actually stop.
await advanceTimersAndFlush(5000);
expect(callCount).toBe(2);
});
});
describe("job cache consumers", () => {
beforeEach(() => setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false })));
afterEach(() => vi.clearAllTimers());
it("does not request full details when a base-only consumer renders before the response", async () => {
const requests: (string | null)[] = [];
server.use(
http.get("/api/jobs/{job_id}", ({ response, request }) => {
requests.push(new URL(request.url).searchParams.get("full"));
return response(200).json(buildJob("base", "running"));
}),
);
const { job } = mountJobDetails(ref("base"), { full: false });
expect(job.value).toBeNull();
await flushPromises();
expect(job.value?.state).toBe("running");
expect(requests).toEqual(["false"]);
await advanceTimersAndFlush(1000);
expect(requests).toEqual(["false", "false"]);
});
it("keeps a one-shot running job visible after the terminal cache fills, without refetching", async () => {
const calls: Record<string, number> = {};
server.use(
http.get("/api/jobs/{job_id}", ({ response, params }) => {
const id = params.job_id as string;
calls[id] = (calls[id] ?? 0) + 1;
return response(200).json(buildJob(id, id === "live" ? "running" : "ok"));
}),
);
const store = useJobStore();
for (let i = 0; i < MAX_CACHED_JOBS; i++) {
store.fetchJob({ id: `done-${i}` });
await flushPromises();
}
const wrapper = mount(
defineComponent({
setup() {
const { job } = useJobDetails(ref("live"), { poll: false });
return () => h("div", job.value?.state ?? "loading");
},
}),
);
await flushPromises();
expect(wrapper.text()).toBe("running");
for (let i = 0; i < MAX_CACHED_JOBS; i++) {
store.fetchJob({ id: `next-${i}` });
await flushPromises();
}
await advanceTimersAndFlush(5000);
expect(wrapper.text()).toBe("running");
expect(calls.live).toBe(1);
wrapper.unmount();
store.fetchJob({ id: "another" });
await flushPromises();
expect(store.getCachedJob("live")).toBeNull();
});
it("retains more than the cache target while displayed, then releases entries on job change and unmount", async () => {
server.use(
http.get("/api/jobs/{job_id}", ({ response, params }) =>
response(200).json(buildJob(params.job_id as string, "ok")),
),
);
const store = useJobStore();
const id = ref<string | undefined>("shared");
const first = mountJobDetails(id, { poll: false });
const second = mountJobDetails(ref("shared"), { poll: false });
await flushPromises();
const others = [];
for (let i = 0; i < MAX_CACHED_JOBS; i++) {
others.push(mountJobDetails(ref(`other-${i}`), { poll: false }));
await flushPromises();
}
expect(first.job.value?.id).toBe("shared");
expect(others.every(({ job }) => job.value !== null)).toBe(true);
id.value = "replacement";
await flushPromises();
expect(second.job.value?.id).toBe("shared");
second.wrapper.unmount();
expect(store.getCachedJob("shared")).toBeNull();
expect(first.job.value?.id).toBe("replacement");
first.wrapper.unmount();
expect(store.getCachedJob("replacement")).toBeNull();
});
});
+152
View File
@@ -0,0 +1,152 @@
import { computed, onUnmounted, type Ref, ref, watch } from "vue";
import { GalaxyApi } from "@/api";
import type { JobConsoleOutput } from "@/api/jobs";
import { useResourceWatcher } from "@/composables/resourceWatcher";
import { useJobStore } from "@/stores/jobStore";
import { rethrowSimple } from "@/utils/simple-error";
import { stateIsTerminal } from "@/utils/utils";
const DEFAULT_POLL_INTERVAL_MS = 3000;
/** Reads a retained job and polls it until completion. Set `poll: false` for a single fetch. */
export function useJobDetails(jobId: Ref<string | undefined>, options: { full?: boolean; poll?: boolean } = {}) {
const { full = true, poll = true } = options;
const jobStore = useJobStore();
const job = computed(() => (jobId.value ? jobStore.getCachedJob(jobId.value) : null));
const error = computed(() => (jobId.value ? jobStore.getJobLoadError(jobId.value) : null));
const loading = computed(() => (jobId.value ? jobStore.isLoadingJob(jobId.value) : false));
watch(
jobId,
(id, _oldId, onCleanup) => {
if (!id) {
return;
}
const releaseJob = jobStore.retainJob(id);
const subscription = poll ? jobStore.pollJobUntilTerminal({ id, full }) : undefined;
if (!poll) {
jobStore.fetchJob({ id, full });
}
onCleanup(() => {
subscription?.stopWatchingJob();
releaseJob?.();
});
},
{ immediate: true },
);
return { job, error, loading };
}
/** Fetches and auto-polls a job's console output (stdout/stderr)*/
export function useJobConsoleOutput(
jobId: Ref<string | undefined>,
options: { pollInterval?: number; chunkLength?: number } = {},
) {
const { pollInterval = DEFAULT_POLL_INTERVAL_MS, chunkLength = 50000 } = options;
const stdout = ref("");
const stderr = ref("");
const state = ref<string | null | undefined>(undefined);
const error = ref<unknown>(null);
let currentJobId: string | undefined;
/**
* Incremented on every `restart()` call. A response belongs to the current restart only if
* this still matches the value captured when its fetch began; comparing `id` alone isn't
* enough, since switching away from a job and back to it reuses the same id for a new restart.
*/
let restartCount = 0;
/** Set once the current job's output is complete (terminal, or the destination can't report it). */
let finished = false;
async function fetchConsoleOutput(id: string) {
const {
data,
error: fetchError,
response,
} = await GalaxyApi().GET("/api/jobs/{job_id}/console_output", {
params: {
path: { job_id: id },
query: {
stdout_position: stdout.value.length,
stdout_length: chunkLength,
stderr_position: stderr.value.length,
stderr_length: chunkLength,
},
},
});
if (fetchError) {
// Destinations without live output reporting cannot satisfy subsequent polls either.
if (response.status === 403 && fetchError.err_code === 403004) {
return null;
}
rethrowSimple(fetchError);
}
return data as JobConsoleOutput;
}
const watcher = useResourceWatcher<string>(
async (id?: unknown) => {
const fetchRestartCount = restartCount;
if (typeof id !== "string" || id !== currentJobId || finished) {
// `useResourceWatcher` restarts a stopped watcher when the tab becomes visible again,
// without the job id. Nothing is left to fetch, so stop instead of polling a no-op.
watcher.stopWatchingResource();
return;
}
try {
const result = await fetchConsoleOutput(id);
if (fetchRestartCount !== restartCount) {
return;
}
if (result === null) {
error.value = null;
finished = true;
watcher.stopWatchingResource();
return;
}
if (result.stdout != null) {
stdout.value += result.stdout;
}
if (result.stderr != null) {
stderr.value += result.stderr;
}
state.value = result.state;
error.value = null;
if (stateIsTerminal({ state: state.value })) {
finished = true;
watcher.stopWatchingResource();
}
} catch (e) {
if (fetchRestartCount !== restartCount) {
return;
}
error.value = e;
}
},
{ shortPollingInterval: pollInterval, longPollingInterval: pollInterval },
);
function restart(id: string | undefined) {
watcher.stopWatchingResource();
restartCount++;
currentJobId = id;
finished = false;
stdout.value = "";
stderr.value = "";
state.value = undefined;
error.value = null;
if (id) {
watcher.startWatchingResource(id);
}
}
watch(jobId, (id) => restart(id), { immediate: true });
onUnmounted(() => watcher.dispose());
return { stdout, stderr, error };
}
@@ -0,0 +1,80 @@
import { createTestingPinia } from "@pinia/testing";
import { mount } from "@vue/test-utils";
import { HttpResponse } from "msw";
import { setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent, ref } from "vue";
import { useServerMock } from "@/api/client/__mocks__";
import { useRerunnableTool } from "./rerunnableTool";
const { server, http } = useServerMock();
/** The tool store fetches via axios, whose responses need more than a microtask flush. */
function settleRequests() {
return new Promise((resolve) => setTimeout(resolve, 100));
}
function mountRerunnableTool(toolId: ReturnType<typeof ref<string | undefined>>) {
let result: ReturnType<typeof useRerunnableTool> | undefined;
mount(
defineComponent({
setup() {
result = useRerunnableTool(toolId);
return () => null;
},
}),
);
return result!;
}
describe("useRerunnableTool", () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }));
});
it("settles to an error when the tool can't be fetched", async () => {
server.use(
http.untyped.get("/api/tools/:tool_id", () =>
HttpResponse.json({ err_msg: "Tool not found", err_code: 404 }, { status: 404 }),
),
);
const { loading, error, canRerun } = mountRerunnableTool(ref<string | undefined>("tool_a"));
expect(loading.value).toBe(true);
await settleRequests();
expect(loading.value).toBe(false);
expect(error.value).toContain("Tool not found");
expect(canRerun.value).toBe(false);
});
it("ignores a failure from an earlier fetch after switching away and back to the same tool", async () => {
let toolAFetches = 0;
let failFirstFetch: () => void = () => {};
server.use(
http.untyped.get("/api/tools/:tool_id", async ({ params }) => {
if (params.tool_id === "tool_a" && ++toolAFetches === 1) {
await new Promise<void>((resolve) => (failFirstFetch = resolve));
return HttpResponse.json({ err_msg: "Tool not found", err_code: 404 }, { status: 404 });
}
// Every other fetch stays pending
return new Promise<never>(() => {});
}),
);
const toolId = ref<string | undefined>("tool_a");
const { loading, error } = mountRerunnableTool(toolId);
await settleRequests();
toolId.value = "tool_b";
await settleRequests();
toolId.value = "tool_a";
await settleRequests();
failFirstFetch();
await settleRequests();
expect(error.value).toBeNull();
expect(loading.value).toBe(true);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { computed, type Ref, ref, watch } from "vue";
import { useToolStore } from "@/stores/toolStore";
import { errorMessageAsString } from "@/utils/simple-error";
/** Resolves whether the tool that ran a job can be rerun (`is_workflow_compatible`), fetching the
* tool if it isn't cached yet. A failed fetch (e.g. the tool is no longer installed) settles to
* "cannot rerun" with an `error` instead of leaving the check loading forever. */
export function useRerunnableTool(toolId: Ref<string | undefined>) {
const toolStore = useToolStore();
const error = ref<string | null>(null);
const tool = computed(() => (toolId.value ? toolStore.getToolForId(toolId.value) : undefined));
const loading = computed(() => !!toolId.value && !tool.value && !error.value);
const canRerun = computed(() => !!tool.value?.is_workflow_compatible);
watch(
toolId,
async (id, _oldId, onCleanup) => {
// Ignore a failure from a fetch started for an earlier tool id, even if the id has since
// changed back to the same tool (with a newer fetch still pending).
let cancelled = false;
onCleanup(() => (cancelled = true));
error.value = null;
if (!id || toolStore.getToolForId(id)) {
return;
}
try {
await toolStore.fetchToolForId(id);
} catch (e) {
if (!cancelled) {
error.value = errorMessageAsString(e);
}
}
},
{ immediate: true },
);
return { canRerun, loading, error };
}
@@ -314,6 +314,39 @@ describe("useResourceWatcher", () => {
});
describe("cleanup and resource management", () => {
it("should stop polling and remove its visibilitychange listener when disposed", async () => {
const { startWatchingResource, dispose } = useResourceWatcher(mockWatchHandler);
startWatchingResource();
await flushPromises();
expect(mockWatchHandler).toHaveBeenCalledTimes(1);
expect(mockRemoveEventListener).not.toHaveBeenCalled();
dispose();
expect(mockRemoveEventListener).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
// No further polling after disposal.
vi.advanceTimersByTime(60000);
await flushPromises();
expect(mockWatchHandler).toHaveBeenCalledTimes(1);
});
it("dispose is idempotent and safe to call more than once", async () => {
const { startWatchingResource, dispose } = useResourceWatcher(mockWatchHandler);
startWatchingResource();
await flushPromises();
dispose();
expect(mockRemoveEventListener).toHaveBeenCalledTimes(1);
// A second dispose (e.g. an unmount hook plus an explicit caller both calling it)
// must not throw or remove a listener that's already gone.
expect(() => dispose()).not.toThrow();
expect(mockRemoveEventListener).toHaveBeenCalledTimes(1);
});
it("should clear existing timeout when starting watching again", async () => {
const { startWatchingResource } = useResourceWatcher(mockWatchHandler);
@@ -155,6 +155,10 @@ export function useResourceWatcher<T = unknown>(
stopWatchingResource,
startWatchingResourceIfNeeded,
stopWatchingResourceIfNeeded,
/**
* Fully tears down this watcher (stops polling and removes its `visibilitychange`
* listener). Call once, when this watcher instance is permanently done being used.
*/
dispose,
/**
* Reactive boolean ref indicating whether the resource watcher is currently active.
-14
View File
@@ -1,14 +0,0 @@
import { computed, type Ref } from "vue";
import { useJobStore } from "@/stores/jobStore";
/**
* Reactive accessor for a single job's details — shares the jobStore's keyed
* cache so multiple consumers with the same id don't refetch. Returns null
* when the id is empty or the job hasn't loaded yet.
*/
export function useJobBasic(jobId: Ref<string | null | undefined>) {
const jobStore = useJobStore();
const job = computed(() => (jobId.value ? (jobStore.getJob(jobId.value) ?? null) : null));
return { job };
}
+4 -1
View File
@@ -452,7 +452,10 @@ export function getRouter(Galaxy) {
{
path: "jobs/:jobId/view",
component: JobDetails,
props: true,
props: (route) => ({
jobId: route.params.jobId,
showHeader: true,
}),
},
{
path: "object_store_instances/create",
+715
View File
@@ -0,0 +1,715 @@
import { advanceTimersAndFlush } from "@tests/vitest/helpers";
import flushPromises from "flush-promises";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useServerMock } from "@/api/client/__mocks__";
import type { JobState, ShowFullJobResponse } from "@/api/jobs";
import { MAX_CACHED_JOBS, useJobStore } from "./jobStore";
const { server, http } = useServerMock();
// Cache eviction exercises many requests; API rate limiting has its own tests.
vi.mock("@/api/client/rateLimiter", () => ({ createRateLimiterMiddleware: () => ({ onRequest() {} }) }));
vi.useFakeTimers();
function buildJob(id: string, state: JobState): ShowFullJobResponse {
return {
id,
state,
model_class: "Job",
create_time: "2024-01-01T00:00:00",
update_time: "2024-01-01T00:00:00",
inputs: {},
outputs: {},
output_collections: {},
params: {},
tool_id: "cat1",
} as ShowFullJobResponse;
}
/** Starts `pollJobUntilTerminal` for each id one at a time, awaiting an MSW flush after each. */
async function pollManyJobs(store: ReturnType<typeof useJobStore>, ids: string[]) {
for (const id of ids) {
store.pollJobUntilTerminal({ id });
await flushPromises();
}
}
describe("useJobStore", () => {
beforeEach(() => {
setActivePinia(createPinia());
});
afterEach(() => {
vi.clearAllTimers();
});
it("auto-fetches a job on read via getJob, without going through pollJobUntilTerminal", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
callCount++;
return response(200).json(buildJob("job0", "ok"));
}),
);
const store = useJobStore();
// Nothing has polled this id -- a plain read should still trigger a fetch, e.g. for a
// one-off, non-polled lookup that never calls pollJobUntilTerminal.
expect(store.getJob("job0")).toBeNull();
await flushPromises();
expect(callCount).toBe(1);
expect(store.getJob("job0")?.id).toBe("job0");
// Reading again is satisfied by the cache, not re-fetched.
await flushPromises();
expect(callCount).toBe(1);
});
it("does not start a second polling loop for a job id that is already being polled", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
callCount++;
return response(200).json(buildJob("job1", "running"));
}),
);
const store = useJobStore();
store.pollJobUntilTerminal({ id: "job1" });
store.pollJobUntilTerminal({ id: "job1" }); // second caller for the same id
await flushPromises();
expect(callCount).toBe(1);
await advanceTimersAndFlush(1000);
// Only one loop ticking: one more fetch, not two.
expect(callCount).toBe(2);
});
it("a stale stopWatchingJob handle from an ended poll does not stop a newer poll for the same id", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
callCount++;
return response(200).json(buildJob("job9", "running"));
}),
);
const store = useJobStore();
// Consumer A starts a poll and later stops it; this ends the *first* poll entry for
// "job9" (refCount drops to 0, entry removed from `activePolls`).
const a = store.pollJobUntilTerminal({ id: "job9" });
await flushPromises();
expect(callCount).toBe(1);
a.stopWatchingJob();
// Consumer B starts a brand new poll for the same id: a *second*, independent entry.
const b = store.pollJobUntilTerminal({ id: "job9" });
await flushPromises();
expect(callCount).toBe(2);
// A's handle is invoked again here (e.g. a component unmounting late, calling its cleanup
// a second time, or having queued the call before A originally stopped). If `activePolls`
// were keyed only by id, this would decrement *B's* entry (the only one now at "job9") and
// could stop it even though B still needs it.
a.stopWatchingJob();
// B's poll must still be running: we keep fetching.
await advanceTimersAndFlush(1000);
expect(callCount).toBe(3);
b.stopWatchingJob();
await advanceTimersAndFlush(1000);
// Now that B has actually released it, the poll really does stop.
expect(callCount).toBe(3);
});
it("stops polling once the job reaches a terminal state", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
callCount++;
return response(200).json(buildJob("job1", "ok"));
}),
);
const store = useJobStore();
store.pollJobUntilTerminal({ id: "job1" });
await flushPromises();
expect(callCount).toBe(1);
await advanceTimersAndFlush(5000);
expect(callCount).toBe(1);
});
it("does not re-fetch a job that is already cached in a terminal state", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
callCount++;
return response(200).json(buildJob("job1", "ok"));
}),
);
const store = useJobStore();
store.pollJobUntilTerminal({ id: "job1" });
await flushPromises();
expect(callCount).toBe(1);
// Let the loop's next tick see the terminal state and clean itself up.
await advanceTimersAndFlush(1000);
expect(callCount).toBe(1); // still terminal, no second fetch from that loop
// Calling again for the same (now terminal, cached) id must not trigger a new fetch --
// real job ids never go non-terminal again, so the cache can be trusted permanently.
store.pollJobUntilTerminal({ id: "job1" });
await flushPromises();
expect(callCount).toBe(1);
});
it("skips the initial fetch entirely when the job is already cached and terminal", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
callCount++;
return response(200).json(buildJob("job2", "ok"));
}),
);
const store = useJobStore();
store.pollJobUntilTerminal({ id: "job2" });
await flushPromises();
expect(callCount).toBe(1);
// A brand new call for the same id, from a totally separate caller, should be satisfied
// by the cache without issuing any request at all.
store.pollJobUntilTerminal({ id: "job2" });
await flushPromises();
expect(callCount).toBe(1);
});
it("upgrades a base-only cached job to full when a full: true caller asks for it", async () => {
let callCount = 0;
let lastFullParam: string | null = null;
server.use(
http.get("/api/jobs/{job_id}", ({ response, request }) => {
callCount++;
lastFullParam = new URL(request.url).searchParams.get("full");
return response(200).json(buildJob("job3", "ok"));
}),
);
const store = useJobStore();
store.pollJobUntilTerminal({ id: "job3", full: false });
await flushPromises();
expect(callCount).toBe(1);
expect(lastFullParam).toBe("false");
// A full: true caller for the same (terminal, base-only cached) id must still fetch --
// the cache doesn't yet hold the full shape.
store.pollJobUntilTerminal({ id: "job3", full: true });
await flushPromises();
expect(callCount).toBe(2);
expect(lastFullParam).toBe("true");
// Once upgraded, a later full: false caller is satisfied by the now-full cache.
store.pollJobUntilTerminal({ id: "job3", full: false });
await flushPromises();
expect(callCount).toBe(2);
});
it("does not re-fetch a terminal job that was already cached full via getJob, not pollJobUntilTerminal", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
callCount++;
return response(200).json(buildJob("job10", "ok"));
}),
);
const store = useJobStore();
// Cached via getJob's auto-fetch (default full: true), not via pollJobUntilTerminal.
// Nothing marks this id as "fully loaded" unless every fetch path does so consistently.
store.getJob("job10");
await flushPromises();
expect(callCount).toBe(1);
// A later poll (default full: true) for the same id must be satisfied by the cache.
store.pollJobUntilTerminal({ id: "job10" });
await flushPromises();
expect(callCount).toBe(1);
});
it("does not skip a non-terminal job even if a base version is already cached", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
callCount++;
return response(200).json(buildJob("job4", "running"));
}),
);
const store = useJobStore();
store.pollJobUntilTerminal({ id: "job4", full: false });
await flushPromises();
expect(callCount).toBe(1);
// Still running -- a second caller must keep the poll going, not treat the cache as final.
await advanceTimersAndFlush(1000);
expect(callCount).toBe(2);
});
it("upgrades an already-running poll for a non-terminal job to full on its next tick", async () => {
let callCount = 0;
let lastFullParam: string | null = null;
server.use(
http.get("/api/jobs/{job_id}", ({ response, request }) => {
callCount++;
lastFullParam = new URL(request.url).searchParams.get("full");
return response(200).json(buildJob("job5", "running"));
}),
);
const store = useJobStore();
store.pollJobUntilTerminal({ id: "job5", full: false });
await flushPromises();
expect(callCount).toBe(1);
expect(lastFullParam).toBe("false");
// A full: true caller arrives while that poll is still active (job still running) --
// rather than starting a second loop, it should upgrade the existing one in place.
store.pollJobUntilTerminal({ id: "job5", full: true });
await flushPromises();
expect(callCount).toBe(1); // no immediate second fetch, just flags the running loop
await advanceTimersAndFlush(1000);
expect(callCount).toBe(2);
expect(lastFullParam).toBe("true");
});
it("merges a base response into an already-cached full job instead of overwriting it", async () => {
server.use(
http.get("/api/jobs/{job_id}", ({ response, request }) => {
const full = new URL(request.url).searchParams.get("full") === "true";
if (full) {
return response(200).json({
...buildJob("job8", "running"),
stdout: "full stdout",
stderr: "full stderr",
});
}
// A base (`full=false`) response never carries `stdout`/`stderr` at all -- they're
// not merely empty, they're absent from this (differently-shaped) response.
const { stdout: _stdout, stderr: _stderr, ...baseJob } = buildJob("job8", "running");
return response(200).json(baseJob);
}),
);
const store = useJobStore();
await store.fetchJob({ id: "job8", full: true });
expect(store.getJob("job8")?.stdout).toBe("full stdout");
// A later base fetch for the same id (e.g. a second, non-full caller) must not clobber
// the full-only fields already cached.
await store.fetchJob({ id: "job8", full: false });
expect(store.getJob("job8")?.stdout).toBe("full stdout");
expect(store.getJob("job8")?.stderr).toBe("full stderr");
// The base response's own fields (e.g. `state`) still come through fresh.
expect(store.getJob("job8")?.state).toBe("running");
});
it("does not let a full: true request piggyback on a concurrent in-flight full: false request", async () => {
let callCount = 0;
const fullParamsByCall: (string | null)[] = [];
let resolveBaseFetch: (() => void) | undefined;
server.use(
http.get("/api/jobs/{job_id}", async ({ response, request }) => {
callCount++;
const full = new URL(request.url).searchParams.get("full");
fullParamsByCall.push(full);
if (full === "false") {
// Hold the base request open so a full: true request can race it.
await new Promise<void>((resolve) => {
resolveBaseFetch = resolve;
});
}
return response(200).json(buildJob("job6", "ok"));
}),
);
const store = useJobStore();
// Kick off a base fetch and let it start (but not finish) before the full fetch begins.
const basePromise = store.fetchJob({ id: "job6", full: false });
await flushPromises();
expect(callCount).toBe(1);
// While the base fetch is still in flight, a full fetch for the same id comes in -- it
// must not be satisfied by the base fetch's (still pending) in-flight promise/result.
const fullPromise = store.fetchJob({ id: "job6", full: true });
resolveBaseFetch?.();
await Promise.all([basePromise, fullPromise]);
expect(callCount).toBe(2);
expect(fullParamsByCall).toEqual(["false", "true"]);
});
it("still fetches full once more if the poll was upgraded after an in-flight base tick already started", async () => {
let callCount = 0;
const fullParamsByCall: (string | null)[] = [];
let resolveBaseTick: (() => void) | undefined;
server.use(
http.get("/api/jobs/{job_id}", async ({ response, request }) => {
callCount++;
const full = new URL(request.url).searchParams.get("full");
fullParamsByCall.push(full);
// The first tick reports the job still running, so the poll schedules a second
// tick; the second tick (still base, since the upgrade hasn't landed yet) is held
// open so the upgrade below can land while it's in flight, then reports terminal.
if (full === "false" && callCount === 2) {
await new Promise<void>((resolve) => {
resolveBaseTick = resolve;
});
return response(200).json(buildJob("job7", "ok"));
}
return response(200).json(buildJob("job7", "running"));
}),
);
const store = useJobStore();
store.pollJobUntilTerminal({ id: "job7", full: false });
await flushPromises();
expect(callCount).toBe(1);
// Start the loop's next tick (still base, since it reads `full` before the upgrade below).
await advanceTimersAndFlush(1000);
expect(callCount).toBe(2);
// The upgrade arrives while that base tick is still in flight.
store.pollJobUntilTerminal({ id: "job7", full: true });
// Let the held-open base tick resolve (terminal). It must not stop polling having only
// ever fetched the base representation -- it should fetch full once more first.
resolveBaseTick?.();
await flushPromises();
expect(callCount).toBe(3);
expect(fullParamsByCall[2]).toBe("true");
expect(store.getJob("job7")).not.toBeNull();
});
it("stops polling for good after a non-retryable fetch error (e.g. a malformed job id)", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
callCount++;
return response("4XX").json(
{ err_msg: "Wrong id specified, unable to decode.", err_code: 400 },
{ status: 400 },
);
}),
);
const store = useJobStore();
store.pollJobUntilTerminal({ id: "bad-id" });
await flushPromises();
expect(callCount).toBe(1);
// A 400 will never succeed no matter how many times we ask -- must not keep polling.
await advanceTimersAndFlush(5000);
expect(callCount).toBe(1);
});
it("stops polling once a retryable fetch error has exhausted its retries", async () => {
let callCount = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response }) => {
callCount++;
return response("5XX").json({ err_msg: "Server error", err_code: 500 }, { status: 500 });
}),
);
const store = useJobStore();
store.pollJobUntilTerminal({ id: "flaky-id" });
await flushPromises();
expect(callCount).toBe(1);
// Keeps retrying a transient-looking error for a while...
await advanceTimersAndFlush(1000);
await advanceTimersAndFlush(1000);
await advanceTimersAndFlush(1000);
expect(callCount).toBeGreaterThan(1);
const callsAfterRetries = callCount;
// ...but eventually gives up rather than retrying forever.
await advanceTimersAndFlush(1000);
await advanceTimersAndFlush(1000);
await advanceTimersAndFlush(1000);
expect(callCount).toBe(callsAfterRetries);
});
});
describe("useJobStore eviction", () => {
beforeEach(() => {
setActivePinia(createPinia());
});
afterEach(() => {
vi.clearAllTimers();
});
it("evicts unused jobs while retaining displayed terminal jobs", async () => {
server.use(
http.get("/api/jobs/{job_id}", ({ response, params }) =>
response(200).json(buildJob(params.job_id as string, "ok")),
),
);
const store = useJobStore();
const release = store.retainJob("displayed");
await store.fetchJob({ id: "displayed" });
await pollManyJobs(
store,
Array.from({ length: MAX_CACHED_JOBS }, (_, i) => `job-${i}`),
);
expect(store.getCachedJob("job-0")).toBeNull();
expect(store.getCachedJob(`job-${MAX_CACHED_JOBS - 1}`)?.state).toBe("ok");
expect(store.getCachedJob("displayed")?.state).toBe("ok");
release();
await pollManyJobs(
store,
Array.from({ length: MAX_CACHED_JOBS }, (_, i) => `next-${i}`),
);
expect(store.getCachedJob("displayed")).toBeNull();
});
it("never evicts a job that is still actively being polled", async () => {
let runningJobCallCount = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response, params }) => {
if (params.job_id === "still-running") {
runningJobCallCount++;
return response(200).json(buildJob("still-running", "running"));
}
return response(200).json(buildJob(params.job_id as string, "ok"));
}),
);
const store = useJobStore();
// Start a poll for a non-terminal job first, so it's the oldest entry once the cache
// fills up with terminal jobs after it.
const { stopWatchingJob } = store.pollJobUntilTerminal({ id: "still-running" });
await flushPromises();
expect(runningJobCallCount).toBe(1);
await pollManyJobs(
store,
Array.from({ length: MAX_CACHED_JOBS }, (_, i) => `job-${i}`),
);
// Despite being the least-recently-touched entry, "still-running" must survive because
// it's still actively polled -- evicting it would silently stop a live poll's caller
// from ever seeing further updates.
expect(store.getJob("still-running")).not.toBeNull();
await advanceTimersAndFlush(1000);
expect(runningJobCallCount).toBe(2);
stopWatchingJob();
});
});
describe("full job freshness", () => {
beforeEach(() => setActivePinia(createPinia()));
afterEach(() => vi.clearAllTimers());
it("fetches final full details after a base poll observes completion", async () => {
let completed = false;
const fullRequests: boolean[] = [];
server.use(
http.get("/api/jobs/{job_id}", ({ response, request }) => {
const full = new URL(request.url).searchParams.get("full") === "true";
fullRequests.push(full);
const job = buildJob("job", completed ? "ok" : "running");
return response(200).json(full ? { ...job, tool_stdout: completed ? "final output" : "" } : job);
}),
);
const store = useJobStore();
await store.fetchJob({ id: "job", full: true });
completed = true;
store.pollJobUntilTerminal({ id: "job", full: false });
await flushPromises();
store.pollJobUntilTerminal({ id: "job", full: true });
await flushPromises();
expect(store.getCachedJob("job")?.tool_stdout).toBe("final output");
expect(fullRequests).toEqual([true, false, true]);
store.pollJobUntilTerminal({ id: "job", full: true });
await flushPromises();
expect(fullRequests).toEqual([true, false, true]);
});
it("retries a failed full upgrade instead of caching incomplete terminal details", async () => {
let fullCalls = 0;
server.use(
http.get("/api/jobs/{job_id}", ({ response, request }) => {
if (new URL(request.url).searchParams.get("full") === "true") {
fullCalls++;
if (fullCalls === 1) {
return response("5XX").json(
{ err_msg: "Temporarily unavailable", err_code: 503 },
{ status: 503 },
);
}
return response(200).json({ ...buildJob("job", "ok"), tool_stdout: "final output" });
}
return response(200).json(buildJob("job", "ok"));
}),
);
const store = useJobStore();
await store.fetchJob({ id: "job", full: false });
store.pollJobUntilTerminal({ id: "job", full: true });
await flushPromises();
expect(store.getJobLoadError("job")).toBeTruthy();
await advanceTimersAndFlush(1000);
expect(store.getCachedJob("job")?.tool_stdout).toBe("final output");
expect(store.getJobLoadError("job")).toBeNull();
await advanceTimersAndFlush(1000);
expect(fullCalls).toBe(2);
});
it("does not let a slow, older response overwrite a newer job state", async () => {
let releaseFull: () => void = () => {};
server.use(
http.get("/api/jobs/{job_id}", async ({ response, request }) => {
if (new URL(request.url).searchParams.get("full") === "true") {
// The server snapshot was taken while the job was still running, but arrives late
await new Promise<void>((resolve) => (releaseFull = resolve));
return response(200).json({ ...buildJob("job", "running"), tool_stdout: "partial" });
}
return response(200).json(buildJob("job", "ok"));
}),
);
const store = useJobStore();
const slowFull = store.fetchJob({ id: "job", full: true });
await flushPromises();
store.pollJobUntilTerminal({ id: "job", full: false });
await flushPromises();
expect(store.getCachedJob("job")?.state).toBe("ok");
releaseFull();
await slowFull;
await advanceTimersAndFlush(3000);
const job = store.getCachedJob("job");
expect(job?.state).toBe("ok");
// Fields the cache lacked are still filled in from the older response
expect(job?.tool_stdout).toBe("partial");
});
it("does not orphan a newer poll when a superseded poll's final full fetch fails", async () => {
let requestCount = 0;
let releaseFull: () => void = () => {};
server.use(
http.get("/api/jobs/{job_id}", async ({ response, request }) => {
requestCount++;
if (new URL(request.url).searchParams.get("full") === "true") {
await new Promise<void>((resolve) => (releaseFull = resolve));
return response("4XX").json({ err_msg: "Forbidden", err_code: 403 }, { status: 403 });
}
return response(200).json(buildJob("job", "ok"));
}),
);
const store = useJobStore();
// A base poll is upgraded to full; its base fetch sees completion, so it fetches final full fields
const basePoll = store.pollJobUntilTerminal({ id: "job", full: false });
const fullPoll = store.pollJobUntilTerminal({ id: "job", full: true });
await flushPromises();
// Every caller leaves while that full fetch is pending, and a new caller starts a new poll
basePoll.stopWatchingJob();
fullPoll.stopWatchingJob();
const newPoll = store.pollJobUntilTerminal({ id: "job", full: true });
await flushPromises();
releaseFull();
await flushPromises();
newPoll.stopWatchingJob();
const requestsAfterRelease = requestCount;
await advanceTimersAndFlush(5000);
expect(requestCount).toBe(requestsAfterRelease);
expect(vi.getTimerCount()).toBe(0);
});
it("keeps a full poll going when its final full response is superseded by a newer base response", async () => {
let fullCalls = 0;
let releaseFinalFull: () => void = () => {};
server.use(
http.get("/api/jobs/{job_id}", async ({ response, request }) => {
if (new URL(request.url).searchParams.get("full") !== "true") {
return response(200).json(buildJob("job", "ok"));
}
fullCalls++;
if (fullCalls === 1) {
return response(200).json({ ...buildJob("job", "running"), tool_stdout: "partial" });
}
if (fullCalls === 2) {
await new Promise<void>((resolve) => (releaseFinalFull = resolve));
}
return response(200).json({ ...buildJob("job", "ok"), tool_stdout: "FINAL OUTPUT" });
}),
);
const store = useJobStore();
await store.fetchJob({ id: "job", full: true });
// A base poll sees completion after a full consumer joined, so it fetches the final full fields...
store.pollJobUntilTerminal({ id: "job", full: false });
store.pollJobUntilTerminal({ id: "job", full: true });
await flushPromises();
// ...but a newer base request is applied before that full response arrives
await store.fetchJob({ id: "job", full: false });
releaseFinalFull();
await flushPromises();
await advanceTimersAndFlush(1000);
expect(store.getCachedJob("job")?.tool_stdout).toBe("FINAL OUTPUT");
expect(fullCalls).toBe(3);
expect(vi.getTimerCount()).toBe(0);
});
it("stops polling a finished job whose full responses keep being superseded", async () => {
let fullRequests = 0;
const pendingFull: Array<() => void> = [];
server.use(
http.get("/api/jobs/{job_id}", async ({ response, request }) => {
if (new URL(request.url).searchParams.get("full") !== "true") {
return response(200).json(buildJob("job", "ok"));
}
fullRequests++;
await new Promise<void>((resolve) => pendingFull.push(resolve));
return response("4XX").json({ err_msg: "Forbidden", err_code: 403 }, { status: 403 });
}),
);
const store = useJobStore();
store.pollJobUntilTerminal({ id: "job", full: false });
store.pollJobUntilTerminal({ id: "job", full: true });
for (let tick = 0; tick < 10; tick++) {
await flushPromises();
// A newer base request is applied before each pending full response arrives
await store.fetchJob({ id: "job", full: false });
pendingFull.splice(0).forEach((release) => release());
await advanceTimersAndFlush(1000);
}
expect(vi.getTimerCount()).toBe(0);
expect(fullRequests).toBeLessThan(10);
});
});
+328 -35
View File
@@ -1,23 +1,87 @@
/**
* Requests jobs
* Requests response generated by a tool run
*/
import { defineStore } from "pinia";
import { ref } from "vue";
import { computed, ref } from "vue";
import { GalaxyApi } from "@/api";
import { type ResponseVal, type ShowFullJobResponse, TERMINAL_STATES } from "@/api/jobs";
import { type FetchParams, useKeyedCache } from "@/composables/keyedCache";
import { rethrowSimpleWithStatus } from "@/utils/simple-error";
import { useResourceWatcher } from "@/composables/resourceWatcher";
import { isRetryableApiError, MAX_RETRIES, rethrowSimpleWithStatus } from "@/utils/simple-error";
interface JobFetchParams {
id: string;
/** Whether to request the full job representation. Defaults to `true`. */
full?: boolean;
}
interface PollEntry {
watcher: ReturnType<typeof useResourceWatcher>;
full: boolean;
refCount: number;
}
/** Cache target; mounted consumers and in-flight requests may temporarily exceed it. */
export const MAX_CACHED_JOBS = 40;
/**
* The Job store for managing job data and tool-run responses.
*
* - Keeps recently used jobs, evicting unused entries above `MAX_CACHED_JOBS`.
* Mounted consumers retain their entries even after polling finishes.
* - Allows both base and full requests for the same job id to be tracked independently, but merges
* the full response into the cached job rather than overwriting it entirely.
* - Polls a job until it reaches a terminal state, deduped so multiple callers watching the same
* job share one poll.
* - Stores the latest tool-run response (used by the post-run "success" view).
*/
export const useJobStore = defineStore("jobStore", () => {
const latestResponse = ref<ResponseVal | null>(null);
async function fetchJobById(params: FetchParams): Promise<ShowFullJobResponse> {
const storedJobs = ref<{ [id: string]: ShowFullJobResponse }>({});
const loadingErrors = ref<{ [id: string]: Error }>({});
const retryCounts: { [id: string]: number } = {};
/** Keyed by `id:full` so a base and a full request for the same job id are tracked separately.
* Otherwise whichever one resolved first would be (wrongly) treated as satisfying both.
*/
const loadingRequests = new Map<string, Promise<ShowFullJobResponse | undefined>>();
/** Counts in-flight requests per job id (a base and a full request can be in flight for the
* same id at once, under separate `loadingRequests` keys) so `isLoadingJob` stays true until
* *all* of them finish, not just whichever happens to resolve first. */
const loadingCountsByJobId = ref<{ [id: string]: number }>({});
/** Requests are numbered in the order they start. A response is only written to the cache if no
* request for the same job that started *later* has already been applied, so a slow response
* carrying an older snapshot (e.g. `running`) can't overwrite a newer one (e.g. `ok`). */
let requestSequence = 0;
const appliedSequenceByJobId: { [id: string]: number } = {};
function saveLatestResponse(newResponse: ResponseVal) {
latestResponse.value = newResponse;
}
/** Keys a request by `id:full` so that base and full **requests** for the same job id are tracked separately. */
function requestKey(params: JobFetchParams): string {
return `${params.id}:${params.full ?? true}`;
}
/** Whether a failed fetch for `id` is worth retrying: the error is a retryable status and we
* haven't already retried it `MAX_RETRIES` times. */
function canRetryJob(id: string): boolean {
const existingError = loadingErrors.value[id];
return !!existingError && isRetryableApiError(existingError) && (retryCounts[id] ?? 0) <= MAX_RETRIES;
}
const getJobLoadError = computed(() => (id: string) => loadingErrors.value[id] ?? null);
const isLoadingJob = computed(() => (id: string) => (loadingCountsByJobId.value[id] ?? 0) > 0);
function mergeJob(freshJob: ShowFullJobResponse, existingJob?: ShowFullJobResponse): ShowFullJobResponse {
return existingJob ? { ...existingJob, ...freshJob } : freshJob;
}
async function fetchJobFromApi(params: JobFetchParams): Promise<ShowFullJobResponse> {
const { data, error, response } = await GalaxyApi().GET("/api/jobs/{job_id}", {
params: { path: { job_id: params.id } },
query: { full: true },
params: { path: { job_id: params.id }, query: { full: params.full ?? true } },
});
if (error) {
rethrowSimpleWithStatus(error, response);
@@ -25,33 +89,260 @@ export const useJobStore = defineStore("jobStore", () => {
return data;
}
function saveLatestResponse(newResponse: ResponseVal) {
latestResponse.value = newResponse;
}
const {
fetchItemById: fetchJob,
getItemById: getJob,
getItemLoadError: getJobLoadError,
isLoadingItem: isLoadingJob,
} = useKeyedCache<ShowFullJobResponse>(fetchJobById);
function pollJobUntilTerminal(params: FetchParams) {
function poll() {
fetchJob(params);
setTimeout(pollJobUntilTerminal, 1000, params);
/** Fetches a job, deduping concurrent requests for the same id+`full` shape, merging the
* result into the cache, and tracking loading/retry state. */
async function fetchAndCacheJob(params: JobFetchParams): Promise<ShowFullJobResponse | undefined> {
const key = requestKey(params);
const inFlight = loadingRequests.get(key);
if (inFlight) {
return inFlight;
}
const job = getJob.value(params.id);
if (job) {
const jobState = job.state;
if (TERMINAL_STATES.indexOf(jobState) !== -1) {
return;
} else {
poll();
// Increment the count of in-flight requests for this job id. This ensures that `isLoadingJob`
// remains true until all concurrent requests for the same job id (base and full) have completed.
loadingCountsByJobId.value[params.id] = (loadingCountsByJobId.value[params.id] ?? 0) + 1;
const sequence = ++requestSequence;
const isStale = () => sequence < (appliedSequenceByJobId[params.id] ?? 0);
const fetchPromise = (async () => {
try {
const freshJob = await fetchJobFromApi(params);
const existingJob = storedJobs.value[params.id];
if (isStale() && existingJob) {
// A newer response was already applied: only fill in fields the cache lacks
// (e.g. full-only fields), never overwrite the newer state.
const job = mergeJob(existingJob, freshJob);
storedJobs.value[params.id] = job;
return job;
}
appliedSequenceByJobId[params.id] = Math.max(sequence, appliedSequenceByJobId[params.id] ?? 0);
const job = mergeJob(freshJob, existingJob);
storedJobs.value[params.id] = job;
delete loadingErrors.value[params.id];
delete retryCounts[params.id];
if (params.full !== false && TERMINAL_STATES.includes(freshJob.state)) {
terminalFullJobIds.add(params.id);
} else if (params.full !== false || freshJob.state !== existingJob?.state) {
terminalFullJobIds.delete(params.id);
}
return job;
} catch (error) {
if (isStale()) {
// A newer request already succeeded; this failure says nothing about the job now.
return storedJobs.value[params.id];
}
retryCounts[params.id] = (retryCounts[params.id] ?? 0) + 1;
loadingErrors.value[params.id] = error as Error;
return undefined;
} finally {
loadingRequests.delete(key);
const remaining = (loadingCountsByJobId.value[params.id] ?? 1) - 1;
if (remaining <= 0) {
delete loadingCountsByJobId.value[params.id];
} else {
loadingCountsByJobId.value[params.id] = remaining;
}
}
} else {
poll();
})();
loadingRequests.set(key, fetchPromise);
return fetchPromise;
}
function removeJob(id: string) {
delete storedJobs.value[id];
delete loadingErrors.value[id];
delete retryCounts[id];
delete appliedSequenceByJobId[id];
}
// LRU tracking for `MAX_CACHED_JOBS`: a Map's keys iterate oldest-to-newest, and re-`set`ting
// a key moves it to the end, so this alone tracks recency order without extra bookkeeping.
const lruOrder = new Map<string, true>();
function markUsed(id: string) {
lruOrder.delete(id);
lruOrder.set(id, true);
}
const consumers = new Map<string, number>();
/** Protects a displayed job from eviction until the returned release function is called. */
function retainJob(id: string) {
consumers.set(id, (consumers.get(id) ?? 0) + 1);
let released = false;
return () => {
if (released) {
return;
}
released = true;
const remaining = (consumers.get(id) ?? 1) - 1;
if (remaining > 0) {
consumers.set(id, remaining);
} else {
consumers.delete(id);
}
evictIfOverCap();
};
}
function evictIfOverCap() {
for (const id of lruOrder.keys()) {
if (lruOrder.size <= MAX_CACHED_JOBS) {
break;
}
if (consumers.has(id) || activePolls.has(id) || isLoadingJob.value(id)) {
continue;
}
lruOrder.delete(id);
removeJob(id);
terminalFullJobIds.delete(id);
}
}
/** An overloaded fetch function that ensures the job is retrieved from the cache, but with the
* additional, optional `full` option for requesting the full job representation. */
async function fetchJob(params: JobFetchParams) {
markUsed(params.id);
const job = await fetchAndCacheJob(params);
evictIfOverCap();
return job;
}
/** Reads the cache without starting a request. */
const getCachedJob = computed(() => (id: string) => {
const job = storedJobs.value[id] ?? null;
if (job) {
markUsed(id);
}
return job;
});
/** One-off lookup. Mounted readers use `useJobDetails` to retain their cache entries. */
const getJob = computed(() => (id: string) => {
const job = getCachedJob.value(id);
if (!job && id && !loadingRequests.has(requestKey({ id })) && !getJobLoadError.value(id)) {
fetchJob({ id });
}
return job;
});
// Full-only fields are final only when a full response itself observed completion.
const terminalFullJobIds = new Set<string>();
/** A track of all active polls (by `job_id` and whether the stored representation is full),
* plus a count of how many callers still want that poll running, so we don't duplicate polls
* for the same ID and stop polling once the last interested caller goes away. */
const activePolls = new Map<string, PollEntry>();
/**
* Polls a job until it reaches a terminal state. If the job is already terminal and cached,
* it may not poll at all. If the stored job is not a full representation and a full one is
* requested, it will poll for the final full representation, including retries on transient errors.
*
* Returns a `stopWatchingJob` function the caller must invoke (e.g. from `onUnmounted`) once it
* no longer needs this job polled because the underlying poll only actually stops once every caller
* that started it has done so.
*/
function pollJobUntilTerminal(params: JobFetchParams): { stopWatchingJob: () => void } {
const { id, full = true } = params;
const stopWatchingReturn = { stopWatchingJob: () => {} };
// Not using `getJob` here because that would trigger a fetch if the job isn't already cached
const cachedJob = storedJobs.value[id];
const cachedJobIsTerminal = !!cachedJob && TERMINAL_STATES.indexOf(cachedJob.state) !== -1;
/** Whether the cached job satisfies the current request, considering the `full` option. */
const cacheSatisfiesRequest = full ? terminalFullJobIds.has(id) : !!cachedJob;
if (cachedJobIsTerminal && cacheSatisfiesRequest) {
// Already have everything this call needs, and the job won't change anymore.
return stopWatchingReturn;
}
const runningPoll = activePolls.get(id);
if (runningPoll !== undefined) {
runningPoll.refCount++;
// We need to switch an ongoing non-`full` poll to request `full`.
if (full && !runningPoll.full) {
runningPoll.full = true;
}
return { stopWatchingJob: () => stopWatchingJob(id, runningPoll) };
}
/** Stops this poll. Only removes its own `activePolls` entry: by the time an `await` resolves,
* the last caller may have stopped it and a new, unrelated poll may have taken its place. */
function endPoll() {
watcher.dispose();
if (activePolls.get(id) === pollEntry) {
activePolls.delete(id);
}
}
/** Ticks this poll has seen the job terminal without fresh full details yet (see below). */
let terminalTicksWithoutFull = 0;
const watcher = useResourceWatcher(
async () => {
// Read (not close over) the current requested level as a later call may have
// upgraded this poll to `full: true` since it started.
const requestFull = activePolls.get(id)?.full ?? full;
let job = await fetchJob({ id, full: requestFull });
if (activePolls.get(id) !== pollEntry) {
endPoll();
return;
}
if (job && TERMINAL_STATES.indexOf(job.state) !== -1) {
// An in-flight base request may finish after a full consumer joins.
// Fetch final full fields before stopping the shared poll.
if (pollEntry.full && !terminalFullJobIds.has(id)) {
job = await fetchJob({ id, full: true });
if (activePolls.get(id) !== pollEntry) {
endPoll();
return;
}
}
// A full poll is only done once a (non-stale) full response has observed completion;
// otherwise keep polling so the next tick fetches the final full fields again. Cap
// that, so full responses that keep being superseded can't poll a finished job forever.
const satisfied = !pollEntry.full || terminalFullJobIds.has(id);
if (job && TERMINAL_STATES.includes(job.state)) {
if (satisfied || ++terminalTicksWithoutFull > MAX_RETRIES) {
endPoll();
return;
}
}
}
if (!job && getJobLoadError.value(id) && !canRetryJob(id)) {
// Stop fetching if the fetch failed with a non-retryable error
endPoll();
}
},
{ shortPollingInterval: 1000, longPollingInterval: 1000 },
);
const pollEntry = { watcher, full, refCount: 1 };
activePolls.set(id, pollEntry);
watcher.startWatchingResource();
return { stopWatchingJob: () => stopWatchingJob(id, pollEntry) };
}
/** One caller is done watching a job; the poll stops once refCount hits 0.
*
* `entry` is the specific poll entry this caller's handle was issued for.
* If `id`'s current entry in `activePolls` is a *different* one (the original poll already ended
* and a new one started for the same id since), this is a stale handle and must be a no-op:
* decrementing the new, unrelated poll's `refCount` could stop it while a still-active caller
* still needs it.
*/
function stopWatchingJob(id: string, entry: PollEntry) {
const runningPoll = activePolls.get(id);
if (!runningPoll || runningPoll !== entry) {
return;
}
runningPoll.refCount--;
if (runningPoll.refCount <= 0) {
runningPoll.watcher.dispose();
activePolls.delete(id);
}
}
@@ -59,6 +350,8 @@ export const useJobStore = defineStore("jobStore", () => {
fetchJob,
saveLatestResponse,
getJob,
getCachedJob,
retainJob,
getJobLoadError,
isLoadingJob,
latestResponse,
+4 -3
View File
@@ -181,9 +181,9 @@ export const useToolStore = defineStore("toolStore", () => {
});
const getToolNameById = computed(() => {
return (toolId: string) => {
return (toolId: string, defaultValue = "...") => {
const details = toolsById.value[toolId];
return details?.name || "...";
return details?.name || defaultValue;
};
});
@@ -378,7 +378,8 @@ export const useToolStore = defineStore("toolStore", () => {
}
function saveToolForId(toolId: string, toolData: Tool) {
toolsById.value[toolId] = toolData;
// `toolsById` is a `shallowRef`, so only reassigning `.value` notifies watchers.
toolsById.value = { ...toolsById.value, [toolId]: toolData };
}
function saveToolResults(whooshQuery: string, toolsData: Array<string>) {
+89
View File
@@ -1,8 +1,13 @@
import { getFakeRegisteredUser } from "@tests/test-data";
import flushPromises from "flush-promises";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useServerMock } from "@/api/client/__mocks__";
import { useUserStore } from "@/stores/userStore";
const { server, http } = useServerMock();
describe("userStore", () => {
beforeEach(() => {
setActivePinia(createPinia());
@@ -56,4 +61,88 @@ describe("userStore", () => {
expect(userStore.recentTools).toEqual([]);
});
});
describe("getDecodedId", () => {
let callCount = 0;
beforeEach(() => {
server.use(
http.get("/api/configuration/decode/{encoded_id}", ({ response }) => {
callCount++;
return response(200).json({ decoded_id: 123 });
}),
);
});
afterEach(() => {
callCount = 0;
});
it("returns null and does not fetch for a non-admin user", async () => {
const userStore = useUserStore();
userStore.currentUser = getFakeRegisteredUser({ is_admin: false });
expect(userStore.getDecodedId("abc")).toBeNull();
await flushPromises();
expect(callCount).toBe(0);
});
it("decodes and caches the id for an admin user", async () => {
const userStore = useUserStore();
userStore.currentUser = getFakeRegisteredUser({ is_admin: true });
expect(userStore.getDecodedId("abc")).toBeNull(); // not yet resolved
await flushPromises();
expect(callCount).toBe(1);
expect(userStore.getDecodedId("abc")).toBe(123);
// Second read for the same id should hit the cache, not trigger another fetch
await flushPromises();
expect(callCount).toBe(1);
});
it("retries once the current user becomes known, instead of setting the ID prematurely", async () => {
const userStore = useUserStore();
// currentUser starts as `null` (not yet loaded), `isAdmin = false`, but this
// must not be treated as a confirmed "not admin" and cached as such.
expect(userStore.currentUser).toBeNull();
expect(userStore.getDecodedId("abc")).toBeNull();
await flushPromises();
expect(callCount).toBe(0); // never fetched while the user is unknown
// The user loads in and turns out to be an admin.
userStore.currentUser = getFakeRegisteredUser({ is_admin: true });
expect(userStore.getDecodedId("abc")).toBeNull(); // not yet resolved
await flushPromises();
expect(callCount).toBe(1);
expect(userStore.getDecodedId("abc")).toBe(123);
});
it("falls back to the bootstrapped Galaxy user in embedded views, where the store's user is never loaded", async () => {
const windowWithGalaxy = window as unknown as { _galaxyInstance?: unknown };
windowWithGalaxy._galaxyInstance = { user: { isAdmin: () => true } };
try {
const userStore = useUserStore();
expect(userStore.currentUser).toBeNull();
expect(userStore.getDecodedId("abc")).toBeNull(); // not yet resolved
await flushPromises();
expect(callCount).toBe(1);
expect(userStore.getDecodedId("abc")).toBe(123);
} finally {
delete windowWithGalaxy._galaxyInstance;
}
});
it("clears cached decoded ids on $reset so a new user doesn't see a stale result", async () => {
const userStore = useUserStore();
userStore.currentUser = getFakeRegisteredUser({ is_admin: true });
userStore.getDecodedId("abc");
await flushPromises();
expect(userStore.getDecodedId("abc")).toBe(123);
userStore.$reset();
expect(userStore.getDecodedId("abc")).toBeNull();
});
});
});
+29
View File
@@ -2,7 +2,9 @@ import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { type AnyUser, isAdminUser, isAnonymousUser, isRegisteredUser, type RegisteredUser } from "@/api";
import { getGalaxyInstance } from "@/app";
import { useHashedUserId } from "@/composables/hashedUserId";
import { useKeyedCache } from "@/composables/keyedCache";
import { useUserLocalStorageFromHashId } from "@/composables/userLocalStorageFromHashedId";
import { useHistoryStore } from "@/stores/historyStore";
import { useQuotaUsageStore } from "@/stores/quotaUsageStore";
@@ -11,6 +13,7 @@ import {
addFavoriteEdamTopicQuery,
addFavoriteTagQuery,
addFavoriteToolQuery,
decodeIdById,
type FavoriteOrderEntry,
type FavoriteSummary,
getCurrentUser,
@@ -95,6 +98,7 @@ export const useUserStore = defineStore("userStore", () => {
currentPreferences.value = null;
recentTools.value = [];
loadPromise = null;
decodedIdCache.value = {};
}
const isAdmin = computed(() => {
@@ -359,6 +363,30 @@ export const useUserStore = defineStore("userStore", () => {
return normalized;
}
/** Whether the user may decode ids (is an admin), or `null` while that is still unknown.
* Embedded views (e.g. scratchbook iframes) deliberately leave `currentUser` unloaded, so fall
* back to the user the legacy Galaxy instance bootstrapped from `/context`. */
function canDecodeIds(): boolean | null {
if (currentUser.value !== null) {
return isAdmin.value;
}
const legacyUser = getGalaxyInstance()?.user;
return legacyUser ? Boolean(legacyUser.isAdmin()) : null;
}
async function fetchdecodedIdById(params: { id: string }) {
if (canDecodeIds()) {
return await decodeIdById(params.id);
}
return null;
}
const { getItemById: getDecodedId, storedItems: decodedIdCache } = useKeyedCache<number | null>(
fetchdecodedIdById,
// Only fetch a decoded id if the user is known and the item is undefined (not yet cached).
() => (item?: number | null) => canDecodeIds() !== null && item === undefined,
);
return {
currentUser,
currentPreferences,
@@ -367,6 +395,7 @@ export const useUserStore = defineStore("userStore", () => {
currentTheme,
currentFavorites,
currentListViewPreferences,
getDecodedId,
hasSeenUploadHelp,
historyPanelWidth,
chatPanelWidth,
+14
View File
@@ -175,3 +175,17 @@ export async function updateFavoriteOrderQuery(userId: string, order: FavoriteOr
return data as FavoriteSummary;
}
export async function decodeIdById(id: string) {
const { data, error } = await GalaxyApi().GET("/api/configuration/decode/{encoded_id}", {
params: {
path: { encoded_id: id },
},
});
if (error) {
rethrowSimple(error);
}
return data.decoded_id as number;
}
+11
View File
@@ -6,6 +6,7 @@
*
* Usage: mount(Component, { global: getLocalVue() })
*/
import flushPromises from "flush-promises";
import { createPinia, setActivePinia } from "pinia";
import { expect, vi } from "vitest";
import { createRouter, createWebHistory } from "vue-router";
@@ -289,3 +290,13 @@ export function mockUnprivilegedToolsRequest(server, http) {
}),
);
}
/**
* Advances Vitest's fake timers by `ms`, then flushes promises.
* Requires `vi.useFakeTimers()` to have been called in the test file.
* @example await advanceTimersAndFlush(1000);
*/
export async function advanceTimersAndFlush(ms) {
vi.advanceTimersByTime(ms);
await flushPromises();
}