Workers table: Add multi-select and support actions for multiple items (#104395)

Add the following features:

- `Ctrl + Click` or `Cmd + Click` to toggle selection of additional workers
- `Shift + Click` to select a range of additional workers
- Ability to perform `Shutdown`, `Send To Sleep`, and `Wake Up` actions on multiple workers concurrently
- Notifications on how many workers successfully/failed to have an action performed

Reviewed-on: https://projects.blender.org/studio/flamenco/pulls/104395
Reviewed-by: Sybren A. Stüvel <sybren@blender.org>
This commit is contained in:
Vivian Leung 2025-08-18 11:53:28 +02:00 committed by Sybren A. Stüvel
parent 29e0eefdd1
commit bd2ebac519
4 changed files with 229 additions and 115 deletions

View File

@ -1,11 +1,13 @@
<template>
<select v-model="selectedAction">
<option value="" selected>
<template v-if="!hasActiveWorker">Select a Worker</template>
<template v-if="!workers.selectedWorkers.length">Select a Worker</template>
<template v-else>Choose an action...</template>
</option>
<template v-for="(action, key) in WORKER_ACTIONS">
<option :value="key" v-if="action.condition()">{{ action.label }}</option>
<option :key="action.label" :value="key" v-if="action.condition()">
{{ action.label }}
</option>
</template>
</select>
<button :disabled="!canPerformAction" class="btn" @click.prevent="performWorkerAction">
@ -84,26 +86,30 @@ const WORKER_ACTIONS = Object.freeze({
const selectedAction = ref('');
const workers = useWorkers();
const hasActiveWorker = computed(() => !!workers.activeWorkerID);
const canPerformAction = computed(() => hasActiveWorker && !!selectedAction.value);
const canPerformAction = computed(() => workers.selectedWorkers.length && !!selectedAction.value);
const notifs = useNotifs();
function performWorkerAction() {
const workerID = workers.activeWorkerID;
if (!workerID) {
notifs.add('Select a Worker before applying an action.');
if (!workers.selectedWorkers.length) {
notifs.add('Select at least one Worker before applying an action.');
return;
}
const api = new WorkerMgtApi(getAPIClient());
const api = new WorkerMgtApi(getAPIClient()); // Init the api
const action = WORKER_ACTIONS[selectedAction.value];
const statuschange = new WorkerStatusChangeRequest(action.target_status, action.lazy);
console.log('Requesting worker status change', statuschange);
const promises = workers.selectedWorkers.map((worker) =>
api
.requestWorkerStatusChange(workerID, statuschange)
.then((result) => notifs.add(`Worker status change to ${action.target_status} confirmed.`))
.catch((error) => {
notifs.add(`Error requesting worker status change: ${error.body.message}`);
});
.requestWorkerStatusChange(worker.id, statuschange)
.then(() =>
notifs.add(`Worker ${worker.name} status change to ${action.target_status} confirmed.`)
)
.catch((error) =>
notifs.add(`Error requesting worker ${worker.name} status change: ${error.body.message}`)
)
);
Promise.allSettled(promises);
}
</script>

View File

@ -39,9 +39,11 @@ export default {
data: () => {
return {
workers: useWorkers(),
api: new WorkerMgtApi(getAPIClient()),
shownStatuses: [],
availableStatuses: [], // Will be filled after data is loaded from the backend.
lastSelectedWorkerPosition: null,
};
},
mounted() {
@ -100,82 +102,210 @@ export default {
this.$nextTick(this.recalcTableHeight);
},
},
computed: {
selectedIDs() {
return this.tabulator.getSelectedData().map((worker) => worker.id);
},
},
methods: {
onReconnected() {
/**
* @param {string} workerID worker ID to navigate to, can be empty string for "no active worker".
*/
_routeToWorker(workerID) {
const route = { name: 'workers', params: { workerID: workerID } };
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.fetchAllWorkers();
// updates. Just re-initialize the data and start from scratch.
await this.initAllWorkers();
await this.initActiveWorker();
},
sortData() {
const tab = this.tabulator;
tab.setSort(tab.getSorters()); // This triggers re-sorting.
},
_onTableBuilt() {
async _onTableBuilt() {
this.tabulator.setFilter(this._filterByStatus);
this.fetchAllWorkers();
await this.initAllWorkers();
await this.initActiveWorker();
},
fetchAllWorkers() {
const api = new WorkerMgtApi(getAPIClient());
api.fetchWorkers().then(this.onWorkersFetched, function (error) {
// TODO: error handling.
console.error(error);
/**
* Initializes the active worker. Updates pinia stores and state accordingly.
*/
async initActiveWorker() {
// If there's no active Worker, reset the state and Pinia stores
if (!this.activeWorkerID) {
this.workers.clearActiveWorker();
this.workers.clearSelectedWorkers();
this.lastSelectedWorkerPosition = null;
return;
}
try {
const worker = await this.fetchWorker(this.activeWorkerID);
this.workers.setActiveWorker(worker);
this.workers.setSelectedWorkers([worker]);
const activeRow = this.tabulator.getRow(this.activeWorkerID);
// If the page is reloaded, re-initialize the last selected worker (or active worker)
// position, allowing the user to multi-select from that worker.
this.lastSelectedWorkerPosition = activeRow.getPosition();
// Make sure the active row on tabulator has the selected status toggled as well
this.tabulator.selectRow(activeRow);
} catch (e) {
console.error(e);
}
},
/**
* Fetch a Worker based on ID
*/
fetchWorker(workerID) {
return this.api
.fetchWorker(workerID)
.then((worker) => worker)
.catch((err) => {
throw new Error(`Unable to fetch worker with ID ${workerID}:`, err);
});
},
onWorkersFetched(data) {
this.tabulator.setData(data.workers);
/**
* Initializes all workers and sets the Tabulator data. Updates pinia stores and state accordingly.
*/
async initAllWorkers() {
try {
const workers = await this.fetchAllWorkers();
this.tabulator.setData(workers);
this._refreshAvailableStatuses();
this.recalcTableHeight();
} catch (e) {
console.error(e);
}
},
processWorkerUpdate(workerUpdate) {
/**
* Fetch all workers
*/
fetchAllWorkers() {
return this.api
.fetchWorkers()
.then((data) => data.workers)
.catch((e) => {
throw new Error('Unable to fetch all workers:', e);
});
},
async processWorkerUpdate(workerUpdate) {
if (!this.tabulator.initialized) return;
try {
// Contrary to tabulator.getRow(), rowManager.findRow() doesn't log a
// warning when the row cannot be found,
const existingRow = this.tabulator.rowManager.findRow(workerUpdate.id);
let promise;
// TODO: clean up the code below:
// Delete the row
if (existingRow && workerUpdate.deleted_at) {
// Prevents the issue where deleted rows persist on Tabulator's selectedData
this.tabulator.deselectRow(workerUpdate.id);
await existingRow.delete();
// If the deleted worker was active, route to /workers
if (workerUpdate.id === this.activeWorkerID) {
this._routeToWorker('');
// Update Pinia Stores
this.workers.clearActiveWorker();
}
// Update Pinia Stores
this.workers.setSelectedWorkers(this.getSelectedWorkers());
return;
}
if (existingRow) {
if (workerUpdate.deleted_at) {
// This is a deletion, not a regular update.
promise = existingRow.delete();
} else {
// Tabbulator doesn't update ommitted fields, but if `status_change`
// Prepare to update an existing row.
// Tabulator doesn't update ommitted fields, but if `status_change`
// is ommitted it means "no status change requested"; this should still
// force an update of the `status_change` field.
if (!workerUpdate.status_change) {
workerUpdate.status_change = null;
}
promise = this.tabulator.updateData([workerUpdate]);
workerUpdate.status_change = workerUpdate.status_change || null;
// Update the existing row.
// Tabulator doesn't know we're using 'status_change' in the 'status'
// column, so it also won't know to redraw when that field changes.
promise.then(() => existingRow.reinitialize(true));
}
await this.tabulator.updateData([workerUpdate]);
existingRow.reinitialize(true);
} else {
promise = this.tabulator.addData([workerUpdate]);
await this.tabulator.addData([workerUpdate]); // Add a new row.
}
this.sortData();
await this.tabulator.redraw();
this._refreshAvailableStatuses(); // Resize columns based on new data.
// Update Pinia stores
this.workers.setSelectedWorkers(this.getSelectedWorkers());
if (workerUpdate.id === this.activeWorkerID) {
const worker = await this.fetchWorker(this.activeWorkerID);
this.workers.setActiveWorker(worker);
}
promise
.then(this.sortData)
.then(() => {
this.tabulator.redraw();
}) // Resize columns based on new data.
.then(this._refreshAvailableStatuses);
// TODO: this should also resize the columns, as the status column can
// change sizes considerably.
} catch (e) {
console.error(e);
}
},
handleMultiSelect(event, row, tabulator) {
const position = row.getPosition();
onRowClick(event, row) {
// Manage the click event and Tabulator row selection
if (event.shiftKey && this.lastSelectedWorkerPosition) {
// Shift + Click - selects a range of rows
let start = Math.min(position, this.lastSelectedWorkerPosition);
let end = Math.max(position, this.lastSelectedWorkerPosition);
const rowsToSelect = [];
for (let i = start; i <= end; i++) {
const currRow = this.tabulator.getRowFromPosition(i);
rowsToSelect.push(currRow);
}
tabulator.selectRow(rowsToSelect);
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 worker would
// overwrite the old worker's ID, and this prevents that.
const rowData = plain(row.getData());
this.$emit('tableRowClicked', rowData);
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._routeToWorker(rowData.id);
const worker = await this.fetchWorker(rowData.id);
this.workers.setActiveWorker(worker);
this.lastSelectedWorkerPosition = row.getPosition();
} else {
// The row was toggled -> de-selected
this._routeToWorker('');
this.workers.clearActiveWorker();
this.lastSelectedWorkerPosition = null;
}
// Set the selected jobs according to tabulator's selected rows
this.workers.setSelectedWorkers(this.getSelectedWorkers());
},
getSelectedWorkers() {
return this.tabulator.getSelectedData();
},
toggleStatusFilter(status) {
const asSet = new Set(this.shownStatuses);

View File

@ -20,6 +20,9 @@ export const useWorkers = defineStore('workers', {
/* Mapping from tag UUID to API.WorkerTag. */
tagsByID: {},
/** @type {API.Worker[]} */
selectedWorkers: [],
}),
actions: {
setActiveWorkerID(workerID) {
@ -40,12 +43,22 @@ export const useWorkers = defineStore('workers', {
state.hasChanged = true;
});
},
deselectAllWorkers() {
clearActiveWorker() {
this.$patch({
activeWorker: null,
activeWorkerID: '',
});
},
setSelectedWorkers(workers) {
this.$patch({
selectedWorkers: workers,
});
},
clearSelectedWorkers() {
this.$patch({
selectedWorkers: [],
});
},
/**
* Fetch the available worker tags from the Manager.
*
@ -65,10 +78,13 @@ export const useWorkers = defineStore('workers', {
},
/**
* @returns whether the active worker understands how to get restarted.
* @returns {bool} whether atleast one selected worker understands how to get restarted.
*/
canRestart() {
return !!this.activeWorker && !!this.activeWorker.can_restart;
if (this.selectedWorkers.length) {
return this.selectedWorkers.some((worker) => worker.can_restart);
}
return false;
},
},
});

View File

@ -1,9 +1,6 @@
<template>
<div class="col col-workers-list">
<workers-table
ref="workersTable"
:activeWorkerID="workerID"
@tableRowClicked="onTableWorkerClicked" />
<workers-table ref="workersTable" :activeWorkerID="workerID" />
</div>
<div class="col col-workers-details">
<worker-details :workerData="workers.activeWorker" />
@ -32,10 +29,8 @@
</style>
<script>
import { WorkerMgtApi } from '@/manager-api';
import { useNotifs } from '@/stores/notifications';
import { useWorkers } from '@/stores/workers';
import { getAPIClient } from '@/api-client';
import NotificationBar from '@/components/footer/NotificationBar.vue';
import UpdateListener from '@/components/UpdateListener.vue';
@ -54,27 +49,19 @@ export default {
data: () => ({
workers: useWorkers(),
notifs: useNotifs(),
api: new WorkerMgtApi(getAPIClient()),
}),
mounted() {
window.workersView = this;
this._fetchWorker(this.workerID);
document.body.classList.add('is-two-columns');
},
unmounted() {
document.body.classList.remove('is-two-columns');
},
watch: {
workerID(newWorkerID, oldWorkerID) {
this._fetchWorker(newWorkerID);
},
},
methods: {
// SocketIO connection event handlers:
onSIOReconnected() {
this.$refs.workersTable.onReconnected();
this._fetchWorker(this.workerID);
},
onSIODisconnected(reason) {},
onSIOWorkerUpdate(workerUpdate) {
@ -83,43 +70,18 @@ export default {
if (this.$refs.workersTable) {
this.$refs.workersTable.processWorkerUpdate(workerUpdate);
}
if (this.workerID != workerUpdate.id) return;
if (workerUpdate.deleted_at) {
this._routeToWorker('');
return;
}
this._fetchWorker(this.workerID);
},
onSIOWorkerTagsUpdate(workerTagsUpdate) {
this.workers.refreshTags().then(() => this._fetchWorker(this.workerID));
},
onTableWorkerClicked(rowData) {
if (rowData.id == this.workerID) return;
this._routeToWorker(rowData.id);
},
/**
* @param {string} workerID worker ID to navigate to, can be empty string for "no active worker".
*/
_routeToWorker(workerID) {
const route = { name: 'workers', params: { workerID: workerID } };
this.$router.push(route);
},
/**
* Fetch worker info and set the active worker once it's received.
* @param {string} workerID worker ID, can be empty string for "no worker".
*/
_fetchWorker(workerID) {
if (!workerID) {
this.workers.deselectAllWorkers();
if (!this.workerID) {
this.workers.clearActiveWorker();
return;
}
return this.api.fetchWorker(workerID).then((worker) => this.workers.setActiveWorker(worker));
this.workers
.refreshTags()
.then(() =>
this.api.fetchWorker(this.workerID).then((worker) => this.workers.setActiveWorker(worker))
);
},
},
};