add jobDetails composable that will replace job and job console providers

This commit is contained in:
Ahmed Awan
2026-10-13 10:58:50 -05:00
parent 3a7d4981d8
commit c08704a3da
3 changed files with 145 additions and 51 deletions
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { computed, ref, watch } from "vue";
import { computed, ref, toRef, watch } from "vue";
import { GalaxyApi } from "@/api";
import { type JobConsoleOutput, NON_TERMINAL_STATES, type ShowFullJobResponse } from "@/api/jobs";
import { JobConsoleOutputProvider, JobDetailsProvider } from "@/components/providers/JobProvider";
import { useJobConsoleOutput, useJobDetails } from "@/composables/jobDetails";
import { errorMessageAsString, rethrowSimple } from "@/utils/simple-error";
import { stateIsTerminal } from "@/utils/utils";
import type { JobMessage } from "../../api/jobs";
@@ -34,27 +34,17 @@ const props = withDefaults(
{ invocationId: undefined },
);
const job = ref<ShowFullJobResponse | null>(null);
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 } = useJobDetails(toRef(props, "jobId"));
const stdout_position = computed(() => stdout_text.value.length);
const stderr_position = computed(() => stderr_text.value.length);
const jobIsRunning = computed(() => job.value?.state === "running");
function jobStateIsTerminal(jobState: string) {
return jobState && !NON_TERMINAL_STATES.includes(jobState);
}
// Console output is only polled while the job is actively running
const consoleOutputJobId = computed(() => (jobIsRunning.value ? props.jobId : undefined));
const { stdout: stdout_text, stderr: stderr_text } = useJobConsoleOutput(consoleOutputJobId);
function jobStateIsRunning(jobState: string) {
return jobState == "running";
}
const jobIsRunning = computed(() => (job.value?.state ? jobStateIsRunning(job.value.state) : false));
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
@@ -86,29 +76,14 @@ const metadataDetail = ref<Record<string, string>>({
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;
}
// Once the job reaches a terminal state, prefer its own tool_stdout/tool_stderr as the final,
// complete output
watch(job, (newJob) => {
if (newJob && stateIsTerminal({ state: newJob.state })) {
stdout_text.value = newJob.tool_stdout ?? "";
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) => {
@@ -168,17 +143,6 @@ watch(
<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" />
<template v-if="job && props.includeTitle">
<JobHeader :job="job" />
<hr />
+119
View File
@@ -0,0 +1,119 @@
import { computed, onUnmounted, type Ref, ref, watch } from "vue";
import { GalaxyApi } from "@/api";
import type { JobConsoleOutput } from "@/api/jobs";
import { useJobStore } from "@/stores/jobStore";
import { rethrowSimple } from "@/utils/simple-error";
import { stateIsTerminal } from "@/utils/utils";
const DEFAULT_POLL_INTERVAL_MS = 3000;
/** Reactively reads (and keeps polling) a job's full details via `jobStore`. */
export function useJobDetails(jobId: Ref<string | undefined>, options: { autoRefresh?: boolean } = {}) {
const { autoRefresh = true } = options;
const jobStore = useJobStore();
const job = computed(() => jobStore.getJob(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) => {
if (autoRefresh && id) {
jobStore.pollJobUntilTerminal({ id });
}
},
{ 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: { autoRefresh?: boolean; pollInterval?: number; chunkLength?: number } = {},
) {
const { autoRefresh = true, 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 timeoutId: ReturnType<typeof setTimeout> | null = null;
let currentJobId: string | undefined;
function clearPoll() {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
}
async function fetchConsoleOutput(id: string) {
const { data, error: fetchError } = 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) {
rethrowSimple(fetchError);
}
return data as JobConsoleOutput;
}
async function poll(id: string) {
try {
const result = await fetchConsoleOutput(id);
if (id !== currentJobId) {
return;
}
if (result.stdout != null) {
stdout.value += result.stdout;
}
if (result.stderr != null) {
stderr.value += result.stderr;
}
state.value = result.state;
error.value = null;
} catch (e) {
if (id !== currentJobId) {
return;
}
error.value = e;
}
if (id !== currentJobId) {
return;
}
if (autoRefresh && !stateIsTerminal({ state: state.value })) {
timeoutId = setTimeout(() => poll(id), pollInterval);
}
}
function restart(id: string | undefined) {
clearPoll();
currentJobId = id;
stdout.value = "";
stderr.value = "";
state.value = undefined;
error.value = null;
if (id) {
poll(id);
}
}
watch(jobId, (id) => restart(id), { immediate: true });
onUnmounted(clearPoll);
return { stdout, stderr, error };
}
+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();
}