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
indent_style = space
indent_size = 2
max_line_length = 100
[*.go]
indent_style = tab

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

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

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

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

@ -1,6 +1,6 @@
import { ApiClient } from "@/manager-api";
import { CountingApiClient } from "@/stores/api-query-count";
import { api as apiURL } from '@/urls'
import { ApiClient } from '@/manager-api';
import { CountingApiClient } from '@/stores/api-query-count';
import { api as apiURL } from '@/urls';
/**
* 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
// displays (like the TV in the hallway at Blender HQ) pick up on HTML/JS/CSS
// changes eventually.
const reloadAfter = {minute: 60};
const reloadAfter = { minute: 60 };
function getReloadDeadline() {
return DateTime.now().plus(reloadAfter);
@ -27,10 +27,10 @@ export default function autoreload() {
// Check whether reloading is needed every minute.
window.setInterval(maybeReload, 60 * 1000);
window.addEventListener("resize", deferReload);
window.addEventListener("mousedown", deferReload);
window.addEventListener("mouseup", deferReload);
window.addEventListener("mousemove", deferReload);
window.addEventListener("keydown", deferReload);
window.addEventListener("keyup", deferReload);
window.addEventListener('resize', deferReload);
window.addEventListener('mousedown', deferReload);
window.addEventListener('mouseup', deferReload);
window.addEventListener('mousemove', deferReload);
window.addEventListener('keydown', deferReload);
window.addEventListener('keyup', deferReload);
}

@ -1,4 +1,3 @@
/**
* The duration in milliseconds of the "flash" effect, when an element has been
* copied.
@ -7,7 +6,6 @@
*/
const flashAfterCopyDuration = 150;
/**
* Copy the inner text of an element to the clipboard.
*
@ -30,9 +28,9 @@ export function copyElementData(clickEvent) {
}
function copyElementValue(sourceElement, value) {
const inputElement = document.createElement("input");
const inputElement = document.createElement('input');
document.body.appendChild(inputElement);
inputElement.setAttribute("value", value);
inputElement.setAttribute('value', value);
inputElement.select();
// 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()`
// call.
// Source: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard
document.execCommand("copy");
document.execCommand('copy');
document.body.removeChild(inputElement);
flashElement(sourceElement);
}
function flashElement(element) {
element.classList.add("copied");
element.classList.add('copied');
window.setTimeout(() => {
element.classList.remove("copied");
element.classList.remove('copied');
}, 150);
}

@ -29,7 +29,7 @@ span {
.spinner {
-webkit-animation: rotate 2s linear infinite;
animation: rotate 2s linear infinite;
animation: rotate 2s linear infinite;
z-index: 2;
position: absolute;
top: 50%;
@ -42,7 +42,7 @@ span {
stroke: var(--color-text-hint);
stroke-linecap: round;
-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 {

@ -1,5 +1,7 @@
<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>
<script setup>

@ -2,9 +2,15 @@
<div class="details-no-item-selected">
<div class="get-the-addon">
<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><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>
</template>

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

@ -1,8 +1,13 @@
<template>
<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 }}
(<span class="status-label" :class="'status-' + workerTask.status">{{ workerTask.status }}</span>)
(<span class="status-label" :class="'status-' + workerTask.status">{{
workerTask.status
}}</span
>)
</router-link>
<span v-else>-</span>
</span>

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

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

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

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

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

@ -3,9 +3,9 @@
</template>
<script>
import io from "socket.io-client";
import { ws } from '@/urls'
import * as API from "@/manager-api"
import io from 'socket.io-client';
import { ws } from '@/urls';
import * as API from '@/manager-api';
import { useSocketStatus } from '@/stores/socket-status';
const websocketURL = ws();
@ -13,26 +13,32 @@ const websocketURL = ws();
export default {
emits: [
// Data from Flamenco Manager:
"jobUpdate", "taskUpdate", "taskLogUpdate", "message", "workerUpdate",
"lastRenderedUpdate", "workerTagUpdate",
'jobUpdate',
'taskUpdate',
'taskLogUpdate',
'message',
'workerUpdate',
'lastRenderedUpdate',
'workerTagUpdate',
// SocketIO events:
"sioReconnected", "sioDisconnected"
'sioReconnected',
'sioDisconnected',
],
props: [
"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`.
"subscribedJobID",
"subscribedTaskID",
'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`.
'subscribedJobID',
'subscribedTaskID',
],
data() {
return {
socket: null,
sockStatus: useSocketStatus(),
}
};
},
mounted: function () {
if (!websocketURL) {
console.warn("UpdateListener: no websocketURL given, cannot do anything");
console.warn('UpdateListener: no websocketURL given, cannot do anything');
return;
}
this.connectToWebsocket();
@ -46,34 +52,34 @@ export default {
watch: {
subscribedJobID(newJobID, oldJobID) {
if (oldJobID) {
this._updateJobSubscription("unsubscribe", oldJobID);
this._updateJobSubscription('unsubscribe', oldJobID);
}
if (newJobID) {
this._updateJobSubscription("subscribe", newJobID);
this._updateJobSubscription('subscribe', newJobID);
}
},
subscribedTaskID(newTaskID, oldTaskID) {
if (oldTaskID) {
this._updateTaskLogSubscription("unsubscribe", oldTaskID);
this._updateTaskLogSubscription('unsubscribe', oldTaskID);
}
if (newTaskID) {
this._updateTaskLogSubscription("subscribe", newTaskID);
this._updateTaskLogSubscription('subscribe', newTaskID);
}
},
mainSubscription(newType, oldType) {
if (oldType) {
this._updateMainSubscription("unsubscribe", oldType);
this._updateMainSubscription('unsubscribe', oldType);
}
if (newType) {
this._updateMainSubscription("subscribe", newType);
this._updateMainSubscription('subscribe', newType);
}
},
extraSubscription(newType, oldType) {
if (oldType) {
this._updateMainSubscription("unsubscribe", oldType);
this._updateMainSubscription('unsubscribe', oldType);
}
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
// console.log("connecting JobsListener to WS", websocketURL);
const ws = io(websocketURL, {
transports: ["websocket"],
transports: ['websocket'],
});
this.socket = ws;
@ -99,104 +105,103 @@ export default {
});
this.socket.on('connect_error', (error) => {
// 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.socket.on('error', (error) => {
console.log("socketIO error:", error);
console.log('socketIO error:', error);
this.sockStatus.disconnected(error);
});
this.socket.on('connect_timeout', (timeout) => {
console.log("socketIO connection timeout:", timeout);
this.sockStatus.disconnected("Connection timeout");
console.log('socketIO connection timeout:', timeout);
this.sockStatus.disconnected('Connection timeout');
});
this.socket.on("disconnect", (reason) => {
this.socket.on('disconnect', (reason) => {
// console.log("socketIO disconnected:", reason);
this.$emit("sioDisconnected", reason);
this.$emit('sioDisconnected', reason);
this.sockStatus.disconnected(reason);
if (reason === 'io server disconnect') {
// The disconnection was initiated by the server, need to reconnect
// manually. If the disconnect was for other reasons, the socket
// should automatically try to reconnect.
// Intentionally commented out function call, because this should
// happen with some nice exponential backoff instead of hammering the
// server:
// socket.connect();
}
});
this.socket.on("reconnect", (attemptNumber) => {
console.log("socketIO reconnected after", attemptNumber, "attempts");
this.socket.on('reconnect', (attemptNumber) => {
console.log('socketIO reconnected after', attemptNumber, 'attempts');
this.sockStatus.connected();
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
// when we'd do an API call.
const apiJobUpdate = API.SocketIOJobUpdate.constructFromObject(jobUpdate)
this.$emit("jobUpdate", apiJobUpdate);
const apiJobUpdate = API.SocketIOJobUpdate.constructFromObject(jobUpdate);
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
// when we'd do an API call.
const apiUpdate = API.SocketIOLastRenderedUpdate.constructFromObject(update)
this.$emit("lastRenderedUpdate", apiUpdate);
const apiUpdate = API.SocketIOLastRenderedUpdate.constructFromObject(update);
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
// when we'd do an API call.
const apiTaskUpdate = API.SocketIOTaskUpdate.constructFromObject(taskUpdate)
this.$emit("taskUpdate", apiTaskUpdate);
const apiTaskUpdate = API.SocketIOTaskUpdate.constructFromObject(taskUpdate);
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
// when we'd do an API call.
const apiTaskLogUpdate = API.SocketIOTaskLogUpdate.constructFromObject(taskLogUpdate)
this.$emit("taskLogUpdate", apiTaskLogUpdate);
const apiTaskLogUpdate = API.SocketIOTaskLogUpdate.constructFromObject(taskLogUpdate);
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
// when we'd do an API call.
const apiWorkerUpdate = API.SocketIOWorkerUpdate.constructFromObject(workerUpdate)
this.$emit("workerUpdate", apiWorkerUpdate);
const apiWorkerUpdate = API.SocketIOWorkerUpdate.constructFromObject(workerUpdate);
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
// when we'd do an API call.
const apiWorkerTagUpdate = API.SocketIOWorkerTagUpdate.constructFromObject(workerTagUpdate)
this.$emit("workerTagUpdate", apiWorkerTagUpdate);
const apiWorkerTagUpdate = API.SocketIOWorkerTagUpdate.constructFromObject(workerTagUpdate);
this.$emit('workerTagUpdate', apiWorkerTagUpdate);
});
// Chat system, useful for debugging.
this.socket.on("/message", (message) => {
this.$emit("message", message);
this.socket.on('/message', (message) => {
this.$emit('message', message);
});
},
disconnectWebsocket() {
if (this.socket == null) {
console.log("no JobListener socket to disconnect");
console.log('no JobListener socket to disconnect');
return;
}
console.log("disconnecting JobsListener WS", websocketURL);
console.log('disconnecting JobsListener WS', websocketURL);
this.socket.disconnect();
this.socket = null;
},
sendBroadcastMessage(name, 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) {
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
*/
_updateJobSubscription(operation, jobID) {
const payload = new API.SocketIOSubscription(operation, "job");
const payload = new API.SocketIOSubscription(operation, 'job');
payload.uuid = jobID;
this.socket.emit("/subscription", payload);
this.socket.emit('/subscription', payload);
},
/**
@ -226,17 +231,18 @@ export default {
* @param {string} jobID
*/
_updateTaskLogSubscription(operation, taskID) {
const payload = new API.SocketIOSubscription(operation, "tasklog");
const payload = new API.SocketIOSubscription(operation, 'tasklog');
payload.uuid = taskID;
this.socket.emit("/subscription", payload);
this.socket.emit('/subscription', payload);
},
// Resubscribe to whatever we want to be subscribed to:
_resubscribe() {
if (this.subscribedJobID) this._updateJobSubscription("subscribe", this.subscribedJobID);
if (this.subscribedTaskID) this._updateTaskLogSubscription("subscribe", this.subscribedTaskID);
if (this.mainSubscription) this._updateMainSubscription("subscribe", this.mainSubscription);
if (this.extraSubscription) this._updateMainSubscription("subscribe", this.extraSubscription);
if (this.subscribedJobID) this._updateJobSubscription('subscribe', this.subscribedJobID);
if (this.subscribedTaskID)
this._updateTaskLogSubscription('subscribe', this.subscribedTaskID);
if (this.mainSubscription) this._updateMainSubscription('subscribe', this.mainSubscription);
if (this.extraSubscription) this._updateMainSubscription('subscribe', this.extraSubscription);
},
},
};

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

