Web: first implementation of Workers overview

Show workers with their status, and allow clicking on a worker to activate
it and show its details (which currently is limited to just its ID). Does
include Vue Router handling of the active worker ID and CSS classes for
worker statuses.

This basically copies the `JobsTable` component to `workers/WorkersTable`.
The intention is that all the jobs-specific components will move into a
`jobs` subdirectory at some point.
This commit is contained in:
Sybren A. Stüvel 2022-05-30 18:50:58 +02:00
parent 08676f48f4
commit 88346d8244
9 changed files with 292 additions and 27 deletions

View File

@ -55,6 +55,14 @@
--color-status-cancel-requested: hsl(194 30% 50%);
--color-status-under-construction: hsl(194 30% 50%);
--color-worker-status-starting: hsl(68, 100%, 30%);
--color-worker-status-awake: var(--color-status-active);
--color-worker-status-asleep: hsl(194, 100%, 19%);
--color-worker-status-error: var(--color-status-failed);
--color-worker-status-shutdown: var(--color-status-paused);
--color-worker-status-testing: hsl(166 100% 46%);
--color-worker-status-offline: var(--color-status-canceled);
--color-connection-lost-text: hsl(17, 65%, 65%);
--color-connection-lost-bg: hsl(17, 65%, 20%);
}
@ -360,6 +368,28 @@ ul.status-filter-bar .status-filter-indicator .indicator {
--indicator-color: var(--color-status-under-construction);
}
.worker-status-starting {
--indicator-color: var(--color-worker-status-starting);
}
.worker-status-awake {
--indicator-color: var(--color-worker-status-awake);
}
.worker-status-asleep {
--indicator-color: var(--color-worker-status-asleep);
}
.worker-status-error {
--indicator-color: var(--color-worker-status-error);
}
.worker-status-shutdown {
--indicator-color: var(--color-worker-status-shutdown);
}
.worker-status-testing {
--indicator-color: var(--color-worker-status-testing);
}
.worker-status-offline {
--indicator-color: var(--color-worker-status-offline);
}
.status-archiving,
.status-active,
.status-queued,

View File

