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:
parent
29e0eefdd1
commit
bd2ebac519
@ -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);
|
||||
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}`);
|
||||
});
|
||||
|
||||
const promises = workers.selectedWorkers.map((worker) =>
|
||||
api
|
||||
.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>
|
||||
|
@ -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();
|
||||
},
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Fetch all workers
|
||||
*/
|
||||
fetchAllWorkers() {
|
||||
const api = new WorkerMgtApi(getAPIClient());
|
||||
api.fetchWorkers().then(this.onWorkersFetched, function (error) {
|
||||
// TODO: error handling.
|
||||
console.error(error);
|
||||
});
|
||||
return this.api
|
||||
.fetchWorkers()
|
||||
.then((data) => data.workers)
|
||||
.catch((e) => {
|
||||
throw new Error('Unable to fetch all workers:', e);
|
||||
});
|
||||
},
|
||||
onWorkersFetched(data) {
|
||||
this.tabulator.setData(data.workers);
|
||||
this._refreshAvailableStatuses();
|
||||
this.recalcTableHeight();
|
||||
},
|
||||
processWorkerUpdate(workerUpdate) {
|
||||
async processWorkerUpdate(workerUpdate) {
|
||||
if (!this.tabulator.initialized) return;
|
||||
|
||||
// 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);
|
||||
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:
|
||||
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`
|
||||
// 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) {
|
||||
// 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 {
|
||||
await this.tabulator.addData([workerUpdate]); // Add a new row.
|
||||
}
|
||||
} else {
|
||||
promise = this.tabulator.addData([workerUpdate]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// TODO: this should also resize the columns, as the status column can
|
||||
// change sizes considerably.
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
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.
|
||||
},
|
||||
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);
|
||||
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -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))
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user