Webapp: tweak the .editorconfig and .prettierrc files + re-format

Try to get the `.editorconfig` and `.prettierrc` files as close as possible
to the formatting that was used in Flamenco. Because these files weren't
here during most of Flamenco's development so far, having them caused quite
a few changes in the webapp files.

No functional changes intended.
This commit is contained in:
Sybren A. Stüvel 2023-09-11 17:22:18 +02:00
parent 68c55f97be
commit 819767ea1a
55 changed files with 1132 additions and 914 deletions

@ -8,6 +8,7 @@ insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
max_line_length = 100
[*.go] [*.go]
indent_style = tab indent_style = tab

@ -3,6 +3,7 @@
"useTabs": false, "useTabs": false,
"singleQuote": true, "singleQuote": true,
"semi": true, "semi": true,
"bracketSpacing": false, "bracketSpacing": true,
"bracketSameLine": true,
"arrowParens": "always" "arrowParens": "always"
} }

@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel='icon' href='/favicon.ico'> <link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon-precomposed" type='image/png' href="/apple-touch-icon.png" /> <link rel="apple-touch-icon-precomposed" type="image/png" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#8982c9"> <meta name="theme-color" content="#8982c9" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flamenco Manager</title> <title>Flamenco Manager</title>
</head> </head>

@ -20,8 +20,7 @@
<api-spinner /> <api-spinner />
<span class="app-version"> <span class="app-version">
<a :href="backendURL('/flamenco-addon.zip')">add-on</a> <a :href="backendURL('/flamenco-addon.zip')">add-on</a>
| <a :href="backendURL('/api/v3/swagger-ui/')">API</a> | <a :href="backendURL('/api/v3/swagger-ui/')">API</a> | version: {{ flamencoVersion }}
| version: {{ flamencoVersion }}
</span> </span>
</header> </header>
<router-view></router-view> <router-view></router-view>
@ -29,14 +28,14 @@
<script> <script>
import * as API from '@/manager-api'; import * as API from '@/manager-api';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
import { backendURL } from '@/urls'; import { backendURL } from '@/urls';
import { useSocketStatus } from '@/stores/socket-status'; import { useSocketStatus } from '@/stores/socket-status';
import ApiSpinner from '@/components/ApiSpinner.vue'; import ApiSpinner from '@/components/ApiSpinner.vue';
const DEFAULT_FLAMENCO_NAME = "Flamenco"; const DEFAULT_FLAMENCO_NAME = 'Flamenco';
const DEFAULT_FLAMENCO_VERSION = "unknown"; const DEFAULT_FLAMENCO_VERSION = 'unknown';
export default { export default {
name: 'App', name: 'App',
@ -53,12 +52,14 @@ export default {
this.fetchManagerInfo(); this.fetchManagerInfo();
const sockStatus = useSocketStatus(); const sockStatus = useSocketStatus();
this.$watch(() => sockStatus.isConnected, (isConnected) => { this.$watch(
if (!isConnected) return; () => sockStatus.isConnected,
if (!sockStatus.wasEverDisconnected) return; (isConnected) => {
this.socketIOReconnect(); if (!isConnected) return;
}); if (!sockStatus.wasEverDisconnected) return;
this.socketIOReconnect();
}
);
}, },
methods: { methods: {
fetchManagerInfo() { fetchManagerInfo() {
@ -67,23 +68,22 @@ export default {
this.flamencoName = version.name; this.flamencoName = version.name;
this.flamencoVersion = version.version; this.flamencoVersion = version.version;
document.title = version.name; document.title = version.name;
}) });
}, },
socketIOReconnect() { socketIOReconnect() {
const metaAPI = new API.MetaApi(getAPIClient()) const metaAPI = new API.MetaApi(getAPIClient());
metaAPI.getVersion().then((version) => { metaAPI.getVersion().then((version) => {
if (version.name === this.flamencoName && version.version == this.flamencoVersion) if (version.name === this.flamencoName && version.version == this.flamencoVersion) return;
return;
console.log(`Updated from ${this.flamencoVersion} to ${version.version}`); console.log(`Updated from ${this.flamencoVersion} to ${version.version}`);
location.reload(); location.reload();
}); });
}, },
}, },
} };
</script> </script>
<style> <style>
@import "assets/base.css"; @import 'assets/base.css';
@import "assets/tabulator.css"; @import 'assets/tabulator.css';
</style> </style>

@ -12,12 +12,11 @@
</template> </template>
<script> <script>
const DEFAULT_FLAMENCO_NAME = 'Flamenco';
const DEFAULT_FLAMENCO_NAME = "Flamenco"; const DEFAULT_FLAMENCO_VERSION = 'unknown';
const DEFAULT_FLAMENCO_VERSION = "unknown"; import ApiSpinner from '@/components/ApiSpinner.vue';
import ApiSpinner from '@/components/ApiSpinner.vue' import { MetaApi } from '@/manager-api';
import { MetaApi } from "@/manager-api"; import { getAPIClient } from '@/api-client';
import { getAPIClient } from "@/api-client";
export default { export default {
name: 'SetupAssistant', name: 'SetupAssistant',
@ -39,13 +38,13 @@ export default {
metaAPI.getVersion().then((version) => { metaAPI.getVersion().then((version) => {
this.flamencoName = version.name; this.flamencoName = version.name;
this.flamencoVersion = version.version; this.flamencoVersion = version.version;
}) });
}, },
}, },
} };
</script> </script>
<style> <style>
@import "assets/base.css"; @import 'assets/base.css';
@import "assets/tabulator.css"; @import 'assets/tabulator.css';
</style> </style>

@ -1,6 +1,6 @@
import { ApiClient } from "@/manager-api"; import { ApiClient } from '@/manager-api';
import { CountingApiClient } from "@/stores/api-query-count"; import { CountingApiClient } from '@/stores/api-query-count';
import { api as apiURL } from '@/urls' import { api as apiURL } from '@/urls';
/** /**
* Scrub the custom User-Agent header from the API client, for those webbrowsers * Scrub the custom User-Agent header from the API client, for those webbrowsers

@ -1,9 +1,9 @@
import { DateTime } from "luxon"; import { DateTime } from 'luxon';
// Do a full refresh once per hour. This is just to make sure that long-lived // Do a full refresh once per hour. This is just to make sure that long-lived
// displays (like the TV in the hallway at Blender HQ) pick up on HTML/JS/CSS // displays (like the TV in the hallway at Blender HQ) pick up on HTML/JS/CSS
// changes eventually. // changes eventually.
const reloadAfter = {minute: 60}; const reloadAfter = { minute: 60 };
function getReloadDeadline() { function getReloadDeadline() {
return DateTime.now().plus(reloadAfter); return DateTime.now().plus(reloadAfter);
@ -27,10 +27,10 @@ export default function autoreload() {
// Check whether reloading is needed every minute. // Check whether reloading is needed every minute.
window.setInterval(maybeReload, 60 * 1000); window.setInterval(maybeReload, 60 * 1000);
window.addEventListener("resize", deferReload); window.addEventListener('resize', deferReload);
window.addEventListener("mousedown", deferReload); window.addEventListener('mousedown', deferReload);
window.addEventListener("mouseup", deferReload); window.addEventListener('mouseup', deferReload);
window.addEventListener("mousemove", deferReload); window.addEventListener('mousemove', deferReload);
window.addEventListener("keydown", deferReload); window.addEventListener('keydown', deferReload);
window.addEventListener("keyup", deferReload); window.addEventListener('keyup', deferReload);
} }

@ -1,4 +1,3 @@
/** /**
* The duration in milliseconds of the "flash" effect, when an element has been * The duration in milliseconds of the "flash" effect, when an element has been
* copied. * copied.
@ -7,7 +6,6 @@
*/ */
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.
* *
@ -30,9 +28,9 @@ export function copyElementData(clickEvent) {
} }
function copyElementValue(sourceElement, value) { 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", value); 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
@ -40,15 +38,15 @@ function copyElementValue(sourceElement, value) {
// This is why this code falls back to the deprecated `document.execCommand()` // This is why this code falls back to the deprecated `document.execCommand()`
// call. // call.
// Source: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard // Source: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard
document.execCommand("copy"); document.execCommand('copy');
document.body.removeChild(inputElement); document.body.removeChild(inputElement);
flashElement(sourceElement); flashElement(sourceElement);
} }
function flashElement(element) { function flashElement(element) {
element.classList.add("copied"); element.classList.add('copied');
window.setTimeout(() => { window.setTimeout(() => {
element.classList.remove("copied"); element.classList.remove('copied');
}, 150); }, 150);
} }

