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" /> - +
@@ -116,7 +111,6 @@ export default { // }) this._fetchJob(this.jobID); - this._fetchTask(this.taskID); window.addEventListener('resize', this._recalcTasksTableHeight); }, @@ -127,9 +121,6 @@ export default { jobID(newJobID, oldJobID) { this._fetchJob(newJobID); }, - taskID(newTaskID, oldTaskID) { - this._fetchTask(newTaskID); - }, showFooterPopup(shown) { if (shown) localStorage.setItem('footer-popover-visible', 'true'); else localStorage.removeItem('footer-popover-visible'); @@ -139,31 +130,12 @@ 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; + if (rowData.id === this.jobID) return; this._routeToJob(rowData.id); }, - onTableTaskClicked(rowData) { - this._routeToTask(rowData.id); - }, onActiveJobDeleted(deletedJobUUID) { this._routeToJobOverview(); }, - - onSelectedTaskChanged(taskSummary) { - if (!taskSummary) { - // There is no active task. - this.tasks.deselectAllTasks(); - return; - } - - const jobsAPI = new API.JobsApi(getAPIClient()); - jobsAPI.fetchTask(taskSummary.id).then((task) => { - this.tasks.setActiveTask(task); - // Forward the full task to Tabulator, so that that gets updated too. - if (this.$refs.tasksTable) this.$refs.tasksTable.processTaskUpdate(task); - }); - }, - showTaskLogTail() { this.showFooterPopup = true; this.$nextTick(() => { @@ -186,7 +158,6 @@ export default { this._fetchJob(this.jobID); if (jobUpdate.refresh_tasks) { if (this.$refs.tasksTable) this.$refs.tasksTable.fetchTasks(); - this._fetchTask(this.taskID); } }, @@ -196,7 +167,7 @@ export default { */ onSioTaskUpdate(taskUpdate) { if (this.$refs.tasksTable) this.$refs.tasksTable.processTaskUpdate(taskUpdate); - if (this.taskID == taskUpdate.id) this._fetchTask(this.taskID); + this.notifs.addTaskUpdate(taskUpdate); }, @@ -230,14 +201,6 @@ export default { const route = { name: 'jobs', params: { jobID: jobID } }; this.$router.push(route); }, - /** - * @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); - }, /** * Fetch job info and set the active job once it's received. @@ -268,24 +231,6 @@ export default { }); }, - /** - * Fetch task info and set the active task once it's received. - * @param {string} taskID task ID, can be empty string for "no task". - */ - _fetchTask(taskID) { - if (!taskID) { - this.tasks.deselectAllTasks(); - return; - } - - const jobsAPI = new API.JobsApi(getAPIClient()); - return jobsAPI.fetchTask(taskID).then((task) => { - this.tasks.setActiveTask(task); - // Forward the full task to Tabulator, so that that gets updated too.\ - if (this.$refs.tasksTable) this.$refs.tasksTable.processTaskUpdate(task); - }); - }, - onChatMessage(message) { console.log('chat message received:', message); this.messages.push(`${message.text}`);