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:
Vivian Leung 2025-07-04 11:18:48 +02:00 committed by Sybren A. Stüvel
parent 1044350b4b
commit 11a052d854
4 changed files with 329 additions and 187 deletions

View File

@ -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;
}, },
}, },
}; };

View File

@ -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);

View File

@ -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,
}));
}, },
}, },
}); });

View File

@ -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}`);