@ -29,7 +29,7 @@ span {
.spinner { .spinner {
-webkit-animation: rotate 2s linear infinite; -webkit-animation: rotate 2s linear infinite;
animation: rotate 2s linear infinite; animation: rotate 2s linear infinite;
z-index: 2; z-index: 2;
position: absolute; position: absolute;
top: 50%; top: 50%;
@ -42,7 +42,7 @@ span {
stroke: var(--color-text-hint); stroke: var(--color-text-hint);
stroke-linecap: round; stroke-linecap: round;
-webkit-animation: dash 1.5s ease-in-out infinite; -webkit-animation: dash 1.5s ease-in-out infinite;
animation: dash 1.5s ease-in-out infinite; animation: dash 1.5s ease-in-out infinite;
} }
@-webkit-keyframes rotate { @-webkit-keyframes rotate {

@ -1,5 +1,7 @@
<template> <template>
<span class='socket-status' v-if="!sockStatus.isConnected" :title="sockStatus.message">Connection Lost</span> <span class="socket-status" v-if="!sockStatus.isConnected" :title="sockStatus.message"
>Connection Lost</span
>
</template> </template>
<script setup> <script setup>

@ -2,9 +2,15 @@
<div class="details-no-item-selected"> <div class="details-no-item-selected">
<div class="get-the-addon"> <div class="get-the-addon">
<p>Get the Blender add-on and submit a job.</p> <p>Get the Blender add-on and submit a job.</p>
<p><a class="btn btn-primary" :href="backendURL('/flamenco-addon.zip')">Get the add-on!</a></p> <p>
<a class="btn btn-primary" :href="backendURL('/flamenco-addon.zip')">Get the add-on!</a>
</p>
<p>Use the URL below in the add-on preferences. Click on it to copy.</p> <p>Use the URL below in the add-on preferences. Click on it to copy.</p>
<p><span class="click-to-copy" title="Click to copy this URL" @click="copyElementText">{{ api() }}</span></p> <p>
<span class="click-to-copy" title="Click to copy this URL" @click="copyElementText">{{
api()
}}</span>
</p>
</div> </div>
</div> </div>
</template> </template>

@ -5,12 +5,12 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue';
// 'worker' should be a Worker or TaskWorker (see schemas defined in `flamenco-openapi.yaml`). // 'worker' should be a Worker or TaskWorker (see schemas defined in `flamenco-openapi.yaml`).
const props = defineProps(['worker']); const props = defineProps(['worker']);
const workerAddress = computed(() => { const workerAddress = computed(() => {
if (props.worker.address) return `(${props.worker.address})`; if (props.worker.address) return `(${props.worker.address})`;
return ""; return '';
}); });
</script> </script>

@ -1,8 +1,13 @@
<template> <template>
<span> <span>
<router-link v-if="workerTask" :to="{ name: 'jobs', params: { jobID: workerTask.job_id, taskID: workerTask.id } }"> <router-link
v-if="workerTask"
:to="{ name: 'jobs', params: { jobID: workerTask.job_id, taskID: workerTask.id } }">
{{ workerTask.name }} {{ workerTask.name }}
(<span class="status-label" :class="'status-' + workerTask.status">{{ workerTask.status }}</span>) (<span class="status-label" :class="'status-' + workerTask.status">{{
workerTask.status
}}</span
>)
</router-link> </router-link>
<span v-else>-</span> <span v-else>-</span>
</span> </span>

@ -9,12 +9,10 @@
<button @click="togglePopover">&#10006;</button> <button @click="togglePopover">&#10006;</button>
</div> </div>
<div class="popover-form"> <div class="popover-form">
<input type="number" v-model="priorityState"> <input type="number" v-model="priorityState" />
<button @click="updateJobPriority" class="btn-primary">Set</button> <button @click="updateJobPriority" class="btn-primary">Set</button>
</div> </div>
<div class="input-help-text"> <div class="input-help-text">Range 1-100.</div>
Range 1-100.
</div>
<div class="popover-error" v-if="errorMessage"> <div class="popover-error" v-if="errorMessage">
<span>{{ errorMessage }}</span> <span>{{ errorMessage }}</span>
</div> </div>
@ -26,12 +24,12 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { useNotifs } from '@/stores/notifications'; import { useNotifs } from '@/stores/notifications';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
import { JobsApi, JobPriorityChange } from '@/manager-api'; import { JobsApi, JobPriorityChange } from '@/manager-api';
const props = defineProps({ const props = defineProps({
jobId: String, jobId: String,
priority: Number priority: Number,
}); });
// Init notification state // Init notification state
@ -46,9 +44,10 @@ const errorMessage = ref('');
function updateJobPriority() { function updateJobPriority() {
const jobPriorityChange = new JobPriorityChange(priorityState.value); const jobPriorityChange = new JobPriorityChange(priorityState.value);
const jobsAPI = new JobsApi(getAPIClient()); const jobsAPI = new JobsApi(getAPIClient());
return jobsAPI.setJobPriority(props.jobId, jobPriorityChange) return jobsAPI
.setJobPriority(props.jobId, jobPriorityChange)
.then(() => { .then(() => {
notifs.add(`Updated job priority to ${priorityState.value}`) notifs.add(`Updated job priority to ${priorityState.value}`);
showPopover.value = false; showPopover.value = false;
errorMessage.value = ''; errorMessage.value = '';
}) })
@ -122,7 +121,7 @@ function togglePopover() {
/* Save/Set button. */ /* Save/Set button. */
.popover-form button { .popover-form button {
flex: 1 flex: 1;
} }
.input-help-text { .input-help-text {

@ -1,9 +1,9 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue';
import { indicator } from '@/statusindicator'; import { indicator } from '@/statusindicator';
const props = defineProps(['availableStatuses', 'activeStatuses', 'classPrefix']); const props = defineProps(['availableStatuses', 'activeStatuses', 'classPrefix']);
const emit = defineEmits(['click']) const emit = defineEmits(['click']);
/** /**
* visibleStatuses is a union between `availableStatuses` and `activeStatuses`, * visibleStatuses is a union between `availableStatuses` and `activeStatuses`,
@ -14,17 +14,17 @@ const visibleStatuses = computed(() => {
const available = props.availableStatuses; const available = props.availableStatuses;
const unavailable = props.activeStatuses.filter((status) => available.indexOf(status) == -1); const unavailable = props.activeStatuses.filter((status) => available.indexOf(status) == -1);
return available.concat(unavailable); return available.concat(unavailable);
}) });
</script> </script>
<template> <template>
<ul class="status-filter-bar" <ul class="status-filter-bar" :class="{ 'is-filtered': activeStatuses.length > 0 }">
:class="{'is-filtered': activeStatuses.length > 0}"> <li
<li v-for="status in visibleStatuses" class="status-filter-indicator" v-for="status in visibleStatuses"
class="status-filter-indicator"
:data-status="status" :data-status="status"
:class="{active: activeStatuses.indexOf(status) >= 0}" :class="{ active: activeStatuses.indexOf(status) >= 0 }"
@click="emit('click', status)" @click="emit('click', status)"
v-html="indicator(status, classPrefix)" v-html="indicator(status, classPrefix)"></li>
></li>
</ul> </ul>
</template> </template>

@ -1,7 +1,7 @@
<template> <template>
<label :title="title"> <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>
</span> </span>
<span class="switch-label">{{ label }}</span> <span class="switch-label">{{ label }}</span>
@ -13,7 +13,6 @@ const props = defineProps(['isChecked', 'label', 'title']);
</script> </script>
<style scoped> <style scoped>
label { label {
display: inline-block; display: inline-block;
} }
@ -38,20 +37,20 @@ label {
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: var(--color-text-muted); background-color: var(--color-text-muted);
-webkit-transition: .4s; -webkit-transition: 0.4s;
transition: .4s; transition: 0.4s;
} }
.slider:before { .slider:before {
position: absolute; position: absolute;
content: ""; content: '';
height: 10px; height: 10px;
width: 10px; width: 10px;
left: 4px; left: 4px;
bottom: 2px; bottom: 2px;
background-color: white; background-color: white;
-webkit-transition: .4s; -webkit-transition: 0.4s;
transition: .4s; transition: 0.4s;
} }
input:checked + .slider { input:checked + .slider {
@ -80,5 +79,4 @@ input:checked + .slider:before {
margin-left: 0.5rem; margin-left: 0.5rem;
cursor: pointer; cursor: pointer;
} }
</style> </style>

@ -1,11 +1,11 @@
<script setup> <script setup>
import { inject, computed, provide } from "vue"; import { inject, computed, provide } from 'vue';
const props = defineProps({ const props = defineProps({
title: String, title: String,
}); });
const selectedTitle = inject("selectedTitle"); const selectedTitle = inject('selectedTitle');
const isVisible = computed(() => selectedTitle.value === props.title) const isVisible = computed(() => selectedTitle.value === props.title);
provide("isVisible", isVisible); provide('isVisible', isVisible);
</script> </script>
<template> <template>

@ -1,17 +1,16 @@
<script setup> <script setup>
import { useSlots, ref, provide } from "vue"; import { useSlots, ref, provide } from 'vue';
const emit = defineEmits(['clickedJobDetailsTab',]) const emit = defineEmits(['clickedJobDetailsTab']);
const slots = useSlots(); const slots = useSlots();
const tabTitles = ref(slots.default().map((tab) => tab.props.title)); const tabTitles = ref(slots.default().map((tab) => tab.props.title));
const selectedTitle = ref(tabTitles.value[0]); const selectedTitle = ref(tabTitles.value[0]);
provide("selectedTitle", selectedTitle); provide('selectedTitle', selectedTitle);
function updateTabTitle(title) { function updateTabTitle(title) {
selectedTitle.value = title; selectedTitle.value = title;
emit('clickedJobDetailsTab'); emit('clickedJobDetailsTab');
} }
</script> </script>
<template> <template>
@ -22,8 +21,7 @@ function updateTabTitle(title) {
:key="title" :key="title"
class="tab-item" class="tab-item"
:class="{ active: selectedTitle === title }" :class="{ active: selectedTitle === title }"
@click="updateTabTitle(title)" @click="updateTabTitle(title)">
>
{{ title }} {{ title }}
</li> </li>
</ul> </ul>
@ -47,7 +45,8 @@ nav {
color: var(--color-text-hint); color: var(--color-text-hint);
cursor: pointer; cursor: pointer;
padding: var(--spacer-sm) 0; padding: var(--spacer-sm) 0;
transition: border-color var(--transition-speed) ease-in-out, color var(--transition-speed) ease-in-out; transition: border-color var(--transition-speed) ease-in-out,
color var(--transition-speed) ease-in-out;
user-select: none; user-select: none;
} }

@ -3,9 +3,9 @@
</template> </template>
<script> <script>
import io from "socket.io-client"; import io from 'socket.io-client';
import { ws } from '@/urls' import { ws } from '@/urls';
import * as API from "@/manager-api" import * as API from '@/manager-api';
import { useSocketStatus } from '@/stores/socket-status'; import { useSocketStatus } from '@/stores/socket-status';
const websocketURL = ws(); const websocketURL = ws();
@ -13,26 +13,32 @@ const websocketURL = ws();
export default { export default {
emits: [ emits: [
// Data from Flamenco Manager: // Data from Flamenco Manager:
"jobUpdate", "taskUpdate", "taskLogUpdate", "message", "workerUpdate", 'jobUpdate',
"lastRenderedUpdate", "workerTagUpdate", 'taskUpdate',
'taskLogUpdate',
'message',
'workerUpdate',
'lastRenderedUpdate',
'workerTagUpdate',
// SocketIO events: // SocketIO events:
"sioReconnected", "sioDisconnected" 'sioReconnected',
'sioDisconnected',
], ],
props: [ props: [
"mainSubscription", // One of the 'allXXX' subscription types, see `SocketIOSubscriptionType` in `flamenco-openapi.yaml`. 'mainSubscription', // One of the 'allXXX' subscription types, see `SocketIOSubscriptionType` in `flamenco-openapi.yaml`.
"extraSubscription", // One of the 'allXXX' subscription types, see `SocketIOSubscriptionType` in `flamenco-openapi.yaml`. 'extraSubscription', // One of the 'allXXX' subscription types, see `SocketIOSubscriptionType` in `flamenco-openapi.yaml`.
"subscribedJobID", 'subscribedJobID',
"subscribedTaskID", 'subscribedTaskID',
], ],
data() { data() {
return { return {
socket: null, socket: null,
sockStatus: useSocketStatus(), sockStatus: useSocketStatus(),
} };
}, },
mounted: function () { mounted: function () {
if (!websocketURL) { if (!websocketURL) {
console.warn("UpdateListener: no websocketURL given, cannot do anything"); console.warn('UpdateListener: no websocketURL given, cannot do anything');
return; return;
} }
this.connectToWebsocket(); this.connectToWebsocket();
@ -46,34 +52,34 @@ export default {
watch: { watch: {
subscribedJobID(newJobID, oldJobID) { subscribedJobID(newJobID, oldJobID) {
if (oldJobID) { if (oldJobID) {
this._updateJobSubscription("unsubscribe", oldJobID); this._updateJobSubscription('unsubscribe', oldJobID);
} }
if (newJobID) { if (newJobID) {
this._updateJobSubscription("subscribe", newJobID); this._updateJobSubscription('subscribe', newJobID);
} }
}, },
subscribedTaskID(newTaskID, oldTaskID) { subscribedTaskID(newTaskID, oldTaskID) {
if (oldTaskID) { if (oldTaskID) {
this._updateTaskLogSubscription("unsubscribe", oldTaskID); this._updateTaskLogSubscription('unsubscribe', oldTaskID);
} }
if (newTaskID) { if (newTaskID) {
this._updateTaskLogSubscription("subscribe", newTaskID); this._updateTaskLogSubscription('subscribe', newTaskID);
} }
}, },
mainSubscription(newType, oldType) { mainSubscription(newType, oldType) {
if (oldType) { if (oldType) {
this._updateMainSubscription("unsubscribe", oldType); this._updateMainSubscription('unsubscribe', oldType);
} }
if (newType) { if (newType) {
this._updateMainSubscription("subscribe", newType); this._updateMainSubscription('subscribe', newType);
} }
}, },
extraSubscription(newType, oldType) { extraSubscription(newType, oldType) {
if (oldType) { if (oldType) {
this._updateMainSubscription("unsubscribe", oldType); this._updateMainSubscription('unsubscribe', oldType);
} }
if (newType) { if (newType) {
this._updateMainSubscription("subscribe", newType); this._updateMainSubscription('subscribe', newType);
} }
}, },
}, },
@ -83,7 +89,7 @@ export default {
// https://github.com/socketio/socket.io-client/blob/2.4.x/docs/API.md // https://github.com/socketio/socket.io-client/blob/2.4.x/docs/API.md
// console.log("connecting JobsListener to WS", websocketURL); // console.log("connecting JobsListener to WS", websocketURL);
const ws = io(websocketURL, { const ws = io(websocketURL, {
transports: ["websocket"], transports: ['websocket'],
}); });
this.socket = ws; this.socket = ws;
@ -99,104 +105,103 @@ export default {
}); });
this.socket.on('connect_error', (error) => { this.socket.on('connect_error', (error) => {
// Don't log the error here, it's too long and noisy for regular logs. // Don't log the error here, it's too long and noisy for regular logs.
console.log("socketIO connection error"); console.log('socketIO connection error');
this.sockStatus.disconnected(error); this.sockStatus.disconnected(error);
}); });
this.socket.on('error', (error) => { this.socket.on('error', (error) => {
console.log("socketIO error:", error); console.log('socketIO error:', error);
this.sockStatus.disconnected(error); this.sockStatus.disconnected(error);
}); });
this.socket.on('connect_timeout', (timeout) => { this.socket.on('connect_timeout', (timeout) => {
console.log("socketIO connection timeout:", timeout); console.log('socketIO connection timeout:', timeout);
this.sockStatus.disconnected("Connection timeout"); this.sockStatus.disconnected('Connection timeout');
}); });
this.socket.on("disconnect", (reason) => { this.socket.on('disconnect', (reason) => {
// console.log("socketIO disconnected:", reason); // console.log("socketIO disconnected:", reason);
this.$emit("sioDisconnected", reason); this.$emit('sioDisconnected', reason);
this.sockStatus.disconnected(reason); this.sockStatus.disconnected(reason);
if (reason === 'io server disconnect') { if (reason === 'io server disconnect') {
// The disconnection was initiated by the server, need to reconnect // The disconnection was initiated by the server, need to reconnect
// manually. If the disconnect was for other reasons, the socket // manually. If the disconnect was for other reasons, the socket
// should automatically try to reconnect. // should automatically try to reconnect.
// Intentionally commented out function call, because this should // Intentionally commented out function call, because this should
// happen with some nice exponential backoff instead of hammering the // happen with some nice exponential backoff instead of hammering the
// server: // server:
// socket.connect(); // socket.connect();
} }
}); });
this.socket.on("reconnect", (attemptNumber) => { this.socket.on('reconnect', (attemptNumber) => {
console.log("socketIO reconnected after", attemptNumber, "attempts"); console.log('socketIO reconnected after', attemptNumber, 'attempts');
this.sockStatus.connected(); this.sockStatus.connected();
this._resubscribe(); this._resubscribe();
this.$emit("sioReconnected", attemptNumber); this.$emit('sioReconnected', attemptNumber);
}); });
this.socket.on("/jobs", (jobUpdate) => { this.socket.on('/jobs', (jobUpdate) => {
// Convert to API object, in order to have the same parsing of data as // Convert to API object, in order to have the same parsing of data as
// when we'd do an API call. // when we'd do an API call.
const apiJobUpdate = API.SocketIOJobUpdate.constructFromObject(jobUpdate) const apiJobUpdate = API.SocketIOJobUpdate.constructFromObject(jobUpdate);
this.$emit("jobUpdate", apiJobUpdate); this.$emit('jobUpdate', apiJobUpdate);
}); });
this.socket.on("/last-rendered", (update) => { this.socket.on('/last-rendered', (update) => {
// Convert to API object, in order to have the same parsing of data as // Convert to API object, in order to have the same parsing of data as
// when we'd do an API call. // when we'd do an API call.
const apiUpdate = API.SocketIOLastRenderedUpdate.constructFromObject(update) const apiUpdate = API.SocketIOLastRenderedUpdate.constructFromObject(update);
this.$emit("lastRenderedUpdate", apiUpdate); this.$emit('lastRenderedUpdate', apiUpdate);
}); });
this.socket.on("/task", (taskUpdate) => { this.socket.on('/task', (taskUpdate) => {
// Convert to API object, in order to have the same parsing of data as // Convert to API object, in order to have the same parsing of data as
// when we'd do an API call. // when we'd do an API call.
const apiTaskUpdate = API.SocketIOTaskUpdate.constructFromObject(taskUpdate) const apiTaskUpdate = API.SocketIOTaskUpdate.constructFromObject(taskUpdate);
this.$emit("taskUpdate", apiTaskUpdate); this.$emit('taskUpdate', apiTaskUpdate);
}); });
this.socket.on("/tasklog", (taskLogUpdate) => { this.socket.on('/tasklog', (taskLogUpdate) => {
// Convert to API object, in order to have the same parsing of data as // Convert to API object, in order to have the same parsing of data as
// when we'd do an API call. // when we'd do an API call.
const apiTaskLogUpdate = API.SocketIOTaskLogUpdate.constructFromObject(taskLogUpdate) const apiTaskLogUpdate = API.SocketIOTaskLogUpdate.constructFromObject(taskLogUpdate);
this.$emit("taskLogUpdate", apiTaskLogUpdate); this.$emit('taskLogUpdate', apiTaskLogUpdate);
}); });
this.socket.on("/workers", (workerUpdate) => { this.socket.on('/workers', (workerUpdate) => {
// Convert to API object, in order to have the same parsing of data as // Convert to API object, in order to have the same parsing of data as
// when we'd do an API call. // when we'd do an API call.
const apiWorkerUpdate = API.SocketIOWorkerUpdate.constructFromObject(workerUpdate) const apiWorkerUpdate = API.SocketIOWorkerUpdate.constructFromObject(workerUpdate);
this.$emit("workerUpdate", apiWorkerUpdate); this.$emit('workerUpdate', apiWorkerUpdate);
}); });
this.socket.on("/workertags", (workerTagUpdate) => { this.socket.on('/workertags', (workerTagUpdate) => {
// Convert to API object, in order to have the same parsing of data as // Convert to API object, in order to have the same parsing of data as
// when we'd do an API call. // when we'd do an API call.
const apiWorkerTagUpdate = API.SocketIOWorkerTagUpdate.constructFromObject(workerTagUpdate) const apiWorkerTagUpdate = API.SocketIOWorkerTagUpdate.constructFromObject(workerTagUpdate);
this.$emit("workerTagUpdate", apiWorkerTagUpdate); this.$emit('workerTagUpdate', apiWorkerTagUpdate);
}); });
// Chat system, useful for debugging. // Chat system, useful for debugging.
this.socket.on("/message", (message) => { this.socket.on('/message', (message) => {
this.$emit("message", message); this.$emit('message', message);
}); });
}, },
disconnectWebsocket() { disconnectWebsocket() {
if (this.socket == null) { if (this.socket == null) {
console.log("no JobListener socket to disconnect"); console.log('no JobListener socket to disconnect');
return; return;
} }
console.log("disconnecting JobsListener WS", websocketURL); console.log('disconnecting JobsListener WS', websocketURL);
this.socket.disconnect(); this.socket.disconnect();
this.socket = null; this.socket = null;
}, },
sendBroadcastMessage(name, message) { sendBroadcastMessage(name, message) {
const payload = { name: name, text: message }; const payload = { name: name, text: message };
this.socket.emit("/chat", payload); this.socket.emit('/chat', payload);
}, },
/** /**
@ -206,7 +211,7 @@ export default {
*/ */
_updateMainSubscription(operation, type) { _updateMainSubscription(operation, type) {
const payload = new API.SocketIOSubscription(operation, type); const payload = new API.SocketIOSubscription(operation, type);
this.socket.emit("/subscription", payload); this.socket.emit('/subscription', payload);
}, },
/** /**
@ -215,9 +220,9 @@ export default {
* @param {string} jobID * @param {string} jobID
*/ */
_updateJobSubscription(operation, jobID) { _updateJobSubscription(operation, jobID) {
const payload = new API.SocketIOSubscription(operation, "job"); const payload = new API.SocketIOSubscription(operation, 'job');
payload.uuid = jobID; payload.uuid = jobID;
this.socket.emit("/subscription", payload); this.socket.emit('/subscription', payload);
}, },
/** /**
@ -226,17 +231,18 @@ export default {
* @param {string} jobID * @param {string} jobID
*/ */
_updateTaskLogSubscription(operation, taskID) { _updateTaskLogSubscription(operation, taskID) {
const payload = new API.SocketIOSubscription(operation, "tasklog"); const payload = new API.SocketIOSubscription(operation, 'tasklog');
payload.uuid = taskID; payload.uuid = taskID;
this.socket.emit("/subscription", payload); this.socket.emit('/subscription', payload);
}, },
// Resubscribe to whatever we want to be subscribed to: // Resubscribe to whatever we want to be subscribed to:
_resubscribe() { _resubscribe() {
if (this.subscribedJobID) this._updateJobSubscription("subscribe", this.subscribedJobID); if (this.subscribedJobID) this._updateJobSubscription('subscribe', this.subscribedJobID);
if (this.subscribedTaskID) this._updateTaskLogSubscription("subscribe", this.subscribedTaskID); if (this.subscribedTaskID)
if (this.mainSubscription) this._updateMainSubscription("subscribe", this.mainSubscription); this._updateTaskLogSubscription('subscribe', this.subscribedTaskID);
if (this.extraSubscription) this._updateMainSubscription("subscribe", this.extraSubscription); if (this.mainSubscription) this._updateMainSubscription('subscribe', this.mainSubscription);
if (this.extraSubscription) this._updateMainSubscription('subscribe', this.extraSubscription);
}, },
}, },
}; };

@ -1,17 +1,17 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue';
import NotificationList from './NotificationList.vue' import NotificationList from './NotificationList.vue';
import TaskLog from './TaskLog.vue' import TaskLog from './TaskLog.vue';
import ConnectionStatus from '@/components/ConnectionStatus.vue' import ConnectionStatus from '@/components/ConnectionStatus.vue';
const emit = defineEmits(['clickClose']) const emit = defineEmits(['clickClose']);
const initialTab = localStorage.getItem("footer-popover-active-tab") || 'NotificationList'; const initialTab = localStorage.getItem('footer-popover-active-tab') || 'NotificationList';
const currentTab = ref(initialTab) const currentTab = ref(initialTab);
const tabs = { NotificationList, TaskLog } const tabs = { NotificationList, TaskLog };
watch(currentTab, async (newTab) => { watch(currentTab, async (newTab) => {
localStorage.setItem("footer-popover-active-tab", newTab); localStorage.setItem('footer-popover-active-tab', newTab);
}); });
function showTaskLogTail() { function showTaskLogTail() {
@ -27,22 +27,17 @@ defineExpose({
<nav> <nav>
<ul> <ul>
<li <li
:class='["footer-tab", {"active": currentTab == "NotificationList"}]' :class="['footer-tab', { active: currentTab == 'NotificationList' }]"
@click="currentTab = 'NotificationList'"> @click="currentTab = 'NotificationList'">
Notifications Notifications
</li> </li>
<li <li
:class='["footer-tab", {"active": currentTab == "TaskLog"}]' :class="['footer-tab', { active: currentTab == 'TaskLog' }]"
@click="currentTab = 'TaskLog'"> @click="currentTab = 'TaskLog'">
Task Log Task Log
</li> </li>
<connection-status /> <connection-status />
<li <li class="collapse" @click="emit('clickClose')" title="Collapse">&#10005;</li>
class="collapse"
@click="emit('clickClose')"
title="Collapse">
&#10005;
</li>
</ul> </ul>
</nav> </nav>
<component :is="tabs[currentTab]" class="tab"></component> <component :is="tabs[currentTab]" class="tab"></component>
@ -54,7 +49,7 @@ footer {
background-color: var(--color-background-column); background-color: var(--color-background-column);
border-radius: var(--border-radius); border-radius: var(--border-radius);
bottom: var(--grid-gap); bottom: var(--grid-gap);
box-shadow: 0 0 5rem rgba(0, 0, 0, .66), 0 0 1.33rem rgba(0, 0, 0, .66); box-shadow: 0 0 5rem rgba(0, 0, 0, 0.66), 0 0 1.33rem rgba(0, 0, 0, 0.66);
left: var(--grid-gap); left: var(--grid-gap);
padding: var(--spacer-xs) var(--spacer-sm) var(--spacer-sm); padding: var(--spacer-xs) var(--spacer-sm) var(--spacer-sm);
position: fixed; position: fixed;
@ -77,7 +72,8 @@ footer nav ul li {
color: var(--color-text-hint); color: var(--color-text-hint);
cursor: pointer; cursor: pointer;
padding: var(--spacer-sm) 0; padding: var(--spacer-sm) 0;
transition: border-color var(--transition-speed) ease-in-out, color var(--transition-speed) ease-in-out; transition: border-color var(--transition-speed) ease-in-out,
color var(--transition-speed) ease-in-out;
user-select: none; user-select: none;
} }
@ -99,7 +95,6 @@ footer nav ul li.active {
padding: 0 var(--spacer-sm) 0; padding: 0 var(--spacer-sm) 0;
} }
footer button.footer-tab { footer button.footer-tab {
border: none; border: none;
margin-right: 1rem; margin-right: 1rem;

@ -1,13 +1,13 @@
<template> <template>
<section class="notification-bar"> <section class="notification-bar">
<span class='notifications' v-if="notifs.last">{{ notifs.last.msg }}</span> <span class="notifications" v-if="notifs.last">{{ notifs.last.msg }}</span>
<connection-status /> <connection-status />
</section> </section>
</template> </template>
<script> <script>
import { useNotifs } from '@/stores/notifications'; import { useNotifs } from '@/stores/notifications';
import ConnectionStatus from '@/components/ConnectionStatus.vue' import ConnectionStatus from '@/components/ConnectionStatus.vue';
export default { export default {
name: 'NotificationBar', name: 'NotificationBar',
@ -17,7 +17,7 @@ export default {
data: () => ({ data: () => ({
notifs: useNotifs(), notifs: useNotifs(),
}), }),
} };
</script> </script>
<style scoped> <style scoped>

@ -1,16 +1,18 @@
<script setup> <script setup>
import { onMounted } from 'vue' import { onMounted } from 'vue';
import { TabulatorFull as Tabulator } from 'tabulator-tables'; import { TabulatorFull as Tabulator } from 'tabulator-tables';
import { useNotifs } from '@/stores/notifications' import { useNotifs } from '@/stores/notifications';
import * as datetime from "@/datetime"; import * as datetime from '@/datetime';
const notifs = useNotifs(); const notifs = useNotifs();
const tabOptions = { const tabOptions = {
columns: [ columns: [
{ {
title: 'Time', field: 'time', title: 'Time',
sorter: 'alphanum', sorterParams: { alignEmptyValues: "top" }, field: 'time',
sorter: 'alphanum',
sorterParams: { alignEmptyValues: 'top' },
formatter(cell) { formatter(cell) {
const cellValue = cell.getData().time; const cellValue = cell.getData().time;
return datetime.shortened(cellValue); return datetime.shortened(cellValue);
@ -26,37 +28,34 @@ const tabOptions = {
resizable: true, resizable: true,
}, },
], ],
initialSort: [ initialSort: [{ column: 'time', dir: 'asc' }],
{ column: "time", dir: "asc" },
],
headerVisible: false, headerVisible: false,
layout: "fitDataStretch", layout: 'fitDataStretch',
resizableColumnFit: true, resizableColumnFit: true,
height: "calc(25vh - 3rem)", // Must be set in order for the virtual DOM to function correctly. height: 'calc(25vh - 3rem)', // Must be set in order for the virtual DOM to function correctly.
data: notifs.history, data: notifs.history,
placeholder: "Notification history will appear here", placeholder: 'Notification history will appear here',
selectable: false, selectable: false,
}; };
let tabulator = null; let tabulator = null;
onMounted(() => { onMounted(() => {
tabulator = new Tabulator('#notification_list', tabOptions); tabulator = new Tabulator('#notification_list', tabOptions);
tabulator.on("tableBuilt", _scrollToBottom); tabulator.on('tableBuilt', _scrollToBottom);
tabulator.on("tableBuilt", _subscribeToPinia); tabulator.on('tableBuilt', _subscribeToPinia);
}); });
function _scrollToBottom() { function _scrollToBottom() {
if (notifs.empty) return; if (notifs.empty) return;
tabulator.scrollToRow(notifs.lastID, "bottom", false); tabulator.scrollToRow(notifs.lastID, 'bottom', false);
} }
function _subscribeToPinia() { function _subscribeToPinia() {
notifs.$subscribe(() => { notifs.$subscribe(() => {
tabulator.setData(notifs.history) tabulator.setData(notifs.history).then(_scrollToBottom);
.then(_scrollToBottom) });
})
} }
</script> </script>
<template> <template>
<div id="notification_list"></div> <div id="notification_list"></div>
</template> </template>

@ -1,9 +1,9 @@
<script setup> <script setup>
import { onMounted, onUnmounted } from 'vue' import { onMounted, onUnmounted } from 'vue';
import { TabulatorFull as Tabulator } from 'tabulator-tables'; import { TabulatorFull as Tabulator } from 'tabulator-tables';
import { useTaskLog } from '@/stores/tasklog' import { useTaskLog } from '@/stores/tasklog';
import { useTasks } from '@/stores/tasks' import { useTasks } from '@/stores/tasks';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
import { JobsApi } from '@/manager-api'; import { JobsApi } from '@/manager-api';
const taskLog = useTaskLog(); const taskLog = useTaskLog();
@ -20,19 +20,19 @@ const tabOptions = {
}, },
], ],
headerVisible: false, headerVisible: false,
layout: "fitDataStretch", layout: 'fitDataStretch',
resizableColumnFit: true, resizableColumnFit: true,
height: "calc(25vh - 3rem)", // Must be set in order for the virtual DOM to function correctly. height: 'calc(25vh - 3rem)', // Must be set in order for the virtual DOM to function correctly.
data: taskLog.history, data: taskLog.history,
placeholder: "Task log will appear here", placeholder: 'Task log will appear here',
selectable: false, selectable: false,
}; };
let tabulator = null; let tabulator = null;
onMounted(() => { onMounted(() => {
tabulator = new Tabulator('#task_log_list', tabOptions); tabulator = new Tabulator('#task_log_list', tabOptions);
tabulator.on("tableBuilt", _scrollToBottom); tabulator.on('tableBuilt', _scrollToBottom);
tabulator.on("tableBuilt", _subscribeToPinia); tabulator.on('tableBuilt', _subscribeToPinia);
_fetchLogTail(tasks.activeTaskID); _fetchLogTail(tasks.activeTaskID);
}); });
onUnmounted(() => { onUnmounted(() => {
@ -45,13 +45,12 @@ tasks.$subscribe((_, state) => {
function _scrollToBottom() { function _scrollToBottom() {
if (taskLog.empty) return; if (taskLog.empty) return;
tabulator.scrollToRow(taskLog.lastID, "bottom", false); tabulator.scrollToRow(taskLog.lastID, 'bottom', false);
} }
function _subscribeToPinia() { function _subscribeToPinia() {
taskLog.$subscribe(() => { taskLog.$subscribe(() => {
tabulator.setData(taskLog.history) tabulator.setData(taskLog.history).then(_scrollToBottom);
.then(_scrollToBottom) });
})
} }
function _fetchLogTail(taskID) { function _fetchLogTail(taskID) {
@ -60,10 +59,9 @@ function _fetchLogTail(taskID) {
if (!taskID) return; if (!taskID) return;
const jobsAPI = new JobsApi(getAPIClient()); const jobsAPI = new JobsApi(getAPIClient());
return jobsAPI.fetchTaskLogTail(taskID) return jobsAPI.fetchTaskLogTail(taskID).then((logTail) => {
.then((logTail) => { taskLog.addChunk(logTail);
taskLog.addChunk(logTail); });
});
} }
</script> </script>

@ -14,8 +14,14 @@
<link-worker :worker="{ id: entry.worker_id, name: entry.worker_name }" /> <link-worker :worker="{ id: entry.worker_id, name: entry.worker_name }" />
</td> </td>
<td>{{ entry.task_type }}</td> <td>{{ entry.task_type }}</td>
<td><button class="btn in-table-row" @click="removeBlocklistEntry(entry)" <td>
title="Allow this worker to execute these task types"></button></td> <button
class="btn in-table-row"
@click="removeBlocklistEntry(entry)"
title="Allow this worker to execute these task types">
</button>
</td>
</tr> </tr>
</table> </table>
<div v-else class="dl-no-data"> <div v-else class="dl-no-data">
@ -26,19 +32,19 @@
</template> </template>
<script setup> <script setup>
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
import { JobsApi } from '@/manager-api'; import { JobsApi } from '@/manager-api';
import LinkWorker from '@/components/LinkWorker.vue'; import LinkWorker from '@/components/LinkWorker.vue';
import { watch, onMounted, inject, ref, nextTick } from 'vue' import { watch, onMounted, inject, ref, nextTick } from 'vue';
// jobID should be the job UUID string. // jobID should be the job UUID string.
const props = defineProps(['jobID']); const props = defineProps(['jobID']);
const emit = defineEmits(['reshuffled']) const emit = defineEmits(['reshuffled']);
const jobsApi = new JobsApi(getAPIClient()); const jobsApi = new JobsApi(getAPIClient());
const isVisible = inject("isVisible"); const isVisible = inject('isVisible');
const isFetching = ref(false); const isFetching = ref(false);
const errorMsg = ref(""); const errorMsg = ref('');
const blocklist = ref([]); const blocklist = ref([]);
function refreshBlocklist() { function refreshBlocklist() {
@ -47,7 +53,8 @@ function refreshBlocklist() {
} }
isFetching.value = true; isFetching.value = true;
jobsApi.fetchJobBlocklist(props.jobID) jobsApi
.fetchJobBlocklist(props.jobID)
.then((newBlocklist) => { .then((newBlocklist) => {
blocklist.value = newBlocklist; blocklist.value = newBlocklist;
}) })
@ -56,28 +63,36 @@ function refreshBlocklist() {
}) })
.finally(() => { .finally(() => {
isFetching.value = false; isFetching.value = false;
}) });
} }
function removeBlocklistEntry(blocklistEntry) { function removeBlocklistEntry(blocklistEntry) {
jobsApi.removeJobBlocklist(props.jobID, { jobBlocklistEntry: [blocklistEntry] }) jobsApi
.removeJobBlocklist(props.jobID, { jobBlocklistEntry: [blocklistEntry] })
.then(() => { .then(() => {
blocklist.value = blocklist.value.filter( blocklist.value = blocklist.value.filter(
(entry) => !(entry.worker_id == blocklistEntry.worker_id && entry.task_type == blocklistEntry.task_type)); (entry) =>
!(
entry.worker_id == blocklistEntry.worker_id &&
entry.task_type == blocklistEntry.task_type
)
);
}) })
.catch((error) => { .catch((error) => {
console.log("Error removing entry from blocklist", error); console.log('Error removing entry from blocklist', error);
refreshBlocklist(); refreshBlocklist();
}) });
} }
watch(() => props.jobID, refreshBlocklist); watch(() => props.jobID, refreshBlocklist);
watch(blocklist, () => { watch(blocklist, () => {
const emitter = () => { emit("reshuffled") }; const emitter = () => {
emit('reshuffled');
};
nextTick(() => { nextTick(() => {
nextTick(emitter); nextTick(emitter);
}); });
}) });
watch(isVisible, refreshBlocklist); watch(isVisible, refreshBlocklist);
onMounted(refreshBlocklist); onMounted(refreshBlocklist);
</script> </script>
@ -93,7 +108,7 @@ table.blocklist {
table.blocklist td, table.blocklist td,
table.blocklist th { table.blocklist th {
text-align: left; text-align: left;
padding: calc(var(--spacer-sm)/2) var(--spacer-sm); padding: calc(var(--spacer-sm) / 2) var(--spacer-sm);
} }
table.blocklist th { table.blocklist th {

@ -8,26 +8,32 @@
<button class="btn delete dangerous" v-on:click="onButtonDeleteConfirmed">Delete</button> <button class="btn delete dangerous" v-on:click="onButtonDeleteConfirmed">Delete</button>
</div> </div>
</div> </div>
<button class="btn cancel" :disabled="!jobs.canCancel" v-on:click="onButtonCancel">Cancel Job</button> <button class="btn cancel" :disabled="!jobs.canCancel" v-on:click="onButtonCancel">
<button class="btn requeue" :disabled="!jobs.canRequeue" v-on:click="onButtonRequeue">Requeue</button> Cancel Job
<button class="action delete dangerous" title="Mark this job for deletion, after asking for a confirmation." </button>
:disabled="!jobs.canDelete" v-on:click="onButtonDelete">Delete...</button> <button class="btn requeue" :disabled="!jobs.canRequeue" v-on:click="onButtonRequeue">
Requeue
</button>
<button
class="action delete dangerous"
title="Mark this job for deletion, after asking for a confirmation."
:disabled="!jobs.canDelete"
v-on:click="onButtonDelete">
Delete...
</button>
</div> </div>
</template> </template>
<script> <script>
import { useJobs } from '@/stores/jobs'; import { useJobs } from '@/stores/jobs';
import { useNotifs } from '@/stores/notifications'; import { useNotifs } from '@/stores/notifications';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
import { JobsApi } from '@/manager-api'; import { JobsApi } from '@/manager-api';
import { JobDeletionInfo } from '@/manager-api'; import { JobDeletionInfo } from '@/manager-api';
export default { export default {
name: "JobActionsBar", name: 'JobActionsBar',
props: [ props: ['activeJobID'],
"activeJobID",
],
data: () => ({ data: () => ({
jobs: useJobs(), jobs: useJobs(),
notifs: useNotifs(), notifs: useNotifs(),
@ -35,8 +41,7 @@ export default {
deleteInfo: null, deleteInfo: null,
}), }),
computed: { computed: {},
},
watch: { watch: {
activeJobID() { activeJobID() {
this._hideDeleteJobPopup(); this._hideDeleteJobPopup();
@ -47,51 +52,48 @@ export default {
this._startJobDeletionFlow(); this._startJobDeletionFlow();
}, },
onButtonDeleteConfirmed() { onButtonDeleteConfirmed() {
return this.jobs.deleteJobs() return this.jobs
.deleteJobs()
.then(() => { .then(() => {
this.notifs.add("job marked for deletion"); this.notifs.add('job marked for deletion');
}) })
.catch((error) => { .catch((error) => {
const errorMsg = JSON.stringify(error); // TODO: handle API errors better. const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
this.notifs.add(`Error: ${errorMsg}`); this.notifs.add(`Error: ${errorMsg}`);
}) })
.finally(this._hideDeleteJobPopup) .finally(this._hideDeleteJobPopup);
;
}, },
onButtonCancel() { onButtonCancel() {
return this._handleJobActionPromise( return this._handleJobActionPromise(this.jobs.cancelJobs(), 'marked for cancellation');
this.jobs.cancelJobs(), "marked for cancellation");
}, },
onButtonRequeue() { onButtonRequeue() {
return this._handleJobActionPromise( return this._handleJobActionPromise(this.jobs.requeueJobs(), 'requeueing');
this.jobs.requeueJobs(), "requeueing");
}, },
_handleJobActionPromise(promise, description) { _handleJobActionPromise(promise, description) {
return promise return promise.then(() => {
.then(() => { // There used to be a call to `this.notifs.add(message)` here, but now
// There used to be a call to `this.notifs.add(message)` here, but now // that job status changes are logged in the notifications anyway,
// that job status changes are logged in the notifications anyway, // it's no longer necessary.
// it's no longer necessary. // This function is still kept, in case we want to bring back the
// This function is still kept, in case we want to bring back the // notifications when multiple jobs can be selected. Then a summary
// notifications when multiple jobs can be selected. Then a summary // ("N jobs requeued") could be logged here.btn-bar-popover
// ("N jobs requeued") could be logged here.btn-bar-popover });
})
}, },
_startJobDeletionFlow() { _startJobDeletionFlow() {
if (!this.activeJobID) { if (!this.activeJobID) {
this.notifs.add("No active job, unable to delete anything"); this.notifs.add('No active job, unable to delete anything');
return; return;
} }
this.jobsAPI.deleteJobWhatWouldItDo(this.activeJobID) this.jobsAPI
.deleteJobWhatWouldItDo(this.activeJobID)
.then(this._showDeleteJobPopup) .then(this._showDeleteJobPopup)
.catch((error) => { .catch((error) => {
const errorMsg = JSON.stringify(error); // TODO: handle API errors better. const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
this.notifs.add(`Error: ${errorMsg}`); this.notifs.add(`Error: ${errorMsg}`);
}) });
;
}, },
/** /**
@ -103,10 +105,9 @@ export default {
_hideDeleteJobPopup() { _hideDeleteJobPopup() {
this.deleteInfo = null; this.deleteInfo = null;
} },
} },
} };
</script> </script>
<style scoped> <style scoped>

@ -7,7 +7,7 @@
<TabsWrapper @clicked-job-details-tab="emit_reshuffled_delayed"> <TabsWrapper @clicked-job-details-tab="emit_reshuffled_delayed">
<TabItem title="Job Settings"> <TabItem title="Job Settings">
<dl v-if="hasSettings"> <dl v-if="hasSettings">
<template v-for="value, key in settingsToDisplay"> <template v-for="(value, key) in settingsToDisplay">
<dt :class="`field-${key}`" :title="key">{{ key }}</dt> <dt :class="`field-${key}`" :title="key">{{ key }}</dt>
<dd>{{ value }}</dd> <dd>{{ value }}</dd>
</template> </template>
@ -18,7 +18,7 @@
</TabItem> </TabItem>
<TabItem title="Metadata"> <TabItem title="Metadata">
<dl v-if="hasMetadata"> <dl v-if="hasMetadata">
<template v-for="value, key in jobData.metadata"> <template v-for="(value, key) in jobData.metadata">
<dt :class="`field-${key}`" :title="key">{{ key }}</dt> <dt :class="`field-${key}`" :title="key">{{ key }}</dt>
<dd>{{ value }}</dd> <dd>{{ value }}</dd>
</template> </template>
@ -30,18 +30,17 @@
<TabItem title="Details"> <TabItem title="Details">
<dl> <dl>
<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="workerTag"> <template v-if="workerTag">
<!-- TODO: fetch tag name and show that instead, and allow editing of the tag. --> <!-- TODO: fetch tag name and show that instead, and allow editing of the tag. -->
<dt class="field-name" title="Worker Tag">Tag</dt> <dt class="field-name" title="Worker Tag">Tag</dt>
<dd :title="workerTag.description"> <dd :title="workerTag.description">
<span <span @click="copyElementData" class="click-to-copy" :data-clipboard="workerTag.id">{{
@click="copyElementData" workerTag.name
class="click-to-copy" }}</span>
:data-clipboard="workerTag.id"
>{{ workerTag.name }}</span
>
</dd> </dd>
</template> </template>
@ -49,7 +48,9 @@
<dd>{{ jobData.name }}</dd> <dd>{{ jobData.name }}</dd>
<dt class="field-status" title="Status">Status</dt> <dt class="field-status" title="Status">Status</dt>
<dd class="field-status-label" :class="'status-' + jobData.status">{{ jobData.status }}</dd> <dd class="field-status-label" :class="'status-' + jobData.status">
{{ jobData.status }}
</dd>
<dt class="field-type" title="Type">Type</dt> <dt class="field-type" title="Type">Type</dt>
<dd>{{ jobType ? jobType.label : jobData.type }}</dd> <dd>{{ jobType ? jobType.label : jobData.type }}</dd>
@ -87,24 +88,24 @@
</template> </template>
<script> <script>
import * as datetime from "@/datetime"; import * as datetime from '@/datetime';
import * as API from '@/manager-api'; import * as API from '@/manager-api';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
import LastRenderedImage from '@/components/jobs/LastRenderedImage.vue' import LastRenderedImage from '@/components/jobs/LastRenderedImage.vue';
import Blocklist from './Blocklist.vue' 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, copyElementData } from '@/clipboard'; import { copyElementText, copyElementData } from '@/clipboard';
import { useWorkers } from '@/stores/workers' import { useWorkers } from '@/stores/workers';
import { useNotifs } from '@/stores/notifications'; import { useNotifs } from '@/stores/notifications';
export default { export default {
props: [ props: [
"jobData", // Job data to show. 'jobData', // Job data to show.
], ],
emits: [ emits: [
"reshuffled", // Emitted when the size of this component may have changed. Used to resize other components in response. 'reshuffled', // Emitted when the size of this component may have changed. Used to resize other components in response.
], ],
components: { components: {
LastRenderedImage, LastRenderedImage,
@ -192,9 +193,12 @@ export default {
if (objectEmpty(this.jobType) || this.jobType.name != newJobData.type) { if (objectEmpty(this.jobType) || this.jobType.name != newJobData.type) {
this._clearJobSettings(); // They should only be shown when the type info is known. this._clearJobSettings(); // They should only be shown when the type info is known.
this.jobsApi.getJobType(newJobData.type) this.jobsApi
.getJobType(newJobData.type)
.then(this.onJobTypeLoaded) .then(this.onJobTypeLoaded)
.catch((error) => { console.warn("error fetching job type:", error) }); .catch((error) => {
console.warn('error fetching job type:', error);
});
} else { } else {
this._setJobSettings(newJobData.settings); this._setJobSettings(newJobData.settings);
} }
@ -205,8 +209,7 @@ export default {
// Construct a lookup table for the settings. // Construct a lookup table for the settings.
const jobTypeSettings = {}; const jobTypeSettings = {};
for (let setting of jobType.settings) for (let setting of jobType.settings) jobTypeSettings[setting.key] = setting;
jobTypeSettings[setting.key] = setting;
this.jobTypeSettings = jobTypeSettings; this.jobTypeSettings = jobTypeSettings;
if (this.jobData) { if (this.jobData) {
@ -227,7 +230,7 @@ export default {
} }
if (objectEmpty(this.jobTypeSettings)) { if (objectEmpty(this.jobTypeSettings)) {
console.warn("empty job type settings"); console.warn('empty job type settings');
this._clearJobSettings(); this._clearJobSettings();
return; return;
} }
@ -255,7 +258,9 @@ export default {
this.$emit('reshuffled'); this.$emit('reshuffled');
}, },
emit_reshuffled_delayed() { emit_reshuffled_delayed() {
const reshuffle = () => { this.$emit('reshuffled'); } const reshuffle = () => {
this.$emit('reshuffled');
};
// Changing tabs requires two sequential "reshuffled" events, at least it // Changing tabs requires two sequential "reshuffled" events, at least it
// does on Firefox. Not sure what the reason is, but it works to get rid // does on Firefox. Not sure what the reason is, but it works to get rid
@ -269,7 +274,7 @@ export default {
<style scoped> <style scoped>
/* Prevent fields with long IDs from overflowing. */ /* Prevent fields with long IDs from overflowing. */
.field-id+dd { .field-id + dd {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;

@ -3,7 +3,9 @@
<div class="btn-bar-group"> <div class="btn-bar-group">
<job-actions-bar :activeJobID="jobs.activeJobID" /> <job-actions-bar :activeJobID="jobs.activeJobID" />
<div class="align-right"> <div class="align-right">
<status-filter-bar :availableStatuses="availableStatuses" :activeStatuses="shownStatuses" <status-filter-bar
:availableStatuses="availableStatuses"
:activeStatuses="shownStatuses"
@click="toggleStatusFilter" /> @click="toggleStatusFilter" />
</div> </div>
</div> </div>
@ -14,21 +16,22 @@
<script> <script>
import { TabulatorFull as Tabulator } from 'tabulator-tables'; import { TabulatorFull as Tabulator } from 'tabulator-tables';
import * as datetime from "@/datetime"; import * as datetime from '@/datetime';
import * as API from '@/manager-api' import * as API from '@/manager-api';
import { indicator } from '@/statusindicator'; import { indicator } from '@/statusindicator';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
import { useJobs } from '@/stores/jobs'; import { useJobs } from '@/stores/jobs';
import JobActionsBar from '@/components/jobs/JobActionsBar.vue' import JobActionsBar from '@/components/jobs/JobActionsBar.vue';
import StatusFilterBar from '@/components/StatusFilterBar.vue' import StatusFilterBar from '@/components/StatusFilterBar.vue';
export default { export default {
name: 'JobsTable', name: 'JobsTable',
props: ["activeJobID"], props: ['activeJobID'],
emits: ["tableRowClicked", "activeJobDeleted"], emits: ['tableRowClicked', 'activeJobDeleted'],
components: { components: {
JobActionsBar, StatusFilterBar, JobActionsBar,
StatusFilterBar,
}, },
data: () => { data: () => {
return { return {
@ -51,7 +54,9 @@ export default {
// Useful for debugging when there are many similar jobs: // Useful for debugging when there are many similar jobs:
// { title: "ID", field: "id", headerSort: false, formatter: (cell) => cell.getData().id.substr(0, 8), }, // { title: "ID", field: "id", headerSort: false, formatter: (cell) => cell.getData().id.substr(0, 8), },
{ {
title: 'Status', field: 'status', sorter: 'string', title: 'Status',
field: 'status',
sorter: 'string',
formatter: (cell) => { formatter: (cell) => {
const status = cell.getData().status; const status = cell.getData().status;
const dot = indicator(status); const dot = indicator(status);
@ -62,8 +67,10 @@ export default {
{ title: 'Type', field: 'type', sorter: 'string' }, { title: 'Type', field: 'type', sorter: 'string' },
{ title: 'Prio', field: 'priority', sorter: 'number' }, { title: 'Prio', field: 'priority', sorter: 'number' },
{ {
title: 'Updated', field: 'updated', title: 'Updated',
sorter: 'alphanum', sorterParams: { alignEmptyValues: "top" }, field: 'updated',
sorter: 'alphanum',
sorterParams: { alignEmptyValues: 'top' },
formatter(cell) { formatter(cell) {
const cellValue = cell.getData().updated; const cellValue = cell.getData().updated;
// TODO: if any "{amount} {units} ago" shown, the table should be // TODO: if any "{amount} {units} ago" shown, the table should be
@ -75,23 +82,21 @@ export default {
], ],
rowFormatter(row) { rowFormatter(row) {
const data = row.getData(); const data = row.getData();
const isActive = (data.id === vueComponent.activeJobID); const isActive = data.id === vueComponent.activeJobID;
const classList = row.getElement().classList; const classList = row.getElement().classList;
classList.toggle("active-row", isActive); classList.toggle('active-row', isActive);
classList.toggle("deletion-requested", !!data.delete_requested_at); classList.toggle('deletion-requested', !!data.delete_requested_at);
}, },
initialSort: [ initialSort: [{ column: 'updated', dir: 'desc' }],
{ column: "updated", dir: "desc" }, layout: 'fitData',
],
layout: "fitData",
layoutColumnsOnNewData: true, layoutColumnsOnNewData: true,
height: "720px", // Must be set in order for the virtual DOM to function correctly. height: '720px', // Must be set in order for the virtual DOM to function correctly.
data: [], // Will be filled via a Flamenco API request. data: [], // Will be filled via a Flamenco API request.
selectable: false, // The active job is tracked by click events, not row selection. selectable: false, // The active job is tracked by click events, not row selection.
}; };
this.tabulator = new Tabulator('#flamenco_job_list', options); this.tabulator = new Tabulator('#flamenco_job_list', options);
this.tabulator.on("rowClick", this.onRowClick); this.tabulator.on('rowClick', this.onRowClick);
this.tabulator.on("tableBuilt", this._onTableBuilt); this.tabulator.on('tableBuilt', this._onTableBuilt);
window.addEventListener('resize', this.recalcTableHeight); window.addEventListener('resize', this.recalcTableHeight);
}, },
@ -113,7 +118,7 @@ export default {
computed: { computed: {
selectedIDs() { selectedIDs() {
return this.tabulator.getSelectedData().map((job) => job.id); return this.tabulator.getSelectedData().map((job) => job.id);
} },
}, },
methods: { methods: {
onReconnected() { onReconnected() {
@ -160,18 +165,20 @@ export default {
if (jobUpdate.was_deleted) { if (jobUpdate.was_deleted) {
if (row) promise = row.delete(); if (row) promise = row.delete();
else promise = Promise.resolve(); else promise = Promise.resolve();
promise.finally(() => { this.$emit("activeJobDeleted", jobUpdate.id); }); promise.finally(() => {
} this.$emit('activeJobDeleted', jobUpdate.id);
else { });
} else {
if (row) promise = this.tabulator.updateData([jobUpdate]); if (row) promise = this.tabulator.updateData([jobUpdate]);
else promise = this.tabulator.addData([jobUpdate]); else promise = this.tabulator.addData([jobUpdate]);
} }
promise promise
.then(this.sortData) .then(this.sortData)
.then(() => { this.tabulator.redraw(); }) // Resize columns based on new data. .then(() => {
.then(this._refreshAvailableStatuses) this.tabulator.redraw();
; }) // Resize columns based on new data.
.then(this._refreshAvailableStatuses);
}, },
onRowClick(event, row) { onRowClick(event, row) {
@ -179,7 +186,7 @@ export default {
// store. There were some issues where navigating to another job would // store. There were some issues where navigating to another job would
// overwrite the old job's ID, and this prevents that. // overwrite the old job's ID, and this prevents that.
const rowData = plain(row.getData()); const rowData = plain(row.getData());
this.$emit("tableRowClicked", rowData); this.$emit('tableRowClicked', rowData);
}, },
toggleStatusFilter(status) { toggleStatusFilter(status) {
const asSet = new Set(this.shownStatuses); const asSet = new Set(this.shownStatuses);
@ -207,7 +214,7 @@ export default {
// Use tab.rowManager.findRow() instead of `tab.getRow()` as the latter // Use tab.rowManager.findRow() instead of `tab.getRow()` as the latter
// logs a warning when the row cannot be found. // logs a warning when the row cannot be found.
const row = this.tabulator.rowManager.findRow(jobID); const row = this.tabulator.rowManager.findRow(jobID);
if (!row) return if (!row) return;
if (row.reformat) row.reformat(); if (row.reformat) row.reformat();
else if (row.reinitialize) row.reinitialize(true); else if (row.reinitialize) row.reinitialize(true);
}, },
@ -237,7 +244,9 @@ export default {
// `offsetParent` is assumed to be the actual column in the 3-column // `offsetParent` is assumed to be the actual column in the 3-column
// view. To ensure this, it's given `position: relative` in the CSS // view. To ensure this, it's given `position: relative` in the CSS
// styling. // styling.
console.warn("JobsTable.recalcTableHeight() only works when the offset parent is the real parent of the element."); console.warn(
'JobsTable.recalcTableHeight() only works when the offset parent is the real parent of the element.'
);
return; return;
} }

@ -1,15 +1,14 @@
<template> <template>
<div v-if="imageURL != ''" :class="cssClasses"> <div v-if="imageURL != ''" :class="cssClasses">
<img :src="imageURL" alt="Last-rendered image for this job"> <img :src="imageURL" alt="Last-rendered image for this job" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { reactive, ref, watch } from 'vue' import { reactive, ref, watch } from 'vue';
import { api } from '@/urls'; import { api } from '@/urls';
import { JobsApi, JobLastRenderedImageInfo, SocketIOLastRenderedUpdate } from '@/manager-api'; import { JobsApi, JobLastRenderedImageInfo, SocketIOLastRenderedUpdate } from '@/manager-api';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
const props = defineProps([ const props = defineProps([
/* The job UUID to show renders for, or some false-y value if renders from all /* The job UUID to show renders for, or some false-y value if renders from all
@ -25,7 +24,7 @@ const imageURL = ref('');
const cssClasses = reactive({ const cssClasses = reactive({
'last-rendered': true, 'last-rendered': true,
'nothing-rendered-yet': true, 'nothing-rendered-yet': true,
}) });
const jobsApi = new JobsApi(getAPIClient()); const jobsApi = new JobsApi(getAPIClient());
@ -34,14 +33,12 @@ const jobsApi = new JobsApi(getAPIClient());
*/ */
function fetchImageURL(jobID) { function fetchImageURL(jobID) {
let promise; let promise;
if (jobID) if (jobID) promise = jobsApi.fetchJobLastRenderedInfo(jobID);
promise = jobsApi.fetchJobLastRenderedInfo(jobID); else promise = jobsApi.fetchGlobalLastRenderedInfo();
else
promise = jobsApi.fetchGlobalLastRenderedInfo();
promise promise.then(setImageURL).catch((error) => {
.then(setImageURL) console.warn('error fetching last-rendered image info:', error);
.catch((error) => { console.warn("error fetching last-rendered image info:", error) }); });
} }
/** /**
@ -51,7 +48,7 @@ function setImageURL(thumbnailInfo) {
if (thumbnailInfo == null) { if (thumbnailInfo == null) {
// This indicates that there is no last-rendered image. // This indicates that there is no last-rendered image.
// Default to a hard-coded 'nothing to be seen here, move along' image. // Default to a hard-coded 'nothing to be seen here, move along' image.
imageURL.value = "/app/nothing-rendered-yet.svg"; imageURL.value = '/app/nothing-rendered-yet.svg';
cssClasses['nothing-rendered-yet'] = true; cssClasses['nothing-rendered-yet'] = true;
return; return;
} }
@ -66,14 +63,17 @@ function setImageURL(thumbnailInfo) {
// Flamenco Manager, and not from any development server that might be // Flamenco Manager, and not from any development server that might be
// serving the webapp. // serving the webapp.
let url = new URL(api()); let url = new URL(api());
url.pathname = thumbnailInfo.base + "/" + suffix url.pathname = thumbnailInfo.base + '/' + suffix;
url.search = new Date().getTime(); // This forces the image to be reloaded. url.search = new Date().getTime(); // This forces the image to be reloaded.
imageURL.value = url.toString(); imageURL.value = url.toString();
foundThumbnail = true; foundThumbnail = true;
break; break;
} }
if (!foundThumbnail) { if (!foundThumbnail) {
console.warn(`LastRenderedImage.vue: could not find thumbnail with suffix "${suffixToFind}"; available are:`, thumbnailInfo.suffixes); console.warn(
`LastRenderedImage.vue: could not find thumbnail with suffix "${suffixToFind}"; available are:`,
thumbnailInfo.suffixes
);
} }
cssClasses['nothing-rendered-yet'] = !foundThumbnail; cssClasses['nothing-rendered-yet'] = !foundThumbnail;
} }
@ -85,9 +85,11 @@ function refreshLastRenderedImage(lastRenderedUpdate) {
// Only filter out other job IDs if this component has actually a non-empty job ID. // Only filter out other job IDs if this component has actually a non-empty job ID.
if (props.jobID && lastRenderedUpdate.job_id != props.jobID) { if (props.jobID && lastRenderedUpdate.job_id != props.jobID) {
console.log( console.log(
"LastRenderedImage.vue: refreshLastRenderedImage() received update for job", 'LastRenderedImage.vue: refreshLastRenderedImage() received update for job',
lastRenderedUpdate.job_id, lastRenderedUpdate.job_id,
"but this component is showing job", props.jobID); 'but this component is showing job',
props.jobID
);
return; return;
} }
@ -95,9 +97,12 @@ function refreshLastRenderedImage(lastRenderedUpdate) {
} }
// Call fetchImageURL(jobID) whenever the job ID prop changes value. // Call fetchImageURL(jobID) whenever the job ID prop changes value.
watch(() => props.jobID, (newJobID) => { watch(
fetchImageURL(newJobID); () => props.jobID,
}); (newJobID) => {
fetchImageURL(newJobID);
}
);
fetchImageURL(props.jobID); fetchImageURL(props.jobID);
// Expose refreshLastRenderedImage() so that it can be called from the parent // Expose refreshLastRenderedImage() so that it can be called from the parent

@ -1,7 +1,11 @@
<template> <template>
<section class="btn-bar tasks"> <section class="btn-bar tasks">
<button class="btn cancel" :disabled="!tasks.canCancel" v-on:click="onButtonCancel">Cancel Task</button> <button class="btn cancel" :disabled="!tasks.canCancel" v-on:click="onButtonCancel">
<button class="btn requeue" :disabled="!tasks.canRequeue" v-on:click="onButtonRequeue">Requeue</button> Cancel Task
</button>
<button class="btn requeue" :disabled="!tasks.canRequeue" v-on:click="onButtonRequeue">
Requeue
</button>
</section> </section>
</template> </template>
@ -10,21 +14,18 @@ import { useTasks } from '@/stores/tasks';
import { useNotifs } from '@/stores/notifications'; import { useNotifs } from '@/stores/notifications';
export default { export default {
name: "TaskActionsBar", name: 'TaskActionsBar',
data: () => ({ data: () => ({
tasks: useTasks(), tasks: useTasks(),
notifs: useNotifs(), notifs: useNotifs(),
}), }),
computed: { computed: {},
},
methods: { methods: {
onButtonCancel() { onButtonCancel() {
return this._handleTaskActionPromise( return this._handleTaskActionPromise(this.tasks.cancelTasks(), 'cancelled');
this.tasks.cancelTasks(), "cancelled");
}, },
onButtonRequeue() { onButtonRequeue() {
return this._handleTaskActionPromise( return this._handleTaskActionPromise(this.tasks.requeueTasks(), 'requeueing');
this.tasks.requeueTasks(), "requeueing");
}, },
_handleTaskActionPromise(promise, description) { _handleTaskActionPromise(promise, description) {
@ -42,8 +43,8 @@ export default {
.catch((error) => { .catch((error) => {
const errorMsg = JSON.stringify(error); // TODO: handle API errors better. const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
this.notifs.add(`Error: ${errorMsg}`); this.notifs.add(`Error: ${errorMsg}`);
}) });
}, },
} },
} };
</script> </script>

@ -4,7 +4,9 @@
<template v-if="hasTaskData"> <template v-if="hasTaskData">
<dl> <dl>
<dt class="field-id" title="ID">ID</dt> <dt class="field-id" title="ID">ID</dt>
<dd><span @click="copyElementText" class="click-to-copy">{{ taskData.id }}</span></dd> <dd>
<span @click="copyElementText" class="click-to-copy">{{ taskData.id }}</span>
</dd>
<dt class="field-name" title="Name">Name</dt> <dt class="field-name" title="Name">Name</dt>
<dd>{{ taskData.name }}</dd> <dd>{{ taskData.name }}</dd>
@ -57,9 +59,15 @@
<h3 class="sub-title">Task Log</h3> <h3 class="sub-title">Task Log</h3>
<div class="btn-bar-group"> <div class="btn-bar-group">
<section class="btn-bar tasklog"> <section class="btn-bar tasklog">
<button class="btn" @click="$emit('showTaskLogTail')" title="Open the task log tail in the footer."> <button
Follow Task Log</button> class="btn"
<button class="btn" @click="openFullLog" title="Opens the task log in a new window.">Open Full Log</button> @click="$emit('showTaskLogTail')"
title="Open the task log tail in the footer.">
Follow Task Log
</button>
<button class="btn" @click="openFullLog" title="Opens the task log in a new window.">
Open Full Log
</button>
</section> </section>
</div> </div>
</template> </template>
@ -70,20 +78,20 @@
</template> </template>
<script> <script>
import * as datetime from "@/datetime"; import * as datetime from '@/datetime';
import { JobsApi } from '@/manager-api'; import { JobsApi } from '@/manager-api';
import { backendURL } from '@/urls'; import { backendURL } from '@/urls';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
import { useNotifs } from "@/stores/notifications"; import { useNotifs } from '@/stores/notifications';
import LinkWorker from '@/components/LinkWorker.vue'; import LinkWorker from '@/components/LinkWorker.vue';
import { copyElementText } from '@/clipboard'; import { copyElementText } from '@/clipboard';
export default { export default {
props: [ props: [
"taskData", // Task data to show. 'taskData', // Task data to show.
], ],
emits: [ emits: [
"showTaskLogTail", // Emitted when the user presses the "follow task log" button. 'showTaskLogTail', // Emitted when the user presses the "follow task log" button.
], ],
components: { LinkWorker }, components: { LinkWorker },
data() { data() {
@ -107,20 +115,21 @@ export default {
openFullLog() { openFullLog() {
const taskUUID = this.taskData.id; const taskUUID = this.taskData.id;
this.jobsApi.fetchTaskLogInfo(taskUUID) this.jobsApi
.fetchTaskLogInfo(taskUUID)
.then((logInfo) => { .then((logInfo) => {
if (logInfo == null) { if (logInfo == null) {
this.notifs.add(`Task ${taskUUID} has no log yet`) this.notifs.add(`Task ${taskUUID} has no log yet`);
return; return;
} }
console.log(`task ${taskUUID} log info:`, logInfo); console.log(`task ${taskUUID} log info:`, logInfo);
const url = backendURL(logInfo.url); const url = backendURL(logInfo.url);
window.open(url, "_blank"); window.open(url, '_blank');
}) })
.catch((error) => { .catch((error) => {
console.log(`Error fetching task ${taskUUID} log info:`, error); console.log(`Error fetching task ${taskUUID} log info:`, error);
}) });
}, },
}, },
}; };
@ -128,7 +137,7 @@ export default {
<style scoped> <style scoped>
/* Prevent fields with long IDs from overflowing. */ /* Prevent fields with long IDs from overflowing. */
.field-id+dd { .field-id + dd {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;

@ -3,7 +3,9 @@
<div class="btn-bar-group"> <div class="btn-bar-group">
<task-actions-bar /> <task-actions-bar />
<div class="align-right"> <div class="align-right">
<status-filter-bar :availableStatuses="availableStatuses" :activeStatuses="shownStatuses" <status-filter-bar
:availableStatuses="availableStatuses"
:activeStatuses="shownStatuses"
@click="toggleStatusFilter" /> @click="toggleStatusFilter" />
</div> </div>
</div> </div>
@ -14,23 +16,24 @@
<script> <script>
import { TabulatorFull as Tabulator } from 'tabulator-tables'; import { TabulatorFull as Tabulator } from 'tabulator-tables';
import * as datetime from "@/datetime"; import * as datetime from '@/datetime';
import * as API from '@/manager-api' import * as API from '@/manager-api';
import { indicator } from '@/statusindicator'; import { indicator } from '@/statusindicator';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
import { useTasks } from '@/stores/tasks'; import { useTasks } from '@/stores/tasks';
import TaskActionsBar from '@/components/jobs/TaskActionsBar.vue' import TaskActionsBar from '@/components/jobs/TaskActionsBar.vue';
import StatusFilterBar from '@/components/StatusFilterBar.vue' import StatusFilterBar from '@/components/StatusFilterBar.vue';
export default { export default {
emits: ["tableRowClicked"], emits: ['tableRowClicked'],
props: [ props: [
"jobID", // ID of the job of which the tasks are shown here. 'jobID', // ID of the job of which the tasks are shown here.
"taskID", // The active task. 'taskID', // The active task.
], ],
components: { components: {
TaskActionsBar, StatusFilterBar, TaskActionsBar,
StatusFilterBar,
}, },
data: () => { data: () => {
return { return {
@ -52,7 +55,9 @@ export default {
// Useful for debugging when there are many similar tasks: // Useful for debugging when there are many similar tasks:
// { title: "ID", field: "id", headerSort: false, formatter: (cell) => cell.getData().id.substr(0, 8), }, // { title: "ID", field: "id", headerSort: false, formatter: (cell) => cell.getData().id.substr(0, 8), },
{ {
title: 'Status', field: 'status', sorter: 'string', title: 'Status',
field: 'status',
sorter: 'string',
formatter: (cell) => { formatter: (cell) => {
const status = cell.getData().status; const status = cell.getData().status;
const dot = indicator(status); const dot = indicator(status);
@ -61,36 +66,36 @@ export default {
}, },
{ title: 'Name', field: 'name', sorter: 'string' }, { title: 'Name', field: 'name', sorter: 'string' },
{ {
title: 'Updated', field: 'updated', title: 'Updated',
sorter: 'alphanum', sorterParams: { alignEmptyValues: "top" }, field: 'updated',
sorter: 'alphanum',
sorterParams: { alignEmptyValues: 'top' },
formatter(cell) { formatter(cell) {
const cellValue = cell.getData().updated; const cellValue = cell.getData().updated;
// TODO: if any "{amount} {units} ago" shown, the table should be // TODO: if any "{amount} {units} ago" shown, the table should be
// refreshed every few {units}, so that it doesn't show any stale "4 // refreshed every few {units}, so that it doesn't show any stale "4
// seconds ago" for days. // seconds ago" for days.
return datetime.relativeTime(cellValue); return datetime.relativeTime(cellValue);
} },
}, },
], ],
rowFormatter(row) { rowFormatter(row) {
const data = row.getData(); const data = row.getData();
const isActive = (data.id === vueComponent.taskID); const isActive = data.id === vueComponent.taskID;
row.getElement().classList.toggle("active-row", isActive); row.getElement().classList.toggle('active-row', isActive);
}, },
initialSort: [ initialSort: [{ column: 'updated', dir: 'desc' }],
{ column: "updated", dir: "desc" }, layout: 'fitData',
],
layout: "fitData",
layoutColumnsOnNewData: true, layoutColumnsOnNewData: true,
height: "100%", // Must be set in order for the virtual DOM to function correctly. height: '100%', // Must be set in order for the virtual DOM to function correctly.
maxHeight: "100%", maxHeight: '100%',
data: [], // Will be filled via a Flamenco API request. data: [], // Will be filled via a Flamenco API request.
selectable: false, // The active task is tracked by click events. selectable: false, // The active task is tracked by click events.
}; };
this.tabulator = new Tabulator('#flamenco_task_list', options); this.tabulator = new Tabulator('#flamenco_task_list', options);
this.tabulator.on("rowClick", this.onRowClick); this.tabulator.on('rowClick', this.onRowClick);
this.tabulator.on("tableBuilt", this._onTableBuilt); this.tabulator.on('tableBuilt', this._onTableBuilt);
window.addEventListener('resize', this.recalcTableHeight); window.addEventListener('resize', this.recalcTableHeight);
}, },
@ -133,11 +138,10 @@ export default {
} }
const jobsApi = new API.JobsApi(getAPIClient()); const jobsApi = new API.JobsApi(getAPIClient());
jobsApi.fetchJobTasks(this.jobID) jobsApi.fetchJobTasks(this.jobID).then(this.onTasksFetched, function (error) {
.then(this.onTasksFetched, function (error) { // TODO: error handling.
// TODO: error handling. console.error(error);
console.error(error); });
})
}, },
onTasksFetched(data) { onTasksFetched(data) {
// "Down-cast" to TaskUpdate to only get those fields, just for debugging things: // "Down-cast" to TaskUpdate to only get those fields, just for debugging things:
@ -151,9 +155,12 @@ export default {
// updateData() will only overwrite properties that are actually set on // updateData() will only overwrite properties that are actually set on
// taskUpdate, and leave the rest as-is. // taskUpdate, and leave the rest as-is.
if (this.tabulator.initialized) { if (this.tabulator.initialized) {
this.tabulator.updateData([taskUpdate]) this.tabulator
.updateData([taskUpdate])
.then(this.sortData) .then(this.sortData)
.then(() => { this.tabulator.redraw(); }) // Resize columns based on new data. .then(() => {
this.tabulator.redraw();
}); // Resize columns based on new data.
} }
this._refreshAvailableStatuses(); this._refreshAvailableStatuses();
}, },
@ -163,7 +170,7 @@ export default {
// store. There were some issues where navigating to another job would // store. There were some issues where navigating to another job would
// overwrite the old job's ID, and this prevents that. // overwrite the old job's ID, and this prevents that.
const rowData = plain(row.getData()); const rowData = plain(row.getData());
this.$emit("tableRowClicked", rowData); this.$emit('tableRowClicked', rowData);
}, },
toggleStatusFilter(status) { toggleStatusFilter(status) {
const asSet = new Set(this.shownStatuses); const asSet = new Set(this.shownStatuses);
@ -191,7 +198,7 @@ export default {
// Use tab.rowManager.findRow() instead of `tab.getRow()` as the latter // Use tab.rowManager.findRow() instead of `tab.getRow()` as the latter
// logs a warning when the row cannot be found. // logs a warning when the row cannot be found.
const row = this.tabulator.rowManager.findRow(jobID); const row = this.tabulator.rowManager.findRow(jobID);
if (!row) return if (!row) return;
if (row.reformat) row.reformat(); if (row.reformat) row.reformat();
else if (row.reinitialize) row.reinitialize(true); else if (row.reinitialize) row.reinitialize(true);
}, },
@ -221,7 +228,9 @@ export default {
// `offsetParent` is assumed to be the actual column in the 3-column // `offsetParent` is assumed to be the actual column in the 3-column
// view. To ensure this, it's given `position: relative` in the CSS // view. To ensure this, it's given `position: relative` in the CSS
// styling. // styling.
console.warn("TaskTable.recalcTableHeight() only works when the offset parent is the real parent of the element."); console.warn(
'TaskTable.recalcTableHeight() only works when the offset parent is the real parent of the element.'
);
return; return;
} }
@ -234,7 +243,6 @@ export default {
this.tabulator.setHeight(tableHeight); this.tabulator.setHeight(tableHeight);
}, },
} },
}; };
</script> </script>

@ -3,15 +3,15 @@ const props = defineProps({
title: String, title: String,
nextLabel: { nextLabel: {
type: String, type: String,
default: 'Next' default: 'Next',
}, },
isBackVisible: { isBackVisible: {
type: Boolean, type: Boolean,
default: true default: true,
}, },
isNextClickable: { isNextClickable: {
type: Boolean, type: Boolean,
default: false default: false,
}, },
}); });
</script> </script>
@ -21,18 +21,12 @@ const props = defineProps({
<h2>{{ title }}</h2> <h2>{{ title }}</h2>
<slot></slot> <slot></slot>
<div class="btn-bar btn-bar-wide"> <div class="btn-bar btn-bar-wide">
<button <button v-show="isBackVisible" @click="$emit('backClicked')" class="btn btn-lg">Back</button>
v-show="isBackVisible"
@click="$emit('backClicked')"
class="btn btn-lg"
>Back
</button>
<button <button
@click="$emit('nextClicked')" @click="$emit('nextClicked')"
:disabled="!isNextClickable" :disabled="!isNextClickable"
class="btn btn-lg btn-primary" class="btn btn-lg btn-primary">
> {{ nextLabel }}
{{ nextLabel }}
</button> </button>
</div> </div>
</div> </div>

@ -8,15 +8,17 @@
<option :value="key" v-if="action.condition()">{{ action.label }}</option> <option :value="key" v-if="action.condition()">{{ action.label }}</option>
</template> </template>
</select> </select>
<button :disabled="!canPerformAction" class="btn" @click.prevent="performWorkerAction">Apply</button> <button :disabled="!canPerformAction" class="btn" @click.prevent="performWorkerAction">
Apply
</button>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue';
import { useWorkers } from '@/stores/workers'; import { useWorkers } from '@/stores/workers';
import { useNotifs } from '@/stores/notifications'; import { useNotifs } from '@/stores/notifications';
import { WorkerMgtApi, WorkerStatusChangeRequest } from '@/manager-api'; import { WorkerMgtApi, WorkerStatusChangeRequest } from '@/manager-api';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
/* Freeze to prevent Vue.js from creating getters & setters all over this object. /* Freeze to prevent Vue.js from creating getters & setters all over this object.
* We don't need it to be tracked, as it won't be changed anyway. */ * We don't need it to be tracked, as it won't be changed anyway. */
@ -24,7 +26,8 @@ const WORKER_ACTIONS = Object.freeze({
offline_lazy: { offline_lazy: {
label: 'Shut Down (after task is finished)', label: 'Shut Down (after task is finished)',
icon: '✝', icon: '✝',
title: 'Shut down the worker after the current task finishes. The worker may automatically restart.', title:
'Shut down the worker after the current task finishes. The worker may automatically restart.',
target_status: 'offline', target_status: 'offline',
lazy: true, lazy: true,
condition: () => true, condition: () => true,
@ -88,19 +91,19 @@ const notifs = useNotifs();
function performWorkerAction() { function performWorkerAction() {
const workerID = workers.activeWorkerID; const workerID = workers.activeWorkerID;
if (!workerID) { if (!workerID) {
notifs.add("Select a Worker before applying an action."); notifs.add('Select a Worker before applying an action.');
return; return;
} }
const api = new WorkerMgtApi(getAPIClient()); const api = new WorkerMgtApi(getAPIClient());
const action = WORKER_ACTIONS[selectedAction.value]; const action = WORKER_ACTIONS[selectedAction.value];
const statuschange = new WorkerStatusChangeRequest(action.target_status, action.lazy); const statuschange = new WorkerStatusChangeRequest(action.target_status, action.lazy);
console.log("Requesting worker status change", statuschange); console.log('Requesting worker status change', statuschange);
api.requestWorkerStatusChange(workerID, statuschange) api
.requestWorkerStatusChange(workerID, statuschange)
.then((result) => notifs.add(`Worker status change to ${action.target_status} confirmed.`)) .then((result) => notifs.add(`Worker status change to ${action.target_status} confirmed.`))
.catch((error) => { .catch((error) => {
notifs.add(`Error requesting worker status change: ${error.body.message}`) notifs.add(`Error requesting worker status change: ${error.body.message}`);
}); });
} }
</script> </script>

@ -4,7 +4,9 @@
<template v-if="hasWorkerData"> <template v-if="hasWorkerData">
<dl> <dl>
<dt class="field-id">ID</dt> <dt class="field-id">ID</dt>
<dd><span @click="copyElementText" class="click-to-copy">{{ workerData.id }}</span></dd> <dd>
<span @click="copyElementText" class="click-to-copy">{{ workerData.id }}</span>
</dd>
<dt class="field-name">Name</dt> <dt class="field-name">Name</dt>
<dd>{{ workerData.name }}</dd> <dd>{{ workerData.name }}</dd>
@ -20,7 +22,9 @@
<dd title="Version of Flamenco">{{ workerData.version }}</dd> <dd title="Version of Flamenco">{{ workerData.version }}</dd>
<dt class="field-ip_address">IP Addr</dt> <dt class="field-ip_address">IP Addr</dt>
<dd><span @click="copyElementText" class="click-to-copy">{{ workerData.ip_address }}</span></dd> <dd>
<span @click="copyElementText" class="click-to-copy">{{ workerData.ip_address }}</span>
</dd>
<dt class="field-platform">Platform</dt> <dt class="field-platform">Platform</dt>
<dd>{{ workerData.platform }}</dd> <dd>{{ workerData.platform }}</dd>
@ -35,7 +39,7 @@
<template v-if="workerData.can_restart"> <template v-if="workerData.can_restart">
<dt class="field-can-restart">Can Restart</dt> <dt class="field-can-restart">Can Restart</dt>
<dd>{{ workerData.can_restart }}</dd> <dd>{{ workerData.can_restart }}</dd>
</template> </template>
</dl> </dl>
@ -47,56 +51,73 @@
:isChecked="thisWorkerTags[tag.id]" :isChecked="thisWorkerTags[tag.id]"
:label="tag.name" :label="tag.name"
:title="tag.description" :title="tag.description"
@switch-toggle="toggleWorkerTag(tag.id)" @switch-toggle="toggleWorkerTag(tag.id)">
>
</switch-checkbox> </switch-checkbox>
</li> </li>
</ul> </ul>
<p class="hint" v-if="hasTagsAssigned"> <p class="hint" v-if="hasTagsAssigned">
This worker will only pick up jobs assigned to one of its tags, and This worker will only pick up jobs assigned to one of its tags, and tagless jobs.
tagless jobs.
</p> </p>
<p class="hint" v-else>This worker will only pick up tagless jobs.</p> <p class="hint" v-else>This worker will only pick up tagless jobs.</p>
</section> </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">
</switch-checkbox> </switch-checkbox>
Sleep Schedule Sleep Schedule
<div v-if="!isScheduleEditing" class="sub-title-buttons"> <div v-if="!isScheduleEditing" class="sub-title-buttons">
<button @click="isScheduleEditing = true">Edit</button> <button @click="isScheduleEditing = true">Edit</button>
</div> </div>
</h3> </h3>
<p>Time of the day (and on which days) this worker should go to sleep. </p> <p>Time of the day (and on which days) this worker should go to sleep.</p>
<div class="sleep-schedule-edit" v-if="isScheduleEditing"> <div class="sleep-schedule-edit" v-if="isScheduleEditing">
<div> <div>
<label>Days of the week</label> <label>Days of the week</label>
<input type="text" placeholder="mo tu we th fr" v-model="workerSleepSchedule.days_of_week"> <input
type="text"
placeholder="mo tu we th fr"
v-model="workerSleepSchedule.days_of_week" />
<span class="input-help-text"> <span class="input-help-text">
Write each day name using their first two letters, separated by spaces. Write each day name using their first two letters, separated by spaces. (e.g. mo tu we
(e.g. mo tu we th fr) th fr)
</span> </span>
</div> </div>
<div class="sleep-schedule-edit-time"> <div class="sleep-schedule-edit-time">
<div> <div>
<label>Start Time</label> <label>Start Time</label>
<input type="text" placeholder="09:00" v-model="workerSleepSchedule.start_time" class="time"> <input
type="text"
placeholder="09:00"
v-model="workerSleepSchedule.start_time"
class="time" />
</div> </div>
<div> <div>
<label>End Time</label> <label>End Time</label>
<input type="text" placeholder="18:00" v-model="workerSleepSchedule.end_time" class="time"> <input
type="text"
placeholder="18:00"
v-model="workerSleepSchedule.end_time"
class="time" />
</div> </div>
</div> </div>
<span class="input-help-text"> <span class="input-help-text"> Use 24-hour format. </span>
Use 24-hour format.
</span>
<div class="btn-bar-group"> <div class="btn-bar-group">
<div class="btn-bar"> <div class="btn-bar">
<button v-if="isScheduleEditing" @click="cancelEditWorkerSleepSchedule" class="btn">Cancel</button> <button v-if="isScheduleEditing" @click="cancelEditWorkerSleepSchedule" class="btn">
<button v-if="isScheduleEditing" @click="saveWorkerSleepSchedule" class="btn btn-primary">Save Cancel
Schedule</button> </button>
<button
v-if="isScheduleEditing"
@click="saveWorkerSleepSchedule"
class="btn btn-primary">
Save Schedule
</button>
</div> </div>
</div> </div>
</div> </div>
@ -130,15 +151,17 @@
</template> </template>
<template v-else> <template v-else>
<template v-if="workerData.status == 'error'"> <template v-if="workerData.status == 'error'">
in <span class="worker-status">error</span> state in <span class="worker-status">error</span> state
</template> </template>
<template v-else> <template v-else>
<span class="worker-status">{{ workerData.status }}</span> <span class="worker-status">{{ workerData.status }}</span> </template
</template>, which means removing it now can cause it to log errors. It >, which means removing it now can cause it to log errors. It is advised to shut down the
is advised to shut down the Worker before removing it from the system. Worker before removing it from the system.
</template> </template>
</p> </p>
<p><button @click="deleteWorker">Remove {{ workerData.name }}</button></p> <p>
<button @click="deleteWorker">Remove {{ workerData.name }}</button>
</p>
</section> </section>
</template> </template>
@ -148,20 +171,20 @@
</template> </template>
<script> <script>
import { useNotifs } from '@/stores/notifications' import { useNotifs } from '@/stores/notifications';
import { useWorkers } from '@/stores/workers' import { useWorkers } from '@/stores/workers';
import * as datetime from "@/datetime"; import * as datetime from '@/datetime';
import { WorkerMgtApi, WorkerSleepSchedule, WorkerTagChangeRequest } from "@/manager-api"; import { WorkerMgtApi, WorkerSleepSchedule, WorkerTagChangeRequest } 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';
import SwitchCheckbox from '@/components/SwitchCheckbox.vue'; import SwitchCheckbox from '@/components/SwitchCheckbox.vue';
import { copyElementText } from '@/clipboard'; import { copyElementText } from '@/clipboard';
export default { export default {
props: [ props: [
"workerData", // Worker data to show. 'workerData', // Worker data to show.
], ],
components: { components: {
LinkWorkerTask, LinkWorkerTask,
@ -171,7 +194,7 @@ export default {
return { return {
datetime: datetime, // So that the template can access it. datetime: datetime, // So that the template can access it.
api: new WorkerMgtApi(getAPIClient()), api: new WorkerMgtApi(getAPIClient()),
workerStatusHTML: "", workerStatusHTML: '',
workerSleepSchedule: this.defaultWorkerSleepSchedule(), workerSleepSchedule: this.defaultWorkerSleepSchedule(),
isScheduleEditing: false, isScheduleEditing: false,
notifs: useNotifs(), notifs: useNotifs(),
@ -194,11 +217,11 @@ export default {
if (newData) { if (newData) {
this.workerStatusHTML = workerStatus(newData); this.workerStatusHTML = workerStatus(newData);
} else { } else {
this.workerStatusHTML = ""; this.workerStatusHTML = '';
} }
// Update workerSleepSchedule only if oldData and newData have different ids, or if there is no oldData // Update workerSleepSchedule only if oldData and newData have different ids, or if there is no oldData
// and we provide newData. // and we provide newData.
if (((oldData && newData) && (oldData.id != newData.id)) || !oldData && newData) { if ((oldData && newData && oldData.id != newData.id) || (!oldData && newData)) {
this.fetchWorkerSleepSchedule(); this.fetchWorkerSleepSchedule();
} }
@ -213,10 +236,17 @@ export default {
// Utility to display workerSleepSchedule, taking into account the case when the default values are used. // Utility to display workerSleepSchedule, taking into account the case when the default values are used.
// This way, empty strings are represented more meaningfully. // This way, empty strings are represented more meaningfully.
return { return {
'days_of_week': this.workerSleepSchedule.days_of_week === '' ? 'every day' : this.workerSleepSchedule.days_of_week, days_of_week:
'start_time': this.workerSleepSchedule.start_time === '' ? '00:00' : this.workerSleepSchedule.start_time, this.workerSleepSchedule.days_of_week === ''
'end_time': this.workerSleepSchedule.end_time === '' ? '24:00' : this.workerSleepSchedule.end_time, ? 'every day'
} : this.workerSleepSchedule.days_of_week,
start_time:
this.workerSleepSchedule.start_time === ''
? '00:00'
: this.workerSleepSchedule.start_time,
end_time:
this.workerSleepSchedule.end_time === '' ? '24:00' : this.workerSleepSchedule.end_time,
};
}, },
workerSleepScheduleStatusLabel() { workerSleepScheduleStatusLabel() {
return this.workerSleepSchedule.is_active ? 'Enabled' : 'Disabled'; return this.workerSleepSchedule.is_active ? 'Enabled' : 'Disabled';
@ -228,7 +258,8 @@ export default {
}, },
methods: { methods: {
fetchWorkerSleepSchedule() { fetchWorkerSleepSchedule() {
this.api.fetchWorkerSleepSchedule(this.workerData.id) this.api
.fetchWorkerSleepSchedule(this.workerData.id)
.then((schedule) => { .then((schedule) => {
// Replace the default workerSleepSchedule if the Worker has one // Replace the default workerSleepSchedule if the Worker has one
@ -244,8 +275,9 @@ export default {
}); });
}, },
setWorkerSleepSchedule(notifMessage) { setWorkerSleepSchedule(notifMessage) {
this.api.setWorkerSleepSchedule(this.workerData.id, this.workerSleepSchedule).then( this.api
this.notifs.add(notifMessage)); .setWorkerSleepSchedule(this.workerData.id, this.workerSleepSchedule)
.then(this.notifs.add(notifMessage));
}, },
toggleWorkerSleepSchedule() { toggleWorkerSleepSchedule() {
this.workerSleepSchedule.is_active = !this.workerSleepSchedule.is_active; this.workerSleepSchedule.is_active = !this.workerSleepSchedule.is_active;
@ -261,12 +293,12 @@ export default {
this.isScheduleEditing = false; this.isScheduleEditing = false;
}, },
defaultWorkerSleepSchedule() { defaultWorkerSleepSchedule() {
return new WorkerSleepSchedule(false, '', '', '') // Default values in OpenAPI return new WorkerSleepSchedule(false, '', '', ''); // Default values in OpenAPI
}, },
deleteWorker() { deleteWorker() {
let msg = `Are you sure you want to remove ${this.workerData.name}?`; let msg = `Are you sure you want to remove ${this.workerData.name}?`;
if (this.workerData.status != "offline") { if (this.workerData.status != 'offline') {
msg += "\nRemoving it without first shutting it down will cause it to log errors."; msg += '\nRemoving it without first shutting it down will cause it to log errors.';
} }
if (!confirm(msg)) { if (!confirm(msg)) {
return; return;
@ -286,9 +318,9 @@ export default {
this.thisWorkerTags = assignedTags; this.thisWorkerTags = assignedTags;
}, },
toggleWorkerTag(tagID) { toggleWorkerTag(tagID) {
console.log("Toggled", tagID); console.log('Toggled', tagID);
this.thisWorkerTags[tagID] = !this.thisWorkerTags[tagID]; this.thisWorkerTags[tagID] = !this.thisWorkerTags[tagID];
console.log("New assignment:", plain(this.thisWorkerTags)); console.log('New assignment:', plain(this.thisWorkerTags));
// Construct tag change request. // Construct tag change request.
const tagIDs = this.getAssignedTagIDs(); const tagIDs = this.getAssignedTagIDs();
@ -298,7 +330,7 @@ export default {
this.api this.api
.setWorkerTags(this.workerData.id, changeRequest) .setWorkerTags(this.workerData.id, changeRequest)
.then(() => { .then(() => {
this.notifs.add("Tag assignment updated"); this.notifs.add('Tag assignment updated');
}) })
.catch((error) => { .catch((error) => {
const errorMsg = JSON.stringify(error); // TODO: handle API errors better. const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
@ -333,7 +365,7 @@ export default {
bottom: var(--spacer-xs); bottom: var(--spacer-xs);
} }
.sleep-schedule .btn-bar label+.btn { .sleep-schedule .btn-bar label + .btn {
margin-left: var(--spacer-sm); margin-left: var(--spacer-sm);
} }
@ -351,7 +383,7 @@ export default {
flex-direction: column; flex-direction: column;
} }
.sleep-schedule-edit>div { .sleep-schedule-edit > div {
margin: var(--spacer-sm) 0; margin: var(--spacer-sm) 0;
} }
@ -362,7 +394,7 @@ export default {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.sleep-schedule-edit>.sleep-schedule-edit-time { .sleep-schedule-edit > .sleep-schedule-edit-time {
display: flex; display: flex;
margin-bottom: 0; margin-bottom: 0;
} }
@ -372,19 +404,19 @@ export default {
display: none; display: none;
} }
.sleep-schedule-edit input[type="text"] { .sleep-schedule-edit input[type='text'] {
font-family: var(--font-family-mono); font-family: var(--font-family-mono);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
width: 23ch; width: 23ch;
} }
.sleep-schedule-edit input[type="text"].time { .sleep-schedule-edit input[type='text'].time {
width: 10ch; width: 10ch;
margin-right: var(--spacer); margin-right: var(--spacer);
} }
/* Prevent fields with long IDs from overflowing. */ /* Prevent fields with long IDs from overflowing. */
.field-id+dd { .field-id + dd {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;

@ -5,7 +5,10 @@
<worker-actions-bar /> <worker-actions-bar />
<div class="align-right"> <div class="align-right">
<status-filter-bar :availableStatuses="availableStatuses" :activeStatuses="shownStatuses" classPrefix="worker-" <status-filter-bar
:availableStatuses="availableStatuses"
:activeStatuses="shownStatuses"
classPrefix="worker-"
@click="toggleStatusFilter" /> @click="toggleStatusFilter" />
</div> </div>
</div> </div>
@ -17,18 +20,18 @@
<script> <script>
import { TabulatorFull as Tabulator } from 'tabulator-tables'; import { TabulatorFull as Tabulator } from 'tabulator-tables';
import { WorkerMgtApi } from '@/manager-api' import { WorkerMgtApi } from '@/manager-api';
import { indicator, workerStatus } from '@/statusindicator'; import { indicator, workerStatus } from '@/statusindicator';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
import { useWorkers } from '@/stores/workers'; import { useWorkers } from '@/stores/workers';
import StatusFilterBar from '@/components/StatusFilterBar.vue' import StatusFilterBar from '@/components/StatusFilterBar.vue';
import WorkerActionsBar from '@/components/workers/WorkerActionsBar.vue' import WorkerActionsBar from '@/components/workers/WorkerActionsBar.vue';
export default { export default {
name: 'WorkersTable', name: 'WorkersTable',
props: ["activeWorkerID"], props: ['activeWorkerID'],
emits: ["tableRowClicked"], emits: ['tableRowClicked'],
components: { components: {
StatusFilterBar, StatusFilterBar,
WorkerActionsBar, WorkerActionsBar,
@ -51,7 +54,9 @@ export default {
// Useful for debugging when there are many similar workers: // Useful for debugging when there are many similar workers:
// { title: "ID", field: "id", headerSort: false, formatter: (cell) => cell.getData().id.substr(0, 8), }, // { title: "ID", field: "id", headerSort: false, formatter: (cell) => cell.getData().id.substr(0, 8), },
{ {
title: 'Status', field: 'status', sorter: 'string', title: 'Status',
field: 'status',
sorter: 'string',
formatter: (cell) => { formatter: (cell) => {
const data = cell.getData(); const data = cell.getData();
const dot = indicator(data.status, 'worker-'); const dot = indicator(data.status, 'worker-');
@ -64,21 +69,19 @@ export default {
], ],
rowFormatter(row) { rowFormatter(row) {
const data = row.getData(); const data = row.getData();
const isActive = (data.id === vueComponent.activeWorkerID); const isActive = data.id === vueComponent.activeWorkerID;
row.getElement().classList.toggle("active-row", isActive); row.getElement().classList.toggle('active-row', isActive);
}, },
initialSort: [ initialSort: [{ column: 'name', dir: 'asc' }],
{ column: "name", dir: "asc" }, layout: 'fitData',
],
layout: "fitData",
layoutColumnsOnNewData: true, layoutColumnsOnNewData: true,
height: "360px", // Must be set in order for the virtual DOM to function correctly. height: '360px', // Must be set in order for the virtual DOM to function correctly.
data: [], // Will be filled via a Flamenco API request. data: [], // Will be filled via a Flamenco API request.
selectable: false, // The active worker is tracked by click events, not row selection. selectable: false, // The active worker is tracked by click events, not row selection.
}; };
this.tabulator = new Tabulator('#flamenco_workers_list', options); this.tabulator = new Tabulator('#flamenco_workers_list', options);
this.tabulator.on("rowClick", this.onRowClick); this.tabulator.on('rowClick', this.onRowClick);
this.tabulator.on("tableBuilt", this._onTableBuilt); this.tabulator.on('tableBuilt', this._onTableBuilt);
window.addEventListener('resize', this.recalcTableHeight); window.addEventListener('resize', this.recalcTableHeight);
}, },
@ -100,7 +103,7 @@ export default {
computed: { computed: {
selectedIDs() { selectedIDs() {
return this.tabulator.getSelectedData().map((worker) => worker.id); return this.tabulator.getSelectedData().map((worker) => worker.id);
} },
}, },
methods: { methods: {
onReconnected() { onReconnected() {
@ -159,7 +162,9 @@ export default {
} }
promise promise
.then(this.sortData) .then(this.sortData)
.then(() => { this.tabulator.redraw(); }) // Resize columns based on new data. .then(() => {
this.tabulator.redraw();
}) // Resize columns based on new data.
.then(this._refreshAvailableStatuses); .then(this._refreshAvailableStatuses);
// TODO: this should also resize the columns, as the status column can // TODO: this should also resize the columns, as the status column can
@ -171,7 +176,7 @@ export default {
// store. There were some issues where navigating to another worker would // store. There were some issues where navigating to another worker would
// overwrite the old worker's ID, and this prevents that. // overwrite the old worker's ID, and this prevents that.
const rowData = plain(row.getData()); const rowData = plain(row.getData());
this.$emit("tableRowClicked", rowData); this.$emit('tableRowClicked', rowData);
}, },
toggleStatusFilter(status) { toggleStatusFilter(status) {
const asSet = new Set(this.shownStatuses); const asSet = new Set(this.shownStatuses);
@ -199,7 +204,7 @@ export default {
// Use tab.rowManager.findRow() instead of `tab.getRow()` as the latter // Use tab.rowManager.findRow() instead of `tab.getRow()` as the latter
// logs a warning when the row cannot be found. // logs a warning when the row cannot be found.
const row = this.tabulator.rowManager.findRow(workerID); const row = this.tabulator.rowManager.findRow(workerID);
if (!row) return if (!row) return;
if (row.reformat) row.reformat(); if (row.reformat) row.reformat();
else if (row.reinitialize) row.reinitialize(true); else if (row.reinitialize) row.reinitialize(true);
}, },
@ -229,7 +234,9 @@ export default {
// `offsetParent` is assumed to be the actual column in the 3-column // `offsetParent` is assumed to be the actual column in the 3-column
// view. To ensure this, it's given `position: relative` in the CSS // view. To ensure this, it's given `position: relative` in the CSS
// styling. // styling.
console.warn("JobsTable.recalcTableHeight() only works when the offset parent is the real parent of the element."); console.warn(
'JobsTable.recalcTableHeight() only works when the offset parent is the real parent of the element.'
);
return; return;
} }

@ -1,9 +1,9 @@
import { DateTime } from "luxon"; import { DateTime } from 'luxon';
const relativeTimeDefaultOptions = { const relativeTimeDefaultOptions = {
thresholdDays: 14, thresholdDays: 14,
format: DateTime.DATE_MED_WITH_WEEKDAY, format: DateTime.DATE_MED_WITH_WEEKDAY,
} };
/** /**
* Convert the given timestamp to a Luxon time object. * Convert the given timestamp to a Luxon time object.
@ -29,16 +29,14 @@ export function relativeTime(timestamp, options) {
const now = DateTime.local(); const now = DateTime.local();
const ageInDays = now.diff(parsedTimestamp).as('days'); const ageInDays = now.diff(parsedTimestamp).as('days');
if (ageInDays > options.format) if (ageInDays > options.format) return parsedTimestamp.toLocaleString(options.format);
return parsedTimestamp.toLocaleString(options.format); return parsedTimestamp.toRelative({ style: 'narrow' });
return parsedTimestamp.toRelative({style: "narrow"});
} }
export function shortened(timestamp) { export function shortened(timestamp) {
const parsedTimestamp = parseTimestamp(timestamp); const parsedTimestamp = parseTimestamp(timestamp);
const now = DateTime.local(); const now = DateTime.local();
const ageInHours = now.diff(parsedTimestamp).as('hours'); const ageInHours = now.diff(parsedTimestamp).as('hours');
if (ageInHours < 24) if (ageInHours < 24) return parsedTimestamp.toLocaleString(DateTime.TIME_24_SIMPLE);
return parsedTimestamp.toLocaleString(DateTime.TIME_24_SIMPLE);
return parsedTimestamp.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY); return parsedTimestamp.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY);
} }

@ -1,15 +1,15 @@
import { createApp } from 'vue' import { createApp } from 'vue';
import { createPinia } from 'pinia' import { createPinia } from 'pinia';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import App from '@/App.vue' import App from '@/App.vue';
import SetupAssistant from '@/SetupAssistant.vue' import SetupAssistant from '@/SetupAssistant.vue';
import autoreload from '@/autoreloader' import autoreload from '@/autoreloader';
import router from '@/router/index' import router from '@/router/index';
import setupAssistantRouter from '@/router/setup-assistant' import setupAssistantRouter from '@/router/setup-assistant';
import { MetaApi } from "@/manager-api"; import { MetaApi } from '@/manager-api';
import { newBareAPIClient } from "@/api-client"; import { newBareAPIClient } from '@/api-client';
import * as urls from '@/urls' import * as urls from '@/urls';
// Ensure Tabulator can find `luxon`, which it needs for sorting by // Ensure Tabulator can find `luxon`, which it needs for sorting by
// date/time/datetime. // date/time/datetime.
@ -22,21 +22,21 @@ window.objectEmpty = (o) => !o || Object.entries(o).length == 0;
// Automatically reload the window after a period of inactivity from the user. // Automatically reload the window after a period of inactivity from the user.
autoreload(); autoreload();
const pinia = createPinia() const pinia = createPinia();
function normalMode() { function normalMode() {
const app = createApp(App) const app = createApp(App);
app.use(pinia) app.use(pinia);
app.use(router) app.use(router);
app.mount('#app') app.mount('#app');
} }
function setupAssistantMode() { function setupAssistantMode() {
console.log("Flamenco Setup Assistant is starting"); console.log('Flamenco Setup Assistant is starting');
const app = createApp(SetupAssistant) const app = createApp(SetupAssistant);
app.use(pinia) app.use(pinia);
app.use(setupAssistantRouter) app.use(setupAssistantRouter);
app.mount('#app') app.mount('#app');
} }
/* This cannot use the client from '@/stores/api-query-count', as that would /* This cannot use the client from '@/stores/api-query-count', as that would
@ -44,11 +44,12 @@ function setupAssistantMode() {
* know which app to start, this API call needs to return data. */ * know which app to start, this API call needs to return data. */
const apiClient = newBareAPIClient(); const apiClient = newBareAPIClient();
const metaAPI = new MetaApi(apiClient); const metaAPI = new MetaApi(apiClient);
metaAPI.getConfiguration() metaAPI
.getConfiguration()
.then((config) => { .then((config) => {
if (config.isFirstRun) setupAssistantMode(); if (config.isFirstRun) setupAssistantMode();
else normalMode(); else normalMode();
}) })
.catch((error) => { .catch((error) => {
console.warn("Error getting Manager configuration:", error); console.warn('Error getting Manager configuration:', error);
}) });

@ -1,4 +1,4 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -32,6 +32,6 @@ const router = createRouter({
component: () => import('../views/LastRenderedView.vue'), component: () => import('../views/LastRenderedView.vue'),
}, },
], ],
}) });
export default router export default router;

@ -1,16 +1,16 @@
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{ {
path: "/", path: '/',
name: "index", name: 'index',
component: () => import("../views/SetupAssistantView.vue"), component: () => import('../views/SetupAssistantView.vue'),
}, },
{ {
path: "/:pathMatch(.*)*", path: '/:pathMatch(.*)*',
name: "redirect-to-index", name: 'redirect-to-index',
redirect: '/', redirect: '/',
}, },
], ],

@ -1,8 +1,8 @@
import { createApp } from 'vue' import { createApp } from 'vue';
import { createPinia } from 'pinia' import { createPinia } from 'pinia';
import SetupAssistant from '@/SetupAssistant.vue' import SetupAssistant from '@/SetupAssistant.vue';
import router from '@/router/setup-assistant' import router from '@/router/setup-assistant';
// Ensure Tabulator can find `luxon`, which it needs for sorting by // Ensure Tabulator can find `luxon`, which it needs for sorting by
// date/time/datetime. // date/time/datetime.
@ -14,13 +14,13 @@ window.plain = (x) => JSON.parse(JSON.stringify(x));
// objectEmpty returns whether the object is empty or not. // objectEmpty returns whether the object is empty or not.
window.objectEmpty = (o) => !o || Object.entries(o).length == 0; window.objectEmpty = (o) => !o || Object.entries(o).length == 0;
const app = createApp(SetupAssistant) const app = createApp(SetupAssistant);
const pinia = createPinia() const pinia = createPinia();
app.use(pinia) app.use(pinia);
app.use(router) app.use(router);
app.mount('#app') app.mount('#app');
// Automatically reload the window after a period of inactivity from the user. // Automatically reload the window after a period of inactivity from the user.
import autoreload from '@/autoreloader' import autoreload from '@/autoreloader';
autoreload(); autoreload();

@ -13,7 +13,7 @@ import { toTitleCase } from '@/strings';
*/ */
export function indicator(status, classNamePrefix) { export function indicator(status, classNamePrefix) {
const label = toTitleCase(status); const label = toTitleCase(status);
if (!classNamePrefix) classNamePrefix = ""; // force an empty string for any false value. if (!classNamePrefix) classNamePrefix = ''; // force an empty string for any false value.
return `<span title="${label}" class="indicator ${classNamePrefix}status-${status}"></span>`; return `<span title="${label}" class="indicator ${classNamePrefix}status-${status}"></span>`;
} }
@ -31,9 +31,9 @@ export function workerStatus(worker) {
let arrow; let arrow;
if (worker.status_change.is_lazy) { if (worker.status_change.is_lazy) {
arrow = `<span class='state-transition-arrow lazy' title='lazy status transition'>➠</span>` arrow = `<span class='state-transition-arrow lazy' title='lazy status transition'>➠</span>`;
} else { } else {
arrow = `<span class='state-transition-arrow forced' title='forced status transition'>➜</span>` arrow = `<span class='state-transition-arrow forced' title='forced status transition'>➜</span>`;
} }
return `<span class="worker-status-${worker.status}">${worker.status}</span> return `<span class="worker-status-${worker.status}">${worker.status}</span>

@ -1,10 +1,10 @@
import { defineStore } from "pinia"; import { defineStore } from 'pinia';
import { ApiClient } from "@/manager-api"; import { ApiClient } from '@/manager-api';
/** /**
* Keep track of running API queries. * Keep track of running API queries.
*/ */
export const useAPIQueryCount = defineStore("apiQueryCount", { export const useAPIQueryCount = defineStore('apiQueryCount', {
state: () => ({ state: () => ({
/** /**
* Number of running queries. * Number of running queries.
@ -28,14 +28,38 @@ export const useAPIQueryCount = defineStore("apiQueryCount", {
}); });
export class CountingApiClient extends ApiClient { export class CountingApiClient extends ApiClient {
callApi(path, httpMethod, pathParams, queryParams, headerParams, formParams, callApi(
bodyParam, authNames, contentTypes, accepts, returnType, apiBasePath ) { path,
httpMethod,
pathParams,
queryParams,
headerParams,
formParams,
bodyParam,
authNames,
contentTypes,
accepts,
returnType,
apiBasePath
) {
const apiQueryCount = useAPIQueryCount(); const apiQueryCount = useAPIQueryCount();
apiQueryCount.num++; apiQueryCount.num++;
return super return super
.callApi(path, httpMethod, pathParams, queryParams, headerParams, formParams, .callApi(
bodyParam, authNames, contentTypes, accepts, returnType, apiBasePath) path,
httpMethod,
pathParams,
queryParams,
headerParams,
formParams,
bodyParam,
authNames,
contentTypes,
accepts,
returnType,
apiBasePath
)
.finally(() => { .finally(() => {
apiQueryCount.num--; apiQueryCount.num--;
}); });

@ -1,8 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
import * as API from '@/manager-api'; import * as API from '@/manager-api';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
const jobsAPI = new API.JobsApi(getAPIClient()); const jobsAPI = new API.JobsApi(getAPIClient());
@ -16,7 +15,7 @@ export const useJobs = defineStore('jobs', {
* ID of the active job. Easier to query than `activeJob ? activeJob.id : ""`. * ID of the active job. Easier to query than `activeJob ? activeJob.id : ""`.
* @type {string} * @type {string}
*/ */
activeJobID: "", activeJobID: '',
/** /**
* Set to true when it is known that there are no jobs at all in the system. * Set to true when it is known that there are no jobs at all in the system.
@ -26,13 +25,13 @@ export const useJobs = defineStore('jobs', {
}), }),
getters: { getters: {
canDelete() { canDelete() {
return this._anyJobWithStatus(["queued", "paused", "failed", "completed", "canceled"]) return this._anyJobWithStatus(['queued', 'paused', 'failed', 'completed', 'canceled']);
}, },
canCancel() { canCancel() {
return this._anyJobWithStatus(["queued", "active", "failed"]) return this._anyJobWithStatus(['queued', 'active', 'failed']);
}, },
canRequeue() { canRequeue() {
return this._anyJobWithStatus(["canceled", "completed", "failed", "paused"]) return this._anyJobWithStatus(['canceled', 'completed', 'failed', 'paused']);
}, },
}, },
actions: { actions: {
@ -41,7 +40,7 @@ export const useJobs = defineStore('jobs', {
}, },
setActiveJobID(jobID) { setActiveJobID(jobID) {
this.$patch({ this.$patch({
activeJob: {id: jobID, settings: {}, metadata: {}}, activeJob: { id: jobID, settings: {}, metadata: {} },
activeJobID: jobID, activeJobID: jobID,
}); });
}, },
@ -60,7 +59,7 @@ export const useJobs = defineStore('jobs', {
deselectAllJobs() { deselectAllJobs() {
this.$patch({ this.$patch({
activeJob: null, activeJob: null,
activeJobID: "", activeJobID: '',
}); });
}, },
@ -72,13 +71,17 @@ export const useJobs = defineStore('jobs', {
* TODO: actually have these work on all selected jobs. For simplicity, the * TODO: actually have these work on all selected jobs. For simplicity, the
* code now assumes that only the active job needs to be operated on. * code now assumes that only the active job needs to be operated on.
*/ */
cancelJobs() { return this._setJobStatus("cancel-requested"); }, cancelJobs() {
requeueJobs() { return this._setJobStatus("requeueing"); }, return this._setJobStatus('cancel-requested');
},
requeueJobs() {
return this._setJobStatus('requeueing');
},
deleteJobs() { deleteJobs() {
if (!this.activeJobID) { if (!this.activeJobID) {
console.warn(`deleteJobs() impossible, no active job ID`); console.warn(`deleteJobs() impossible, no active job ID`);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
reject("No job selected, unable to delete"); reject('No job selected, unable to delete');
}); });
} }
@ -93,7 +96,9 @@ export const useJobs = defineStore('jobs', {
* @returns bool indicating whether there is a selected job with any of the given statuses. * @returns bool indicating whether there is a selected job with any of the given statuses.
*/ */
_anyJobWithStatus(statuses) { _anyJobWithStatus(statuses) {
return !!this.activeJob && !!this.activeJob.status && statuses.includes(this.activeJob.status); return (
!!this.activeJob && !!this.activeJob.status && statuses.includes(this.activeJob.status)
);
// return this.selectedJobs.reduce((foundJob, job) => (foundJob || statuses.includes(job.status)), false); // return this.selectedJobs.reduce((foundJob, job) => (foundJob || statuses.includes(job.status)), false);
}, },
@ -107,8 +112,8 @@ export const useJobs = defineStore('jobs', {
console.warn(`_setJobStatus(${newStatus}) impossible, no active job ID`); console.warn(`_setJobStatus(${newStatus}) impossible, no active job ID`);
return; return;
} }
const statuschange = new API.JobStatusChange(newStatus, "requested from web interface"); const statuschange = new API.JobStatusChange(newStatus, 'requested from web interface');
return jobsAPI.setJobStatus(this.activeJobID, statuschange); return jobsAPI.setJobStatus(this.activeJobID, statuschange);
}, },
}, },
}) });

@ -1,4 +1,4 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
/** Time after which a notification is hidden. */ /** Time after which a notification is hidden. */
const MESSAGE_HIDE_DELAY_MS = 5000; const MESSAGE_HIDE_DELAY_MS = 5000;
@ -17,7 +17,7 @@ export const useNotifs = defineStore('notifications', {
* @type {{ id: Number, msg: string, time: Date }[]} */ * @type {{ id: Number, msg: string, time: Date }[]} */
history: [], history: [],
/** @type { id: Number, msg: string, time: Date } */ /** @type { id: Number, msg: string, time: Date } */
last: "", last: '',
hideTimerID: 0, hideTimerID: 0,
lastID: 0, lastID: 0,
@ -31,7 +31,7 @@ export const useNotifs = defineStore('notifications', {
* @param {string} message * @param {string} message
*/ */
add(message) { add(message) {
const notif = {id: this._generateID(), msg: message, time: new Date()}; const notif = { id: this._generateID(), msg: message, time: new Date() };
this.history.push(notif); this.history.push(notif);
this.last = notif; this.last = notif;
this._prune(); this._prune();
@ -42,19 +42,17 @@ export const useNotifs = defineStore('notifications', {
* @param {API.SocketIOJobUpdate} jobUpdate Job update received via SocketIO. * @param {API.SocketIOJobUpdate} jobUpdate Job update received via SocketIO.
*/ */
addJobUpdate(jobUpdate) { addJobUpdate(jobUpdate) {
let msg = "Job"; let msg = 'Job';
if (jobUpdate.name) msg += ` ${jobUpdate.name}`; if (jobUpdate.name) msg += ` ${jobUpdate.name}`;
if (jobUpdate.was_deleted) { if (jobUpdate.was_deleted) {
msg += " was deleted"; msg += ' was deleted';
} } else if (jobUpdate.previous_status && jobUpdate.previous_status != jobUpdate.status) {
else if (jobUpdate.previous_status && jobUpdate.previous_status != jobUpdate.status) {
msg += ` changed status ${jobUpdate.previous_status}${jobUpdate.status}`; msg += ` changed status ${jobUpdate.previous_status}${jobUpdate.status}`;
} } else {
else {
// Don't bother logging just "Job" + its name, as it conveys no info. // Don't bother logging just "Job" + its name, as it conveys no info.
return; return;
} }
this.add(msg) this.add(msg);
}, },
/** /**
@ -68,19 +66,19 @@ export const useNotifs = defineStore('notifications', {
if (taskUpdate.activity) { if (taskUpdate.activity) {
msg += `: ${taskUpdate.activity}`; msg += `: ${taskUpdate.activity}`;
} }
this.add(msg) this.add(msg);
}, },
/** /**
* @param {API.SocketIOWorkerUpdate} workerUpdate Worker update received via SocketIO. * @param {API.SocketIOWorkerUpdate} workerUpdate Worker update received via SocketIO.
*/ */
addWorkerUpdate(workerUpdate) { addWorkerUpdate(workerUpdate) {
let msg = `Worker ${workerUpdate.name}`; let msg = `Worker ${workerUpdate.name}`;
if (workerUpdate.previous_status && workerUpdate.previous_status != workerUpdate.status) { if (workerUpdate.previous_status && workerUpdate.previous_status != workerUpdate.status) {
msg += ` changed status ${workerUpdate.previous_status}${workerUpdate.status}`; msg += ` changed status ${workerUpdate.previous_status}${workerUpdate.status}`;
this.add(msg); this.add(msg);
} else if (workerUpdate.deleted_at) { } else if (workerUpdate.deleted_at) {
msg += " was removed from the system"; msg += ' was removed from the system';
this.add(msg); this.add(msg);
} }
}, },
@ -98,11 +96,11 @@ export const useNotifs = defineStore('notifications', {
_hideMessage() { _hideMessage() {
this.$patch({ this.$patch({
hideTimerID: 0, hideTimerID: 0,
last: "", last: '',
}); });
}, },
_generateID() { _generateID() {
return ++this.lastID; return ++this.lastID;
} },
}, },
}) });

@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
import { useNotifs } from '@/stores/notifications' import { useNotifs } from '@/stores/notifications';
/** /**
* Status of the SocketIO/Websocket connection to Flamenco Manager. * Status of the SocketIO/Websocket connection to Flamenco Manager.
@ -12,7 +12,7 @@ export const useSocketStatus = defineStore('socket-status', {
wasEverDisconnected: false, wasEverDisconnected: false,
/** @type {string} */ /** @type {string} */
message: "", message: '',
}), }),
actions: { actions: {
/** /**
@ -21,8 +21,7 @@ export const useSocketStatus = defineStore('socket-status', {
*/ */
disconnected(reason) { disconnected(reason) {
// Only patch the state if it actually will change. // Only patch the state if it actually will change.
if (!this.isConnected) if (!this.isConnected) return;
return;
this._get_notifs().add(`Connection to Flamenco Manager lost`); this._get_notifs().add(`Connection to Flamenco Manager lost`);
this.$patch({ this.$patch({
isConnected: false, isConnected: false,
@ -35,14 +34,13 @@ export const useSocketStatus = defineStore('socket-status', {
*/ */
connected() { connected() {
// Only patch the state if it actually will change. // Only patch the state if it actually will change.
if (this.isConnected) if (this.isConnected) return;
return;
if (this.wasEverDisconnected) if (this.wasEverDisconnected)
this._get_notifs().add("Connection to Flamenco Manager established"); this._get_notifs().add('Connection to Flamenco Manager established');
this.$patch({ this.$patch({
isConnected: true, isConnected: true,
message: "", message: '',
}); });
}, },
@ -53,6 +51,6 @@ export const useSocketStatus = defineStore('socket-status', {
// that'll cause the Notifications popover to be handled at the app-global // that'll cause the Notifications popover to be handled at the app-global
// level, instead of per view, creating a better place to put this logic. // level, instead of per view, creating a better place to put this logic.
return useNotifs(); return useNotifs();
} },
} },
}) });

@ -1,4 +1,4 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
// Maximum number of task log lines that will be stored. // Maximum number of task log lines that will be stored.
const capacity = 1000; const capacity = 1000;
@ -17,7 +17,7 @@ export const useTaskLog = defineStore('taskLog', {
* @type {{ id: Number, line: string }[]} */ * @type {{ id: Number, line: string }[]} */
history: [], history: [],
/** @type { id: Number, line: string } */ /** @type { id: Number, line: string } */
last: "", last: '',
lastID: 0, lastID: 0,
}), }),
@ -52,8 +52,7 @@ export const useTaskLog = defineStore('taskLog', {
if (!logChunk) return; if (!logChunk) return;
const lines = logChunk.trimEnd().split('\n'); const lines = logChunk.trimEnd().split('\n');
if (lines.length == 0) if (lines.length == 0) return;
return;
if (lines.length > capacity) { if (lines.length > capacity) {
// Only keep the `capacity` last lines, so that adding them to the // Only keep the `capacity` last lines, so that adding them to the
@ -73,7 +72,7 @@ export const useTaskLog = defineStore('taskLog', {
} }
if (entry == null) { if (entry == null) {
console.warn("taskLog.addChunk: there were lines to add, but no entry created. Weird."); console.warn('taskLog.addChunk: there were lines to add, but no entry created. Weird.');
return; return;
} }
@ -84,7 +83,7 @@ export const useTaskLog = defineStore('taskLog', {
}, },
_createEntry(state, line) { _createEntry(state, line) {
return {id: this._generateID(state), line: line}; return { id: this._generateID(state), line: line };
}, },
/** /**
@ -105,6 +104,6 @@ export const useTaskLog = defineStore('taskLog', {
}, },
_generateID(state) { _generateID(state) {
return ++state.lastID; return ++state.lastID;
} },
}, },
}) });

@ -1,8 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
import * as API from '@/manager-api'; import * as API from '@/manager-api';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
const jobsAPI = new API.JobsApi(getAPIClient()); const jobsAPI = new API.JobsApi(getAPIClient());
@ -16,20 +15,20 @@ export const useTasks = defineStore('tasks', {
* ID of the active task. Easier to query than `activeTask ? activeTask.id : ""`. * ID of the active task. Easier to query than `activeTask ? activeTask.id : ""`.
* @type {string} * @type {string}
*/ */
activeTaskID: "", activeTaskID: '',
}), }),
getters: { getters: {
canCancel() { canCancel() {
return this._anyTaskWithStatus(["queued", "active", "soft-failed"]) return this._anyTaskWithStatus(['queued', 'active', 'soft-failed']);
}, },
canRequeue() { canRequeue() {
return this._anyTaskWithStatus(["canceled", "completed", "failed"]) return this._anyTaskWithStatus(['canceled', 'completed', 'failed']);
}, },
}, },
actions: { actions: {
setActiveTaskID(taskID) { setActiveTaskID(taskID) {
this.$patch({ this.$patch({
activeTask: {id: taskID}, activeTask: { id: taskID },
activeTaskID: taskID, activeTaskID: taskID,
}); });
}, },
@ -42,7 +41,7 @@ export const useTasks = defineStore('tasks', {
deselectAllTasks() { deselectAllTasks() {
this.$patch({ this.$patch({
activeTask: null, activeTask: null,
activeTaskID: "", activeTaskID: '',
}); });
}, },
@ -54,8 +53,12 @@ export const useTasks = defineStore('tasks', {
* TODO: actually have these work on all selected tasks. For simplicity, the * 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. * code now assumes that only the active task needs to be operated on.
*/ */
cancelTasks() { return this._setTaskStatus("canceled"); }, cancelTasks() {
requeueTasks() { return this._setTaskStatus("queued"); }, return this._setTaskStatus('canceled');
},
requeueTasks() {
return this._setTaskStatus('queued');
},
// Internal methods. // Internal methods.
@ -65,7 +68,9 @@ export const useTasks = defineStore('tasks', {
* @returns bool indicating whether there is a selected task with any of the given statuses. * @returns bool indicating whether there is a selected task with any of the given statuses.
*/ */
_anyTaskWithStatus(statuses) { _anyTaskWithStatus(statuses) {
return !!this.activeTask && !!this.activeTask.status && statuses.includes(this.activeTask.status); return (
!!this.activeTask && !!this.activeTask.status && statuses.includes(this.activeTask.status)
);
// return this.selectedTasks.reduce((foundTask, task) => (foundTask || statuses.includes(task.status)), false); // return this.selectedTasks.reduce((foundTask, task) => (foundTask || statuses.includes(task.status)), false);
}, },
@ -79,8 +84,8 @@ export const useTasks = defineStore('tasks', {
console.warn(`_setTaskStatus(${newStatus}) impossible, no active task ID`); console.warn(`_setTaskStatus(${newStatus}) impossible, no active task ID`);
return; return;
} }
const statuschange = new API.TaskStatusChange(newStatus, "requested from web interface"); const statuschange = new API.TaskStatusChange(newStatus, 'requested from web interface');
return jobsAPI.setTaskStatus(this.activeTaskID, statuschange); return jobsAPI.setTaskStatus(this.activeTaskID, statuschange);
}, },
}, },
}) });

@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
import { WorkerMgtApi } from '@/manager-api'; import { WorkerMgtApi } from '@/manager-api';
import { getAPIClient } from "@/api-client"; 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/
@ -13,7 +13,7 @@ export const useWorkers = defineStore('workers', {
* ID of the active worker. Easier to query than `activeWorker ? activeWorker.id : ""`. * ID of the active worker. Easier to query than `activeWorker ? activeWorker.id : ""`.
* @type {string} * @type {string}
*/ */
activeWorkerID: "", activeWorkerID: '',
/** @type {API.WorkerTag[]} */ /** @type {API.WorkerTag[]} */
tags: [], tags: [],
@ -43,7 +43,7 @@ export const useWorkers = defineStore('workers', {
deselectAllWorkers() { deselectAllWorkers() {
this.$patch({ this.$patch({
activeWorker: null, activeWorker: null,
activeWorkerID: "", activeWorkerID: '',
}); });
}, },
/** /**

@ -1,14 +1,13 @@
let url = new URL(window.location.href); let url = new URL(window.location.href);
// Uncomment this when the web interface is running on a different port than the // Uncomment this when the web interface is running on a different port than the
// API, for example when using the Vite devserver. Set the API port here. // API, for example when using the Vite devserver. Set the API port here.
if (url.port == "8081") { if (url.port == '8081') {
url.port = "8080"; url.port = '8080';
} }
url.pathname = "/"; url.pathname = '/';
const flamencoAPIURL = url.href; const flamencoAPIURL = url.href;
url.protocol = "ws:"; url.protocol = 'ws:';
const websocketURL = url.href; const websocketURL = url.href;
const URLs = { const URLs = {

@ -1,25 +1,45 @@
<template> <template>
<div class="col col-1"> <div class="col col-1">
<jobs-table ref="jobsTable" :activeJobID="jobID" @tableRowClicked="onTableJobClicked" <jobs-table
ref="jobsTable"
:activeJobID="jobID"
@tableRowClicked="onTableJobClicked"
@activeJobDeleted="onActiveJobDeleted" /> @activeJobDeleted="onActiveJobDeleted" />
</div> </div>
<div class="col col-2 job-details-column" id="col-job-details"> <div class="col col-2 job-details-column" id="col-job-details">
<get-the-addon v-if="jobs.isJobless" /> <get-the-addon v-if="jobs.isJobless" />
<template v-else> <template v-else>
<job-details ref="jobDetails" :jobData="jobs.activeJob" @reshuffled="_recalcTasksTableHeight" /> <job-details
<tasks-table v-if="hasJobData" ref="tasksTable" :jobID="jobID" :taskID="taskID" ref="jobDetails"
:jobData="jobs.activeJob"
@reshuffled="_recalcTasksTableHeight" />
<tasks-table
v-if="hasJobData"
ref="tasksTable"
:jobID="jobID"
:taskID="taskID"
@tableRowClicked="onTableTaskClicked" /> @tableRowClicked="onTableTaskClicked" />
</template> </template>
</div> </div>
<div class="col col-3"> <div class="col col-3">
<task-details v-if="hasJobData" :taskData="tasks.activeTask" @showTaskLogTail="showTaskLogTail" /> <task-details
v-if="hasJobData"
:taskData="tasks.activeTask"
@showTaskLogTail="showTaskLogTail" />
</div> </div>
<footer class="app-footer" v-if="!showFooterPopup" @click="showFooterPopup = true"> <footer class="app-footer" v-if="!showFooterPopup" @click="showFooterPopup = true">
<notification-bar /> <notification-bar />
<div class="app-footer-expand"> <div class="app-footer-expand">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke-width="2" <svg
stroke-linecap="round" stroke-linejoin="round"> xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<line x1="12" y1="19" x2="12" y2="5"></line> <line x1="12" y1="19" x2="12" y2="5"></line>
<polyline points="5 12 12 5 19 12"></polyline> <polyline points="5 12 12 5 19 12"></polyline>
</svg> </svg>
@ -27,9 +47,17 @@
</footer> </footer>
<footer-popup v-if="showFooterPopup" ref="footerPopup" @clickClose="showFooterPopup = false" /> <footer-popup v-if="showFooterPopup" ref="footerPopup" @clickClose="showFooterPopup = false" />
<update-listener ref="updateListener" mainSubscription="allJobs" :subscribedJobID="jobID" :subscribedTaskID="taskID" <update-listener
@jobUpdate="onSioJobUpdate" @taskUpdate="onSioTaskUpdate" @taskLogUpdate="onSioTaskLogUpdate" ref="updateListener"
@lastRenderedUpdate="onSioLastRenderedUpdate" @message="onChatMessage" @sioReconnected="onSIOReconnected" mainSubscription="allJobs"
:subscribedJobID="jobID"
:subscribedTaskID="taskID"
@jobUpdate="onSioJobUpdate"
@taskUpdate="onSioTaskUpdate"
@taskLogUpdate="onSioTaskLogUpdate"
@lastRenderedUpdate="onSioLastRenderedUpdate"
@message="onChatMessage"
@sioReconnected="onSIOReconnected"
@sioDisconnected="onSIODisconnected" /> @sioDisconnected="onSIODisconnected" />
</template> </template>
@ -37,22 +65,22 @@
import * as API from '@/manager-api'; import * as API from '@/manager-api';
import { useJobs } from '@/stores/jobs'; import { useJobs } from '@/stores/jobs';
import { useTasks } from '@/stores/tasks'; import { useTasks } from '@/stores/tasks';
import { useNotifs } from '@/stores/notifications' import { useNotifs } from '@/stores/notifications';
import { useTaskLog } from '@/stores/tasklog' import { useTaskLog } from '@/stores/tasklog';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
import FooterPopup from '@/components/footer/FooterPopup.vue' import FooterPopup from '@/components/footer/FooterPopup.vue';
import GetTheAddon from '@/components/GetTheAddon.vue' import GetTheAddon from '@/components/GetTheAddon.vue';
import JobDetails from '@/components/jobs/JobDetails.vue' import JobDetails from '@/components/jobs/JobDetails.vue';
import JobsTable from '@/components/jobs/JobsTable.vue' import JobsTable from '@/components/jobs/JobsTable.vue';
import NotificationBar from '@/components/footer/NotificationBar.vue' import NotificationBar from '@/components/footer/NotificationBar.vue';
import TaskDetails from '@/components/jobs/TaskDetails.vue' import TaskDetails from '@/components/jobs/TaskDetails.vue';
import TasksTable from '@/components/jobs/TasksTable.vue' import TasksTable from '@/components/jobs/TasksTable.vue';
import UpdateListener from '@/components/UpdateListener.vue' import UpdateListener from '@/components/UpdateListener.vue';
export default { export default {
name: 'JobsView', name: 'JobsView',
props: ["jobID", "taskID"], // provided by Vue Router. props: ['jobID', 'taskID'], // provided by Vue Router.
components: { components: {
FooterPopup, FooterPopup,
GetTheAddon, GetTheAddon,
@ -70,7 +98,7 @@ export default {
tasks: useTasks(), tasks: useTasks(),
notifs: useNotifs(), notifs: useNotifs(),
taskLog: useTaskLog(), taskLog: useTaskLog(),
showFooterPopup: !!localStorage.getItem("footer-popover-visible"), showFooterPopup: !!localStorage.getItem('footer-popover-visible'),
}), }),
computed: { computed: {
hasJobData() { hasJobData() {
@ -90,10 +118,10 @@ export default {
this._fetchJob(this.jobID); this._fetchJob(this.jobID);
this._fetchTask(this.taskID); this._fetchTask(this.taskID);
window.addEventListener("resize", this._recalcTasksTableHeight); window.addEventListener('resize', this._recalcTasksTableHeight);
}, },
unmounted() { unmounted() {
window.removeEventListener("resize", this._recalcTasksTableHeight); window.removeEventListener('resize', this._recalcTasksTableHeight);
}, },
watch: { watch: {
jobID(newJobID, oldJobID) { jobID(newJobID, oldJobID) {
@ -103,8 +131,8 @@ export default {
this._fetchTask(newTaskID); this._fetchTask(newTaskID);
}, },
showFooterPopup(shown) { showFooterPopup(shown) {
if (shown) localStorage.setItem("footer-popover-visible", "true"); if (shown) localStorage.setItem('footer-popover-visible', 'true');
else localStorage.removeItem("footer-popover-visible"); else localStorage.removeItem('footer-popover-visible');
this._recalcTasksTableHeight(); this._recalcTasksTableHeight();
}, },
}, },
@ -122,26 +150,25 @@ export default {
}, },
onSelectedTaskChanged(taskSummary) { onSelectedTaskChanged(taskSummary) {
if (!taskSummary) { // There is no active task. if (!taskSummary) {
// There is no active task.
this.tasks.deselectAllTasks(); this.tasks.deselectAllTasks();
return; return;
} }
const jobsAPI = new API.JobsApi(getAPIClient()); const jobsAPI = new API.JobsApi(getAPIClient());
jobsAPI.fetchTask(taskSummary.id) jobsAPI.fetchTask(taskSummary.id).then((task) => {
.then((task) => { this.tasks.setActiveTask(task);
this.tasks.setActiveTask(task); // Forward the full task to Tabulator, so that that gets updated too.
// Forward the full task to Tabulator, so that that gets updated too. if (this.$refs.tasksTable) this.$refs.tasksTable.processTaskUpdate(task);
if (this.$refs.tasksTable) });
this.$refs.tasksTable.processTaskUpdate(task);
});
}, },
showTaskLogTail() { showTaskLogTail() {
this.showFooterPopup = true; this.showFooterPopup = true;
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.footerPopup.showTaskLogTail(); this.$refs.footerPopup.showTaskLogTail();
}) });
}, },
// SocketIO data event handlers: // SocketIO data event handlers:
@ -158,8 +185,7 @@ export default {
this._fetchJob(this.jobID); this._fetchJob(this.jobID);
if (jobUpdate.refresh_tasks) { if (jobUpdate.refresh_tasks) {
if (this.$refs.tasksTable) if (this.$refs.tasksTable) this.$refs.tasksTable.fetchTasks();
this.$refs.tasksTable.fetchTasks();
this._fetchTask(this.taskID); this._fetchTask(this.taskID);
} }
}, },
@ -169,10 +195,8 @@ export default {
* @param {API.SocketIOTaskUpdate} taskUpdate * @param {API.SocketIOTaskUpdate} taskUpdate
*/ */
onSioTaskUpdate(taskUpdate) { onSioTaskUpdate(taskUpdate) {
if (this.$refs.tasksTable) if (this.$refs.tasksTable) this.$refs.tasksTable.processTaskUpdate(taskUpdate);
this.$refs.tasksTable.processTaskUpdate(taskUpdate); if (this.taskID == taskUpdate.id) this._fetchTask(this.taskID);
if (this.taskID == taskUpdate.id)
this._fetchTask(this.taskID);
this.notifs.addTaskUpdate(taskUpdate); this.notifs.addTaskUpdate(taskUpdate);
}, },
@ -226,7 +250,8 @@ export default {
} }
const jobsAPI = new API.JobsApi(getAPIClient()); const jobsAPI = new API.JobsApi(getAPIClient());
return jobsAPI.fetchJob(jobID) return jobsAPI
.fetchJob(jobID)
.then((job) => { .then((job) => {
this.jobs.setActiveJob(job); this.jobs.setActiveJob(job);
// Forward the full job to Tabulator, so that that gets updated too. // Forward the full job to Tabulator, so that that gets updated too.
@ -240,8 +265,7 @@ export default {
return; return;
} }
console.log(`Unable to fetch job ${jobID}:`, err); console.log(`Unable to fetch job ${jobID}:`, err);
}) });
;
}, },
/** /**
@ -255,28 +279,24 @@ export default {
} }
const jobsAPI = new API.JobsApi(getAPIClient()); const jobsAPI = new API.JobsApi(getAPIClient());
return jobsAPI.fetchTask(taskID) return jobsAPI.fetchTask(taskID).then((task) => {
.then((task) => { this.tasks.setActiveTask(task);
this.tasks.setActiveTask(task); // Forward the full task to Tabulator, so that that gets updated too.\
// Forward the full task to Tabulator, so that that gets updated too.\ if (this.$refs.tasksTable) this.$refs.tasksTable.processTaskUpdate(task);
if (this.$refs.tasksTable) });
this.$refs.tasksTable.processTaskUpdate(task);
});
}, },
onChatMessage(message) { onChatMessage(message) {
console.log("chat message received:", message); console.log('chat message received:', message);
this.messages.push(`${message.text}`); this.messages.push(`${message.text}`);
}, },
// SocketIO connection event handlers: // SocketIO connection event handlers:
onSIOReconnected() { onSIOReconnected() {
this.$refs.jobsTable.onReconnected(); this.$refs.jobsTable.onReconnected();
if (this.$refs.tasksTable) if (this.$refs.tasksTable) this.$refs.tasksTable.onReconnected();
this.$refs.tasksTable.onReconnected();
},
onSIODisconnected(reason) {
}, },
onSIODisconnected(reason) {},
_recalcTasksTableHeight() { _recalcTasksTableHeight() {
if (!this.$refs.tasksTable) return; if (!this.$refs.tasksTable) return;
@ -284,7 +304,7 @@ export default {
this.$nextTick(this.$refs.tasksTable.recalcTableHeight); this.$nextTick(this.$refs.tasksTable.recalcTableHeight);
}, },
}, },
} };
</script> </script>
<style scoped> <style scoped>

@ -8,15 +8,18 @@
<footer class="app-footer"><notification-bar /></footer> <footer class="app-footer"><notification-bar /></footer>
<update-listener ref="updateListener" mainSubscription="allLastRendered" <update-listener
ref="updateListener"
mainSubscription="allLastRendered"
@lastRenderedUpdate="onSioLastRenderedUpdate" @lastRenderedUpdate="onSioLastRenderedUpdate"
@sioReconnected="onSIOReconnected" @sioDisconnected="onSIODisconnected" /> @sioReconnected="onSIOReconnected"
@sioDisconnected="onSIODisconnected" />
</template> </template>
<script> <script>
import LastRenderedImage from '@/components/jobs/LastRenderedImage.vue' import LastRenderedImage from '@/components/jobs/LastRenderedImage.vue';
import NotificationBar from '@/components/footer/NotificationBar.vue' import NotificationBar from '@/components/footer/NotificationBar.vue';
import UpdateListener from '@/components/UpdateListener.vue' import UpdateListener from '@/components/UpdateListener.vue';
export default { export default {
name: 'LastRenderedView', name: 'LastRenderedView',
@ -25,8 +28,7 @@ export default {
NotificationBar, NotificationBar,
UpdateListener, UpdateListener,
}, },
data: () => ({ data: () => ({}),
}),
methods: { methods: {
/** /**
* Event handler for SocketIO "last-rendered" updates. * Event handler for SocketIO "last-rendered" updates.
@ -37,12 +39,10 @@ export default {
}, },
// SocketIO connection event handlers: // SocketIO connection event handlers:
onSIOReconnected() { onSIOReconnected() {},
}, onSIODisconnected(reason) {},
onSIODisconnected(reason) {
},
}, },
} };
</script> </script>
<style scoped> <style scoped>
@ -52,7 +52,9 @@ export default {
grid-column-end: col-3; grid-column-end: col-3;
grid-column-start: col-1; grid-column-start: col-1;
justify-content: center; justify-content: center;
height: calc(100vh - calc(var(--header-height) - var(--footer-height) - calc(var(--grid-gap) * -12))); height: calc(
100vh - calc(var(--header-height) - var(--footer-height) - calc(var(--grid-gap) * -12))
);
max-height: 100%; max-height: 100%;
} }

@ -2,24 +2,32 @@
<div class="setup-container"> <div class="setup-container">
<h1>Flamenco Setup Assistant</h1> <h1>Flamenco Setup Assistant</h1>
<ul class="progress"> <ul class="progress">
<li v-for="step in totalSetupSteps" :key="step" @click="jumpToStep(step)" :class="{ <li
current: step == currentSetupStep, v-for="step in totalSetupSteps"
done: step < overallSetupStep, :key="step"
done_previously: (step < overallSetupStep && currentSetupStep > step), @click="jumpToStep(step)"
done_and_current: step == currentSetupStep && (step < overallSetupStep || step == 1), :class="{
disabled: step > overallSetupStep, current: step == currentSetupStep,
}"> done: step < overallSetupStep,
done_previously: step < overallSetupStep && currentSetupStep > step,
done_and_current: step == currentSetupStep && (step < overallSetupStep || step == 1),
disabled: step > overallSetupStep,
}">
<span></span> <span></span>
</li> </li>
<div class="progress-bar"></div> <div class="progress-bar"></div>
</ul> </ul>
<div class="setup-step step-welcome"> <div class="setup-step step-welcome">
<step-item
<step-item v-show="currentSetupStep == 1" @next-clicked="nextStep" :is-next-clickable="true" v-show="currentSetupStep == 1"
:is-back-visible="false" title="Welcome!" next-label="Let's go"> @next-clicked="nextStep"
:is-next-clickable="true"
:is-back-visible="false"
title="Welcome!"
next-label="Let's go">
<p> <p>
This setup assistant will guide you through the initial configuration of Flamenco. You will be up This setup assistant will guide you through the initial configuration of Flamenco. You
and running in a few minutes! will be up and running in a few minutes!
</p> </p>
<p>Before we start, here is a quick overview of the Flamenco architecture.</p> <p>Before we start, here is a quick overview of the Flamenco architecture.</p>
@ -27,32 +35,36 @@
<p>The illustration shows the key components and how they interact together:</p> <p>The illustration shows the key components and how they interact together:</p>
<ul> <ul>
<li><strong>Manager</strong>: This application. It coordinates all the activity.</li>
<li> <li>
<strong>Manager</strong>: This application. It coordinates all the activity. <strong>Worker</strong>: A workstation or dedicated rendering machine. It executes the
tasks assigned by the Manager.
</li> </li>
<li> <li>
<strong>Worker</strong>: A workstation or dedicated rendering machine. It executes the tasks assigned by the <strong>Shared Storage</strong>: A location accessible by the Manager and the Workers,
Manager. where the files to be rendered should be saved.
</li> </li>
<li> <li>
<strong>Shared Storage</strong>: A location accessible by the Manager and the Workers, where the files to be <strong>Blender Add-on</strong>: This is needed to connect to the Manager and submit a
rendered should be saved. job from Blender.
</li>
<li>
<strong>Blender Add-on</strong>: This is needed to connect to the Manager and submit a job from Blender.
</li> </li>
</ul> </ul>
<p>More information is available on the online documentation at <p>
More information is available on the online documentation at
<a href="https://flamenco.blender.org">flamenco.blender.org</a>. <a href="https://flamenco.blender.org">flamenco.blender.org</a>.
</p> </p>
</step-item> </step-item>
<step-item v-show="currentSetupStep == 2" @next-clicked="nextStepAfterCheckSharedStoragePath" <step-item
@back-clicked="prevStep" :is-next-clickable="sharedStoragePath.length > 0" title="Shared Storage"> v-show="currentSetupStep == 2"
@next-clicked="nextStepAfterCheckSharedStoragePath"
@back-clicked="prevStep"
:is-next-clickable="sharedStoragePath.length > 0"
title="Shared Storage">
<p>Specify a path (or drive) where you want to store your Flamenco data.</p> <p>Specify a path (or drive) where you want to store your Flamenco data.</p>
<p> <p>
The location of the shared storage should be accessible by Flamenco Manager and by the Workers. The location of the shared storage should be accessible by Flamenco Manager and by the
This could be: Workers. This could be:
</p> </p>
<ul> <ul>
@ -62,33 +74,42 @@
</ul> </ul>
<p> <p>
Using services like Dropbox, Syncthing, or ownCloud for Using services like Dropbox, Syncthing, or ownCloud for this is not recommended, as
this is not recommended, as Flamenco can't coordinate data synchronization. Flamenco can't coordinate data synchronization.
<a href="https://flamenco.blender.org/usage/shared-storage/">Learn more</a>. <a href="https://flamenco.blender.org/usage/shared-storage/">Learn more</a>.
</p> </p>
<input v-model="sharedStoragePath" @keyup.enter="nextStepAfterCheckSharedStoragePath" type="text" <input
placeholder="Path to shared storage" :class="{ v-model="sharedStoragePath"
'is-invalid': (sharedStorageCheckResult != null) && !sharedStorageCheckResult.is_usable @keyup.enter="nextStepAfterCheckSharedStoragePath"
type="text"
placeholder="Path to shared storage"
:class="{
'is-invalid': sharedStorageCheckResult != null && !sharedStorageCheckResult.is_usable,
}" />
<p
v-if="sharedStorageCheckResult != null"
:class="{
'check-ok': sharedStorageCheckResult.is_usable,
'check-failed': !sharedStorageCheckResult.is_usable,
}"> }">
<p v-if="sharedStorageCheckResult != null" :class="{
'check-ok': sharedStorageCheckResult.is_usable,
'check-failed': !sharedStorageCheckResult.is_usable
}">
{{ sharedStorageCheckResult.cause }} {{ sharedStorageCheckResult.cause }}
</p> </p>
</step-item> </step-item>
<step-item v-show="currentSetupStep == 3" @next-clicked="nextStepAfterCheckBlenderExePath" @back-clicked="prevStep" <step-item
:is-next-clickable="selectedBlender != null || customBlenderExe != (null || '')" title="Blender"> v-show="currentSetupStep == 3"
@next-clicked="nextStepAfterCheckBlenderExePath"
@back-clicked="prevStep"
:is-next-clickable="selectedBlender != null || customBlenderExe != (null || '')"
title="Blender">
<div v-if="isBlenderExeFinding" class="is-in-progress">Looking for Blender installs...</div> <div v-if="isBlenderExeFinding" class="is-in-progress">Looking for Blender installs...</div>
<p v-if="autoFoundBlenders.length === 0"> <p v-if="autoFoundBlenders.length === 0">
Provide a path to a Blender executable accessible by all Workers. Provide a path to a Blender executable accessible by all Workers.
<br /><br /> <br /><br />
If your rendering setup features operating systems different from the one you are currently using, If your rendering setup features operating systems different from the one you are
you can manually set up the other paths later. currently using, you can manually set up the other paths later.
</p> </p>
<p v-else>Choose how a Worker should invoke the Blender command when performing a task:</p> <p v-else>Choose how a Worker should invoke the Blender command when performing a task:</p>
@ -96,16 +117,23 @@
<fieldset v-if="autoFoundBlenders.length >= 1"> <fieldset v-if="autoFoundBlenders.length >= 1">
<label v-if="autoFoundBlenderPathEnvvar" for="blender-path_envvar"> <label v-if="autoFoundBlenderPathEnvvar" for="blender-path_envvar">
<div> <div>
<input v-model="selectedBlender" :value="autoFoundBlenderPathEnvvar" id="blender-path_envvar" name="blender" <input
type="radio"> v-model="selectedBlender"
:value="autoFoundBlenderPathEnvvar"
id="blender-path_envvar"
name="blender"
type="radio" />
{{ sourceLabels[autoFoundBlenderPathEnvvar.source] }} {{ sourceLabels[autoFoundBlenderPathEnvvar.source] }}
</div> </div>
<div class="setup-path-command"> <div class="setup-path-command">
<span class="path"> <span class="path">
{{ autoFoundBlenderPathEnvvar.path }} {{ autoFoundBlenderPathEnvvar.path }}
</span> </span>
<span aria-label="Console output when running with --version" class="command-preview" <span
data-microtip-position="top" role="tooltip"> aria-label="Console output when running with --version"
class="command-preview"
data-microtip-position="top"
role="tooltip">
{{ autoFoundBlenderPathEnvvar.cause }} {{ autoFoundBlenderPathEnvvar.cause }}
</span> </span>
</div> </div>
@ -113,16 +141,23 @@
<label v-if="autoFoundBlenderFileAssociation" for="blender-file_association"> <label v-if="autoFoundBlenderFileAssociation" for="blender-file_association">
<div> <div>
<input v-model="selectedBlender" :value="autoFoundBlenderFileAssociation" id="blender-file_association" <input
name="blender" type="radio"> v-model="selectedBlender"
:value="autoFoundBlenderFileAssociation"
id="blender-file_association"
name="blender"
type="radio" />
{{ sourceLabels[autoFoundBlenderFileAssociation.source] }} {{ sourceLabels[autoFoundBlenderFileAssociation.source] }}
</div> </div>
<div class="setup-path-command"> <div class="setup-path-command">
<span class="path"> <span class="path">
{{ autoFoundBlenderFileAssociation.path }} {{ autoFoundBlenderFileAssociation.path }}
</span> </span>
<span aria-label="Console output when running with --version" class="command-preview" <span
data-microtip-position="top" role="tooltip"> aria-label="Console output when running with --version"
class="command-preview"
data-microtip-position="top"
role="tooltip">
{{ autoFoundBlenderFileAssociation.cause }} {{ autoFoundBlenderFileAssociation.cause }}
</span> </span>
</div> </div>
@ -130,35 +165,60 @@
<label for="blender-input_path"> <label for="blender-input_path">
<div> <div>
<input type="radio" v-model="selectedBlender" name="blender" :value="blenderFromInputPath" <input
id="blender-input_path"> type="radio"
v-model="selectedBlender"
name="blender"
:value="blenderFromInputPath"
id="blender-input_path" />
{{ sourceLabels['input_path'] }} {{ sourceLabels['input_path'] }}
</div> </div>
<div> <div>
<input v-model="customBlenderExe" @keyup.enter="nextStepAfterCheckBlenderExePath" <input
v-model="customBlenderExe"
@keyup.enter="nextStepAfterCheckBlenderExePath"
@focus="selectedBlender = null" @focus="selectedBlender = null"
:class="{ 'is-invalid': blenderExeCheckResult != null && !blenderExeCheckResult.is_usable }" type="text" :class="{
placeholder="Path to Blender"> 'is-invalid': blenderExeCheckResult != null && !blenderExeCheckResult.is_usable,
}"
type="text"
placeholder="Path to Blender" />
<p v-if="isBlenderExeChecking" class="is-in-progress">Checking...</p> <p v-if="isBlenderExeChecking" class="is-in-progress">Checking...</p>
<p v-if="blenderExeCheckResult != null && !blenderExeCheckResult.is_usable" class="check-failed"> <p
{{ blenderExeCheckResult.cause }}</p> v-if="blenderExeCheckResult != null && !blenderExeCheckResult.is_usable"
class="check-failed">
{{ blenderExeCheckResult.cause }}
</p>
</div> </div>
</label> </label>
</fieldset> </fieldset>
<div v-if="autoFoundBlenders.length === 0"> <div v-if="autoFoundBlenders.length === 0">
<input v-model="customBlenderExe" @keyup.enter="nextStepAfterCheckBlenderExePath" <input
:class="{ 'is-invalid': blenderExeCheckResult != null && !blenderExeCheckResult.is_usable }" type="text" v-model="customBlenderExe"
placeholder="Path to Blender executable"> @keyup.enter="nextStepAfterCheckBlenderExePath"
:class="{
'is-invalid': blenderExeCheckResult != null && !blenderExeCheckResult.is_usable,
}"
type="text"
placeholder="Path to Blender executable" />
<p v-if="isBlenderExeChecking" class="is-in-progress">Checking...</p> <p v-if="isBlenderExeChecking" class="is-in-progress">Checking...</p>
<p v-if="blenderExeCheckResult != null && !blenderExeCheckResult.is_usable" class="check-failed"> <p
{{ blenderExeCheckResult.cause }}</p> v-if="blenderExeCheckResult != null && !blenderExeCheckResult.is_usable"
class="check-failed">
{{ blenderExeCheckResult.cause }}
</p>
</div> </div>
</step-item> </step-item>
<step-item v-show="currentSetupStep == 4" @next-clicked="confirmSetupAssistant" @back-clicked="prevStep" <step-item
next-label="Confirm" title="Review" :is-next-clickable="setupConfirmIsClickable"> v-show="currentSetupStep == 4"
@next-clicked="confirmSetupAssistant"
@back-clicked="prevStep"
next-label="Confirm"
title="Review"
:is-next-clickable="setupConfirmIsClickable">
<div v-if="isConfigComplete"> <div v-if="isConfigComplete">
<p>This is the configuration that will be used by Flamenco:</p> <p>This is the configuration that will be used by Flamenco:</p>
<dl> <dl>
@ -166,20 +226,25 @@
<dd>{{ sharedStorageCheckResult.path }}</dd> <dd>{{ sharedStorageCheckResult.path }}</dd>
<dt>Blender Command</dt> <dt>Blender Command</dt>
<dd v-if="selectedBlender.source == 'file_association'"> <dd v-if="selectedBlender.source == 'file_association'">
Whatever Blender is associated with .blend files Whatever Blender is associated with .blend files (currently "<code>{{
(currently "<code>{{ selectedBlender.path }}</code>") selectedBlender.path
}}</code
>")
</dd> </dd>
<dd v-if="selectedBlender.source == 'path_envvar'"> <dd v-if="selectedBlender.source == 'path_envvar'">
The command "<code>{{ selectedBlender.input }}</code>" as found on <code>$PATH</code> The command "<code>{{ selectedBlender.input }}</code
(currently "<code>{{ selectedBlender.path }}</code>") >" as found on <code>$PATH</code> (currently "<code>{{ selectedBlender.path }}</code
>")
</dd> </dd>
<dd v-if="selectedBlender.source == 'input_path'"> <dd v-if="selectedBlender.source == 'input_path'">
The command you provided: The command you provided: "<code>{{ selectedBlender.path }}</code
"<code>{{ selectedBlender.path }}</code>" >"
</dd> </dd>
</dl> </dl>
</div> </div>
<p v-if="isConfirmed" class="check-ok">Configuration has been saved, Flamenco will restart.</p> <p v-if="isConfirmed" class="check-ok">
Configuration has been saved, Flamenco will restart.
</p>
</step-item> </step-item>
</div> </div>
</div> </div>
@ -188,16 +253,19 @@
<notification-bar /> <notification-bar />
</footer> </footer>
<update-listener ref="updateListener" @sioReconnected="onSIOReconnected" @sioDisconnected="onSIODisconnected" /> <update-listener
ref="updateListener"
@sioReconnected="onSIOReconnected"
@sioDisconnected="onSIODisconnected" />
</template> </template>
<script> <script>
import 'microtip/microtip.css' import 'microtip/microtip.css';
import NotificationBar from '@/components/footer/NotificationBar.vue' import NotificationBar from '@/components/footer/NotificationBar.vue';
import UpdateListener from '@/components/UpdateListener.vue' import UpdateListener from '@/components/UpdateListener.vue';
import StepItem from '@/components/steps/StepItem.vue'; import StepItem from '@/components/steps/StepItem.vue';
import { MetaApi, PathCheckInput, SetupAssistantConfig } from "@/manager-api"; import { MetaApi, PathCheckInput, SetupAssistantConfig } from '@/manager-api';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
export default { export default {
name: 'SetupAssistantView', name: 'SetupAssistantView',
@ -207,7 +275,7 @@ export default {
NotificationBar, NotificationBar,
}, },
data: () => ({ data: () => ({
sharedStoragePath: "", sharedStoragePath: '',
sharedStorageCheckResult: null, // api.PathCheckResult sharedStorageCheckResult: null, // api.PathCheckResult
metaAPI: new MetaApi(getAPIClient()), metaAPI: new MetaApi(getAPIClient()),
@ -217,13 +285,13 @@ export default {
isBlenderExeFinding: false, isBlenderExeFinding: false,
selectedBlender: null, // the chosen api.BlenderPathCheckResult selectedBlender: null, // the chosen api.BlenderPathCheckResult
customBlenderExe: "", customBlenderExe: '',
isBlenderExeChecking: false, isBlenderExeChecking: false,
blenderExeCheckResult: null, // api.BlenderPathCheckResult blenderExeCheckResult: null, // api.BlenderPathCheckResult
sourceLabels: { sourceLabels: {
file_association: "Blender that runs when you double-click a .blend file:", file_association: 'Blender that runs when you double-click a .blend file:',
path_envvar: "Blender found on the $PATH environment:", path_envvar: 'Blender found on the $PATH environment:',
input_path: "Another Blender executable:", input_path: 'Another Blender executable:',
}, },
isConfirming: false, isConfirming: false,
isConfirmed: false, isConfirmed: false,
@ -248,13 +316,13 @@ export default {
return this.isSharedStorageValid && this.isSelectedBlenderValid; return this.isSharedStorageValid && this.isSelectedBlenderValid;
}, },
autoFoundBlenderPathEnvvar() { autoFoundBlenderPathEnvvar() {
return this.autoFoundBlenders.find(b => b.source === 'path_envvar'); return this.autoFoundBlenders.find((b) => b.source === 'path_envvar');
}, },
autoFoundBlenderFileAssociation() { autoFoundBlenderFileAssociation() {
return this.autoFoundBlenders.find(b => b.source === 'file_association'); return this.autoFoundBlenders.find((b) => b.source === 'file_association');
}, },
blenderFromInputPath() { blenderFromInputPath() {
return this.allBlenders.find(b => b.source === 'input_path'); return this.allBlenders.find((b) => b.source === 'input_path');
}, },
setupConfirmIsClickable() { setupConfirmIsClickable() {
if (this.isConfirming || this.isConfirmed) { if (this.isConfirming || this.isConfirmed) {
@ -262,7 +330,7 @@ export default {
} else { } else {
return true; return true;
} }
} },
}, },
mounted() { mounted() {
this.findBlenderExePath(); this.findBlenderExePath();
@ -271,25 +339,24 @@ export default {
}, },
methods: { methods: {
// SocketIO connection event handlers: // SocketIO connection event handlers:
onSIOReconnected() { onSIOReconnected() {},
}, onSIODisconnected(reason) {},
onSIODisconnected(reason) {
},
nextStepAfterCheckSharedStoragePath() { nextStepAfterCheckSharedStoragePath() {
const pathCheck = new PathCheckInput(this.cleanSharedStoragePath); const pathCheck = new PathCheckInput(this.cleanSharedStoragePath);
console.log("requesting path check:", pathCheck); console.log('requesting path check:', pathCheck);
return this.metaAPI.checkSharedStoragePath({ pathCheckInput: pathCheck }) return this.metaAPI
.checkSharedStoragePath({ pathCheckInput: pathCheck })
.then((result) => { .then((result) => {
console.log("Storage path check result:", result); console.log('Storage path check result:', result);
this.sharedStorageCheckResult = result; this.sharedStorageCheckResult = result;
if (this.isSharedStorageValid) { if (this.isSharedStorageValid) {
this.nextStep(); this.nextStep();
} }
}) })
.catch((error) => { .catch((error) => {
console.log("Error checking storage path:", error); console.log('Error checking storage path:', error);
}) });
}, },
nextStepAfterCheckBlenderExePath() { nextStepAfterCheckBlenderExePath() {
@ -303,24 +370,25 @@ export default {
this.isBlenderExeFinding = true; this.isBlenderExeFinding = true;
this.autoFoundBlenders = []; this.autoFoundBlenders = [];
console.log("Finding Blender"); console.log('Finding Blender');
this.metaAPI.findBlenderExePath() this.metaAPI
.findBlenderExePath()
.then((result) => { .then((result) => {
console.log("Result of finding Blender:", result); console.log('Result of finding Blender:', result);
this.autoFoundBlenders = result; this.autoFoundBlenders = result;
this._refreshAllBlenders(); this._refreshAllBlenders();
}) })
.catch((error) => { .catch((error) => {
console.log("Error finding Blender:", error); console.log('Error finding Blender:', error);
}) })
.finally(() => { .finally(() => {
this.isBlenderExeFinding = false; this.isBlenderExeFinding = false;
}) });
}, },
checkBlenderExePath() { checkBlenderExePath() {
const exeToTry = this.cleanCustomBlenderExe; const exeToTry = this.cleanCustomBlenderExe;
if (exeToTry == "") { if (exeToTry == '') {
// Just erase any previously-found custom Blender executable. // Just erase any previously-found custom Blender executable.
this.isBlenderExeChecking = false; this.isBlenderExeChecking = false;
this.blenderExeCheckResult = null; this.blenderExeCheckResult = null;
@ -332,10 +400,11 @@ export default {
this.blenderExeCheckResult = null; this.blenderExeCheckResult = null;
const pathCheck = new PathCheckInput(exeToTry); const pathCheck = new PathCheckInput(exeToTry);
console.log("requesting path check:", pathCheck); console.log('requesting path check:', pathCheck);
this.metaAPI.checkBlenderExePath({ pathCheckInput: pathCheck }) this.metaAPI
.checkBlenderExePath({ pathCheckInput: pathCheck })
.then((result) => { .then((result) => {
console.log("Blender exe path check result:", result); console.log('Blender exe path check result:', result);
this.blenderExeCheckResult = result; this.blenderExeCheckResult = result;
if (result.is_usable) { if (result.is_usable) {
this.selectedBlender = result; this.selectedBlender = result;
@ -345,11 +414,11 @@ export default {
this._refreshAllBlenders(); this._refreshAllBlenders();
}) })
.catch((error) => { .catch((error) => {
console.log("Error checking storage path:", error); console.log('Error checking storage path:', error);
}) })
.finally(() => { .finally(() => {
this.isBlenderExeChecking = false; this.isBlenderExeChecking = false;
}) });
}, },
_refreshAllBlenders() { _refreshAllBlenders() {
@ -380,26 +449,29 @@ export default {
confirmSetupAssistant() { confirmSetupAssistant() {
const setupAssistantConfig = new SetupAssistantConfig( const setupAssistantConfig = new SetupAssistantConfig(
this.sharedStorageCheckResult.path, this.sharedStorageCheckResult.path,
this.selectedBlender, this.selectedBlender
); );
console.log("saving configuration:", setupAssistantConfig); console.log('saving configuration:', setupAssistantConfig);
this.isConfirming = true; this.isConfirming = true;
this.isConfirmed = false; this.isConfirmed = false;
this.metaAPI.saveSetupAssistantConfig({ setupAssistantConfig: setupAssistantConfig }) this.metaAPI
.saveSetupAssistantConfig({ setupAssistantConfig: setupAssistantConfig })
.then((result) => { .then((result) => {
console.log("Setup Assistant config saved, reload the page"); console.log('Setup Assistant config saved, reload the page');
this.isConfirmed = true; this.isConfirmed = true;
// Give the Manager some time to restart. // Give the Manager some time to restart.
window.setTimeout(() => { window.location.reload() }, 2000); window.setTimeout(() => {
window.location.reload();
}, 2000);
}) })
.catch((error) => { .catch((error) => {
console.log("Error saving setup assistan config:", error); console.log('Error saving setup assistan config:', error);
// Only clear this flag on an error. // Only clear this flag on an error.
this.isConfirming = false; this.isConfirming = false;
}) });
}, },
}, },
} };
</script> </script>
<style> <style>
.step-welcome ul { .step-welcome ul {
@ -462,7 +534,9 @@ export default {
.progress-bar { .progress-bar {
--width-each-segment: calc(100% / calc(v-bind('totalSetupSteps') - 1)); --width-each-segment: calc(100% / calc(v-bind('totalSetupSteps') - 1));
/* Substract 1 because the first step has no progress. */ /* Substract 1 because the first step has no progress. */
--progress-bar-width-at-current-step: calc(var(--width-each-segment) * calc(v-bind('currentSetupStep') - 1)); --progress-bar-width-at-current-step: calc(
var(--width-each-segment) * calc(v-bind('currentSetupStep') - 1)
);
position: absolute; position: absolute;
top: calc(50% - calc(var(--setup-progress-indicator-border-width) / 2)); top: calc(50% - calc(var(--setup-progress-indicator-border-width) / 2));
@ -481,7 +555,8 @@ export default {
background-color: var(--color-background); background-color: var(--color-background);
border-radius: 50%; border-radius: 50%;
border: var(--setup-progress-indicator-border-width) solid var(--color-background); border: var(--setup-progress-indicator-border-width) solid var(--color-background);
box-shadow: 0 0 0 var(--setup-progress-indicator-border-width) var(--setup-progress-indicator-color); box-shadow: 0 0 0 var(--setup-progress-indicator-border-width)
var(--setup-progress-indicator-color);
content: ''; content: '';
cursor: pointer; cursor: pointer;
display: inline-block; display: inline-block;
@ -504,38 +579,42 @@ export default {
.progress li.done span { .progress li.done span {
background-color: var(--setup-progress-indicator-color); background-color: var(--setup-progress-indicator-color);
box-shadow: 0 0 0 var(--setup-progress-indicator-border-width) var(--setup-progress-indicator-color); box-shadow: 0 0 0 var(--setup-progress-indicator-border-width)
var(--setup-progress-indicator-color);
} }
.progress li.done_previously span { .progress li.done_previously span {
background-color: var(--setup-progress-indicator-color-done); background-color: var(--setup-progress-indicator-color-done);
box-shadow: 0 0 0 var(--setup-progress-indicator-border-width) var(--setup-progress-indicator-color-done); box-shadow: 0 0 0 var(--setup-progress-indicator-border-width)
var(--setup-progress-indicator-color-done);
} }
.progress li.current span { .progress li.current span {
background-color: var(--color-background-column); background-color: var(--color-background-column);
box-shadow: 0 0 0 var(--setup-progress-indicator-border-width) var(--setup-progress-indicator-color-current); box-shadow: 0 0 0 var(--setup-progress-indicator-border-width)
var(--setup-progress-indicator-color-current);
} }
.progress li.done_and_current span { .progress li.done_and_current span {
background-color: var(--setup-progress-indicator-color-current); background-color: var(--setup-progress-indicator-color-current);
box-shadow: 0 0 0 var(--setup-progress-indicator-border-width) var(--setup-progress-indicator-color-current); box-shadow: 0 0 0 var(--setup-progress-indicator-border-width)
var(--setup-progress-indicator-color-current);
} }
body.is-setup-assistant #app { body.is-setup-assistant #app {
grid-template-areas: grid-template-areas:
"header" 'header'
"col-full-width" 'col-full-width'
"footer"; 'footer';
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@media (max-width: 1280px) { @media (max-width: 1280px) {
body.is-setup-assistant #app { body.is-setup-assistant #app {
grid-template-areas: grid-template-areas:
"header" 'header'
"col-full-width" 'col-full-width'
"footer"; 'footer';
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: var(--header-height) 1fr var(--footer-height); grid-template-rows: var(--header-height) 1fr var(--footer-height);
} }
@ -573,7 +652,7 @@ body.is-setup-assistant #app {
text-decoration: underline; text-decoration: underline;
} }
input[type="text"] { input[type='text'] {
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
} }
@ -627,7 +706,12 @@ h2 {
.is-in-progress { .is-in-progress {
animation: is-in-progress 3s infinite linear; animation: is-in-progress 3s infinite linear;
background-image: linear-gradient(to left, var(--color-text-muted), rgba(255, 255, 255, 0.25), var(--color-text-muted)); background-image: linear-gradient(
to left,
var(--color-text-muted),
rgba(255, 255, 255, 0.25),
var(--color-text-muted)
);
background-size: 200px; background-size: 200px;
background-clip: text; background-clip: text;
-webkit-background-clip: text; -webkit-background-clip: text;
@ -644,7 +728,7 @@ h2 {
} }
} }
fieldset input[type="text"], fieldset input[type='text'],
.setup-path-command { .setup-path-command {
margin-left: 1.66rem; margin-left: 1.66rem;
margin-top: var(--spacer-sm); margin-top: var(--spacer-sm);

@ -16,13 +16,8 @@
name="newtagname" name="newtagname"
v-model="newTagName" v-model="newTagName"
placeholder="New Tag Name" placeholder="New Tag Name"
class="create-tag-input" class="create-tag-input" />
/> <button id="submit-button" type="submit" :disabled="newTagName.trim() === ''">
<button
id="submit-button"
type="submit"
:disabled="newTagName.trim() === ''"
>
Create Tag Create Tag
</button> </button>
</div> </div>
@ -35,32 +30,25 @@
<h2 class="column-title">Information</h2> <h2 class="column-title">Information</h2>
<p> <p>
Workers and jobs can be tagged. With these tags you can assign a job to a Workers and jobs can be tagged. With these tags you can assign a job to a subset of your
subset of your workers. workers.
</p> </p>
<h4>Job Perspective:</h4> <h4>Job Perspective:</h4>
<ul> <ul>
<li>A job can have one tag, or no tag.</li> <li>A job can have one tag, or no tag.</li>
<li> <li>A job <strong>with</strong> a tag will only be assigned to workers with that tag.</li>
A job <strong>with</strong> a tag will only be assigned to workers with <li>A job <strong>without</strong> tag will be assigned to any worker.</li>
that tag.
</li>
<li>
A job <strong>without</strong> tag will be assigned to any worker.
</li>
</ul> </ul>
<h4>Worker Perspective:</h4> <h4>Worker Perspective:</h4>
<ul> <ul>
<li>A worker can have any number of tags.</li> <li>A worker can have any number of tags.</li>
<li> <li>
A worker <strong>with</strong> one or more tags will work only on jobs A worker <strong>with</strong> one or more tags will work only on jobs with one those tags,
with one those tags, and on tagless jobs. and on tagless jobs.
</li>
<li>
A worker <strong>without</strong> tags will only work on tagless jobs.
</li> </li>
<li>A worker <strong>without</strong> tags will only work on tagless jobs.</li>
</ul> </ul>
</div> </div>
<footer class="app-footer"> <footer class="app-footer">
@ -70,8 +58,7 @@
mainSubscription="allWorkerTags" mainSubscription="allWorkerTags"
@workerTagUpdate="onSIOWorkerTagsUpdate" @workerTagUpdate="onSIOWorkerTagsUpdate"
@sioReconnected="onSIOReconnected" @sioReconnected="onSIOReconnected"
@sioDisconnected="onSIODisconnected" @sioDisconnected="onSIODisconnected" />
/>
</footer> </footer>
</template> </template>
@ -108,14 +95,14 @@
</style> </style>
<script> <script>
import { TabulatorFull as Tabulator } from "tabulator-tables"; import { TabulatorFull as Tabulator } from 'tabulator-tables';
import { useWorkers } from "@/stores/workers"; import { useWorkers } from '@/stores/workers';
import { useNotifs } from "@/stores/notifications"; import { useNotifs } from '@/stores/notifications';
import { WorkerMgtApi } from "@/manager-api"; import { WorkerMgtApi } from '@/manager-api';
import { WorkerTag } from "@/manager-api"; import { WorkerTag } from '@/manager-api';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
import NotificationBar from "@/components/footer/NotificationBar.vue"; import NotificationBar from '@/components/footer/NotificationBar.vue';
import UpdateListener from "@/components/UpdateListener.vue"; import UpdateListener from '@/components/UpdateListener.vue';
export default { export default {
components: { components: {
@ -126,25 +113,25 @@ export default {
return { return {
tags: [], tags: [],
selectedTag: null, selectedTag: null,
newTagName: "", newTagName: '',
workers: useWorkers(), workers: useWorkers(),
activeRowIndex: -1, activeRowIndex: -1,
}; };
}, },
mounted() { mounted() {
document.body.classList.add("is-two-columns"); document.body.classList.add('is-two-columns');
this.fetchTags(); this.fetchTags();
const tag_options = { const tag_options = {
columns: [ columns: [
{ title: "Name", field: "name", sorter: "string", editor: "input" }, { title: 'Name', field: 'name', sorter: 'string', editor: 'input' },
{ {
title: "Description", title: 'Description',
field: "description", field: 'description',
sorter: "string", sorter: 'string',
editor: "input", editor: 'input',
formatter(cell) { formatter(cell) {
const cellValue = cell.getData().description; const cellValue = cell.getData().description;
if (!cellValue) { if (!cellValue) {
@ -154,24 +141,24 @@ export default {
}, },
}, },
], ],
layout: "fitData", layout: 'fitData',
layoutColumnsOnNewData: true, layoutColumnsOnNewData: true,
height: "82%", height: '82%',
selectable: true, selectable: true,
}; };
this.tabulator = new Tabulator("#tag-table-container", tag_options); this.tabulator = new Tabulator('#tag-table-container', tag_options);
this.tabulator.on("rowClick", this.onRowClick); this.tabulator.on('rowClick', this.onRowClick);
this.tabulator.on("tableBuilt", () => { this.tabulator.on('tableBuilt', () => {
this.fetchTags(); this.fetchTags();
}); });
this.tabulator.on("cellEdited", (cell) => { this.tabulator.on('cellEdited', (cell) => {
const editedTag = cell.getRow().getData(); const editedTag = cell.getRow().getData();
this.updateTagInAPI(editedTag); this.updateTagInAPI(editedTag);
}); });
}, },
unmounted() { unmounted() {
document.body.classList.remove("is-two-columns"); document.body.classList.remove('is-two-columns');
}, },
methods: { methods: {
@ -201,7 +188,7 @@ export default {
.createWorkerTag(newTag) .createWorkerTag(newTag)
.then(() => { .then(() => {
this.fetchTags(); // Refresh table data this.fetchTags(); // Refresh table data
this.newTagName = ""; this.newTagName = '';
}) })
.catch((error) => { .catch((error) => {
const errorMsg = JSON.stringify(error); const errorMsg = JSON.stringify(error);
@ -216,7 +203,7 @@ export default {
api api
.updateWorkerTag(tagId, updatedTagData) .updateWorkerTag(tagId, updatedTagData)
.then(() => { .then(() => {
console.log("Tag updated successfully"); console.log('Tag updated successfully');
}) })
.catch((error) => { .catch((error) => {
const errorMsg = JSON.stringify(error); const errorMsg = JSON.stringify(error);

@ -1,16 +1,23 @@
<template> <template>
<div class="col col-workers-list"> <div class="col col-workers-list">
<workers-table ref="workersTable" :activeWorkerID="workerID" @tableRowClicked="onTableWorkerClicked" /> <workers-table
ref="workersTable"
:activeWorkerID="workerID"
@tableRowClicked="onTableWorkerClicked" />
</div> </div>
<div class="col col-workers-details"> <div class="col col-workers-details">
<worker-details :workerData="workers.activeWorker" /> <worker-details :workerData="workers.activeWorker" />
</div> </div>
<footer class="app-footer"> <footer class="app-footer">
<notification-bar /> <notification-bar />
<update-listener ref="updateListener" <update-listener
mainSubscription="allWorkers" extraSubscription="allWorkerTags" ref="updateListener"
@workerUpdate="onSIOWorkerUpdate" @workerTagUpdate="onSIOWorkerTagsUpdate" mainSubscription="allWorkers"
@sioReconnected="onSIOReconnected" @sioDisconnected="onSIODisconnected" /> extraSubscription="allWorkerTags"
@workerUpdate="onSIOWorkerUpdate"
@workerTagUpdate="onSIOWorkerTagsUpdate"
@sioReconnected="onSIOReconnected"
@sioDisconnected="onSIODisconnected" />
</footer> </footer>
</template> </template>
@ -26,18 +33,18 @@
<script> <script>
import { WorkerMgtApi } from '@/manager-api'; import { WorkerMgtApi } from '@/manager-api';
import { useNotifs } from '@/stores/notifications' import { useNotifs } from '@/stores/notifications';
import { useWorkers } from '@/stores/workers'; import { useWorkers } from '@/stores/workers';
import { getAPIClient } from "@/api-client"; import { getAPIClient } from '@/api-client';
import NotificationBar from '@/components/footer/NotificationBar.vue' import NotificationBar from '@/components/footer/NotificationBar.vue';
import UpdateListener from '@/components/UpdateListener.vue' import UpdateListener from '@/components/UpdateListener.vue';
import WorkerDetails from '@/components/workers/WorkerDetails.vue' import WorkerDetails from '@/components/workers/WorkerDetails.vue';
import WorkersTable from '@/components/workers/WorkersTable.vue' import WorkersTable from '@/components/workers/WorkersTable.vue';
export default { export default {
name: 'WorkersView', name: 'WorkersView',
props: ["workerID"], // provided by Vue Router. props: ['workerID'], // provided by Vue Router.
components: { components: {
NotificationBar, NotificationBar,
UpdateListener, UpdateListener,
@ -69,27 +76,24 @@ export default {
this.$refs.workersTable.onReconnected(); this.$refs.workersTable.onReconnected();
this._fetchWorker(this.workerID); this._fetchWorker(this.workerID);
}, },
onSIODisconnected(reason) { onSIODisconnected(reason) {},
},
onSIOWorkerUpdate(workerUpdate) { onSIOWorkerUpdate(workerUpdate) {
this.notifs.addWorkerUpdate(workerUpdate); this.notifs.addWorkerUpdate(workerUpdate);
if (this.$refs.workersTable) { if (this.$refs.workersTable) {
this.$refs.workersTable.processWorkerUpdate(workerUpdate); this.$refs.workersTable.processWorkerUpdate(workerUpdate);
} }
if (this.workerID != workerUpdate.id) if (this.workerID != workerUpdate.id) return;
return;
if (workerUpdate.deleted_at) { if (workerUpdate.deleted_at) {
this._routeToWorker(""); this._routeToWorker('');
return; return;
} }
this._fetchWorker(this.workerID); this._fetchWorker(this.workerID);
}, },
onSIOWorkerTagsUpdate(workerTagsUpdate) { onSIOWorkerTagsUpdate(workerTagsUpdate) {
this.workers.refreshTags() this.workers.refreshTags().then(() => this._fetchWorker(this.workerID));
.then(() => this._fetchWorker(this.workerID));
}, },
onTableWorkerClicked(rowData) { onTableWorkerClicked(rowData) {
@ -115,9 +119,8 @@ export default {
return; return;
} }
return this.api.fetchWorker(workerID) return this.api.fetchWorker(workerID).then((worker) => this.workers.setActiveWorker(worker));
.then((worker) => this.workers.setActiveWorker(worker));
}, },
}, },
} };
</script> </script>

@ -1,14 +1,14 @@
import { fileURLToPath, URL } from 'url' import { fileURLToPath, URL } from 'url';
import { defineConfig } from 'vite' import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url)),
} },
}, },
}) });