Web: remove concept of "selected jobs" and replace with "active job"

The selection mechanism of Tabulator was getting in the way of having nice
navigation, as it would deselect (i.e. nav to "/") before selecting the
next job (i.e. nav to "/jobs/{job-id}").

The active job is now determined by the URL and thus handled by Vue Router.
Clicking on a job simply navigates to its URL, which causes the reactive
system to load & display it.

It is still intended to get job selection for "mass actions", but that's
only possible after normal navigation is working well.
This commit is contained in:
Sybren A. Stüvel 2022-05-11 12:13:25 +02:00
parent af39414a06
commit 63ac728732
10 changed files with 182 additions and 89 deletions

View File

@ -1,16 +1,16 @@
<template> <template>
<header> <header>
<a href="/" class="navbar-brand">{{ flamencoName }}</a> <router-link :to="{ name: 'index' }" class="navbar-brand">{{ flamencoName }}</router-link>
<nav> <nav>
<ul> <ul>
<li> <li>
<router-link to="/">Jobs</router-link> <router-link :to="{ name: 'jobs' }">Jobs</router-link>
</li> </li>
<li> <li>
<router-link to="/workers">Workers</router-link> <router-link :to="{ name: 'workers' }">Workers</router-link>
</li> </li>
<li> <li>
<router-link to="/settings">Settings</router-link> <router-link :to="{ name: 'settings' }">Settings</router-link>
</li> </li>
</ul> </ul>
</nav> </nav>

View File