@ -1,13 +1,13 @@
<template>
<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 />
</section>
</template>
<script>
import { useNotifs } from '@/stores/notifications';
import ConnectionStatus from '@/components/ConnectionStatus.vue'
import ConnectionStatus from '@/components/ConnectionStatus.vue';
export default {
name: 'NotificationBar',
@ -17,7 +17,7 @@ export default {
data: () => ({
notifs: useNotifs(),
}),
}
};
</script>
<style scoped>

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

@ -1,9 +1,9 @@
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { onMounted, onUnmounted } from 'vue';
import { TabulatorFull as Tabulator } from 'tabulator-tables';
import { useTaskLog } from '@/stores/tasklog'
import { useTasks } from '@/stores/tasks'
import { getAPIClient } from "@/api-client";
import { useTaskLog } from '@/stores/tasklog';
import { useTasks } from '@/stores/tasks';
import { getAPIClient } from '@/api-client';
import { JobsApi } from '@/manager-api';
const taskLog = useTaskLog();
@ -20,19 +20,19 @@ const tabOptions = {
},
],
headerVisible: false,
layout: "fitDataStretch",
layout: 'fitDataStretch',
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,
placeholder: "Task log will appear here",
placeholder: 'Task log will appear here',
selectable: false,
};
let tabulator = null;
onMounted(() => {
tabulator = new Tabulator('#task_log_list', tabOptions);
tabulator.on("tableBuilt", _scrollToBottom);
tabulator.on("tableBuilt", _subscribeToPinia);
tabulator.on('tableBuilt', _scrollToBottom);
tabulator.on('tableBuilt', _subscribeToPinia);
_fetchLogTail(tasks.activeTaskID);
});
onUnmounted(() => {
@ -45,13 +45,12 @@ tasks.$subscribe((_, state) => {
function _scrollToBottom() {
if (taskLog.empty) return;
tabulator.scrollToRow(taskLog.lastID, "bottom", false);
tabulator.scrollToRow(taskLog.lastID, 'bottom', false);
}
function _subscribeToPinia() {
taskLog.$subscribe(() => {
tabulator.setData(taskLog.history)
.then(_scrollToBottom)
})
tabulator.setData(taskLog.history).then(_scrollToBottom);
});
}
function _fetchLogTail(taskID) {
@ -60,10 +59,9 @@ function _fetchLogTail(taskID) {
if (!taskID) return;
const jobsAPI = new JobsApi(getAPIClient());
return jobsAPI.fetchTaskLogTail(taskID)
.then((logTail) => {
taskLog.addChunk(logTail);
});
return jobsAPI.fetchTaskLogTail(taskID).then((logTail) => {
taskLog.addChunk(logTail);
});
}
</script>

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

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

@ -7,7 +7,7 @@
<TabsWrapper @clicked-job-details-tab="emit_reshuffled_delayed">
<TabItem title="Job Settings">
<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>
<dd>{{ value }}</dd>
</template>
@ -18,7 +18,7 @@
</TabItem>
<TabItem title="Metadata">
<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>
<dd>{{ value }}</dd>
</template>
@ -30,18 +30,17 @@
<TabItem title="Details">
<dl>
<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">
<!-- TODO: fetch tag name and show that instead, and allow editing of the tag. -->
<dt class="field-name" title="Worker Tag">Tag</dt>
<dd :title="workerTag.description">
<span
@click="copyElementData"
class="click-to-copy"
:data-clipboard="workerTag.id"
>{{ workerTag.name }}</span
>
<span @click="copyElementData" class="click-to-copy" :data-clipboard="workerTag.id">{{
workerTag.name
}}</span>
</dd>
</template>
@ -49,7 +48,9 @@
<dd>{{ jobData.name }}</dd>
<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>
<dd>{{ jobType ? jobType.label : jobData.type }}</dd>
@ -87,24 +88,24 @@
</template>
<script>
import * as datetime from "@/datetime";
import * as datetime from '@/datetime';
import * as API from '@/manager-api';
import { getAPIClient } from "@/api-client";
import LastRenderedImage from '@/components/jobs/LastRenderedImage.vue'
import Blocklist from './Blocklist.vue'
import TabItem from '@/components/TabItem.vue'
import TabsWrapper from '@/components/TabsWrapper.vue'
import PopoverEditableJobPriority from '@/components/PopoverEditableJobPriority.vue'
import { getAPIClient } from '@/api-client';
import LastRenderedImage from '@/components/jobs/LastRenderedImage.vue';
import Blocklist from './Blocklist.vue';
import TabItem from '@/components/TabItem.vue';
import TabsWrapper from '@/components/TabsWrapper.vue';
import PopoverEditableJobPriority from '@/components/PopoverEditableJobPriority.vue';
import { copyElementText, copyElementData } from '@/clipboard';
import { useWorkers } from '@/stores/workers'
import { useWorkers } from '@/stores/workers';
import { useNotifs } from '@/stores/notifications';
export default {
props: [
"jobData", // Job data to show.
'jobData', // Job data to show.
],
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: {
LastRenderedImage,
@ -192,9 +193,12 @@ export default {
if (objectEmpty(this.jobType) || this.jobType.name != newJobData.type) {
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)
.catch((error) => { console.warn("error fetching job type:", error) });
.catch((error) => {
console.warn('error fetching job type:', error);
});
} else {
this._setJobSettings(newJobData.settings);
}
@ -205,8 +209,7 @@ export default {
// Construct a lookup table for the settings.
const jobTypeSettings = {};
for (let setting of jobType.settings)
jobTypeSettings[setting.key] = setting;
for (let setting of jobType.settings) jobTypeSettings[setting.key] = setting;
this.jobTypeSettings = jobTypeSettings;
if (this.jobData) {
@ -227,7 +230,7 @@ export default {
}
if (objectEmpty(this.jobTypeSettings)) {
console.warn("empty job type settings");
console.warn('empty job type settings');
this._clearJobSettings();
return;
}
@ -255,7 +258,9 @@ export default {
this.$emit('reshuffled');
},
emit_reshuffled_delayed() {
const reshuffle = () => { this.$emit('reshuffled'); }
const reshuffle = () => {
this.$emit('reshuffled');
};
// 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
@ -269,7 +274,7 @@ export default {
<style scoped>
/* Prevent fields with long IDs from overflowing. */
.field-id+dd {
.field-id + dd {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

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

@ -1,15 +1,14 @@
<template>
<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>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import { reactive, ref, watch } from 'vue';
import { api } from '@/urls';
import { JobsApi, JobLastRenderedImageInfo, SocketIOLastRenderedUpdate } from '@/manager-api';
import { getAPIClient } from "@/api-client";
import { getAPIClient } from '@/api-client';
const props = defineProps([
/* 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({
'last-rendered': true,
'nothing-rendered-yet': true,
})
});
const jobsApi = new JobsApi(getAPIClient());
@ -34,14 +33,12 @@ const jobsApi = new JobsApi(getAPIClient());
*/
function fetchImageURL(jobID) {
let promise;
if (jobID)
promise = jobsApi.fetchJobLastRenderedInfo(jobID);
else
promise = jobsApi.fetchGlobalLastRenderedInfo();
if (jobID) promise = jobsApi.fetchJobLastRenderedInfo(jobID);
else promise = jobsApi.fetchGlobalLastRenderedInfo();
promise
.then(setImageURL)
.catch((error) => { console.warn("error fetching last-rendered image info:", error) });
promise.then(setImageURL).catch((error) => {
console.warn('error fetching last-rendered image info:', error);
});
}
/**
@ -51,7 +48,7 @@ function setImageURL(thumbnailInfo) {
if (thumbnailInfo == null) {
// This indicates that there is no last-rendered 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;
return;
}
@ -66,14 +63,17 @@ function setImageURL(thumbnailInfo) {
// Flamenco Manager, and not from any development server that might be
// serving the webapp.
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.
imageURL.value = url.toString();
foundThumbnail = true;
break;
}
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;
}
@ -85,9 +85,11 @@ function refreshLastRenderedImage(lastRenderedUpdate) {
// Only filter out other job IDs if this component has actually a non-empty job ID.
if (props.jobID && lastRenderedUpdate.job_id != props.jobID) {
console.log(
"LastRenderedImage.vue: refreshLastRenderedImage() received update for job",
'LastRenderedImage.vue: refreshLastRenderedImage() received update for job',
lastRenderedUpdate.job_id,
"but this component is showing job", props.jobID);
'but this component is showing job',
props.jobID
);
return;
}
@ -95,9 +97,12 @@ function refreshLastRenderedImage(lastRenderedUpdate) {
}
// Call fetchImageURL(jobID) whenever the job ID prop changes value.
watch(() => props.jobID, (newJobID) => {
fetchImageURL(newJobID);
});
watch(
() => props.jobID,
(newJobID) => {
fetchImageURL(newJobID);
}
);
fetchImageURL(props.jobID);
// Expose refreshLastRenderedImage() so that it can be called from the parent

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

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

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

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

@ -8,15 +8,17 @@
<option :value="key" v-if="action.condition()">{{ action.label }}</option>
</template>
</select>
<button :disabled="!canPerformAction" class="btn" @click.prevent="performWorkerAction">Apply</button>
<button :disabled="!canPerformAction" class="btn" @click.prevent="performWorkerAction">
Apply
</button>
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, ref } from 'vue';
import { useWorkers } from '@/stores/workers';
import { useNotifs } from '@/stores/notifications';
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.
* 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: {
label: 'Shut Down (after task is finished)',
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',
lazy: true,
condition: () => true,
@ -88,19 +91,19 @@ const notifs = useNotifs();
function performWorkerAction() {
const workerID = workers.activeWorkerID;
if (!workerID) {
notifs.add("Select a Worker before applying an action.");
notifs.add('Select a Worker before applying an action.');
return;
}
const api = new WorkerMgtApi(getAPIClient());
const action = WORKER_ACTIONS[selectedAction.value];
const statuschange = new WorkerStatusChangeRequest(action.target_status, action.lazy);
console.log("Requesting worker status change", statuschange);
api.requestWorkerStatusChange(workerID, statuschange)
console.log('Requesting worker status change', statuschange);
api
.requestWorkerStatusChange(workerID, statuschange)
.then((result) => notifs.add(`Worker status change to ${action.target_status} confirmed.`))
.catch((error) => {
notifs.add(`Error requesting worker status change: ${error.body.message}`)
notifs.add(`Error requesting worker status change: ${error.body.message}`);
});
}
</script>

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

@ -5,7 +5,10 @@
<worker-actions-bar />
<div class="align-right">
<status-filter-bar :availableStatuses="availableStatuses" :activeStatuses="shownStatuses" classPrefix="worker-"
<status-filter-bar
:availableStatuses="availableStatuses"
:activeStatuses="shownStatuses"
classPrefix="worker-"
@click="toggleStatusFilter" />
</div>
</div>
@ -17,18 +20,18 @@
<script>
import { TabulatorFull as Tabulator } from 'tabulator-tables';
import { WorkerMgtApi } from '@/manager-api'
import { WorkerMgtApi } from '@/manager-api';
import { indicator, workerStatus } from '@/statusindicator';
import { getAPIClient } from "@/api-client";
import { getAPIClient } from '@/api-client';
import { useWorkers } from '@/stores/workers';
import StatusFilterBar from '@/components/StatusFilterBar.vue'
import WorkerActionsBar from '@/components/workers/WorkerActionsBar.vue'
import StatusFilterBar from '@/components/StatusFilterBar.vue';
import WorkerActionsBar from '@/components/workers/WorkerActionsBar.vue';
export default {
name: 'WorkersTable',
props: ["activeWorkerID"],
emits: ["tableRowClicked"],
props: ['activeWorkerID'],
emits: ['tableRowClicked'],
components: {
StatusFilterBar,
WorkerActionsBar,
@ -51,7 +54,9 @@ export default {
// Useful for debugging when there are many similar workers:
// { title: "ID", field: "id", headerSort: false, formatter: (cell) => cell.getData().id.substr(0, 8), },
{
title: 'Status', field: 'status', sorter: 'string',
title: 'Status',
field: 'status',
sorter: 'string',
formatter: (cell) => {
const data = cell.getData();
const dot = indicator(data.status, 'worker-');
@ -64,21 +69,19 @@ export default {
],
rowFormatter(row) {
const data = row.getData();
const isActive = (data.id === vueComponent.activeWorkerID);
row.getElement().classList.toggle("active-row", isActive);
const isActive = data.id === vueComponent.activeWorkerID;
row.getElement().classList.toggle('active-row', isActive);
},
initialSort: [
{ column: "name", dir: "asc" },
],
layout: "fitData",
initialSort: [{ column: 'name', dir: 'asc' }],
layout: 'fitData',
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.
selectable: false, // The active worker is tracked by click events, not row selection.
};
this.tabulator = new Tabulator('#flamenco_workers_list', options);
this.tabulator.on("rowClick", this.onRowClick);
this.tabulator.on("tableBuilt", this._onTableBuilt);
this.tabulator.on('rowClick', this.onRowClick);
this.tabulator.on('tableBuilt', this._onTableBuilt);
window.addEventListener('resize', this.recalcTableHeight);
},
@ -100,7 +103,7 @@ export default {
computed: {
selectedIDs() {
return this.tabulator.getSelectedData().map((worker) => worker.id);
}
},
},
methods: {
onReconnected() {
@ -159,7 +162,9 @@ export default {
}
promise
.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);
// 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
// overwrite the old worker's ID, and this prevents that.
const rowData = plain(row.getData());
this.$emit("tableRowClicked", rowData);
this.$emit('tableRowClicked', rowData);
},
toggleStatusFilter(status) {
const asSet = new Set(this.shownStatuses);
@ -199,7 +204,7 @@ export default {
// Use tab.rowManager.findRow() instead of `tab.getRow()` as the latter
// logs a warning when the row cannot be found.
const row = this.tabulator.rowManager.findRow(workerID);
if (!row) return
if (!row) return;
if (row.reformat) row.reformat();
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
// view. To ensure this, it's given `position: relative` in the CSS
// 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;
}

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

@ -1,15 +1,15 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { DateTime } from 'luxon';
import App from '@/App.vue'
import SetupAssistant from '@/SetupAssistant.vue'
import autoreload from '@/autoreloader'
import router from '@/router/index'
import setupAssistantRouter from '@/router/setup-assistant'
import { MetaApi } from "@/manager-api";
import { newBareAPIClient } from "@/api-client";
import * as urls from '@/urls'
import App from '@/App.vue';
import SetupAssistant from '@/SetupAssistant.vue';
import autoreload from '@/autoreloader';
import router from '@/router/index';
import setupAssistantRouter from '@/router/setup-assistant';
import { MetaApi } from '@/manager-api';
import { newBareAPIClient } from '@/api-client';
import * as urls from '@/urls';
// Ensure Tabulator can find `luxon`, which it needs for sorting by
// 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.
autoreload();
const pinia = createPinia()
const pinia = createPinia();
function normalMode() {
const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')
const app = createApp(App);
app.use(pinia);
app.use(router);
app.mount('#app');
}
function setupAssistantMode() {
console.log("Flamenco Setup Assistant is starting");
const app = createApp(SetupAssistant)
app.use(pinia)
app.use(setupAssistantRouter)
app.mount('#app')
console.log('Flamenco Setup Assistant is starting');
const app = createApp(SetupAssistant);
app.use(pinia);
app.use(setupAssistantRouter);
app.mount('#app');
}
/* 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. */
const apiClient = newBareAPIClient();
const metaAPI = new MetaApi(apiClient);
metaAPI.getConfiguration()
metaAPI
.getConfiguration()
.then((config) => {
if (config.isFirstRun) setupAssistantMode();
else normalMode();
})
.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({
history: createWebHistory(import.meta.env.BASE_URL),
@ -32,6 +32,6 @@ const router = createRouter({
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({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "index",
component: () => import("../views/SetupAssistantView.vue"),
path: '/',
name: 'index',
component: () => import('../views/SetupAssistantView.vue'),
},
{
path: "/:pathMatch(.*)*",
name: "redirect-to-index",
path: '/:pathMatch(.*)*',
name: 'redirect-to-index',
redirect: '/',
},
],

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

@ -13,7 +13,7 @@ import { toTitleCase } from '@/strings';
*/
export function indicator(status, classNamePrefix) {
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>`;
}
@ -31,9 +31,9 @@ export function workerStatus(worker) {
let arrow;
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 {
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>

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

@ -1,8 +1,7 @@
import { defineStore } from 'pinia'
import { defineStore } from 'pinia';
import * as API from '@/manager-api';
import { getAPIClient } from "@/api-client";
import { getAPIClient } from '@/api-client';
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 : ""`.
* @type {string}
*/
activeJobID: "",
activeJobID: '',
/**
* 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: {
canDelete() {
return this._anyJobWithStatus(["queued", "paused", "failed", "completed", "canceled"])
return this._anyJobWithStatus(['queued', 'paused', 'failed', 'completed', 'canceled']);
},
canCancel() {
return this._anyJobWithStatus(["queued", "active", "failed"])
return this._anyJobWithStatus(['queued', 'active', 'failed']);
},
canRequeue() {
return this._anyJobWithStatus(["canceled", "completed", "failed", "paused"])
return this._anyJobWithStatus(['canceled', 'completed', 'failed', 'paused']);
},
},
actions: {
@ -41,7 +40,7 @@ export const useJobs = defineStore('jobs', {
},
setActiveJobID(jobID) {
this.$patch({
activeJob: {id: jobID, settings: {}, metadata: {}},
activeJob: { id: jobID, settings: {}, metadata: {} },
activeJobID: jobID,
});
},
@ -60,7 +59,7 @@ export const useJobs = defineStore('jobs', {
deselectAllJobs() {
this.$patch({
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
* code now assumes that only the active job needs to be operated on.
*/
cancelJobs() { return this._setJobStatus("cancel-requested"); },
requeueJobs() { return this._setJobStatus("requeueing"); },
cancelJobs() {
return this._setJobStatus('cancel-requested');
},
requeueJobs() {
return this._setJobStatus('requeueing');
},
deleteJobs() {
if (!this.activeJobID) {
console.warn(`deleteJobs() impossible, no active job ID`);
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.
*/
_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);
},
@ -107,8 +112,8 @@ export const useJobs = defineStore('jobs', {
console.warn(`_setJobStatus(${newStatus}) impossible, no active job ID`);
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);
},
},
})
});

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

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

@ -1,4 +1,4 @@
import { defineStore } from 'pinia'
import { defineStore } from 'pinia';
// Maximum number of task log lines that will be stored.
const capacity = 1000;
@ -17,7 +17,7 @@ export const useTaskLog = defineStore('taskLog', {
* @type {{ id: Number, line: string }[]} */
history: [],
/** @type { id: Number, line: string } */
last: "",
last: '',
lastID: 0,
}),
@ -52,8 +52,7 @@ export const useTaskLog = defineStore('taskLog', {
if (!logChunk) return;
const lines = logChunk.trimEnd().split('\n');
if (lines.length == 0)
return;
if (lines.length == 0) return;
if (lines.length > capacity) {
// Only keep the `capacity` last lines, so that adding them to the
@ -73,7 +72,7 @@ export const useTaskLog = defineStore('taskLog', {
}
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;
}
@ -84,7 +83,7 @@ export const useTaskLog = defineStore('taskLog', {
},
_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) {
return ++state.lastID;
}
},
},
})
});

@ -1,8 +1,7 @@
import { defineStore } from 'pinia'
import { defineStore } from 'pinia';
import * as API from '@/manager-api';
import { getAPIClient } from "@/api-client";
import { getAPIClient } from '@/api-client';
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 : ""`.
* @type {string}
*/
activeTaskID: "",
activeTaskID: '',
}),
getters: {
canCancel() {
return this._anyTaskWithStatus(["queued", "active", "soft-failed"])
return this._anyTaskWithStatus(['queued', 'active', 'soft-failed']);
},
canRequeue() {
return this._anyTaskWithStatus(["canceled", "completed", "failed"])
return this._anyTaskWithStatus(['canceled', 'completed', 'failed']);
},
},
actions: {
setActiveTaskID(taskID) {
this.$patch({
activeTask: {id: taskID},
activeTask: { id: taskID },
activeTaskID: taskID,
});
},
@ -42,7 +41,7 @@ export const useTasks = defineStore('tasks', {
deselectAllTasks() {
this.$patch({
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
* code now assumes that only the active task needs to be operated on.
*/
cancelTasks() { return this._setTaskStatus("canceled"); },
requeueTasks() { return this._setTaskStatus("queued"); },
cancelTasks() {
return this._setTaskStatus('canceled');
},
requeueTasks() {
return this._setTaskStatus('queued');
},
// 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.
*/
_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);
},
@ -79,8 +84,8 @@ export const useTasks = defineStore('tasks', {
console.warn(`_setTaskStatus(${newStatus}) impossible, no active task ID`);
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);
},
},
})
});

@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { defineStore } from 'pinia';
import { WorkerMgtApi } from '@/manager-api';
import { getAPIClient } from "@/api-client";
import { getAPIClient } from '@/api-client';
// 'use' prefix is idiomatic for Pinia stores.
// 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 : ""`.
* @type {string}
*/
activeWorkerID: "",
activeWorkerID: '',
/** @type {API.WorkerTag[]} */
tags: [],
@ -43,7 +43,7 @@ export const useWorkers = defineStore('workers', {
deselectAllWorkers() {
this.$patch({
activeWorker: null,
activeWorkerID: "",
activeWorkerID: '',
});
},
/**

@ -1,14 +1,13 @@
let url = new URL(window.location.href);
// 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.
if (url.port == "8081") {
url.port = "8080";
if (url.port == '8081') {
url.port = '8080';
}
url.pathname = "/";
url.pathname = '/';
const flamencoAPIURL = url.href;
url.protocol = "ws:";
url.protocol = 'ws:';
const websocketURL = url.href;
const URLs = {

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

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

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

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

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

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