Web: add support for worker clusters

The support is still fairly minimal. Clusters cannot be managed via the
webapp yet, so the API has to be used directly for that. Workers can be
assigned to clusters via the webapp though.
This commit is contained in:
Sybren A. Stüvel 2023-04-04 12:19:14 +02:00
parent 8408d28a6b
commit 3306c7fc8d
5 changed files with 137 additions and 6 deletions

View File

@ -7,6 +7,7 @@
*/ */
const flashAfterCopyDuration = 150; const flashAfterCopyDuration = 150;
/** /**
* Copy the inner text of an element to the clipboard. * Copy the inner text of an element to the clipboard.
* *
@ -14,9 +15,24 @@ const flashAfterCopyDuration = 150;
*/ */
export function copyElementText(clickEvent) { export function copyElementText(clickEvent) {
const sourceElement = clickEvent.target; const sourceElement = clickEvent.target;
copyElementValue(sourceElement, sourceElement.innerText);
}
/**
* Copy the inner text of an element to the clipboard.
*
* @param {Event } clickEvent the click event that triggered this function call.
*/
export function copyElementData(clickEvent) {
const sourceElement = clickEvent.target;
window.sourceElement = sourceElement;
copyElementValue(sourceElement, sourceElement.dataset.clipboard);
}
function copyElementValue(sourceElement, value) {
const inputElement = document.createElement("input"); const inputElement = document.createElement("input");
document.body.appendChild(inputElement); document.body.appendChild(inputElement);
inputElement.setAttribute("value", sourceElement.innerText); inputElement.setAttribute("value", value);
inputElement.select(); inputElement.select();
// Note that the `navigator.clipboard` interface is only available when using // Note that the `navigator.clipboard` interface is only available when using
@ -27,7 +43,6 @@ export function copyElementText(clickEvent) {
document.execCommand("copy"); document.execCommand("copy");
document.body.removeChild(inputElement); document.body.removeChild(inputElement);
flashElement(sourceElement); flashElement(sourceElement);
} }

View File

@ -1,5 +1,5 @@
<template> <template>
<label> <label :title="title">
<span class="switch"> <span class="switch">
<input type="checkbox" :checked="isChecked" @change="$emit('switchToggle')"> <input type="checkbox" :checked="isChecked" @change="$emit('switchToggle')">
<span class="slider round"></span> <span class="slider round"></span>
@ -9,7 +9,7 @@
</template> </template>
<script setup> <script setup>
const props = defineProps(['isChecked', 'label']); const props = defineProps(['isChecked', 'label', 'title']);
</script> </script>
<style scoped> <style scoped>

View File

@ -32,6 +32,14 @@
<dt class="field-name" title="ID">ID</dt> <dt class="field-name" title="ID">ID</dt>
<dd><span @click="copyElementText" class="click-to-copy">{{ jobData.id }}</span></dd> <dd><span @click="copyElementText" class="click-to-copy">{{ jobData.id }}</span></dd>
<template v-if="workerCluster">
<!-- TODO: fetch cluster name and show that instead, and allow editing of the cluster. -->
<dt class="field-name" title="Worker Cluster">Cluster</dt>
<dd :title="workerCluster.description"><span @click="copyElementData" class="click-to-copy"
:data-clipboard="workerCluster.id">{{
workerCluster.name }}</span></dd>
</template>
<dt class="field-name" title="Name">Name</dt> <dt class="field-name" title="Name">Name</dt>
<dd>{{ jobData.name }}</dd> <dd>{{ jobData.name }}</dd>
@ -82,7 +90,8 @@ import Blocklist from './Blocklist.vue'
import TabItem from '@/components/TabItem.vue' import TabItem from '@/components/TabItem.vue'
import TabsWrapper from '@/components/TabsWrapper.vue' import TabsWrapper from '@/components/TabsWrapper.vue'
import PopoverEditableJobPriority from '@/components/PopoverEditableJobPriority.vue' import PopoverEditableJobPriority from '@/components/PopoverEditableJobPriority.vue'
import { copyElementText } from '@/clipboard'; import { copyElementText, copyElementData } from '@/clipboard';
import { useWorkers } from '@/stores/workers'
export default { export default {
props: [ props: [
@ -102,11 +111,13 @@ export default {
return { return {
datetime: datetime, // So that the template can access it. datetime: datetime, // So that the template can access it.
copyElementText: copyElementText, copyElementText: copyElementText,
copyElementData: copyElementData,
simpleSettings: null, // Object with filtered job settings, or null if there is no job. simpleSettings: null, // Object with filtered job settings, or null if there is no job.
jobsApi: new API.JobsApi(getAPIClient()), jobsApi: new API.JobsApi(getAPIClient()),
jobType: null, // API.AvailableJobType object for the current job type. jobType: null, // API.AvailableJobType object for the current job type.
jobTypeSettings: null, // Mapping from setting key to its definition in the job type. jobTypeSettings: null, // Mapping from setting key to its definition in the job type.
showAllSettings: false, showAllSettings: false,
workers: useWorkers(),
}; };
}, },
mounted() { mounted() {
@ -116,6 +127,12 @@ export default {
if (!objectEmpty(this.jobData)) { if (!objectEmpty(this.jobData)) {
this._refreshJobSettings(this.jobData); this._refreshJobSettings(this.jobData);
} }
this.workers.refreshClusters()
.catch((error) => {
const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
this.notifs.add(`Error: ${errorMsg}`);
});
}, },
computed: { computed: {
hasJobData() { hasJobData() {
@ -139,6 +156,10 @@ export default {
} }
return this.jobData.settings; return this.jobData.settings;
}, },
workerCluster() {
if (!this.jobData.worker_cluster) return undefined;
return this.workers.clustersByID[this.jobData.worker_cluster];
},
}, },
watch: { watch: {
jobData(newJobData) { jobData(newJobData) {

View File

@ -34,6 +34,20 @@
</dd> </dd>
</dl> </dl>
<section class="worker-clusters" v-if="workers.clusters">
<h3 class="sub-title">Clusters</h3>
<ul>
<li v-for="cluster in workers.clusters">
<switch-checkbox :isChecked="thisWorkerClusters[cluster.id]" :label="cluster.name" :title="cluster.description"
@switch-toggle="toggleWorkerCluster(cluster.id)">
</switch-checkbox>
</li>
</ul>
<p class="hint">
When a worker is assigned to one or more cluster, it will ignore jobs assigned to other clusters.
</p>
</section>
<section class="sleep-schedule" :class="{ 'is-schedule-active': workerSleepSchedule.is_active }"> <section class="sleep-schedule" :class="{ 'is-schedule-active': workerSleepSchedule.is_active }">
<h3 class="sub-title"> <h3 class="sub-title">
<switch-checkbox :isChecked="workerSleepSchedule.is_active" @switch-toggle="toggleWorkerSleepSchedule"> <switch-checkbox :isChecked="workerSleepSchedule.is_active" @switch-toggle="toggleWorkerSleepSchedule">
@ -120,9 +134,10 @@
<script> <script>
import { useNotifs } from '@/stores/notifications' import { useNotifs } from '@/stores/notifications'
import { useWorkers } from '@/stores/workers'
import * as datetime from "@/datetime"; import * as datetime from "@/datetime";
import { WorkerMgtApi, WorkerSleepSchedule } from '@/manager-api'; import { WorkerMgtApi, WorkerSleepSchedule, WorkerClusterChangeRequest } from '@/manager-api';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from "@/api-client";
import { workerStatus } from "../../statusindicator"; import { workerStatus } from "../../statusindicator";
import LinkWorkerTask from '@/components/LinkWorkerTask.vue'; import LinkWorkerTask from '@/components/LinkWorkerTask.vue';
@ -146,11 +161,19 @@ export default {
isScheduleEditing: false, isScheduleEditing: false,
notifs: useNotifs(), notifs: useNotifs(),
copyElementText: copyElementText, copyElementText: copyElementText,
workers: useWorkers(),
thisWorkerClusters: {}, // Mapping from UUID to 'isAssigned' boolean.
}; };
}, },
mounted() { mounted() {
// Allow testing from the JS console: // Allow testing from the JS console:
window.workerDetailsVue = this; window.workerDetailsVue = this;
this.workers.refreshClusters()
.catch((error) => {
const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
this.notifs.add(`Error: ${errorMsg}`);
});
}, },
watch: { watch: {
workerData(newData, oldData) { workerData(newData, oldData) {
@ -164,6 +187,8 @@ export default {
if (((oldData && newData) && (oldData.id != newData.id)) || !oldData && newData) { if (((oldData && newData) && (oldData.id != newData.id)) || !oldData && newData) {
this.fetchWorkerSleepSchedule(); this.fetchWorkerSleepSchedule();
} }
this.updateThisWorkerClusters(newData);
}, },
}, },
computed: { computed: {
@ -230,6 +255,41 @@ export default {
} }
this.api.deleteWorker(this.workerData.id); this.api.deleteWorker(this.workerData.id);
}, },
updateThisWorkerClusters(newWorkerData) {
if (!newWorkerData || !newWorkerData.clusters) {
this.thisWorkerClusters = {};
return;
}
const assignedClusters = newWorkerData.clusters.reduce(
(accu, cluster) => { accu[cluster.id] = true; return accu; },
{});
this.thisWorkerClusters = assignedClusters;
},
toggleWorkerCluster(clusterID) {
console.log("Toggled", clusterID);
this.thisWorkerClusters[clusterID] = !this.thisWorkerClusters[clusterID];
console.log("New assignment:", plain(this.thisWorkerClusters))
// Construct cluster change request.
const clusterIDs = [];
for (clusterID in this.thisWorkerClusters) {
// Values can exist and be set to 'false'.
const isAssigned = this.thisWorkerClusters[clusterID];
if (isAssigned) clusterIDs.push(clusterID);
}
// Send to the Manager.
const changeRequest = new WorkerClusterChangeRequest(clusterIDs);
this.api.setWorkerClusters(this.workerData.id, changeRequest)
.then(() => {
this.notifs.add('Cluster assignment updated');
})
.catch((error) => {
const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
this.notifs.add(`Error: ${errorMsg}`);
});
},
} }
}; };
</script> </script>
@ -305,4 +365,12 @@ export default {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.worker-clusters ul {
list-style: none;
}
.worker-clusters ul li {
margin-bottom: 0.25rem;
}
</style> </style>

View File

@ -1,5 +1,8 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { WorkerMgtApi } from '@/manager-api';
import { getAPIClient } from "@/api-client";
// '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 useWorkers = defineStore('workers', { export const useWorkers = defineStore('workers', {
@ -11,6 +14,12 @@ export const useWorkers = defineStore('workers', {
* @type {string} * @type {string}
*/ */
activeWorkerID: "", activeWorkerID: "",
/** @type {API.WorkerCluster[]} */
clusters: [],
/* Mapping from cluster UUID to API.WorkerCluster. */
clustersByID: {},
}), }),
actions: { actions: {
setActiveWorkerID(workerID) { setActiveWorkerID(workerID) {
@ -37,5 +46,23 @@ export const useWorkers = defineStore('workers', {
activeWorkerID: "", activeWorkerID: "",
}); });
}, },
/**
* Fetch the available worker clusters from the Manager.
*
* @returns a promise.
*/
refreshClusters() {
const api = new WorkerMgtApi(getAPIClient());
return api.fetchWorkerClusters()
.then((resp) => {
this.clusters = resp.clusters;
let clustersByID = {};
for (let cluster of this.clusters) {
clustersByID[cluster.id] = cluster;
}
this.clustersByID = clustersByID;
})
},
}, },
}) })