diff --git a/web/app/src/components/jobs/TaskActionsBar.vue b/web/app/src/components/jobs/TaskActionsBar.vue
index 0e687797..8ad2267d 100644
--- a/web/app/src/components/jobs/TaskActionsBar.vue
+++ b/web/app/src/components/jobs/TaskActionsBar.vue
@@ -29,16 +29,18 @@ export default {
},
_handleTaskActionPromise(promise, description) {
- // const numTasks = this.tasks.numSelected;
- const numTasks = 1;
return promise
- .then(() => {
- // There used to be a call to `this.notifs.add(message)` here, but now
- // that task 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 tasks can be selected. Then a summary
- // ("N tasks requeued") could be logged here.
+ .then((values) => {
+ const { incompatibleTasks, compatibleTasks, totalTaskCount } = values;
+
+ // TODO: messages could be improved to specify the names of tasks that failed
+ const failedMessage = `Could not apply ${description} status to ${incompatibleTasks.length} out of ${totalTaskCount} task(s).`;
+ const successMessage = `${compatibleTasks.length} task(s) successfully ${description}.`;
+
+ this.notifs.add(
+ `${compatibleTasks.length > 0 ? successMessage : ''}
+ ${incompatibleTasks.length > 0 ? failedMessage : ''}`
+ );
})
.catch((error) => {
const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
diff --git a/web/app/src/components/jobs/TasksTable.vue b/web/app/src/components/jobs/TasksTable.vue
index 986e7507..569dad11 100644
--- a/web/app/src/components/jobs/TasksTable.vue
+++ b/web/app/src/components/jobs/TasksTable.vue
@@ -26,7 +26,6 @@ import TaskActionsBar from '@/components/jobs/TaskActionsBar.vue';
import StatusFilterBar from '@/components/StatusFilterBar.vue';
export default {
- emits: ['tableRowClicked'],
props: [
'jobID', // ID of the job of which the tasks are shown here.
'taskID', // The active task.
@@ -40,6 +39,7 @@ export default {
tasks: useTasks(),
shownStatuses: [],
availableStatuses: [], // Will be filled after data is loaded from the backend.
+ lastSelectedTaskPosition: null,
};
},
mounted() {
@@ -105,7 +105,7 @@ export default {
jobID() {
this.fetchTasks();
},
- taskID(oldID, newID) {
+ taskID(newID, oldID) {
this._reformatRow(oldID);
this._reformatRow(newID);
},
@@ -122,6 +122,14 @@ export default {
// updates. Just fetch the data and start from scratch.
this.fetchTasks();
},
+ /**
+ * @param {string} taskID task ID to navigate to within this job, can be
+ * empty string for "no active task".
+ */
+ _routeToTask(taskID) {
+ const route = { name: 'jobs', params: { jobID: this.jobID, taskID: taskID } };
+ this.$router.push(route);
+ },
sortData() {
const tab = this.tabulator;
tab.setSort(tab.getSorters()); // This triggers re-sorting.
@@ -130,27 +138,62 @@ export default {
this.tabulator.setFilter(this._filterByStatus);
this.fetchTasks();
},
+ /**
+ * Fetch task info and set the active task once it's received.
+ */
+ fetchActiveTask() {
+ // If there's no active task, reset the state and Pinia stores
+ if (!this.taskID) {
+ this.tasks.clearActiveTask();
+ this.tasks.clearSelectedTasks();
+ this.lastSelectedTaskPosition = null;
+ return;
+ }
+
+ // Otherwise, set the state and Pinia stores
+ const jobsApi = new API.JobsApi(getAPIClient()); // init the API
+ jobsApi.fetchTask(this.taskID).then((task) => {
+ this.tasks.setActiveTask(task);
+ this.tasks.setSelectedTasks([task]);
+
+ const activeRow = this.tabulator.getRow(this.taskID);
+ // If the page is reloaded, re-initialize the last selected task (or active task) position, allowing the user to multi-select from that task.
+ this.lastSelectedTaskPosition = activeRow.getPosition();
+ // Make sure the active row on tabulator has the selected status toggled as well
+ this.tabulator.selectRow(activeRow);
+ });
+ },
+ /**
+ * Fetch all tasks and set the Tabulator data
+ */
fetchTasks() {
+ // No active job
if (!this.jobID) {
this.tabulator.setData([]);
return;
}
- const jobsApi = new API.JobsApi(getAPIClient());
- jobsApi.fetchJobTasks(this.jobID).then(this.onTasksFetched, function (error) {
- // TODO: error handling.
- console.error(error);
- });
- },
- onTasksFetched(data) {
- // "Down-cast" to TaskUpdate to only get those fields, just for debugging things:
- // let tasks = data.tasks.map((j) => API.TaskUpdate.constructFromObject(j));
- this.tabulator.setData(data.tasks);
- this._refreshAvailableStatuses();
+ // Deselect all rows before setting new task data. This prevents the error caused by trying to deselect rows that don't exist on the new data.
+ this.tabulator.deselectRow();
- this.recalcTableHeight();
+ const jobsApi = new API.JobsApi(getAPIClient()); // init the API
+
+ jobsApi.fetchJobTasks(this.jobID).then(
+ (data) => {
+ this.tabulator.setData(data.tasks);
+ this._refreshAvailableStatuses();
+ this.recalcTableHeight();
+
+ this.fetchActiveTask();
+ },
+ (error) => {
+ // TODO: error handling.
+ console.error(error);
+ }
+ );
},
processTaskUpdate(taskUpdate) {
+ // Any updates to tasks i.e. status changes will need to reflect its changes to the rows on Tabulator here.
// updateData() will only overwrite properties that are actually set on
// taskUpdate, and leave the rest as-is.
if (this.tabulator.initialized) {
@@ -161,15 +204,68 @@ export default {
this.tabulator.redraw();
}); // Resize columns based on new data.
}
+ this.tasks.setSelectedTasks(this.getSelectedTasks()); // Update Pinia stores
this._refreshAvailableStatuses();
},
+ getSelectedTasks() {
+ return this.tabulator.getSelectedData();
+ },
+ handleMultiSelect(event, row, tabulator) {
+ const position = row.getPosition();
+ // Manage the click event and Tabulator row selection
+ if (event.shiftKey && this.lastSelectedTaskPosition) {
+ // Shift + Click - selects a range of rows
+ let start = Math.min(position, this.lastSelectedTaskPosition);
+ let end = Math.max(position, this.lastSelectedTaskPosition);
+ const rowsToSelect = [];
+
+ for (let i = start; i <= end; i++) {
+ const currRow = this.tabulator.getRowFromPosition(i);
+ rowsToSelect.push(currRow);
+ }
+ tabulator.selectRow(rowsToSelect);
+
+ // Remove text-selection that occurs during Shift + Click
+ 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);
+ }
+ },
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 (this.tabulator.getSelectedRows().includes(row)) {
+ // The row was toggled -> selected
+ const rowData = row.getData();
+ this._routeToTask(rowData.id);
+
+ const jobsApi = new API.JobsApi(getAPIClient()); // init the API
+ jobsApi.fetchTask(rowData.id).then((task) => {
+ // row.getData() will return the API.TaskSummary data, while tasks.setActiveTask() needs the entire API.Task
+ this.tasks.setActiveTask(task);
+ });
+ this.lastSelectedTaskPosition = row.getPosition();
+ } else {
+ // The row was toggled -> de-selected
+ this._routeToTask('');
+ this.tasks.clearActiveTask();
+ this.lastSelectedTaskPosition = null;
+ }
+
+ this.tasks.setSelectedTasks(this.getSelectedTasks()); // Set the selected tasks according to tabulator's selected rows
},
toggleStatusFilter(status) {
const asSet = new Set(this.shownStatuses);
diff --git a/web/app/src/stores/jobs.js b/web/app/src/stores/jobs.js
index 3e524717..120cb5b6 100644
--- a/web/app/src/stores/jobs.js
+++ b/web/app/src/stores/jobs.js
@@ -25,10 +25,10 @@ export const useJobs = defineStore('jobs', {
}),
getters: {
canDelete() {
- return this._anyJobWithStatus(['queued', 'paused', 'failed', 'completed', 'canceled']);
+ return this._anyJobWithStatus(['completed', 'canceled', 'failed', 'paused', 'queued']);
},
canCancel() {
- return this._anyJobWithStatus(['queued', 'active', 'failed']);
+ return this._anyJobWithStatus(['active', 'failed', 'queued']);
},
canRequeue() {
return this._anyJobWithStatus(['canceled', 'completed', 'failed', 'paused']);
diff --git a/web/app/src/stores/tasks.js b/web/app/src/stores/tasks.js
index 351dff6e..43d23306 100644
--- a/web/app/src/stores/tasks.js
+++ b/web/app/src/stores/tasks.js
@@ -6,6 +6,8 @@ import { useJobs } from '@/stores/jobs';
const jobsAPI = new API.JobsApi(getAPIClient());
+const taskStatusCanCancel = ['active', 'queued', 'soft-failed'];
+const taskStatusCanRequeue = ['canceled', 'completed', 'failed'];
// 'use' prefix is idiomatic for Pinia stores.
// See https://pinia.vuejs.org/core-concepts/
export const useTasks = defineStore('tasks', {
@@ -17,6 +19,7 @@ export const useTasks = defineStore('tasks', {
* @type {string}
*/
activeTaskID: '',
+ selectedTasks: [],
}),
getters: {
canCancel() {
@@ -24,24 +27,33 @@ export const useTasks = defineStore('tasks', {
const activeJob = jobs.activeJob;
if (!activeJob) {
- console.warn('no active job, unable to determine whether the active task is cancellable');
+ console.warn('no active job, unable to determine whether the task(s) is cancellable');
return false;
}
if (activeJob.status == 'pause-requested') {
- // Cancelling a task should not be possible while the job is being paused.
+ // Cancelling task(s) should not be possible while the job is being paused.
// In the future this might be supported, see issue #104315.
return false;
}
-
// Allow cancellation for specified task statuses.
- return this._anyTaskWithStatus(['queued', 'active', 'soft-failed']);
+ return this._anyTaskWithStatus(taskStatusCanCancel);
},
canRequeue() {
- return this._anyTaskWithStatus(['canceled', 'completed', 'failed']);
+ return this._anyTaskWithStatus(taskStatusCanRequeue);
},
},
actions: {
+ setSelectedTasks(tasks) {
+ this.$patch({
+ selectedTasks: tasks,
+ });
+ },
+ clearSelectedTasks() {
+ this.$patch({
+ selectedTasks: [],
+ });
+ },
setActiveTaskID(taskID) {
this.$patch({
activeTask: { id: taskID },
@@ -54,7 +66,7 @@ export const useTasks = defineStore('tasks', {
activeTaskID: task.id,
});
},
- deselectAllTasks() {
+ clearActiveTask() {
this.$patch({
activeTask: null,
activeTaskID: '',
@@ -65,43 +77,64 @@ export const useTasks = defineStore('tasks', {
* Actions on the selected tasks.
*
* All the action functions return a promise that resolves when the action has been performed.
- *
- * TODO: actually have these work on all selected tasks. For simplicity, the
- * code now assumes that only the active task needs to be operated on.
*/
cancelTasks() {
- return this._setTaskStatus('canceled');
+ return this._setTaskStatus('canceled', taskStatusCanCancel);
},
requeueTasks() {
- return this._setTaskStatus('queued');
+ return this._setTaskStatus('queued', taskStatusCanRequeue);
},
// Internal methods.
/**
*
- * @param {string[]} statuses
+ * @param {string[]} task_statuses
* @returns bool indicating whether there is a selected task with any of the given statuses.
*/
- _anyTaskWithStatus(statuses) {
- return (
- !!this.activeTask && !!this.activeTask.status && statuses.includes(this.activeTask.status)
- );
- // return this.selectedTasks.reduce((foundTask, task) => (foundTask || statuses.includes(task.status)), false);
+ _anyTaskWithStatus(task_statuses) {
+ if (this.selectedTasks.length) {
+ return this.selectedTasks.some((task) => task_statuses.includes(task.status));
+ }
+ return false;
},
/**
* Transition the selected task(s) to the new status.
* @param {string} newStatus
- * @returns a Promise for the API request.
+ * @param {string[]} task_statuses The task statuses compatible with the transition to new status
+ * @returns a Promise for the API request(s).
*/
- _setTaskStatus(newStatus) {
- if (!this.activeTaskID) {
- console.warn(`_setTaskStatus(${newStatus}) impossible, no active task ID`);
+ _setTaskStatus(newStatus, task_statuses) {
+ const totalTaskCount = this.selectedTasks.length;
+
+ if (!totalTaskCount) {
+ console.warn(`_setTaskStatus(${newStatus}) impossible, no selected tasks`);
return;
}
+
+ const { compatibleTasks, incompatibleTasks } = this.selectedTasks.reduce(
+ (result, task) => {
+ if (task_statuses.includes(task.status)) {
+ result.compatibleTasks.push(task);
+ } else {
+ result.incompatibleTasks.push(task);
+ }
+ return result;
+ },
+ { compatibleTasks: [], incompatibleTasks: [] }
+ );
+
const statuschange = new API.TaskStatusChange(newStatus, 'requested from web interface');
- return jobsAPI.setTaskStatus(this.activeTaskID, statuschange);
+ const setTaskStatusPromises = compatibleTasks.map((task) =>
+ jobsAPI.setTaskStatus(task.id, statuschange)
+ );
+
+ return Promise.allSettled(setTaskStatusPromises).then((results) => ({
+ compatibleTasks: results,
+ incompatibleTasks,
+ totalTaskCount,
+ }));
},
},
});
diff --git a/web/app/src/views/JobsView.vue b/web/app/src/views/JobsView.vue
index 4379cac9..9f1f0074 100644
--- a/web/app/src/views/JobsView.vue
+++ b/web/app/src/views/JobsView.vue
@@ -13,12 +13,7 @@
ref="jobDetails"
:jobData="jobs.activeJob"
@reshuffled="_recalcTasksTableHeight" />
-