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>
|
<template>
|
||||||
<div class="btn-bar jobs">
|
<div class="btn-bar jobs">
|
||||||
<div class="btn-bar-popover" v-if="deleteInfo != null">
|
<div class="btn-bar-popover" v-if="showDeleteJobPopup">
|
||||||
<p v-if="deleteInfo.shaman_checkout">Delete job, including Shaman checkout?</p>
|
<p v-if="shamanEnv">
|
||||||
<p v-else>Delete job? The job files will be kept.</p>
|
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">
|
<div class="inner-btn-bar">
|
||||||
<button class="btn cancel" v-on:click="_hideDeleteJobPopup">Cancel</button>
|
<button class="btn cancel" v-on:click="_hideDeleteJobPopup">Cancel</button>
|
||||||
<button class="btn delete dangerous" v-on:click="onButtonDeleteConfirmed">Delete</button>
|
<button class="btn delete dangerous" v-on:click="onButtonDeleteConfirmed">Delete</button>
|
||||||
@ -30,10 +32,13 @@
|
|||||||
<script>
|
<script>
|
||||||
import { useJobs } from '@/stores/jobs';
|
import { useJobs } from '@/stores/jobs';
|
||||||
import { useNotifs } from '@/stores/notifications';
|
import { useNotifs } from '@/stores/notifications';
|
||||||
import { getAPIClient } from '@/api-client';
|
import { getAPIClient, newBareAPIClient } from '@/api-client';
|
||||||
import { JobsApi } from '@/manager-api';
|
import { JobsApi, MetaApi } from '@/manager-api';
|
||||||
import { JobDeletionInfo } 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 {
|
export default {
|
||||||
name: 'JobActionsBar',
|
name: 'JobActionsBar',
|
||||||
props: ['activeJobID'],
|
props: ['activeJobID'],
|
||||||
@ -41,8 +46,10 @@ export default {
|
|||||||
jobs: useJobs(),
|
jobs: useJobs(),
|
||||||
notifs: useNotifs(),
|
notifs: useNotifs(),
|
||||||
jobsAPI: new JobsApi(getAPIClient()),
|
jobsAPI: new JobsApi(getAPIClient()),
|
||||||
|
metaAPI: new MetaApi(newBareAPIClient()),
|
||||||
|
|
||||||
deleteInfo: null,
|
shamanEnv: null,
|
||||||
|
showDeleteJobPopup: false,
|
||||||
}),
|
}),
|
||||||
computed: {},
|
computed: {},
|
||||||
watch: {
|
watch: {
|
||||||
@ -55,62 +62,77 @@ export default {
|
|||||||
this._startJobDeletionFlow();
|
this._startJobDeletionFlow();
|
||||||
},
|
},
|
||||||
onButtonDeleteConfirmed() {
|
onButtonDeleteConfirmed() {
|
||||||
return this.jobs
|
return this._handleJobActionPromise(this.jobs.deleteJobs(), deleteDescription);
|
||||||
.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);
|
|
||||||
},
|
},
|
||||||
onButtonCancel() {
|
onButtonCancel() {
|
||||||
return this._handleJobActionPromise(this.jobs.cancelJobs(), 'marked for cancellation');
|
return this._handleJobActionPromise(this.jobs.cancelJobs(), cancelDescription);
|
||||||
},
|
},
|
||||||
onButtonRequeue() {
|
onButtonRequeue() {
|
||||||
return this._handleJobActionPromise(this.jobs.requeueJobs(), 'requeueing');
|
return this._handleJobActionPromise(this.jobs.requeueJobs(), requeueDescription);
|
||||||
},
|
},
|
||||||
onButtonPause() {
|
onButtonPause() {
|
||||||
return this._handleJobActionPromise(this.jobs.pauseJobs(), 'marked for pausing');
|
return this._handleJobActionPromise(this.jobs.pauseJobs(), pauseDescription);
|
||||||
},
|
},
|
||||||
|
|
||||||
_handleJobActionPromise(promise, description) {
|
_handleJobActionPromise(promise, description) {
|
||||||
return promise.then(() => {
|
return promise
|
||||||
// There used to be a call to `this.notifs.add(message)` here, but now
|
.then((values) => {
|
||||||
// that job status changes are logged in the notifications anyway,
|
const { incompatibleJobs, compatibleJobs, totalJobCount } = values;
|
||||||
// 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
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
_startJobDeletionFlow() {
|
// TODO: messages could be improved to specify the names of jobs that failed
|
||||||
if (!this.activeJobID) {
|
const failedMessage = `Could not apply ${description} status to ${incompatibleJobs.length} out of ${totalJobCount} job(s).`;
|
||||||
this.notifs.add('No active job, unable to delete anything');
|
const successMessage = `${compatibleJobs.length} job(s) successfully ${description}.`;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.jobsAPI
|
this.notifs.add(
|
||||||
.deleteJobWhatWouldItDo(this.activeJobID)
|
`${compatibleJobs.length > 0 ? successMessage : ''}
|
||||||
.then(this._showDeleteJobPopup)
|
${incompatibleJobs.length > 0 ? failedMessage : ''}`
|
||||||
|
);
|
||||||
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
|
const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
|
||||||
this.notifs.add(`Error: ${errorMsg}`);
|
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) {
|
_showDeleteJobPopup() {
|
||||||
this.deleteInfo = deleteInfo;
|
// 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() {
|
_hideDeleteJobPopup() {
|
||||||
this.deleteInfo = null;
|
this.shamanEnv = null;
|
||||||
|
this.showDeleteJobPopup = false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -28,7 +28,6 @@ import StatusFilterBar from '@/components/StatusFilterBar.vue';
|
|||||||
export default {
|
export default {
|
||||||
name: 'JobsTable',
|
name: 'JobsTable',
|
||||||
props: ['activeJobID'],
|
props: ['activeJobID'],
|
||||||
emits: ['tableRowClicked', 'activeJobDeleted', 'jobDeleted'],
|
|
||||||
components: {
|
components: {
|
||||||
JobActionsBar,
|
JobActionsBar,
|
||||||
StatusFilterBar,
|
StatusFilterBar,
|
||||||
@ -37,8 +36,8 @@ export default {
|
|||||||
return {
|
return {
|
||||||
shownStatuses: [],
|
shownStatuses: [],
|
||||||
availableStatuses: [], // Will be filled after data is loaded from the backend.
|
availableStatuses: [], // Will be filled after data is loaded from the backend.
|
||||||
|
|
||||||
jobs: useJobs(),
|
jobs: useJobs(),
|
||||||
|
lastSelectedJobPosition: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@ -115,80 +114,210 @@ export default {
|
|||||||
this.$nextTick(this.recalcTableHeight);
|
this.$nextTick(this.recalcTableHeight);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
|
||||||
selectedIDs() {
|
|
||||||
return this.tabulator.getSelectedData().map((job) => job.id);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
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
|
// If the connection to the backend was lost, we have likely missed some
|
||||||
// updates. Just fetch the data and start from scratch.
|
// updates. Just fetch the data and start from scratch.
|
||||||
this.fetchAllJobs();
|
await this.initAllJobs();
|
||||||
|
await this.initActiveJob();
|
||||||
},
|
},
|
||||||
sortData() {
|
sortData() {
|
||||||
const tab = this.tabulator;
|
const tab = this.tabulator;
|
||||||
tab.setSort(tab.getSorters()); // This triggers re-sorting.
|
tab.setSort(tab.getSorters()); // This triggers re-sorting.
|
||||||
},
|
},
|
||||||
_onTableBuilt() {
|
async _onTableBuilt() {
|
||||||
this.tabulator.setFilter(this._filterByStatus);
|
this.tabulator.setFilter(this._filterByStatus);
|
||||||
this.fetchAllJobs();
|
await this.initAllJobs();
|
||||||
|
await this.initActiveJob();
|
||||||
},
|
},
|
||||||
fetchAllJobs() {
|
async fetchAllJobs() {
|
||||||
const jobsApi = new API.JobsApi(getAPIClient());
|
const jobsApi = new API.JobsApi(getAPIClient());
|
||||||
this.jobs.isJobless = false;
|
return jobsApi
|
||||||
jobsApi.fetchJobs().then(this.onJobsFetched, function (error) {
|
.fetchJobs()
|
||||||
// TODO: error handling.
|
.then((data) => data.jobs)
|
||||||
console.error(error);
|
.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:
|
* Initializes all jobs and sets the Tabulator data. Updates pinia stores and state accordingly.
|
||||||
// data.jobs = data.jobs.map((j) => API.JobUpdate.constructFromObject(j));
|
*/
|
||||||
const hasJobs = data && data.jobs && data.jobs.length > 0;
|
async initAllJobs() {
|
||||||
this.jobs.isJobless = !hasJobs;
|
try {
|
||||||
this.tabulator.setData(data.jobs);
|
this.jobs.isJobless = false;
|
||||||
this._refreshAvailableStatuses();
|
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
|
// updateData() will only overwrite properties that are actually set on
|
||||||
// jobUpdate, and leave the rest as-is.
|
// jobUpdate, and leave the rest as-is.
|
||||||
if (!this.tabulator.initialized) {
|
if (!this.tabulator.initialized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const row = this.tabulator.rowManager.findRow(jobUpdate.id);
|
|
||||||
|
|
||||||
let promise = null;
|
try {
|
||||||
if (jobUpdate.was_deleted) {
|
const row = this.tabulator.rowManager.findRow(jobUpdate.id);
|
||||||
if (row) promise = row.delete();
|
// If the row update is for deletion, delete the row and route to /jobs
|
||||||
else promise = Promise.resolve();
|
if (jobUpdate.was_deleted && row) {
|
||||||
promise.finally(() => {
|
// Prevents the issue where deleted rows persist on Tabulator's selectedData
|
||||||
this.$emit('jobDeleted', jobUpdate.id);
|
// (this should technically not happen -- need to investigate more)
|
||||||
if (jobUpdate.id == this.activeJobID) {
|
this.tabulator.deselectRow(jobUpdate.id);
|
||||||
this.$emit('activeJobDeleted', jobUpdate.id);
|
row.delete().then(() => {
|
||||||
}
|
if (jobUpdate.id === this.activeJobID) {
|
||||||
});
|
this._routeToJobOverview();
|
||||||
} else {
|
|
||||||
if (row) promise = this.tabulator.updateData([jobUpdate]);
|
// Update Pinia Stores
|
||||||
else promise = this.tabulator.addData([jobUpdate]);
|
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
|
// 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
|
// store. There were some issues where navigating to another job would
|
||||||
// overwrite the old job's ID, and this prevents that.
|
// 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) {
|
toggleStatusFilter(status) {
|
||||||
const asSet = new Set(this.shownStatuses);
|
const asSet = new Set(this.shownStatuses);
|
||||||
|
@ -5,6 +5,25 @@ import { getAPIClient } from '@/api-client';
|
|||||||
|
|
||||||
const jobsAPI = new API.JobsApi(getAPIClient());
|
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.
|
// 'use' prefix is idiomatic for Pinia stores.
|
||||||
// See https://pinia.vuejs.org/core-concepts/
|
// See https://pinia.vuejs.org/core-concepts/
|
||||||
export const useJobs = defineStore('jobs', {
|
export const useJobs = defineStore('jobs', {
|
||||||
@ -16,25 +35,27 @@ export const useJobs = defineStore('jobs', {
|
|||||||
* @type {string}
|
* @type {string}
|
||||||
*/
|
*/
|
||||||
activeJobID: '',
|
activeJobID: '',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set to true when it is known that there are no jobs at all in the system.
|
* 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.
|
* This is written by the JobsTable.vue component.
|
||||||
|
* @type {bool}
|
||||||
*/
|
*/
|
||||||
isJobless: false,
|
isJobless: false,
|
||||||
|
/** @type {API.Job[]} */
|
||||||
|
selectedJobs: [],
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
canDelete() {
|
canDelete() {
|
||||||
return this._anyJobWithStatus(['completed', 'canceled', 'failed', 'paused', 'queued']);
|
return this._anyJobWithStatus(JOB_ACTIONS.DELETE.prerequisiteStatuses);
|
||||||
},
|
},
|
||||||
canCancel() {
|
canCancel() {
|
||||||
return this._anyJobWithStatus(['active', 'failed', 'queued']);
|
return this._anyJobWithStatus(JOB_ACTIONS.CANCEL.prerequisiteStatuses);
|
||||||
},
|
},
|
||||||
canRequeue() {
|
canRequeue() {
|
||||||
return this._anyJobWithStatus(['canceled', 'completed', 'failed', 'paused']);
|
return this._anyJobWithStatus(JOB_ACTIONS.REQUEUE.prerequisiteStatuses);
|
||||||
},
|
},
|
||||||
canPause() {
|
canPause() {
|
||||||
return this._anyJobWithStatus(['active', 'queued', 'canceled']);
|
return this._anyJobWithStatus(JOB_ACTIONS.PAUSE.prerequisiteStatuses);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
@ -59,39 +80,42 @@ export const useJobs = defineStore('jobs', {
|
|||||||
state.hasChanged = true;
|
state.hasChanged = true;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
deselectAllJobs() {
|
clearActiveJob() {
|
||||||
this.$patch({
|
this.$patch({
|
||||||
activeJob: null,
|
activeJob: null,
|
||||||
activeJobID: '',
|
activeJobID: '',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
setSelectedJobs(jobs) {
|
||||||
|
this.$patch({
|
||||||
|
selectedJobs: jobs,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearSelectedJobs() {
|
||||||
|
this.$patch({
|
||||||
|
selectedJobs: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Actions on the selected jobs.
|
* Actions on the selected jobs.
|
||||||
*
|
*
|
||||||
* All the action functions return a promise that resolves when the action has been performed.
|
* 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() {
|
cancelJobs() {
|
||||||
return this._setJobStatus('cancel-requested');
|
return this._setJobStatus(JOB_ACTIONS.CANCEL.status, JOB_ACTIONS.CANCEL.prerequisiteStatuses);
|
||||||
},
|
},
|
||||||
pauseJobs() {
|
pauseJobs() {
|
||||||
return this._setJobStatus('pause-requested');
|
return this._setJobStatus(JOB_ACTIONS.PAUSE.status, JOB_ACTIONS.PAUSE.prerequisiteStatuses);
|
||||||
},
|
},
|
||||||
requeueJobs() {
|
requeueJobs() {
|
||||||
return this._setJobStatus('requeueing');
|
return this._setJobStatus(
|
||||||
|
JOB_ACTIONS.REQUEUE.status,
|
||||||
|
JOB_ACTIONS.REQUEUE.prerequisiteStatuses
|
||||||
|
);
|
||||||
},
|
},
|
||||||
deleteJobs() {
|
deleteJobs() {
|
||||||
if (!this.activeJobID) {
|
return this._setJobStatus(JOB_ACTIONS.DELETE.status, JOB_ACTIONS.DELETE.prerequisiteStatuses);
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Internal methods.
|
// Internal methods.
|
||||||
@ -101,25 +125,56 @@ export const useJobs = defineStore('jobs', {
|
|||||||
* @param {string[]} statuses
|
* @param {string[]} statuses
|
||||||
* @returns bool indicating whether there is a selected job with any of the given statuses.
|
* @returns bool indicating whether there is a selected job with any of the given statuses.
|
||||||
*/
|
*/
|
||||||
_anyJobWithStatus(statuses) {
|
_anyJobWithStatus(job_statuses) {
|
||||||
return (
|
if (this.selectedJobs.length) {
|
||||||
!!this.activeJob && !!this.activeJob.status && statuses.includes(this.activeJob.status)
|
return this.selectedJobs.some((job) => job_statuses.includes(job.status));
|
||||||
);
|
}
|
||||||
// return this.selectedJobs.reduce((foundJob, job) => (foundJob || statuses.includes(job.status)), false);
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transition the selected job(s) to the new status.
|
* Transition the selected job(s) to the new status.
|
||||||
* @param {string} newStatus
|
* @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) {
|
_setJobStatus(newStatus, job_statuses) {
|
||||||
if (!this.activeJobID) {
|
const totalJobCount = this.selectedJobs.length;
|
||||||
console.warn(`_setJobStatus(${newStatus}) impossible, no active job ID`);
|
|
||||||
|
if (!totalJobCount) {
|
||||||
|
console.warn(`_setJobStatus(${newStatus}) impossible, no selected job(s).`);
|
||||||
return;
|
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>
|
<template>
|
||||||
<div class="col col-1">
|
<div class="col col-1">
|
||||||
<jobs-table
|
<jobs-table ref="jobsTable" :activeJobID="jobID" />
|
||||||
ref="jobsTable"
|
|
||||||
:activeJobID="jobID"
|
|
||||||
@tableRowClicked="onTableJobClicked"
|
|
||||||
@activeJobDeleted="onActiveJobDeleted" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-2 job-details-column" id="col-job-details">
|
<div class="col col-2 job-details-column" id="col-job-details">
|
||||||
<get-the-addon v-if="jobs.isJobless" />
|
<get-the-addon v-if="jobs.isJobless" />
|
||||||
@ -57,12 +53,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import * as API from '@/manager-api';
|
|
||||||
import { useJobs } from '@/stores/jobs';
|
import { useJobs } from '@/stores/jobs';
|
||||||
import { useTasks } from '@/stores/tasks';
|
import { useTasks } from '@/stores/tasks';
|
||||||
import { useNotifs } from '@/stores/notifications';
|
import { useNotifs } from '@/stores/notifications';
|
||||||
import { useTaskLog } from '@/stores/tasklog';
|
import { useTaskLog } from '@/stores/tasklog';
|
||||||
import { getAPIClient } from '@/api-client';
|
|
||||||
|
|
||||||
import FooterPopup from '@/components/footer/FooterPopup.vue';
|
import FooterPopup from '@/components/footer/FooterPopup.vue';
|
||||||
import GetTheAddon from '@/components/GetTheAddon.vue';
|
import GetTheAddon from '@/components/GetTheAddon.vue';
|
||||||
@ -110,17 +104,12 @@ export default {
|
|||||||
// console.log("Pinia state :", state)
|
// console.log("Pinia state :", state)
|
||||||
// })
|
// })
|
||||||
|
|
||||||
this._fetchJob(this.jobID);
|
|
||||||
|
|
||||||
window.addEventListener('resize', this._recalcTasksTableHeight);
|
window.addEventListener('resize', this._recalcTasksTableHeight);
|
||||||
},
|
},
|
||||||
unmounted() {
|
unmounted() {
|
||||||
window.removeEventListener('resize', this._recalcTasksTableHeight);
|
window.removeEventListener('resize', this._recalcTasksTableHeight);
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
jobID(newJobID, oldJobID) {
|
|
||||||
this._fetchJob(newJobID);
|
|
||||||
},
|
|
||||||
showFooterPopup(shown) {
|
showFooterPopup(shown) {
|
||||||
if (shown) localStorage.setItem('footer-popover-visible', 'true');
|
if (shown) localStorage.setItem('footer-popover-visible', 'true');
|
||||||
else localStorage.removeItem('footer-popover-visible');
|
else localStorage.removeItem('footer-popover-visible');
|
||||||
@ -128,14 +117,6 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
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() {
|
showTaskLogTail() {
|
||||||
this.showFooterPopup = true;
|
this.showFooterPopup = true;
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@ -155,7 +136,6 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._fetchJob(this.jobID);
|
|
||||||
if (jobUpdate.refresh_tasks) {
|
if (jobUpdate.refresh_tasks) {
|
||||||
if (this.$refs.tasksTable) this.$refs.tasksTable.fetchTasks();
|
if (this.$refs.tasksTable) this.$refs.tasksTable.fetchTasks();
|
||||||
}
|
}
|
||||||
@ -187,50 +167,6 @@ export default {
|
|||||||
this.$refs.jobDetails.refreshLastRenderedImage(lastRenderedUpdate);
|
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) {
|
onChatMessage(message) {
|
||||||
console.log('chat message received:', message);
|
console.log('chat message received:', message);
|
||||||
this.messages.push(`${message.text}`);
|
this.messages.push(`${message.text}`);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user