Merge pull request #11054 from jmchilton/history_export_ui

Revise user experience for history import and export.
This commit is contained in:
Marius van den Beek
2021-01-12 12:26:10 +01:00
committed by GitHub
25 changed files with 1103 additions and 83 deletions
@@ -0,0 +1,54 @@
<template>
<b-form-input v-model="localValue" :placeholder="placeholder" @click="selectFile"> </b-form-input>
</template>
<script>
import { BFormInput } from "bootstrap-vue";
import { filesDialog } from "utils/data";
export default {
components: { BFormInput },
props: {
value: {
type: String,
},
mode: {
type: String,
default: "file",
},
requireWritable: {
type: Boolean,
default: false,
},
},
data() {
return {
localValue: this.value,
};
},
computed: {
placeholder() {
return `Click to select ${this.mode}`;
},
},
methods: {
selectFile() {
const props = {
mode: this.mode,
requireWritable: this.requireWritable,
};
filesDialog((selected) => {
this.localValue = selected?.url;
}, props);
},
},
watch: {
localValue(newValue) {
this.$emit("input", newValue);
},
value(newValue) {
this.localValue = newValue;
},
},
};
</script>
@@ -96,7 +96,7 @@
key="export-history-to-file"
title="Export History to File"
icon="fas fa-file-archive"
@click="iframeRedirect('/history/export_archive?preview=True')"
@click="backboneRoute(`/histories/${history.id}/export`)"
/>
</PriorityMenu>
@@ -0,0 +1,61 @@
<template>
<span>
<b
><a :href="link">{{ link }}</a></b
>
<font-awesome-icon
v-b-tooltip.hover
title="Copy export URL to your clipboard"
icon="link"
style="cursor: pointer;"
@click="copyUrl"
/>
<i
title="Information about when the history export was generated is included in the job details. Additionally, if there are issues with export, the job details may help figure out the underlying problem or communicate issues to your Galaxy administrator."
>
(<a href="#" @click="showDetails">view job details</a>)
</i>
<b-modal v-model="details" scrollable ok-only>
<job-information :job_id="historyExport.job_id" :include-times="true" />
</b-modal>
</span>
</template>
<script>
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faLink } from "@fortawesome/free-solid-svg-icons";
import { BModal } from "bootstrap-vue";
import { copy } from "utils/clipboard";
import JobInformation from "components/JobInformation/JobInformation.vue";
library.add(faLink);
export default {
components: { BModal, JobInformation, FontAwesomeIcon },
props: {
historyExport: {
type: Object,
required: true,
},
},
data() {
return {
details: false,
};
},
computed: {
link() {
return this.historyExport.external_download_permanent_url;
},
},
methods: {
showDetails() {
this.details = true;
},
copyUrl() {
copy(this.latestExportUrl, "Export URL copied to your clipboard");
},
},
};
</script>
@@ -0,0 +1,33 @@
import { shallowMount } from "@vue/test-utils";
import Index from "./Index.vue";
import { getLocalVue } from "jest/helpers";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
const TEST_PLUGINS_URL = "/api/remote_files/plugins";
const localVue = getLocalVue();
describe("Index.vue", () => {
let axiosMock;
beforeEach(() => {
axiosMock = new MockAdapter(axios);
axiosMock.onGet(TEST_PLUGINS_URL).reply(200, [{ id: "foo", writable: false }]);
});
it("should render tabs", () => {
// just make sure the component renders to catch obvious big errors
const wrapper = shallowMount(Index, {
propsData: {
historyId: "test_id",
},
localVue,
});
expect(wrapper.exists("b-tabs-stub")).toBeTruthy();
});
afterEach(() => {
axiosMock.restore();
});
});
@@ -0,0 +1,67 @@
<template>
<span>
<h2>Export history archive</h2>
<span v-if="initializing">
<loading-span message="Loading server configuration." />
</span>
<span v-else-if="hasWritableFileSources">
<b-card no-body>
<b-tabs pills card vertical>
<b-tab title="to a link" active>
<b-card-text>
<ToLink :history-id="historyId" />
</b-card-text>
</b-tab>
<b-tab title="to a remote file">
<ToRemoteFile :history-id="historyId" />
</b-tab>
</b-tabs>
</b-card>
</span>
<span v-else>
<ToLink :history-id="historyId" />
</span>
</span>
</template>
<script>
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import ToLink from "./ToLink.vue";
import ToRemoteFile from "./ToRemoteFile.vue";
import { Services } from "components/FilesDialog/services";
import LoadingSpan from "components/LoadingSpan";
Vue.use(BootstrapVue);
export default {
components: {
LoadingSpan,
ToLink,
ToRemoteFile,
},
props: {
historyId: {
type: String,
required: true,
},
},
data() {
return {
initializing: true,
hasWritableFileSources: false,
};
},
async mounted() {
await this.initialize();
},
methods: {
async initialize() {
const fileSources = await new Services().getFileSources();
this.hasWritableFileSources = fileSources.some((fs) => fs.writable);
console.log(fileSources);
this.initializing = false;
},
},
};
</script>
@@ -0,0 +1,64 @@
import { shallowMount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import ToLink from "./ToLink.vue";
import flushPromises from "flush-promises";
import MockAdapter from "axios-mock-adapter";
import axios from "axios";
import { waitOnJob } from "components/JobStates/wait";
const localVue = getLocalVue();
const TEST_HISTORY_ID = "hist1235";
const TEST_EXPORTS_URL = `/api/histories/${TEST_HISTORY_ID}/exports`;
const TEST_JOB_ID = "test1234job";
jest.mock("components/JobStates/wait");
describe("ToLink.vue", () => {
let axiosMock;
let wrapper;
async function mountWithInitialExports(exports) {
axiosMock.onGet(TEST_EXPORTS_URL).reply(200, exports);
wrapper = shallowMount(ToLink, {
propsData: {
historyId: TEST_HISTORY_ID,
},
localVue,
});
await wrapper.vm.$nextTick();
expect(wrapper.find("loading-span-stub").exists()).toBeTruthy();
await flushPromises();
}
beforeEach(async () => {
axiosMock = new MockAdapter(axios);
});
it("should display a link if no exports ever generated", async () => {
await mountWithInitialExports([]);
expect(wrapper.find(".export-link")).toBeTruthy();
expect(wrapper.find("loading-span-stub").exists()).toBeFalsy(); // loading span gone
});
it("should start polling if latest export is preparing", async () => {
let then = null;
waitOnJob.mockReturnValue(
new Promise((then_) => {
then = then_;
})
);
await mountWithInitialExports([
{
preparing: true,
job_id: TEST_JOB_ID,
},
]);
expect(then).toBeTruthy();
expect(wrapper.vm.waitingOnJob).toBeTruthy();
expect(wrapper.find("loading-span-stub").exists()).toBeTruthy();
});
afterEach(() => {
axiosMock.restore();
});
});
@@ -0,0 +1,173 @@
<template>
<div>
<b-alert show variant="danger" v-if="errorMessage" dismissible @dismissed="errorMessage = null">
{{ errorMessage }}
<JobError
v-if="jobError"
style="margin-top: 15px;"
header="History export job ended in error"
:job="jobError"
/>
</b-alert>
<div v-if="loadingExports">
<loading-span message="Loading history export information from Galaxy server." />
</div>
<div v-else-if="waitingOnJob">
<loading-span message="Galaxy server is preparing history for download, this will likely take a while." />
</div>
<div v-else-if="latestExportReady">
Link for download ready
<export-link :historyExport="latestExport" />
. Use this link to download the archive or import it on another Galaxy server.
</div>
<div v-else-if="hasReadyExport">
<p>An out of date export is ready <export-link :historyExport="latestReadyExport" />.</p>
<p>
The history has changed since this export was generated,
<a class="export-link" href="#" @click.prevent="regenerateExport"
>click here to generate a new archive for the current history state</a
>.
</p>
</div>
<div v-else>
<p>No link for history export ready, {{ whyNoLink }}.</p>
<p>
<b
><a class="export-link" href="#" @click.prevent="regenerateExport"
>Click here to to generate a new archive for this history.</a
></b
>
</p>
</div>
</div>
</template>
<script>
import { getAppRoot } from "onload/loadConfig";
import axios from "axios";
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import { errorMessageAsString } from "utils/simple-error";
import LoadingSpan from "components/LoadingSpan";
import ExportLink from "./ExportLink.vue";
import { waitOnJob } from "components/JobStates/wait";
import JobError from "components/JobInformation/JobError";
Vue.use(BootstrapVue);
export default {
components: { LoadingSpan, ExportLink, JobError },
props: {
historyId: {
type: String,
required: true,
},
},
data() {
return {
errorMessage: null,
exportsInitialized: false,
exports: null,
loadingExports: false,
waitingOnJob: false,
jobError: null,
};
},
created() {
if (!this.exportsInitialized) {
this.exportsInitialized = true;
this.loadExports();
}
},
computed: {
hasExports() {
return this.exports !== null && this.exports.length > 0;
},
latestExport() {
return this.exports && this.exports[0];
},
readyExports() {
return (this.exports || []).filter((e) => e.ready);
},
hasReadyExport() {
return this.latestReadyExport !== null;
},
latestReadyExport() {
return this.readyExports.length > 0 ? this.readyExports[0] : null;
},
latestExportReady() {
return this.latestExport?.ready && this.latestExport?.up_to_date;
},
latestExportPreparing() {
return this.latestExport?.preparing;
},
whyNoLink() {
if (!this.hasExports) {
return `no history export ever initiated for this history`;
} else {
return `previous history export archival likely failed`;
}
},
},
methods: {
loadExports() {
this.loadingExports = true;
const url = `${getAppRoot()}api/histories/${this.historyId}/exports`;
axios
.get(url)
.then((response) => {
this.loadingExports = false;
this.exports = response.data;
if (this.latestExportPreparing) {
this.waitOnExportJob(this.latestExport.job_id);
} else {
this.waitingOnJob = false;
}
})
.catch(this.handleError);
},
regenerateExport() {
this.waitingOnJob = true;
const url = `${getAppRoot()}api/histories/${this.historyId}/exports`;
axios
.put(url)
.then((response) => {
const status = response.status;
if (status == 200) {
this.loadExports();
} else if (status == 202) {
this.waitOnExportJob(response.data.job_id);
} else {
// error ....
this.errorMessage = `Unexpected error while polling history export ${errorMessageAsString(
response
)}`;
}
})
.catch(this.handleError);
},
waitOnExportJob(jobId) {
this.waitingOnJob = true;
this.jobError = false;
waitOnJob(jobId)
.then((jobResponse) => {
this.waitingOnJob = false;
this.loadingExports = true;
// Race condition, for some reasons the job API returns
// ok before the JEHA...
setTimeout(this.loadExports, 2000);
})
.catch(this.handleError);
},
handleError(err) {
this.waitingOnJob = false;
this.loadingExports = false;
this.errorMessage = errorMessageAsString(err, "Error generating a history export link");
if (err?.data?.stderr) {
this.jobError = err.data;
}
},
},
};
</script>
@@ -0,0 +1,72 @@
import { shallowMount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import ToRemoteFile from "./ToRemoteFile.vue";
import MockAdapter from "axios-mock-adapter";
import axios from "axios";
import flushPromises from "flush-promises";
import { waitOnJob } from "components/JobStates/wait";
const localVue = getLocalVue();
const TEST_HISTORY_ID = "hist1235";
const TEST_JOB_ID = "job123789";
const TEST_EXPORTS_URL = `/api/histories/${TEST_HISTORY_ID}/exports`;
jest.mock("components/JobStates/wait");
describe("ToRemoteFile.vue", () => {
let axiosMock;
let wrapper;
beforeEach(async () => {
axiosMock = new MockAdapter(axios);
wrapper = shallowMount(ToRemoteFile, {
propsData: {
historyId: TEST_HISTORY_ID,
},
localVue,
});
});
it("should render a form with export disable because inputs empty", async () => {
expect(wrapper.find(".export-button").exists()).toBeTruthy();
expect(wrapper.find(".export-button").attributes("disabled")).toBeTruthy();
expect(wrapper.vm.canExport).toBeFalsy();
});
it("should allow export when name and directory available", async () => {
await wrapper.setData({
name: "export.tar.gz",
directory: "gxfiles://",
});
expect(wrapper.vm.directory).toEqual("gxfiles://");
expect(wrapper.vm.name).toEqual("export.tar.gz");
expect(wrapper.vm.canExport).toBeTruthy();
});
it("should issue export PUT request on export", async () => {
await wrapper.setData({
name: "export.tar.gz",
directory: "gxfiles://",
});
let request;
axiosMock.onPut(TEST_EXPORTS_URL).reply((request_) => {
request = request_;
return [200, { job_id: TEST_JOB_ID }];
});
waitOnJob.mockReturnValue(
new Promise((then) => {
then({ state: "ok" });
})
);
wrapper.vm.doExport();
await flushPromises();
const putData = JSON.parse(request.data);
expect(putData.directory_uri).toEqual("gxfiles://");
expect(putData.file_name).toEqual("export.tar.gz");
expect(wrapper.find("b-alert-stub").attributes("variant")).toEqual("success");
});
afterEach(() => {
axiosMock.restore();
});
});
@@ -0,0 +1,119 @@
<template>
<div>
<b-alert v-if="errorMessage" show variant="danger" dismissible @dismissed="errorMessage = null">
{{ errorMessage }}
<JobError
v-if="jobError"
style="margin-top: 15px;"
header="History export job ended in error"
:job="jobError"
/>
</b-alert>
<div v-if="waitingOnJob">
<loading-span message="Executing history export job, this will likely take a while." />
</div>
<div v-else-if="jobComplete">
<b-alert show variant="success" dismissible @dismissed="reset">
<h4>Done!</h4>
<p>History successfully exported.</p>
</b-alert>
</div>
<div v-else>
<b-form-group
id="directory"
label-for="directory"
description="Select a 'remote files' directory to export history archive to."
class="mt-3"
>
<files-input v-model="directory" mode="directory" :requireWritable="true" />
</b-form-group>
<b-form-group id="name" label-for="name" description="Give the exported file a name." class="mt-3">
<b-form-input id="name" v-model="name" placeholder="Name" required></b-form-input>
</b-form-group>
<b-row align-h="end">
<b-col
><b-button class="export-button" variant="primary" @click="doExport" :disabled="!canExport"
>Export</b-button
></b-col
>
</b-row>
</div>
</div>
</template>
<script>
import { getAppRoot } from "onload/loadConfig";
import axios from "axios";
import { waitOnJob } from "components/JobStates/wait";
import { errorMessageAsString } from "utils/simple-error";
import LoadingSpan from "components/LoadingSpan";
import FilesInput from "components/FilesDialog/FilesInput.vue";
import JobError from "components/JobInformation/JobError";
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
Vue.use(BootstrapVue);
export default {
components: {
FilesInput,
LoadingSpan,
JobError,
},
props: {
historyId: {
type: String,
required: true,
},
},
computed: {
canExport() {
return !!this.name && !!this.directory;
},
},
data() {
return {
errorMessage: null,
name: null,
directory: null,
waitingOnJob: false,
jobComplete: false,
jobError: null,
};
},
methods: {
doExport() {
const data = {
directory_uri: this.directory,
file_name: this.name,
};
this.waitingOnJob = true;
const url = `${getAppRoot()}api/histories/${this.historyId}/exports`;
axios
.put(url, data)
.then((response) => {
waitOnJob(response.data.job_id)
.then((data) => {
this.waitingOnJob = false;
this.jobComplete = true;
})
.catch(this.handleError);
})
.catch(this.handleError);
},
reset() {
this.jobComplete = false;
this.errorMessage = null;
this.name = "";
this.directory = "";
this.jobError = null;
},
handleError(err) {
this.waitingOnJob = false;
this.errorMessage = errorMessageAsString(err, "History export failed.");
if (err?.data?.stderr) {
this.jobError = err.data;
}
},
},
};
</script>
@@ -0,0 +1 @@
export { default as HistoryExport } from "./Index.vue";
@@ -0,0 +1,82 @@
import { shallowMount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import HistoryImport from "./HistoryImport.vue";
import MockAdapter from "axios-mock-adapter";
import axios from "axios";
import flushPromises from "flush-promises";
import { waitOnJob } from "components/JobStates/wait";
const localVue = getLocalVue();
const TEST_JOB_ID = "job123789";
const TEST_HISTORY_URI = "/api/histories";
const TEST_SOURCE_URL = "http://galaxy.example/import";
const TEST_PLUGINS_URL = "/api/remote_files/plugins";
jest.mock("components/JobStates/wait");
describe("HistoryImport.vue", () => {
let axiosMock;
let wrapper;
beforeEach(async () => {
axiosMock = new MockAdapter(axios);
axiosMock.onGet(TEST_PLUGINS_URL).reply(200, [{ id: "foo", writable: false }]);
wrapper = shallowMount(HistoryImport, {
propsData: {},
localVue,
});
await flushPromises();
});
it("should render a form with submit disabled because inputs empty", async () => {
expect(wrapper.find(".import-button").exists()).toBeTruthy();
expect(wrapper.find(".import-button").attributes("disabled")).toBeTruthy();
expect(wrapper.vm.importReady).toBeFalsy();
});
it("should allow import when URL available", async () => {
await wrapper.setData({
sourceURL: TEST_SOURCE_URL,
});
expect(wrapper.vm.importReady).toBeTruthy();
});
it("should require an URI if that is the import type", async () => {
await wrapper.setData({
sourceURL: TEST_SOURCE_URL,
importType: "sourceRemoteFilesUri",
});
expect(wrapper.vm.importReady).toBeFalsy();
});
it("should post to create a new history and wait on job when submitted", async () => {
await wrapper.setData({
sourceURL: TEST_SOURCE_URL,
});
let formData;
axiosMock.onPost(TEST_HISTORY_URI).reply((request) => {
formData = request.data;
return [200, { job_id: TEST_JOB_ID }];
});
let then;
waitOnJob.mockReturnValue(
new Promise((then_) => {
then = then_;
})
);
wrapper.vm.submit();
await flushPromises();
expect(formData.get("archive_source")).toBe(TEST_SOURCE_URL);
expect(wrapper.vm.waitingOnJob).toBeTruthy();
// complete job and make sure waitingOnJob is false and complete is true
then({ state: "ok" });
await flushPromises();
expect(wrapper.vm.waitingOnJob).toBeFalsy();
expect(wrapper.vm.complete).toBeTruthy();
});
afterEach(() => {
axiosMock.restore();
});
});
+143 -28
View File
@@ -1,57 +1,172 @@
<template>
<b-form @submit="submit">
<b-card title="Import a history from an archive">
<b-alert :show="hasErrorMessage" variant="danger">{{ errorMessage }}</b-alert>
<p>Please provide a Galaxy history export URL or a history file.</p>
<b-form-group label="Archived History URL"> <b-form-input type="url" v-model="sourceURL" /> </b-form-group>
<b-form-group label="Archived History File"> <b-form-file v-model="sourceFile" /> </b-form-group>
<b-button type="submit">Import history</b-button>
</b-card>
</b-form>
<b-card title="Import a history from an archive">
<b-alert v-if="errorMessage" variant="danger" dismissible @dismissed="errorMessage = null" show>
{{ errorMessage }}
<JobError
v-if="jobError"
style="margin-top: 15px;"
header="History import job ended in error"
:job="jobError"
/>
</b-alert>
<div v-if="initializing">
<loading-span message="Loading server configuration." />
</div>
<div v-else-if="waitingOnJob">
<LoadingSpan message="Waiting on history import job, this may take a while." />
</div>
<div v-else-if="complete">
<b-alert :show="complete" variant="success" dismissible @dismissed="complete = false">
<h4>Done!</h4>
<p>History imported, check out <a :href="historyLink">your histories</a>.</p>
</b-alert>
</div>
<div v-else>
<b-form @submit.prevent="submit">
<b-form-group label="How would you like to specify the history archive?" v-slot="{ ariaDescribedby }">
<b-form-radio-group
v-model="importType"
:aria-describedby="ariaDescribedby"
name="import-type"
stacked
>
<b-form-radio value="externalUrl">
Export URL from another Galaxy instance
<font-awesome-icon icon="external-link-alt" />
</b-form-radio>
<b-form-radio value="upload">
Upload local file from your computer
<font-awesome-icon icon="upload" />
</b-form-radio>
<b-form-radio value="remoteFilesUri" v-if="hasFileSources">
Select a remote file (e.g. Galaxy's FTP)
<font-awesome-icon icon="folder-open" />
</b-form-radio>
</b-form-radio-group>
</b-form-group>
<b-form-group v-if="importType == 'externalUrl'" label="Archived History URL">
<b-form-input type="url" v-model="sourceURL" />
</b-form-group>
<b-form-group v-if="importType == 'upload'" label="Archived History File">
<b-form-file v-model="sourceFile" />
</b-form-group>
<b-form-group v-show="importType == 'remoteFilesUri'" label="Remote File">
<!-- using v-show so we can have a persistent ref and launch dialog on select -->
<files-input v-model="sourceRemoteFilesUri" ref="filesInput" />
</b-form-group>
<b-button class="import-button" variant="primary" type="submit" :disabled="!importReady"
>Import history</b-button
>
</b-form>
</div>
</b-card>
</template>
<script>
import { getAppRoot } from "onload/loadConfig";
import axios from "axios";
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faFolderOpen, faUpload, faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
import FilesInput from "components/FilesDialog/FilesInput.vue";
import { waitOnJob } from "components/JobStates/wait";
import { errorMessageAsString } from "utils/simple-error";
import LoadingSpan from "components/LoadingSpan";
import JobError from "components/JobInformation/JobError";
import { Services } from "components/FilesDialog/services";
library.add(faFolderOpen);
library.add(faUpload);
library.add(faExternalLinkAlt);
Vue.use(BootstrapVue);
export default {
components: { FilesInput, FontAwesomeIcon, JobError, LoadingSpan },
data() {
return {
initializing: true,
importType: "externalUrl",
sourceFile: null,
sourceURL: null,
sourceRemoteFilesUri: null,
errorMessage: null,
waitingOnJob: false,
complete: false,
jobError: null,
hasFileSources: false,
};
},
async mounted() {
await this.initialize();
},
computed: {
hasErrorMessage() {
return this.errorMessage != null;
importReady() {
const importType = this.importType;
if (importType == "externalUrl") {
return !!this.sourceURL;
} else if (importType == "upload") {
return !!this.sourceFile;
} else if (importType == "remoteFilesUri") {
return !!this.sourceRemoteFilesUri;
} else {
return false;
}
},
historyLink() {
return `${getAppRoot()}histories/list`;
},
},
methods: {
async initialize() {
const fileSources = await new Services().getFileSources();
this.hasFileSources = fileSources.length > 0;
this.initializing = false;
},
submit: function (ev) {
ev.preventDefault();
if (!this.sourceFile && !this.sourceURL) {
this.errorMessage = "You must provide a history archive URL or file.";
} else {
const formData = new FormData();
formData.append("archive_file", this.sourceFile);
const formData = new FormData();
const importType = this.importType;
if (importType == "externalUrl") {
formData.append("archive_source", this.sourceURL);
axios
.post(`${getAppRoot()}api/histories`, formData)
.then((response) => {
window.location = `${getAppRoot()}histories/list?message=${
response.data.message
}&status=success`;
})
.catch((error) => {
const message = error.response.data && error.response.data.err_msg;
this.errorMessage = message || "Import failed for an unknown reason.";
});
} else if (importType == "upload") {
formData.append("archive_file", this.sourceFile);
formData.append("archive_source", "");
} else if (importType == "remoteFilesUri") {
formData.append("archive_source", this.sourceRemoteFilesUri);
}
axios
.post(`${getAppRoot()}api/histories`, formData)
.then((response) => {
this.waitingOnJob = true;
waitOnJob(response.data.id)
.then((jobResponse) => {
this.waitingOnJob = false;
this.complete = true;
})
.catch(this.handleError);
})
.catch(this.handleError);
},
handleError: function (err) {
this.waitingOnJob = false;
this.errorMessage = errorMessageAsString(err, "History import failed.");
if (err?.data?.stderr) {
this.jobError = err.data;
}
},
},
watch: {
importType() {
if (this.importType == "remoteFilesUri" && !this.sourceRemoteFilesUri) {
this.$refs.filesInput.selectFile();
}
},
},
};
</script>
<style scoped>
.error-wrapper {
margin-top: 10px;
}
</style>
@@ -0,0 +1,70 @@
<template>
<b-card border-variant="danger" :header="header">
<b-card-text>
<div @click="showInfo = true">
<a href="#">See full job details <font-awesome-icon icon="info-circle" /></a>
</div>
<div class="error-wrapper" v-if="job.stderr" @click="toggleExpanded">
Job Standard Error
<pre :class="errorClasses">{{ job.stderr }}</pre>
<b v-if="!expanded">(Click to expand job standard error)</b>
</div>
<!-- TODO: modal for reporting error. -->
</b-card-text>
<b-modal v-model="showInfo" scrollable ok-only>
<job-information :job_id="job.id" :include-times="true" />
</b-modal>
</b-card>
</template>
<script>
import JobInformation from "./JobInformation.vue";
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
library.add(faInfoCircle);
Vue.use(BootstrapVue);
export default {
components: { JobInformation, FontAwesomeIcon },
props: {
job: {
type: Object,
required: true,
},
header: {
type: String,
default: "Job ended in error",
},
},
data() {
return {
expanded: false,
showInfo: false,
};
},
computed: {
errorClasses() {
const classes = ["code"];
if (!this.expanded) {
classes.push("preview");
}
return classes;
},
},
methods: {
toggleExpanded() {
this.expanded = !this.expanded;
},
},
};
</script>
<style scoped>
.error-wrapper {
margin-top: 10px;
}
</style>
@@ -11,6 +11,18 @@
<td>Galaxy Tool Version:</td>
<td id="galaxy-tool-version">{{ job.tool_version }}</td>
</tr>
<tr v-if="job && includeTimes">
<td>Created</td>
<td id="created" v-if="job.create_time">
<UtcDate :date="job.create_time" mode="pretty" />
</td>
</tr>
<tr v-if="job && includeTimes">
<td>Updated</td>
<td id="created" v-if="job.update_time">
<UtcDate :date="job.update_time" mode="pretty" />
</td>
</tr>
<code-row id="command-line" v-if="job" :codeLabel="'Command Line'" :codeItem="job.command_line" />
<code-row id="stdout" v-if="job" :codeLabel="'Tool Standard Output'" :codeItem="job.tool_stdout" />
<code-row id="stderr" v-if="job" :codeLabel="'Tool Standard Error'" :codeItem="job.tool_stderr" />
@@ -46,17 +58,23 @@ import { mapCacheActions } from "vuex-cache";
import { getAppRoot } from "onload/loadConfig";
import DecodedId from "../DecodedId.vue";
import CodeRow from "./CodeRow.vue";
import UtcDate from "components/UtcDate";
export default {
components: {
CodeRow,
DecodedId,
UtcDate,
},
props: {
job_id: {
type: String,
required: true,
},
includeTimes: {
type: Boolean,
default: false,
},
},
created: function () {
this.fetchJob(this.job_id);
+29
View File
@@ -0,0 +1,29 @@
import { getAppRoot } from "onload/loadConfig";
import JOB_STATES_MODEL from "mvc/history/job-states-model";
import axios from "axios";
export function waitOnJob(jobId, onStateUpdate = null, interval = 1000) {
// full=true to capture standard error on last iteration for building
// error messages.
const jobUrl = `${getAppRoot()}api/jobs/${jobId}?full=true`;
const checkCondition = function (resolve, reject) {
axios
.get(jobUrl)
.then((jobResponse) => {
const state = jobResponse.data.state;
if (onStateUpdate !== null) {
onStateUpdate(state);
}
if (JOB_STATES_MODEL.NON_TERMINAL_STATES.indexOf(state) !== -1) {
setTimeout(checkCondition, interval, resolve, reject);
} else if (JOB_STATES_MODEL.ERROR_STATES.indexOf(state) !== -1) {
reject(jobResponse);
} else {
resolve(jobResponse);
}
})
.catch(reject);
};
return new Promise(checkCondition);
}
@@ -32,6 +32,7 @@ import InteractiveTools from "components/InteractiveTools/InteractiveTools.vue";
import LibraryFolder from "components/LibraryFolder/LibraryFolder.vue";
import WorkflowList from "components/Workflow/WorkflowList.vue";
import HistoryImport from "components/HistoryImport.vue";
import { HistoryExport } from "components/HistoryExport/index";
import HistoryView from "components/HistoryView.vue";
import WorkflowInvocationReport from "components/Workflow/InvocationReport.vue";
import WorkflowRun from "components/Workflow/Run/WorkflowRun.vue";
@@ -87,6 +88,7 @@ export const getAnalysisRouter = (Galaxy) =>
"(/)histories(/)rename(/)": "show_histories_rename",
"(/)histories(/)sharing(/)": "show_histories_sharing",
"(/)histories(/)import(/)": "show_histories_import",
"(/)histories(/)(:history_id)(/)export(/)": "show_history_export",
"(/)histories(/)permissions(/)": "show_histories_permissions",
"(/)histories/view": "show_history_view",
"(/)histories/show_structure": "show_history_structure",
@@ -251,6 +253,12 @@ export const getAnalysisRouter = (Galaxy) =>
this._display_vue_helper(HistoryImport);
},
show_history_export: function (history_id) {
this._display_vue_helper(HistoryExport, {
historyId: history_id,
});
},
show_tools_view: function () {
this.page.toolPanel?.component.hide();
this.page.panels.right.hide();
+7 -1
View File
@@ -208,4 +208,10 @@ var JobStatesSummaryCollection = Backbone.Collection.extend({
},
});
export default { JobStatesSummary, JobStatesSummaryCollection, FETCH_STATE_ON_ADD, NON_TERMINAL_STATES, ERROR_STATES };
export default {
JobStatesSummary,
JobStatesSummaryCollection,
FETCH_STATE_ON_ADD,
NON_TERMINAL_STATES,
ERROR_STATES,
};
+6 -1
View File
@@ -148,8 +148,13 @@ var menu = [
},
{
html: _l("Export History to File"),
href: "history/export_archive?preview=True",
anon: true,
func: function () {
const Galaxy = getGalaxyInstance();
if (Galaxy && Galaxy.currHistoryPanel && Galaxy.router) {
Galaxy.router.push(`/histories/${Galaxy.currHistoryPanel.model.id}/export`);
}
},
},
{
html: _l("Beta Features"),
+2 -2
View File
@@ -1,5 +1,5 @@
export function errorMessageAsString(e) {
let message = "Request failed.";
export function errorMessageAsString(e, defaultMessage = "Request failed.") {
let message = defaultMessage;
if (e && e.response && e.response.data && e.response.data.err_msg) {
message = e.response.data.err_msg;
} else if (e && e.response) {
+47
View File
@@ -169,6 +169,53 @@ class HistoryManager(sharable.SharableModelManager, deletable.PurgableManagerMix
return jobs
class HistoryExportView:
def __init__(self, app):
self.app = app
def get_exports(self, trans, history_id):
history = self._history(trans, history_id)
matching_exports = history.exports
return [self.serialize(trans, history_id, e) for e in matching_exports]
def serialize(self, trans, history_id, jeha):
rval = jeha.to_dict()
encoded_jeha_id = trans.security.encode_id(jeha.id)
api_url = self.app.url_for("history_archive_download", id=history_id, jeha_id=encoded_jeha_id)
# this URL is less likely to be blocked by a proxy and require an API key, so export
# older-style controller version for use with within the GUI and such.
external_url = self.app.url_for(controller='history', action="export_archive", id=history_id, qualified=True)
external_permanent_url = self.app.url_for(controller='history', action="export_archive", id=history_id, jeha_id=encoded_jeha_id, qualified=True)
rval["download_url"] = api_url
rval["external_download_latest_url"] = external_url
rval["external_download_permanent_url"] = external_permanent_url
rval = trans.security.encode_all_ids(rval)
return rval
def get_ready_jeha(self, trans, history_id, jeha_id="latest"):
history = self._history(trans, history_id)
matching_exports = history.exports
if jeha_id != "latest":
decoded_jeha_id = trans.security.decode_id(jeha_id)
matching_exports = [e for e in matching_exports if e.id == decoded_jeha_id]
if len(matching_exports) == 0:
raise glx_exceptions.ObjectNotFound("Failed to find target history export")
jeha = matching_exports[0]
if not jeha.ready:
raise glx_exceptions.MessageException("Export not available or not yet ready.")
return jeha
def _history(self, trans, history_id):
if history_id is not None:
history = self.app.history_manager.get_accessible(trans.security.decode_id(history_id), trans.user, current_history=trans.history)
else:
history = trans.history
return history
class HistorySerializer(sharable.SharableModelSerializer, deletable.PurgableSerializerMixin):
"""
Interface/service object for serializing histories into dictionaries.
+9
View File
@@ -1667,6 +1667,15 @@ class JobExportHistoryArchive(RepresentById):
jeha.history_attrs_filename = history_attrs_filename
return jeha
def to_dict(self):
return {
'id': self.id,
'job_id': self.job.id,
'ready': self.ready,
'preparing': self.preparing,
'up_to_date': self.up_to_date,
}
class JobImportHistoryArchive(RepresentById):
def __init__(self, job=None, history=None, archive_dir=None):
+2 -1
View File
@@ -445,7 +445,8 @@ class ImportsHistoryMixin:
# Run job to do import.
history_imp_tool = trans.app.toolbox.get_tool('__IMPORT_HISTORY__')
incoming = {'__ARCHIVE_SOURCE__' : archive_source, '__ARCHIVE_TYPE__' : archive_type}
history_imp_tool.execute(trans, incoming=incoming)
job, _ = history_imp_tool.execute(trans, incoming=incoming)
return job
class UsesLibraryMixin:
+27 -20
View File
@@ -32,7 +32,6 @@ from galaxy.web import (
expose_api_anonymous,
expose_api_anonymous_and_sessionless,
expose_api_raw,
url_for
)
from galaxy.webapps.base.controller import (
BaseAPIController,
@@ -52,6 +51,7 @@ class HistoriesController(BaseAPIController, ExportsHistoryMixin, ImportsHistory
self.user_manager = users.UserManager(app)
self.workflow_manager = workflows.WorkflowsManager(app)
self.manager = histories.HistoryManager(app)
self.history_export_view = histories.HistoryExportView(app)
self.serializer = histories.HistorySerializer(app)
self.deserializer = histories.HistoryDeserializer(app)
self.filters = histories.HistoryFilters(app)
@@ -352,8 +352,10 @@ class HistoriesController(BaseAPIController, ExportsHistoryMixin, ImportsHistory
archive_type = "file"
else:
raise exceptions.MessageException("Please provide a url or file.")
self.queue_history_import(trans, archive_type=archive_type, archive_source=archive_source)
return {"message": "Importing history from source '%s'. This history will be visible when the import is complete." % archive_source}
job = self.queue_history_import(trans, archive_type=archive_type, archive_source=archive_source)
job_dict = job.to_dict()
job_dict["message"] = "Importing history from source '%s'. This history will be visible when the import is complete." % archive_source
return trans.security.encode_all_ids(job_dict)
new_history = None
# if a history id was passed, copy that history
@@ -471,7 +473,16 @@ class HistoriesController(BaseAPIController, ExportsHistoryMixin, ImportsHistory
user=trans.user, trans=trans, **self._parse_serialization_params(kwd, 'detailed'))
@expose_api
def archive_export(self, trans, id, **kwds):
def index_exports(self, trans, id):
"""
index_exports(self, trans, id)
* GET /api/histories/{id}/exports:
Get history exports.
"""
return self.history_export_view.get_exports(trans, id)
@expose_api
def archive_export(self, trans, id, payload=None, **kwds):
"""
PUT /api/histories/{id}/exports
@@ -484,6 +495,7 @@ class HistoriesController(BaseAPIController, ExportsHistoryMixin, ImportsHistory
:rtype: dict
:returns: object containing url to fetch export from.
"""
kwds.update(payload or {})
# PUT instead of POST because multiple requests should just result
# in one object being created.
history = self.manager.get_accessible(self.decode_id(id), trans.user, current_history=trans.history)
@@ -509,6 +521,8 @@ class HistoriesController(BaseAPIController, ExportsHistoryMixin, ImportsHistory
directory_uri=directory_uri,
file_name=file_name,
)
else:
job = jeha.job
if exporting_to_uri:
# we don't have a jeha, there will never be a download_url. Just let
@@ -516,13 +530,18 @@ class HistoriesController(BaseAPIController, ExportsHistoryMixin, ImportsHistory
# written.
job_id = trans.security.encode_id(job.id)
return dict(job_id=job_id)
if up_to_date and jeha.ready:
jeha_id = trans.security.encode_id(jeha.id)
return dict(download_url=url_for("history_archive_download", id=id, jeha_id=jeha_id))
return self.history_export_view.serialize(trans, id, jeha)
else:
# Valid request, just resource is not ready yet.
trans.response.status = "202 Accepted"
return ''
if jeha:
return self.history_export_view.serialize(trans, id, jeha)
else:
assert job is not None, "logic error, don't have a jeha or a job"
job_id = trans.security.encode_id(job.id)
return dict(job_id=job_id)
@expose_api_raw
def archive_download(self, trans, id, jeha_id, **kwds):
@@ -535,19 +554,7 @@ class HistoriesController(BaseAPIController, ExportsHistoryMixin, ImportsHistory
code (instead of 202) with a JSON dictionary containing a
``download_url``.
"""
# Seems silly to put jeha_id in here, but want GET to be immuatable?
# and this is being accomplished this way.
history = self.manager.get_accessible(self.decode_id(id), trans.user, current_history=trans.history)
matching_exports = [e for e in history.exports if trans.security.encode_id(e.id) == jeha_id]
if not matching_exports:
raise exceptions.ObjectNotFound()
jeha = matching_exports[0]
if not jeha.ready:
# User should not have been given this URL, PUT export should have
# return a 202.
raise exceptions.MessageException("Export not available or not yet ready.")
jeha = self.history_export_view.get_ready_jeha(trans, id, jeha_id)
return self.serve_ready_history_export(trans, jeha)
@expose_api
+4
View File
@@ -146,6 +146,7 @@ def app_factory(global_conf, load_app_kwds=None, **kwargs):
webapp.add_client_route('/histories/citations')
webapp.add_client_route('/histories/list')
webapp.add_client_route('/histories/import')
webapp.add_client_route('/histories/{history_id}/export')
webapp.add_client_route('/histories/list_published')
webapp.add_client_route('/histories/list_shared')
webapp.add_client_route('/histories/rename')
@@ -496,6 +497,9 @@ def populate_api_routes(webapp, app):
controller='page_revisions',
parent_resources=dict(member_name='page', collection_name='pages'))
webapp.mapper.connect("history_exports",
"/api/histories/{id}/exports", controller="histories",
action="index_exports", conditions=dict(method=["GET"]))
webapp.mapper.connect("history_archive_export",
"/api/histories/{id}/exports", controller="histories",
action="archive_export", conditions=dict(method=["PUT"]))
@@ -240,6 +240,7 @@ class HistoryController(BaseUIController, SharableMixin, UsesAnnotations, UsesIt
def __init__(self, app):
super().__init__(app)
self.history_manager = managers.histories.HistoryManager(app)
self.history_export_view = managers.histories.HistoryExportView(app)
self.history_serializer = managers.histories.HistorySerializer(self.app)
@web.expose
@@ -1097,39 +1098,13 @@ class HistoryController(BaseUIController, SharableMixin, UsesAnnotations, UsesIt
# TODO: used in display_base.mako
@web.expose
def export_archive(self, trans, id=None, gzip=True, include_hidden=False, include_deleted=False, preview=False):
def export_archive(self, trans, id=None, jeha_id="latest"):
""" Export a history to an archive. """
#
# Get history to export.
#
if id:
history = self.history_manager.get_accessible(self.decode_id(id), trans.user, current_history=trans.history)
else:
# Use current history.
history = trans.history
id = trans.security.encode_id(history.id)
if not history:
return trans.show_error_message("This history does not exist or you cannot export this history.")
# If history has already been exported and it has not changed since export, stream it.
jeha = history.latest_export
if jeha and jeha.up_to_date:
if jeha.ready:
if preview:
url = url_for(controller='history', action="export_archive", id=id, qualified=True)
return trans.show_message("History Ready: '%(n)s'. Use this link to download "
"the archive or import it to another Galaxy server: "
"<a href='%(u)s'>%(u)s</a>" % ({'n': history.name, 'u': url}))
else:
return self.serve_ready_history_export(trans, jeha)
elif jeha.preparing:
return trans.show_message("Still exporting history %(n)s; please check back soon. Link: <a href='%(s)s'>%(s)s</a>"
% ({'n': history.name, 's': url_for(controller='history', action="export_archive", id=id, qualified=True)}))
self.queue_history_export(trans, history, gzip=gzip, include_hidden=include_hidden, include_deleted=include_deleted)
url = url_for(controller='history', action="export_archive", id=id, qualified=True)
return trans.show_message("Exporting History '%(n)s'. You will need to <a href='%(share)s' target='_top'>make this history 'accessible'</a> in order to import this to another galaxy sever. <br/>"
"Use this link to download the archive or import it to another Galaxy server: "
"<a href='%(u)s'>%(u)s</a>" % ({'share': url_for('/histories/sharing', id=id), 'n': history.name, 'u': url}))
# TODO: used in this file and index.mako
jeha = self.history_export_view.get_ready_jeha(trans, id, jeha_id)
return self.serve_ready_history_export(trans, jeha)
@web.expose
@web.json