@ -2,7 +2,7 @@
import { computed } from 'vue'
import { indicator } from '@/statusindicator';
const props = defineProps(['availableStatuses', 'activeStatuses']);
const props = defineProps(['availableStatuses', 'activeStatuses', 'classPrefix']);
const emit = defineEmits(['click'])
/**
@ -23,7 +23,7 @@ const visibleStatuses = computed(() => {
:data-status="status"
:class="{active: activeStatuses.indexOf(status) >= 0}"
@click="emit('click', status)"
v-html="indicator(status)"
v-html="indicator(status, this.classPrefix)"
></li>
</ul>
</template>

View File

@ -4,9 +4,11 @@
<script>
import io from "socket.io-client";
import { ws } from '@/urls'
import * as API from "@/manager-api"
import { useSocketStatus } from '@/stores/socket-status';
const websocketURL = ws();
export default {
emits: [
@ -15,7 +17,7 @@ export default {
// SocketIO events:
"sioReconnected", "sioDisconnected"
],
props: ["websocketURL", "subscribedJobID", "subscribedTaskID"],
props: ["subscribedJobID", "subscribedTaskID"],
data() {
return {
socket: null,
@ -23,7 +25,7 @@ export default {
}
},
mounted: function () {
if (!this.websocketURL) {
if (!websocketURL) {
console.warn("UpdateListener: no websocketURL given, cannot do anything");
return;
}
@ -57,8 +59,8 @@ export default {
connectToWebsocket() {
// The SocketIO client API docs are available at:
// https://github.com/socketio/socket.io-client/blob/2.4.x/docs/API.md
console.log("connecting JobsListener to WS", this.websocketURL);
const ws = io(this.websocketURL, {
console.log("connecting JobsListener to WS", websocketURL);
const ws = io(websocketURL, {
transports: ["websocket"],
});
this.socket = ws;
@ -144,7 +146,7 @@ export default {
return;
}
console.log("disconnecting JobsListener WS", this.websocketURL);
console.log("disconnecting JobsListener WS", websocketURL);
this.socket.disconnect();
this.socket = null;
},

View File

@ -0,0 +1,160 @@
<template>
<div>
<h2 class="column-title">Workers</h2>
<status-filter-bar
:availableStatuses="availableStatuses"
:activeStatuses="shownStatuses"
classPrefix="worker-"
@click="toggleStatusFilter"
/>
<div class="workers-list with-clickable-row" id="flamenco_workers_list"></div>
</div>
</template>
<script lang="js">
import { TabulatorFull as Tabulator } from 'tabulator-tables';
import { WorkerMgtApi } from '@/manager-api'
import { indicator } from '@/statusindicator';
import { apiClient } from '@/stores/api-query-count';
import StatusFilterBar from '@/components/StatusFilterBar.vue'
export default {
name: 'WorkersTable',
props: ["activeWorkerID"],
emits: ["tableRowClicked"],
components: {
StatusFilterBar,
},
data: () => {
return {
shownStatuses: [],
availableStatuses: [], // Will be filled after data is loaded from the backend.
};
},
mounted() {
window.workersTableVue = this;
const vueComponent = this;
const options = {
// See pkg/api/flamenco-openapi.yaml, schemas WorkerSummary and SocketIOWorkerUpdate.
columns: [
// Useful for debugging when there are many similar workers:
// { title: "ID", field: "id", headerSort: false, formatter: (cell) => cell.getData().id.substr(0, 8), },
{
title: 'Status', field: 'status', sorter: 'string',
formatter: (cell) => indicator(cell.getData().status, 'worker-'),
},
{ title: 'Name', field: 'nickname', sorter: 'string' },
],
rowFormatter(row) {
const data = row.getData();
const isActive = (data.id === vueComponent.activeWorkerID);
row.getElement().classList.toggle("active-row", isActive);
},
initialSort: [
{ column: "nickname", dir: "asc" },
],
height: "720px", // Must be set in order for the virtual DOM to function correctly.
data: [], // Will be filled via a Flamenco API request.
selectable: false, // The active worker is tracked by click events, not row selection.
};
this.tabulator = new Tabulator('#flamenco_workers_list', options);
this.tabulator.on("rowClick", this.onRowClick);
this.tabulator.on("tableBuilt", this._onTableBuilt);
},
watch: {
activeWorkerID(newWorkerID, oldWorkerID) {
this._reformatRow(oldWorkerID);
this._reformatRow(newWorkerID);
},
},
computed: {
selectedIDs() {
return this.tabulator.getSelectedData().map((worker) => worker.id);
}
},
methods: {
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();
},
sortData() {
const tab = this.tabulator;
tab.setSort(tab.getSorters()); // This triggers re-sorting.
},
_onTableBuilt() {
this.tabulator.setFilter(this._filterByStatus);
this.fetchAllWorkers();
},
fetchAllWorkers() {
const api = new WorkerMgtApi(apiClient);
api.fetchWorkers().then(this.onWorkersFetched, function (error) {
// TODO: error handling.
console.error(error);
});
},
onWorkersFetched(data) {
this.tabulator.setData(data.workers);
this._refreshAvailableStatuses();
},
// processWorkerUpdate(workerUpdate) {
// // updateData() will only overwrite properties that are actually set on
// // workerUpdate, and leave the rest as-is.
// if (this.tabulator.initialized) {
// this.tabulator.updateData([workerUpdate])
// .then(this.sortData);
// }
// this._refreshAvailableStatuses();
// },
// processNewWorker(workerUpdate) {
// if (this.tabulator.initialized) {
// this.tabulator.updateData([workerUpdate])
// .then(this.sortData);
// }
// this.tabulator.addData([workerUpdate])
// .then(this.sortData);
// this._refreshAvailableStatuses();
// },
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);
},
toggleStatusFilter(status) {
const asSet = new Set(this.shownStatuses);
if (!asSet.delete(status)) {
asSet.add(status);
}
this.shownStatuses = Array.from(asSet).sort();
this.tabulator.refreshFilter();
},
_filterByStatus(worker) {
if (this.shownStatuses.length == 0) {
return true;
}
return this.shownStatuses.indexOf(worker.status) >= 0;
},
_refreshAvailableStatuses() {
const statuses = new Set();
for (let row of this.tabulator.getData()) {
statuses.add(row.status);
}
this.availableStatuses = Array.from(statuses).sort();
},
_reformatRow(workerID) {
// Use tab.rowManager.findRow() instead of `tab.getRow()` as the latter
// logs a warning when the row cannot be found.
const row = this.tabulator.rowManager.findRow(workerID);
if (!row) return
if (row.reformat) row.reformat();
else if (row.reinitialize) row.reinitialize(true);
},
},
};
</script>

View File

@ -15,9 +15,10 @@ const router = createRouter({
props: true,
},
{
path: '/workers',
path: '/workers/:workerID?',
name: 'workers',
component: () => import('../views/WorkersView.vue')
component: () => import('../views/WorkersView.vue'),
props: true,
},
{
path: '/settings',

View File

@ -8,9 +8,11 @@ import { toTitleCase } from '@/strings';
*
* @param {string} status The job/task status. Assumed to only consist of
* letters and dashes, HTML-safe, and valid as a CSS class name.
* @param {string} classNamePrefix optional prefix used for the class name
* @returns the HTML for the status indicator.
*/
export function indicator(status) {
export function indicator(status, classNamePrefix) {
const label = toTitleCase(status);
return `<span title="${label}" class="indicator status-${status}"></span>`;
if (!classNamePrefix) classNamePrefix = ""; // force an empty string for any false value.
return `<span title="${label}" class="indicator ${classNamePrefix}status-${status}"></span>`;
}

View File

@ -0,0 +1,47 @@
import { defineStore } from 'pinia'
import { WorkerMgtApi } from '@/manager-api';
import { apiClient } from '@/stores/api-query-count';
const api = new WorkerMgtApi(apiClient);
// 'use' prefix is idiomatic for Pinia stores.
// See https://pinia.vuejs.org/core-concepts/
export const useWorkers = defineStore('workers', {
state: () => ({
/** @type {API.Worker} */
activeWorker: null,
/**
* ID of the active worker. Easier to query than `activeWorker ? activeWorker.id : ""`.
* @type {string}
*/
activeWorkerID: "",
}),
actions: {
setActiveWorkerID(workerID) {
this.$patch({
activeWorker: {id: workerID, settings: {}, metadata: {}},
activeWorkerID: workerID,
});
},
setActiveWorker(worker) {
// The "function" form of $patch is necessary here, as otherwise it'll
// merge `worker` into `state.activeWorker`. As a result, it won't touch missing
// keys, which means that metadata fields that existed on the previous worker
// but not on the new one will still linger around. By passing a function
// to `$patch` this is resolved.
this.$patch((state) => {
state.activeWorker = worker;
state.activeWorkerID = worker.id;
state.hasChanged = true;
});
},
deselectAllWorkers() {
this.$patch({
activeWorker: null,
activeWorkerID: "",
});
},
},
})

View File

@ -13,7 +13,7 @@
<footer class="window-footer" v-if="!showFooterPopup" @click="showFooterPopup = true"><notification-bar /></footer>
<footer-popup v-if="showFooterPopup" ref="footerPopup" @clickClose="showFooterPopup = false" />
<update-listener ref="updateListener" :websocketURL="websocketURL"
<update-listener ref="updateListener"
:subscribedJobID="jobID" :subscribedTaskID="taskID"
@jobUpdate="onSioJobUpdate" @taskUpdate="onSioTaskUpdate" @taskLogUpdate="onSioTaskLogUpdate"
@message="onChatMessage"
@ -21,7 +21,6 @@
</template>
<script>
import * as urls from '@/urls'
import * as API from '@/manager-api';
import { useJobs } from '@/stores/jobs';
import { useTasks } from '@/stores/tasks';
@ -50,7 +49,6 @@ export default {
UpdateListener,
},
data: () => ({
websocketURL: urls.ws(),
messages: [],
jobs: useJobs(),

View File

@ -1,33 +1,44 @@
<template>
<div class="col col-1">
Workers
<div class="col col-workers-list">
<workers-table ref="workersTable" :activeWorkerID="workerID" @tableRowClicked="onTableWorkerClicked" />
</div>
<div class="col col-2">
Worker Details
</div>
<div class="col col-3">
Column 3
<div class="col col-workers-details">
Worker Details {{ workerID }}
</div>
<footer>
<notification-bar />
<update-listener ref="updateListener" :websocketURL="websocketURL" @sioReconnected="onSIOReconnected"
@sioDisconnected="onSIODisconnected" />
<update-listener ref="updateListener"
@sioReconnected="onSIOReconnected" @sioDisconnected="onSIODisconnected" />
</footer>
</template>
<style scoped>
.col-workers-list {
grid-column-start: col-1;
grid-column-end: col-2;
}
.col-workers-2 {
grid-area: col-3;
}
</style>
<script>
import * as urls from '@/urls'
import { useWorkers } from '@/stores/workers';
import NotificationBar from '@/components/footer/NotificationBar.vue'
import UpdateListener from '@/components/UpdateListener.vue'
import WorkersTable from '@/components/workers/WorkersTable.vue'
export default {
name: 'WorkersView',
props: ["workerID"], // provided by Vue Router.
components: {
NotificationBar, UpdateListener,
NotificationBar,
UpdateListener,
WorkersTable,
},
data: () => ({
websocketURL: urls.ws(),
workers: useWorkers(),
}),
mounted() {
window.workersView = this;
@ -38,6 +49,20 @@ export default {
},
onSIODisconnected(reason) {
},
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 } };
console.log("routing to worker", route.params);
this.$router.push(route);
},
},
}
</script>