@ -335,3 +335,15 @@ footer {
.status-cancel-requested { .status-cancel-requested {
background-color: transparent; background-color: transparent;
} }
.tabulator-row {
cursor: pointer;
}
.tabulator-row.active-row {
font-weight: bold;
background-color: #9900ff44;
}
.tabulator-row.active-row.tabulator-row-even {
background-color: #a92cfd44;
}

View File

@ -33,7 +33,8 @@ export default {
}, },
_handleJobActionPromise(promise, description) { _handleJobActionPromise(promise, description) {
const numJobs = this.jobs.numSelected; // const numJobs = this.jobs.numSelected;
const numJobs = 1;
return promise return promise
.then(() => { .then(() => {
let message; let message;

View File

@ -1,7 +1,6 @@
<template> <template>
<div> <div>
<h2 class="column-title">Jobs</h2> <h2 class="column-title">Jobs</h2>
<job-actions-bar /> <job-actions-bar />
<div class="job-list" id="flamenco_job_list"></div> <div class="job-list" id="flamenco_job_list"></div>
</div> </div>
@ -17,7 +16,9 @@ import { apiClient } from '@/stores/api-query-count';
import JobActionsBar from '@/components/JobActionsBar.vue' import JobActionsBar from '@/components/JobActionsBar.vue'
export default { export default {
emits: ["selectedJobChange"], name: 'JobsTable',
props: ["activeJobID"],
emits: ["tableRowClicked"],
components: { components: {
JobActionsBar, JobActionsBar,
}, },
@ -25,7 +26,11 @@ export default {
const options = { const options = {
// See pkg/api/flamenco-manager.yaml, schemas Job and JobUpdate. // See pkg/api/flamenco-manager.yaml, schemas Job and JobUpdate.
columns: [ columns: [
{ formatter: "rowSelection", titleFormatter: "rowSelection", hozAlign: "center", headerHozAlign: "center", headerSort: false }, // { formatter: "rowSelection", titleFormatter: "rowSelection", hozAlign: "center", headerHozAlign: "center", headerSort: false },
{
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, formatterParams) { // eslint-disable-line no-unused-vars formatter(cell, formatterParams) { // eslint-disable-line no-unused-vars
@ -53,7 +58,7 @@ export default {
{ column: "updated", dir: "desc" }, { column: "updated", dir: "desc" },
], ],
data: [], // Will be filled via a Flamenco API request. data: [], // Will be filled via a Flamenco API request.
selectable: 1, // Only allow a single row to be selected at a time. selectable: false, // The active job is tracked by click events, not row selection.
}; };
return { return {
options: options, options: options,
@ -64,11 +69,30 @@ export default {
// jobsTableVue.processJobUpdate({id: "ad0a5a00-5cb8-4e31-860a-8a405e75910e", status: "heyy", updated: DateTime.local().toISO(), previous_status: "uuuuh", name: "Updated manually"}); // jobsTableVue.processJobUpdate({id: "ad0a5a00-5cb8-4e31-860a-8a405e75910e", status: "heyy", updated: DateTime.local().toISO(), previous_status: "uuuuh", name: "Updated manually"});
// jobsTableVue.processJobUpdate({id: "ad0a5a00-5cb8-4e31-860a-8a405e75910e", status: "heyy", updated: DateTime.local().toISO()}); // jobsTableVue.processJobUpdate({id: "ad0a5a00-5cb8-4e31-860a-8a405e75910e", status: "heyy", updated: DateTime.local().toISO()});
window.jobsTableVue = this; window.jobsTableVue = this;
// Set the `rowFormatter` here (instead of with the rest of the options
// above) as it needs to refer to `this`, which isn't available in the
// `data` function.
this.options.rowFormatter = (row) => {
const data = row.getData();
const isActive = (data.id === this.activeJobID);
row.getElement().classList.toggle("active-row", isActive);
};
this.tabulator = new Tabulator('#flamenco_job_list', this.options); this.tabulator = new Tabulator('#flamenco_job_list', this.options);
this.tabulator.on("rowSelected", this.onRowSelected); this.tabulator.on("rowClick", this.onRowClick);
this.tabulator.on("rowDeselected", this.onRowDeselected);
this.tabulator.on("tableBuilt", this.fetchAllJobs); this.tabulator.on("tableBuilt", this.fetchAllJobs);
}, },
watch: {
activeJobID(newJobID, oldJobID) {
this._reformatRow(oldJobID);
this._reformatRow(newJobID);
},
},
computed: {
selectedIDs() {
return this.tabulator.getSelectedData().map((job) => job.id);
}
},
methods: { methods: {
onReconnected() { onReconnected() {
// If the connection to the backend was lost, we have likely missed some // If the connection to the backend was lost, we have likely missed some
@ -91,7 +115,6 @@ export default {
// "Down-cast" to JobUpdate to only get those fields, just for debugging things: // "Down-cast" to JobUpdate to only get those fields, just for debugging things:
// data.jobs = data.jobs.map((j) => API.JobUpdate.constructFromObject(j)); // data.jobs = data.jobs.map((j) => API.JobUpdate.constructFromObject(j));
this.tabulator.setData(data.jobs); this.tabulator.setData(data.jobs);
this._restoreRowSelection();
}, },
processJobUpdate(jobUpdate) { processJobUpdate(jobUpdate) {
// updateData() will only overwrite properties that are actually set on // updateData() will only overwrite properties that are actually set on
@ -104,27 +127,22 @@ export default {
.then(this.sortData); .then(this.sortData);
}, },
// Selection handling. onRowClick(event, row) {
onRowSelected(selectedRow) { // Take a copy of the data, so that it's decoupled from the tabulator data
const selectedData = selectedRow.getData(); // store. There were some issues where navigating to another job would
this._storeRowSelection([selectedData]); // overwrite the old job's ID, and this prevents that.
this.$emit("selectedJobChange", selectedData); const rowData = plain(row.getData());
}, this.$emit("tableRowClicked", rowData);
onRowDeselected(deselectedRow) {
this._storeRowSelection([]);
this.$emit("selectedJobChange", null);
},
_storeRowSelection(selectedData) {
const selectedJobIDs = selectedData.map((row) => row.id);
localStorage.setItem("selectedJobIDs", selectedJobIDs);
},
_restoreRowSelection() {
const selectedJobIDs = localStorage.getItem('selectedJobIDs');
if (!selectedJobIDs) {
return;
}
this.tabulator.selectRow(selectedJobIDs);
}, },
_reformatRow(jobID) {
// 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.reformat) row.reformat();
else if (row.reinitialize) row.reinitialize(true);
}
} }
}; };
</script> </script>

View File

@ -9,7 +9,7 @@ import router from '@/router/index'
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
window.DateTime = DateTime; window.DateTime = DateTime;
// Help with debugging. This removes any Vue reactivity. // This removes any Vue reactivity.
window.plain = (x) => { return JSON.parse(JSON.stringify(x)) }; window.plain = (x) => { return JSON.parse(JSON.stringify(x)) };
const app = createApp(App) const app = createApp(App)

View File

@ -5,11 +5,14 @@ const router = createRouter({
routes: [ routes: [
{ {
path: '/', path: '/',
name: 'index',
component: () => import('../views/IndexView.vue'),
},
{
path: '/jobs/:jobID?',
name: 'jobs', name: 'jobs',
// route level code-splitting component: () => import('../views/JobsView.vue'),
// this generates a separate chunk (ViewName.[hash].js) for this route props: true,
// which is lazy-loaded when the route is visited.
component: () => import('../views/JobsView.vue')
}, },
{ {
path: '/workers', path: '/workers',
@ -20,8 +23,8 @@ const router = createRouter({
path: '/settings', path: '/settings',
name: 'settings', name: 'settings',
component: () => import('../views/SettingsView.vue') component: () => import('../views/SettingsView.vue')
} },
] ],
}) })
export default router export default router

View File

@ -10,8 +10,6 @@ const jobsAPI = new API.JobsApi(apiClient);
// See https://pinia.vuejs.org/core-concepts/ // See https://pinia.vuejs.org/core-concepts/
export const useJobs = defineStore('jobs', { export const useJobs = defineStore('jobs', {
state: () => ({ state: () => ({
/** @type {API.Job[]} */
selectedJobs: [],
/** @type {API.Job} */ /** @type {API.Job} */
activeJob: null, activeJob: null,
/** /**
@ -21,9 +19,6 @@ export const useJobs = defineStore('jobs', {
activeJobID: "", activeJobID: "",
}), }),
getters: { getters: {
numSelected() {
return this.selectedJobs.length;
},
canDelete() { canDelete() {
return this._anyJobWithStatus(["queued", "paused", "failed", "completed", "canceled"]) return this._anyJobWithStatus(["queued", "paused", "failed", "completed", "canceled"])
}, },
@ -35,25 +30,20 @@ export const useJobs = defineStore('jobs', {
}, },
}, },
actions: { actions: {
// Selection of jobs. setActiveJobID(jobID) {
setSelectedJob(job) { this.$patch({
activeJob: {id: jobID},
activeJobID: jobID,
});
},
setActiveJob(job) {
this.$patch({ this.$patch({
selectedJobs: [job],
activeJob: job, activeJob: job,
activeJobID: job.id, activeJobID: job.id,
}); });
}, },
setSelectedJobs(jobs) {
const activeJob =jobs[jobs.length-1]; // Last-selected is the active one.
this.$patch({
selectedJobs: jobs,
activeJob: activeJob,
activeJobID: activeJob.id,
});
},
deselectAllJobs() { deselectAllJobs() {
this.$patch({ this.$patch({
selectedJobs: [],
activeJob: null, activeJob: null,
activeJobID: "", activeJobID: "",
}); });
@ -67,12 +57,6 @@ export const useJobs = defineStore('jobs', {
* TODO: actually have these work on all selected jobs. For simplicity, the * TODO: actually have these work on all selected jobs. For simplicity, the
* code now assumes that only the active job needs to be operated on. * code now assumes that only the active job needs to be operated on.
*/ */
deleteJobs() {
const deletionPromise = new Promise( (resolutionFunc, rejectionFunc) => {
rejectionFunc({code: 327, message: "deleting jobs is not implemented in JS yet"});
});
return deletionPromise;
},
cancelJobs() { return this._setJobStatus("cancel-requested"); }, cancelJobs() { return this._setJobStatus("cancel-requested"); },
requeueJobs() { return this._setJobStatus("requeued"); }, requeueJobs() { return this._setJobStatus("requeued"); },
@ -84,7 +68,8 @@ export const useJobs = defineStore('jobs', {
* @returns bool indicating whether there is a selected job with any of the given statuses. * @returns bool indicating whether there is a selected job with any of the given statuses.
*/ */
_anyJobWithStatus(statuses) { _anyJobWithStatus(statuses) {
return this.selectedJobs.reduce((foundJob, job) => (foundJob || statuses.includes(job.status)), false); return !!this.activeJob && statuses.includes(this.activeJob.status);
// return this.selectedJobs.reduce((foundJob, job) => (foundJob || statuses.includes(job.status)), false);
}, },
/** /**
@ -93,8 +78,12 @@ export const useJobs = defineStore('jobs', {
* @returns a Promise for the API request. * @returns a Promise for the API request.
*/ */
_setJobStatus(newStatus) { _setJobStatus(newStatus) {
if (!this.activeJobID) {
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.activeJob.id, statuschange); return jobsAPI.setJobStatus(this.activeJobID, statuschange);
}, },
}, },
}) })

View File

@ -12,8 +12,8 @@ const URLs = {
ws: websocketURL, ws: websocketURL,
}; };
console.log("Flamenco API:", URLs.api); // console.log("Flamenco API:", URLs.api);
console.log("Websocket :", URLs.ws); // console.log("Websocket :", URLs.ws);
export function ws() { export function ws() {
return URLs.ws; return URLs.ws;

View File

@ -0,0 +1,46 @@
<template>
<div class="col col-1">
<router-link :to="{ name: 'jobs' }"><span>J</span></router-link>
</div>
<div class="col col-2">
<router-link :to="{ name: 'workers' }"><span>W</span></router-link>
</div>
<div class="col col-3">
<router-link :to="{ name: 'settings' }"><span>S</span></router-link>
</div>
<footer>
Make your choice.
</footer>
</template>
<script>
export default {
name: 'IndexView',
components: {},
}
</script>
<style scoped>
.col a {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
transition: 500ms;
background-color: var(--color-background-column);
}
.col a:hover {
text-decoration: none;
background-color: var(--color-accent-background);
}
.col a span {
font-weight: bold;
font-size: 10rem;
text-align: center;
vertical-align: middle;
}
</style>

View File

@ -1,18 +1,17 @@
<template> <template>
<div class="col col-1"> <div class="col col-1">
<jobs-table ref="jobsTable" @selectedJobChange="onSelectedJobChanged" /> <jobs-table ref="jobsTable" :activeJobID="jobID" @tableRowClicked="onTableJobClicked" />
</div> </div>
<div class="col col-2"> <div class="col col-2">
<job-details :jobData="jobs.activeJob" /> <job-details :jobData="jobs.activeJob" />
<tasks-table v-if="jobs.activeJobID" ref="tasksTable" :jobID="jobs.activeJobID" <tasks-table v-if="jobID" ref="tasksTable" :jobID="jobID" @selectedTaskChange="onSelectedTaskChanged" />
@selectedTaskChange="onSelectedTaskChanged" />
</div> </div>
<div class="col col-3"> <div class="col col-3">
<task-details :taskData="tasks.activeTask" /> <task-details :taskData="tasks.activeTask" />
</div> </div>
<footer> <footer>
<notification-bar /> <notification-bar />
<update-listener ref="updateListener" :websocketURL="websocketURL" :subscribedJob="jobs.activeJobID" <update-listener ref="updateListener" :websocketURL="websocketURL" :subscribedJob="jobID"
@jobUpdate="onSioJobUpdate" @taskUpdate="onSioTaskUpdate" @message="onChatMessage" @jobUpdate="onSioJobUpdate" @taskUpdate="onSioTaskUpdate" @message="onChatMessage"
@sioReconnected="onSIOReconnected" @sioDisconnected="onSIODisconnected" /> @sioReconnected="onSIOReconnected" @sioDisconnected="onSIODisconnected" />
</footer> </footer>
@ -34,6 +33,7 @@ import UpdateListener from '@/components/UpdateListener.vue'
export default { export default {
name: 'JobsView', name: 'JobsView',
props: ["jobID"], // provided by Vue Router.
components: { components: {
JobsTable, JobDetails, TaskDetails, TasksTable, NotificationBar, UpdateListener, JobsTable, JobDetails, TaskDetails, TasksTable, NotificationBar, UpdateListener,
}, },
@ -46,25 +46,18 @@ export default {
}), }),
mounted() { mounted() {
window.jobsView = this; window.jobsView = this;
this._fetchJob(this.jobID);
},
watch: {
jobID(newJobID, oldJobID) {
this._fetchJob(newJobID);
},
}, },
methods: { methods: {
// onSelectedJobChanged is called whenever the selected job changes; this is onTableJobClicked(rowData) {
// both when another job is selected and when the selected job itself gets this._routeToJob(rowData.id);
// updated.
onSelectedJobChanged(jobSummary) {
if (!jobSummary) { // There is no selected job.
this.jobs.deselectAllJobs();
return;
}
const jobsAPI = new API.JobsApi(apiClient);
jobsAPI.fetchJob(jobSummary.id)
.then((job) => {
this.jobs.setSelectedJob(job);
// Forward the full job to Tabulator, so that that gets updated too.
this.$refs.jobsTable.processJobUpdate(job);
});
}, },
onSelectedTaskChanged(taskSummary) { onSelectedTaskChanged(taskSummary) {
if (!taskSummary) { // There is no selected task. if (!taskSummary) { // There is no selected task.
this.tasks.deselectAllTasks(); this.tasks.deselectAllTasks();
@ -95,8 +88,8 @@ export default {
console.warn("App: this.$refs.jobsTable is", this.$refs.jobsTable); console.warn("App: this.$refs.jobsTable is", this.$refs.jobsTable);
} }
if (this.jobs.activeJobID == jobUpdate.id) { if (this.jobID == jobUpdate.id) {
this.onSelectedJobChanged(jobUpdate); this._fetchJob(jobUpdate.id);
} }
}, },
onJobNew(jobUpdate) { onJobNew(jobUpdate) {
@ -109,6 +102,32 @@ export default {
this.$refs.jobsTable.processNewJob(jobUpdate); this.$refs.jobsTable.processNewJob(jobUpdate);
}, },
/**
* @param {string} jobID job ID to navigate to, can be empty string for "no active job".
*/
_routeToJob(jobID) {
this.$router.push({ name: 'jobs', params: { jobID: jobID } });
},
/**
* Fetch job info and set the active job once it's received.
* @param {string} jobID job ID, can be empty string for "no job".
*/
_fetchJob(jobID) {
if (!jobID) {
this.jobs.deselectAllJobs();
return;
}
const jobsAPI = new API.JobsApi(apiClient);
return jobsAPI.fetchJob(jobID)
.then((job) => {
this.jobs.setActiveJob(job);
// Forward the full job to Tabulator, so that that gets updated too.
this.$refs.jobsTable.processJobUpdate(job);
});
},
/** /**
* Event handler for SocketIO task updates. * Event handler for SocketIO task updates.
* @param {API.SocketIOTaskUpdate} taskUpdate * @param {API.SocketIOTaskUpdate} taskUpdate
@ -132,8 +151,13 @@ export default {
this.$refs.tasksTable.onReconnected(); this.$refs.tasksTable.onReconnected();
}, },
onSIODisconnected(reason) { onSIODisconnected(reason) {
this.jobs.deselectAllJobs();
}, },
}, },
} }
</script> </script>
<style scoped>
.isFetching {
opacity: 50%;
}
</style>