Web app: Add multi-select of jobs (#104391)
Add the following features: - `Ctrl + Click` or `Cmd + Click` to toggle selection of additional jobs - `Shift + Click` to select a range of additional jobs - Ability to perform `Pause`, `Cancel`, `Requeue`, and `Delete` actions on multiple jobs concurrently - Notifications on how many jobs successfully/failed to have an action performed Reviewed-on: https://projects.blender.org/studio/flamenco/pulls/104391
This commit is contained in:
parent
1044350b4b
commit
11a052d854
@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<div class="btn-bar jobs">
|
||||
<div class="btn-bar-popover" v-if="deleteInfo != null">
|
||||
<p v-if="deleteInfo.shaman_checkout">Delete job, including Shaman checkout?</p>
|
||||
<p v-else>Delete job? The job files will be kept.</p>
|
||||
<div class="btn-bar-popover" v-if="showDeleteJobPopup">
|
||||
<p v-if="shamanEnv">
|
||||
Delete {{ jobs.selectedJobs.length }} job(s), including Shaman checkout?
|
||||
</p>
|
||||
<p v-else>Delete {{ jobs.selectedJobs.length }} job(s)?</p>
|
||||
<div class="inner-btn-bar">
|
||||
<button class="btn cancel" v-on:click="_hideDeleteJobPopup">Cancel</button>
|
||||
<button class="btn delete dangerous" v-on:click="onButtonDeleteConfirmed">Delete</button>
|
||||
@ -30,10 +32,13 @@
|
||||
<script>
|
||||
import { useJobs } from '@/stores/jobs';
|
||||
import { useNotifs } from '@/stores/notifications';
|
||||
import { getAPIClient } from '@/api-client';
|
||||
import { JobsApi } from '@/manager-api';
|
||||
import { JobDeletionInfo } from '@/manager-api';
|
||||
import { getAPIClient, newBareAPIClient } from '@/api-client';
|
||||
import { JobsApi, MetaApi } from '@/manager-api';
|
||||
|
||||
const cancelDescription = 'marked for cancellation';
|
||||
const requeueDescription = 'marked for requeueing';
|
||||
const pauseDescription = 'marked for pausing';
|
||||
const deleteDescription = 'marked for deleting';
|
||||
export default {
|
||||
name: 'JobActionsBar',
|
||||
props: ['activeJobID'],
|
||||
@ -41,8 +46,10 @@ export default {
|
||||
jobs: useJobs(),
|
||||
notifs: useNotifs(),
|
||||
jobsAPI: new JobsApi(getAPIClient()),
|
||||
metaAPI: new MetaApi(newBareAPIClient()),
|
||||
|
||||
deleteInfo: null,
|
||||
shamanEnv: null,
|
||||
showDeleteJobPopup: false,
|
||||
}),
|
||||
computed: {},
|
||||
watch: {
|
||||
@ -55,62 +62,77 @@ export default {
|
||||
this._startJobDeletionFlow();
|
||||
},
|
||||
onButtonDeleteConfirmed() {
|
||||
return this.jobs
|
||||
.deleteJobs()
|
||||
.then(() => {
|
||||
this.notifs.add('job marked for deletion');
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
|
||||
this.notifs.add(`Error: ${errorMsg}`);
|
||||
})
|
||||
.finally(this._hideDeleteJobPopup);
|
||||
return this._handleJobActionPromise(this.jobs.deleteJobs(), deleteDescription);
|
||||
},
|
||||
onButtonCancel() {
|
||||
return this._handleJobActionPromise(this.jobs.cancelJobs(), 'marked for cancellation');
|
||||
return this._handleJobActionPromise(this.jobs.cancelJobs(), cancelDescription);
|
||||
},
|
||||
onButtonRequeue() {
|
||||
return this._handleJobActionPromise(this.jobs.requeueJobs(), 'requeueing');
|
||||
return this._handleJobActionPromise(this.jobs.requeueJobs(), requeueDescription);
|
||||
},
|
||||
onButtonPause() {
|
||||
return this._handleJobActionPromise(this.jobs.pauseJobs(), 'marked for pausing');
|
||||
return this._handleJobActionPromise(this.jobs.pauseJobs(), pauseDescription);
|
||||
},
|
||||
|
||||
_handleJobActionPromise(promise, description) {
|
||||
return promise.then(() => {
|
||||
// There used to be a call to `this.notifs.add(message)` here, but now
|
||||
// that job status changes are logged in the notifications anyway,
|
||||
// it's no longer necessary.
|
||||
// This function is still kept, in case we want to bring back the
|
||||
// notifications when multiple jobs can be selected. Then a summary
|
||||
// ("N jobs requeued") could be logged here.btn-bar-popover
|
||||
});
|
||||
},
|
||||
return promise
|
||||
.then((values) => {
|
||||
const { incompatibleJobs, compatibleJobs, totalJobCount } = values;
|
||||
|
||||
_startJobDeletionFlow() {
|
||||
if (!this.activeJobID) {
|
||||
this.notifs.add('No active job, unable to delete anything');
|
||||
return;
|
||||
}
|
||||
// TODO: messages could be improved to specify the names of jobs that failed
|
||||
const failedMessage = `Could not apply ${description} status to ${incompatibleJobs.length} out of ${totalJobCount} job(s).`;
|
||||
const successMessage = `${compatibleJobs.length} job(s) successfully ${description}.`;
|
||||
|
||||
this.jobsAPI
|
||||
.deleteJobWhatWouldItDo(this.activeJobID)
|
||||
.then(this._showDeleteJobPopup)
|
||||
this.notifs.add(
|
||||
`${compatibleJobs.length > 0 ? successMessage : ''}
|
||||
${incompatibleJobs.length > 0 ? failedMessage : ''}`
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
|
||||
this.notifs.add(`Error: ${errorMsg}`);
|
||||
})
|
||||
.finally(() => {
|
||||
this._hideDeleteJobPopup();
|
||||
});
|
||||
},
|
||||
|
||||
_startJobDeletionFlow() {
|
||||
if (!this.jobs.selectedJobs.length) {
|
||||
this.notifs.add('No selected job(s), unable to delete anything');
|
||||
return;
|
||||
}
|
||||
|
||||
this._showDeleteJobPopup();
|
||||
},
|
||||
|
||||
/**
|
||||
* @param { JobDeletionInfo } deleteInfo
|
||||
* Shows the delete popup, and checks for Shaman to render the correct delete confirmation message.
|
||||
*/
|
||||
_showDeleteJobPopup(deleteInfo) {
|
||||
this.deleteInfo = deleteInfo;
|
||||
_showDeleteJobPopup() {
|
||||
// Concurrently fetch:
|
||||
// 1) the first job's deletion info
|
||||
// 2) the environment configuration
|
||||
Promise.allSettled([
|
||||
this.jobsAPI
|
||||
.deleteJobWhatWouldItDo(this.jobs.selectedJobs[0].id)
|
||||
.catch((error) => console.error('Error fetching deleteJobWhatWouldItDo:', error)),
|
||||
this.metaAPI
|
||||
.getConfiguration()
|
||||
.catch((error) => console.error('Error getting configuration:', error)),
|
||||
]).then((results) => {
|
||||
const [jobDeletionInfo, managerConfig] = results.map((result) => result.value);
|
||||
|
||||
// If either have Shaman, render the message relevant to an enabled Shaman environment
|
||||
this.shamanEnv = jobDeletionInfo.shaman_checkout || managerConfig.shamanEnabled;
|
||||
});
|
||||
|
||||
this.showDeleteJobPopup = true;
|
||||
},
|
||||
|
||||
_hideDeleteJobPopup() {
|
||||
this.deleteInfo = null;
|
||||
this.shamanEnv = null;
|
||||
this.showDeleteJobPopup = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -28,7 +28,6 @@ import StatusFilterBar from '@/components/StatusFilterBar.vue';
|
||||
export default {
|
||||
name: 'JobsTable',
|
||||
props: ['activeJobID'],
|
||||
emits: ['tableRowClicked', 'activeJobDeleted', 'jobDeleted'],
|
||||
components: {
|
||||
JobActionsBar,
|
||||
StatusFilterBar,
|
||||
@ -37,8 +36,8 @@ export default {
|
||||
return {
|
||||
shownStatuses: [],
|
||||
availableStatuses: [], // Will be filled after data is loaded from the backend.
|
||||
|
||||
jobs: useJobs(),
|
||||
lastSelectedJobPosition: null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@ -115,80 +114,210 @@ export default {
|
||||
this.$nextTick(this.recalcTableHeight);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
selectedIDs() {
|
||||
return this.tabulator.getSelectedData().map((job) => job.id);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onReconnected() {
|
||||
/**
|
||||
* Send to the job overview page, i.e. job view without active job.
|
||||
*/
|
||||
_routeToJobOverview() {
|
||||
const route = { name: 'jobs' };
|
||||
this.$router.push(route);
|
||||
},
|
||||
/**
|
||||
* @param {string} jobID job ID to navigate to, can be empty string for "no active job".
|
||||
*/
|
||||
_routeToJob(jobID) {
|
||||
const route = { name: 'jobs', params: { jobID: jobID } };
|
||||
this.$router.push(route);
|
||||
},
|
||||
async onReconnected() {
|
||||
// If the connection to the backend was lost, we have likely missed some
|
||||
// updates. Just fetch the data and start from scratch.
|
||||
this.fetchAllJobs();
|
||||
await this.initAllJobs();
|
||||
await this.initActiveJob();
|
||||
},
|
||||
sortData() {
|
||||
const tab = this.tabulator;
|
||||
tab.setSort(tab.getSorters()); // This triggers re-sorting.
|
||||
},
|
||||
_onTableBuilt() {
|
||||
async _onTableBuilt() {
|
||||
this.tabulator.setFilter(this._filterByStatus);
|
||||
this.fetchAllJobs();
|
||||
await this.initAllJobs();
|
||||
await this.initActiveJob();
|
||||
},
|
||||
fetchAllJobs() {
|
||||
async fetchAllJobs() {
|
||||
const jobsApi = new API.JobsApi(getAPIClient());
|
||||
this.jobs.isJobless = false;
|
||||
jobsApi.fetchJobs().then(this.onJobsFetched, function (error) {
|
||||
// TODO: error handling.
|
||||
console.error(error);
|
||||
});
|
||||
return jobsApi
|
||||
.fetchJobs()
|
||||
.then((data) => data.jobs)
|
||||
.catch((e) => {
|
||||
throw new Error('Unable to fetch all jobs:', e);
|
||||
});
|
||||
},
|
||||
onJobsFetched(data) {
|
||||
// "Down-cast" to JobUpdate to only get those fields, just for debugging things:
|
||||
// data.jobs = data.jobs.map((j) => API.JobUpdate.constructFromObject(j));
|
||||
const hasJobs = data && data.jobs && data.jobs.length > 0;
|
||||
this.jobs.isJobless = !hasJobs;
|
||||
this.tabulator.setData(data.jobs);
|
||||
this._refreshAvailableStatuses();
|
||||
/**
|
||||
* Initializes all jobs and sets the Tabulator data. Updates pinia stores and state accordingly.
|
||||
*/
|
||||
async initAllJobs() {
|
||||
try {
|
||||
this.jobs.isJobless = false;
|
||||
const jobs = await this.fetchAllJobs();
|
||||
|
||||
this.recalcTableHeight();
|
||||
// Update Tabulator
|
||||
this.tabulator.setData(jobs);
|
||||
this._refreshAvailableStatuses();
|
||||
this.recalcTableHeight();
|
||||
|
||||
// Update Pinia stores
|
||||
const hasJobs = jobs && jobs.length > 0;
|
||||
this.jobs.isJobless = !hasJobs;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
processJobUpdate(jobUpdate) {
|
||||
/**
|
||||
* Initializes the active job. Updates pinia stores and state accordingly.
|
||||
*/
|
||||
async initActiveJob() {
|
||||
// If there's no active job, reset the state and Pinia stores
|
||||
if (!this.activeJobID) {
|
||||
this.jobs.clearActiveJob();
|
||||
this.jobs.clearSelectedJobs();
|
||||
this.lastSelectedJobPosition = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, set the state and Pinia stores
|
||||
try {
|
||||
const job = await this.fetchJob(this.activeJobID);
|
||||
this.jobs.setActiveJob(job);
|
||||
this.processJobUpdate(job);
|
||||
this.jobs.setSelectedJobs([job]);
|
||||
|
||||
const activeRow = this.tabulator.getRow(this.activeJobID);
|
||||
// If the page is reloaded, re-initialize the last selected job (or active job) position, allowing the user to multi-select from that job.
|
||||
this.lastSelectedJobPosition = activeRow.getPosition();
|
||||
// Make sure the active row on tabulator has the selected status toggled as well
|
||||
this.tabulator.selectRow(activeRow);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Fetch a Job based on ID
|
||||
*/
|
||||
fetchJob(jobID) {
|
||||
const jobsApi = new API.JobsApi(getAPIClient());
|
||||
return jobsApi
|
||||
.fetchJob(jobID)
|
||||
.then((job) => job)
|
||||
.catch((err) => {
|
||||
throw new Error(`Unable to fetch job with ID ${jobID}:`, err);
|
||||
});
|
||||
},
|
||||
async processJobUpdate(jobUpdate) {
|
||||
// updateData() will only overwrite properties that are actually set on
|
||||
// jobUpdate, and leave the rest as-is.
|
||||
if (!this.tabulator.initialized) {
|
||||
return;
|
||||
}
|
||||
const row = this.tabulator.rowManager.findRow(jobUpdate.id);
|
||||
|
||||
let promise = null;
|
||||
if (jobUpdate.was_deleted) {
|
||||
if (row) promise = row.delete();
|
||||
else promise = Promise.resolve();
|
||||
promise.finally(() => {
|
||||
this.$emit('jobDeleted', jobUpdate.id);
|
||||
if (jobUpdate.id == this.activeJobID) {
|
||||
this.$emit('activeJobDeleted', jobUpdate.id);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (row) promise = this.tabulator.updateData([jobUpdate]);
|
||||
else promise = this.tabulator.addData([jobUpdate]);
|
||||
try {
|
||||
const row = this.tabulator.rowManager.findRow(jobUpdate.id);
|
||||
// If the row update is for deletion, delete the row and route to /jobs
|
||||
if (jobUpdate.was_deleted && row) {
|
||||
// Prevents the issue where deleted rows persist on Tabulator's selectedData
|
||||
// (this should technically not happen -- need to investigate more)
|
||||
this.tabulator.deselectRow(jobUpdate.id);
|
||||
row.delete().then(() => {
|
||||
if (jobUpdate.id === this.activeJobID) {
|
||||
this._routeToJobOverview();
|
||||
|
||||
// Update Pinia Stores
|
||||
this.jobs.clearActiveJob();
|
||||
}
|
||||
// Update Pinia Stores
|
||||
this.jobs.setSelectedJobs(this.getSelectedJobs());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (row) {
|
||||
await this.tabulator.updateData([jobUpdate]); // Update existing row
|
||||
} else {
|
||||
await this.tabulator.addData([jobUpdate]); // Add new row
|
||||
}
|
||||
this.sortData();
|
||||
await this.tabulator.redraw(); // Resize columns based on new data.
|
||||
this._refreshAvailableStatuses();
|
||||
|
||||
if (jobUpdate.id === this.activeJobID && row) {
|
||||
const job = await this.fetchJob(jobUpdate.id);
|
||||
this.jobs.setActiveJob(job);
|
||||
}
|
||||
this.jobs.setSelectedJobs(this.tabulator.getSelectedData()); // Update Pinia stores
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
promise
|
||||
.then(this.sortData)
|
||||
.then(() => {
|
||||
this.tabulator.redraw();
|
||||
}) // Resize columns based on new data.
|
||||
.then(this._refreshAvailableStatuses);
|
||||
},
|
||||
handleMultiSelect(event, row, tabulator) {
|
||||
const position = row.getPosition();
|
||||
|
||||
onRowClick(event, row) {
|
||||
// Manage the click event and Tabulator row selection
|
||||
if (event.shiftKey && this.lastSelectedJobPosition) {
|
||||
// Shift + Click - selects a range of rows
|
||||
let start = Math.min(position, this.lastSelectedJobPosition);
|
||||
let end = Math.max(position, this.lastSelectedJobPosition);
|
||||
const rowsToSelect = [];
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
const currRow = this.tabulator.getRowFromPosition(i);
|
||||
rowsToSelect.push(currRow);
|
||||
}
|
||||
tabulator.selectRow(rowsToSelect);
|
||||
|
||||
// Remove the text selection that occurs
|
||||
document.getSelection().removeAllRanges();
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
// Supports Cmd key on MacOS
|
||||
// Ctrl + Click - toggles additional rows
|
||||
if (tabulator.getSelectedRows().includes(row)) {
|
||||
tabulator.deselectRow(row);
|
||||
} else {
|
||||
tabulator.selectRow(row);
|
||||
}
|
||||
} else if (!event.ctrlKey && !event.metaKey) {
|
||||
// Regular Click - resets the selection to one row
|
||||
tabulator.deselectRow(); // De-select all rows
|
||||
tabulator.selectRow(row);
|
||||
}
|
||||
},
|
||||
async onRowClick(event, row) {
|
||||
// Take a copy of the data, so that it's decoupled from the tabulator data
|
||||
// store. There were some issues where navigating to another job would
|
||||
// overwrite the old job's ID, and this prevents that.
|
||||
const rowData = plain(row.getData());
|
||||
this.$emit('tableRowClicked', rowData);
|
||||
|
||||
// Handles Shift + Click, Ctrl + Click, and regular Click
|
||||
this.handleMultiSelect(event, row, this.tabulator);
|
||||
|
||||
// Update the app route, Pinia store, and component state
|
||||
if (row.isSelected()) {
|
||||
// The row was toggled -> selected
|
||||
const rowData = row.getData();
|
||||
this._routeToJob(rowData.id);
|
||||
|
||||
const job = await this.fetchJob(rowData.id);
|
||||
this.jobs.setActiveJob(job);
|
||||
this.lastSelectedJobPosition = row.getPosition();
|
||||
} else {
|
||||
// The row was toggled -> de-selected
|
||||
this._routeToJob('');
|
||||
this.jobs.clearActiveJob();
|
||||
this.lastSelectedJobPosition = null;
|
||||
}
|
||||
|
||||
this.jobs.setSelectedJobs(this.getSelectedJobs()); // Set the selected jobs according to tabulator's selected rows
|
||||
},
|
||||
getSelectedJobs() {
|
||||
return this.tabulator.getSelectedData();
|
||||
},
|
||||
toggleStatusFilter(status) {
|
||||
const asSet = new Set(this.shownStatuses);
|
||||
|
@ -5,6 +5,25 @@ import { getAPIClient } from '@/api-client';
|
||||
|
||||
const jobsAPI = new API.JobsApi(getAPIClient());
|
||||
|
||||
const JOB_ACTIONS = Object.freeze({
|
||||
CANCEL: {
|
||||
status: 'cancel-requested',
|
||||
prerequisiteStatuses: ['active', 'paused', 'failed', 'queued'],
|
||||
},
|
||||
PAUSE: {
|
||||
status: 'pause-requested',
|
||||
prerequisiteStatuses: ['active', 'canceled', 'queued'],
|
||||
},
|
||||
REQUEUE: {
|
||||
status: 'requeueing',
|
||||
prerequisiteStatuses: ['canceled', 'completed', 'failed', 'paused'],
|
||||
},
|
||||
DELETE: {
|
||||
status: 'delete-requested',
|
||||
prerequisiteStatuses: ['canceled', 'completed', 'failed', 'paused', 'queued'],
|
||||
},
|
||||
});
|
||||
|
||||
// 'use' prefix is idiomatic for Pinia stores.
|
||||
// See https://pinia.vuejs.org/core-concepts/
|
||||
export const useJobs = defineStore('jobs', {
|
||||
@ -16,25 +35,27 @@ export const useJobs = defineStore('jobs', {
|
||||
* @type {string}
|
||||
*/
|
||||
activeJobID: '',
|
||||
|
||||
/**
|
||||
* Set to true when it is known that there are no jobs at all in the system.
|
||||
* This is written by the JobsTable.vue component.
|
||||
* @type {bool}
|
||||
*/
|
||||
isJobless: false,
|
||||
/** @type {API.Job[]} */
|
||||
selectedJobs: [],
|
||||
}),
|
||||
getters: {
|
||||
canDelete() {
|
||||
return this._anyJobWithStatus(['completed', 'canceled', 'failed', 'paused', 'queued']);
|
||||
return this._anyJobWithStatus(JOB_ACTIONS.DELETE.prerequisiteStatuses);
|
||||
},
|
||||
canCancel() {
|
||||
return this._anyJobWithStatus(['active', 'failed', 'queued']);
|
||||
return this._anyJobWithStatus(JOB_ACTIONS.CANCEL.prerequisiteStatuses);
|
||||
},
|
||||
canRequeue() {
|
||||
return this._anyJobWithStatus(['canceled', 'completed', 'failed', 'paused']);
|
||||
return this._anyJobWithStatus(JOB_ACTIONS.REQUEUE.prerequisiteStatuses);
|
||||
},
|
||||
canPause() {
|
||||
return this._anyJobWithStatus(['active', 'queued', 'canceled']);
|
||||
return this._anyJobWithStatus(JOB_ACTIONS.PAUSE.prerequisiteStatuses);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
@ -59,39 +80,42 @@ export const useJobs = defineStore('jobs', {
|
||||
state.hasChanged = true;
|
||||
});
|
||||
},
|
||||
deselectAllJobs() {
|
||||
clearActiveJob() {
|
||||
this.$patch({
|
||||
activeJob: null,
|
||||
activeJobID: '',
|
||||
});
|
||||
},
|
||||
setSelectedJobs(jobs) {
|
||||
this.$patch({
|
||||
selectedJobs: jobs,
|
||||
});
|
||||
},
|
||||
clearSelectedJobs() {
|
||||
this.$patch({
|
||||
selectedJobs: [],
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Actions on the selected jobs.
|
||||
*
|
||||
* All the action functions return a promise that resolves when the action has been performed.
|
||||
*
|
||||
* TODO: actually have these work on all selected jobs. For simplicity, the
|
||||
* code now assumes that only the active job needs to be operated on.
|
||||
*/
|
||||
cancelJobs() {
|
||||
return this._setJobStatus('cancel-requested');
|
||||
return this._setJobStatus(JOB_ACTIONS.CANCEL.status, JOB_ACTIONS.CANCEL.prerequisiteStatuses);
|
||||
},
|
||||
pauseJobs() {
|
||||
return this._setJobStatus('pause-requested');
|
||||
return this._setJobStatus(JOB_ACTIONS.PAUSE.status, JOB_ACTIONS.PAUSE.prerequisiteStatuses);
|
||||
},
|
||||
requeueJobs() {
|
||||
return this._setJobStatus('requeueing');
|
||||
return this._setJobStatus(
|
||||
JOB_ACTIONS.REQUEUE.status,
|
||||
JOB_ACTIONS.REQUEUE.prerequisiteStatuses
|
||||
);
|
||||
},
|
||||
deleteJobs() {
|
||||
if (!this.activeJobID) {
|
||||
console.warn(`deleteJobs() impossible, no active job ID`);
|
||||
return new Promise((resolve, reject) => {
|
||||
reject('No job selected, unable to delete');
|
||||
});
|
||||
}
|
||||
|
||||
return jobsAPI.deleteJob(this.activeJobID);
|
||||
return this._setJobStatus(JOB_ACTIONS.DELETE.status, JOB_ACTIONS.DELETE.prerequisiteStatuses);
|
||||
},
|
||||
|
||||
// Internal methods.
|
||||
@ -101,25 +125,56 @@ export const useJobs = defineStore('jobs', {
|
||||
* @param {string[]} statuses
|
||||
* @returns bool indicating whether there is a selected job with any of the given statuses.
|
||||
*/
|
||||
_anyJobWithStatus(statuses) {
|
||||
return (
|
||||
!!this.activeJob && !!this.activeJob.status && statuses.includes(this.activeJob.status)
|
||||
);
|
||||
// return this.selectedJobs.reduce((foundJob, job) => (foundJob || statuses.includes(job.status)), false);
|
||||
_anyJobWithStatus(job_statuses) {
|
||||
if (this.selectedJobs.length) {
|
||||
return this.selectedJobs.some((job) => job_statuses.includes(job.status));
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Transition the selected job(s) to the new status.
|
||||
* @param {string} newStatus
|
||||
* @returns a Promise for the API request.
|
||||
* @param {string[]} job_statuses The job statuses compatible with the transition to new status
|
||||
* @returns a Promise for the API request(s).
|
||||
*/
|
||||
_setJobStatus(newStatus) {
|
||||
if (!this.activeJobID) {
|
||||
console.warn(`_setJobStatus(${newStatus}) impossible, no active job ID`);
|
||||
_setJobStatus(newStatus, job_statuses) {
|
||||
const totalJobCount = this.selectedJobs.length;
|
||||
|
||||
if (!totalJobCount) {
|
||||
console.warn(`_setJobStatus(${newStatus}) impossible, no selected job(s).`);
|
||||
return;
|
||||
}
|
||||
const statuschange = new API.JobStatusChange(newStatus, 'requested from web interface');
|
||||
return jobsAPI.setJobStatus(this.activeJobID, statuschange);
|
||||
|
||||
const { compatibleJobs, incompatibleJobs } = this.selectedJobs.reduce(
|
||||
(result, job) => {
|
||||
if (job_statuses.includes(job.status)) {
|
||||
result.compatibleJobs.push(job);
|
||||
} else {
|
||||
result.incompatibleJobs.push(job);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
{ compatibleJobs: [], incompatibleJobs: [] }
|
||||
);
|
||||
|
||||
let setJobStatusPromises = [];
|
||||
|
||||
if (newStatus === JOB_ACTIONS.DELETE.status) {
|
||||
setJobStatusPromises = compatibleJobs.map((job) => jobsAPI.deleteJob(job.id));
|
||||
} else {
|
||||
const statuschange = new API.JobStatusChange(newStatus, 'requested from web interface');
|
||||
|
||||
setJobStatusPromises = compatibleJobs.map((job) =>
|
||||
jobsAPI.setJobStatus(job.id, statuschange)
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.allSettled(setJobStatusPromises).then((results) => ({
|
||||
compatibleJobs: results,
|
||||
incompatibleJobs,
|
||||
totalJobCount,
|
||||
}));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -1,10 +1,6 @@
|
||||
<template>
|
||||
<div class="col col-1">
|
||||
<jobs-table
|
||||
ref="jobsTable"
|
||||
:activeJobID="jobID"
|
||||
@tableRowClicked="onTableJobClicked"
|
||||
@activeJobDeleted="onActiveJobDeleted" />
|
||||
<jobs-table ref="jobsTable" :activeJobID="jobID" />
|
||||
</div>
|
||||
<div class="col col-2 job-details-column" id="col-job-details">
|
||||
<get-the-addon v-if="jobs.isJobless" />
|
||||
@ -57,12 +53,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as API from '@/manager-api';
|
||||
import { useJobs } from '@/stores/jobs';
|
||||
import { useTasks } from '@/stores/tasks';
|
||||
import { useNotifs } from '@/stores/notifications';
|
||||
import { useTaskLog } from '@/stores/tasklog';
|
||||
import { getAPIClient } from '@/api-client';
|
||||
|
||||
import FooterPopup from '@/components/footer/FooterPopup.vue';
|
||||
import GetTheAddon from '@/components/GetTheAddon.vue';
|
||||
@ -110,17 +104,12 @@ export default {
|
||||
// console.log("Pinia state :", state)
|
||||
// })
|
||||
|
||||
this._fetchJob(this.jobID);
|
||||
|
||||
window.addEventListener('resize', this._recalcTasksTableHeight);
|
||||
},
|
||||
unmounted() {
|
||||
window.removeEventListener('resize', this._recalcTasksTableHeight);
|
||||
},
|
||||
watch: {
|
||||
jobID(newJobID, oldJobID) {
|
||||
this._fetchJob(newJobID);
|
||||
},
|
||||
showFooterPopup(shown) {
|
||||
if (shown) localStorage.setItem('footer-popover-visible', 'true');
|
||||
else localStorage.removeItem('footer-popover-visible');
|
||||
@ -128,14 +117,6 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onTableJobClicked(rowData) {
|
||||
// Don't route to the current job, as that'll deactivate the current task.
|
||||
if (rowData.id === this.jobID) return;
|
||||
this._routeToJob(rowData.id);
|
||||
},
|
||||
onActiveJobDeleted(deletedJobUUID) {
|
||||
this._routeToJobOverview();
|
||||
},
|
||||
showTaskLogTail() {
|
||||
this.showFooterPopup = true;
|
||||
this.$nextTick(() => {
|
||||
@ -155,7 +136,6 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
this._fetchJob(this.jobID);
|
||||
if (jobUpdate.refresh_tasks) {
|
||||
if (this.$refs.tasksTable) this.$refs.tasksTable.fetchTasks();
|
||||
}
|
||||
@ -187,50 +167,6 @@ export default {
|
||||
this.$refs.jobDetails.refreshLastRenderedImage(lastRenderedUpdate);
|
||||
},
|
||||
|
||||
/**
|
||||
* Send to the job overview page, i.e. job view without active job.
|
||||
*/
|
||||
_routeToJobOverview() {
|
||||
const route = { name: 'jobs' };
|
||||
this.$router.push(route);
|
||||
},
|
||||
/**
|
||||
* @param {string} jobID job ID to navigate to, can be empty string for "no active job".
|
||||
*/
|
||||
_routeToJob(jobID) {
|
||||
const route = { name: 'jobs', params: { jobID: jobID } };
|
||||
this.$router.push(route);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch job info and set the active job once it's received.
|
||||
* @param {string} jobID job ID, can be empty string for "no job".
|
||||
*/
|
||||
_fetchJob(jobID) {
|
||||
if (!jobID) {
|
||||
this.jobs.deselectAllJobs();
|
||||
return;
|
||||
}
|
||||
|
||||
const jobsAPI = new API.JobsApi(getAPIClient());
|
||||
return jobsAPI
|
||||
.fetchJob(jobID)
|
||||
.then((job) => {
|
||||
this.jobs.setActiveJob(job);
|
||||
// Forward the full job to Tabulator, so that that gets updated too.
|
||||
this.$refs.jobsTable.processJobUpdate(job);
|
||||
this._recalcTasksTableHeight();
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.status == 404) {
|
||||
// It can happen that a job cannot be found, for example when it was asynchronously deleted.
|
||||
this.jobs.deselectAllJobs();
|
||||
return;
|
||||
}
|
||||
console.log(`Unable to fetch job ${jobID}:`, err);
|
||||
});
|
||||
},
|
||||
|
||||
onChatMessage(message) {
|
||||
console.log('chat message received:', message);
|
||||
this.messages.push(`${message.text}`);
|
||||
|
Loading…
x
Reference in New Issue
Block a user