Web: add task table

This commit is contained in:
Sybren A. Stüvel 2022-04-29 13:11:19 +02:00
parent 03d6f99c3d
commit 4ebf4f31f9
5 changed files with 335 additions and 15 deletions

View File

@ -8,7 +8,8 @@
<jobs-table ref="jobsTable" @selectedJobChange="onSelectedJobChanged" />
</div>
<div class="col-2">
<job-details :jobData="selectedJob" />
<job-details :jobData="jobs.activeJob" />
<tasks-table ref="tasksTable" :jobID="jobs.activeJobID" @selectedTaskChange="onSelectedTaskChanged" />
</div>
<div class="col-3">
<task-details />
@ -24,6 +25,7 @@
import * as urls from '@/urls'
import * as API from '@/manager-api';
import { useJobs } from '@/stores/jobs';
import { useTasks } from '@/stores/tasks';
import { useNotifs } from '@/stores/notifications';
import { apiClient } from '@/stores/api-query-count';
@ -31,6 +33,7 @@ import ApiSpinner from '@/components/ApiSpinner.vue'
import JobsTable from '@/components/JobsTable.vue'
import JobDetails from '@/components/JobDetails.vue'
import TaskDetails from '@/components/TaskDetails.vue'
import TasksTable from '@/components/TasksTable.vue'
import UpdateListener from '@/components/UpdateListener.vue'
const DEFAULT_FLAMENCO_NAME = "Flamenco";
@ -39,13 +42,14 @@ const DEFAULT_FLAMENCO_VERSION = "unknown";
export default {
name: 'App',
components: {
ApiSpinner, JobsTable, JobDetails, TaskDetails, UpdateListener,
ApiSpinner, JobsTable, JobDetails, TaskDetails, TasksTable, UpdateListener,
},
data: () => ({
websocketURL: urls.ws(),
messages: [],
jobs: useJobs(),
tasks: useTasks(),
notifs: useNotifs(),
flamencoName: DEFAULT_FLAMENCO_NAME,
@ -55,9 +59,6 @@ export default {
window.app = this;
this.fetchManagerInfo();
},
computed: {
selectedJob() { return this.jobs ? this.jobs.activeJob : null; },
},
methods: {
// UI component event handlers:
onSelectedJobChanged(jobSummary) {
@ -74,6 +75,21 @@ export default {
this.$refs.jobsTable.processJobUpdate(job);
});
},
onSelectedTaskChanged(taskSummary) {
if (!taskSummary) { // There is no selected task.
this.tasks.deselectAllTasks();
return;
}
console.log("selected task changed:", taskSummary);
// const jobsAPI = new API.JobsApi(apiClient);
// jobsAPI.fetchTask(taskSummary.id)
// .then((task) => {
// this.tasks.setSelectedTask(task);
// // Forward the full task to Tabulator, so that that gets updated too.
// this.$refs.tasksTable.processTaskUpdate(task);
// });
},
sendMessage(message) {
this.$refs.jobsListener.sendBroadcastMessage("typer", message);
},
@ -91,8 +107,8 @@ export default {
} else {
console.warn("App: this.$refs.jobsTable is", this.$refs.jobsTable);
}
const selectedJob = this.selectedJob;
if (selectedJob && selectedJob.id == jobUpdate.id) {
const activeJob = this.jobs.activeJob;
if (activeJob && activeJob.id == jobUpdate.id) {
this.onSelectedJobChanged(jobUpdate);
}
},
@ -113,6 +129,7 @@ export default {
// SocketIO connection event handlers:
onSIOReconnected() {
this.$refs.jobsTable.onReconnected();
this.$refs.tasksTable.onReconnected();
this.fetchManagerInfo();
},
onSIODisconnected(reason) {

View File

@ -0,0 +1,49 @@
<template>
<section class="action-bar tasks">
<button class="action cancel" :disabled="!tasks.canCancel" v-on:click="onButtonCancel">Cancel</button>
<button class="action requeue" :disabled="!tasks.canRequeue" v-on:click="onButtonRequeue">Requeue</button>
</section>
</template>
<script>
import { useTasks } from '@/stores/tasks';
import { useNotifs } from '@/stores/notifications';
export default {
name: "TaskActionsBar",
data: () => ({
tasks: useTasks(),
notifs: useNotifs(),
}),
computed: {
},
methods: {
onButtonCancel() {
return this._handleTaskActionPromise(
this.tasks.cancelTasks(), "marked for cancellation");
},
onButtonRequeue() {
return this._handleTaskActionPromise(
this.tasks.requeueTasks(), "requeued");
},
_handleTaskActionPromise(promise, description) {
const numTasks = this.tasks.numSelected;
return promise
.then(() => {
let message;
if (numTasks == 1) {
message = `Task ${description}`;
} else {
message = `${numTasks} tasks ${description}`;
}
this.notifs.add(message);
})
.catch((error) => {
const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
this.notifs.add(`Error: ${errorMsg}`);
})
},
}
}
</script>

View File

@ -0,0 +1,148 @@
<template>
<task-actions-bar />
<div class="task-list-container">
<div class="task-list" id="flamenco_task_list"></div>
</div>
</template>
<script lang="js">
import { TabulatorFull as Tabulator } from 'tabulator-tables';
import * as datetime from "@/datetime";
import * as API from '@/manager-api'
import { apiClient } from '@/stores/api-query-count';
import { useTasks } from '@/stores/tasks';
import TaskActionsBar from '@/components/TaskActionsBar.vue'
export default {
emits: ["selectedTaskChange"],
props: [
"jobID", // ID of the job of which the tasks are shown here.
],
components: {
TaskActionsBar,
},
data: () => {
const options = {
// See pkg/api/flamenco-manager.yaml, schemas Task and TaskUpdate.
columns: [
{ formatter: "rowSelection", titleFormatter: "rowSelection", hozAlign: "center", headerHozAlign: "center", headerSort: false },
{ title: 'ID', field: 'id', sorter: 'string', width: "12%" },
{ title: 'Name', field: 'name', sorter: 'string' },
{ title: 'Status', field: 'status', sorter: 'string' },
{
title: 'Updated', field: 'updated',
sorter: 'alphanum', sorterParams: { alignEmptyValues: "top" },
formatter(cell, formatterParams) { // eslint-disable-line no-unused-vars
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);
}
},
],
initialSort: [
{ column: "updated", dir: "desc" },
],
height: "300px",
data: [], // Will be filled via a Flamenco API request.
selectable: 1, // Only allow a single row to be selected at a time.
};
return {
options: options,
tasks: useTasks(),
};
},
mounted() {
// Allow testing from the JS console:
// tasksTableVue.processTaskUpdate({id: "ad0a5a00-5cb8-4e31-860a-8a405e75910e", status: "heyy", updated: DateTime.local().toISO(), previous_status: "uuuuh", name: "Updated manually"});
// tasksTableVue.processTaskUpdate({id: "ad0a5a00-5cb8-4e31-860a-8a405e75910e", status: "heyy", updated: DateTime.local().toISO()});
window.tasksTableVue = this;
this.tabulator = new Tabulator('#flamenco_task_list', this.options);
this.tabulator.on("rowSelected", this.onRowSelected);
this.tabulator.on("rowDeselected", this.onRowDeselected);
this.fetchTasks();
},
watch: {
jobID() {
this.onRowDeselected([]);
this.fetchTasks();
},
},
methods: {
onReconnected() {
// If the connection to the backend was lost, we have likely missed some
// updates. Just fetch the data and start from scratch.
this.fetchTasks();
},
sortData() {
const tab = this.tabulator;
tab.setSort(tab.getSorters()); // This triggers re-sorting.
},
fetchTasks() {
console.log("Fetching tasks for job", this.jobID);
if (!this.jobID) {
// Prevent a warning when fetchTasks() is called before the tabulator is
// properly initialised. After initialisation the data is empty anyway.
if (this.tabulator.initialized) {
this.tabulator.setData([]);
}
return;
}
const jobsApi = new API.JobsApi(apiClient);
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:
// let tasks = data.tasks.map((j) => API.TaskUpdate.constructFromObject(j));
this.tabulator.setData(data.tasks);
this._restoreRowSelection();
},
processTaskUpdate(taskUpdate) {
// updateData() will only overwrite properties that are actually set on
// taskUpdate, and leave the rest as-is.
this.tabulator.updateData([taskUpdate])
.then(this.sortData);
},
processNewTask(taskUpdate) {
this.tabulator.addData([taskUpdate])
.then(this.sortData);
},
// Selection handling.
onRowSelected(selectedRow) {
const selectedData = selectedRow.getData();
this._storeRowSelection([selectedData]);
this.$emit("selectedTaskChange", selectedData);
},
onRowDeselected(deselectedRow) {
this._storeRowSelection([]);
this.$emit("selectedTaskChange", null);
},
_storeRowSelection(selectedData) {
const selectedTaskIDs = selectedData.map((row) => row.id);
localStorage.setItem("selectedTaskIDs", selectedTaskIDs);
},
_restoreRowSelection() {
const selectedTaskIDs = localStorage.getItem('selectedTaskIDs');
if (!selectedTaskIDs) {
return;
}
this.tabulator.selectRow(selectedTaskIDs);
},
}
};
</script>
<style>
.task-list-container {
font-family: 'Noto Mono', monospace;
font-size: smaller;
max-height: 300px;
}
</style>

View File

@ -10,10 +10,15 @@ const jobsAPI = new API.JobsApi(apiClient);
// See https://pinia.vuejs.org/core-concepts/
export const useJobs = defineStore('jobs', {
state: () => ({
/** @type API.Job[] */
/** @type {API.Job[]} */
selectedJobs: [],
/** @type API.Job */
/** @type {API.Job} */
activeJob: null,
/**
* ID of the active job. Easier to query than `activeJob ? activeJob.id : ""`.
* @type {string}
*/
activeJobID: "",
}),
getters: {
numSelected() {
@ -32,16 +37,26 @@ export const useJobs = defineStore('jobs', {
actions: {
// Selection of jobs.
setSelectedJob(job) {
this.selectedJobs = [job];
this.activeJob = job;
this.$patch({
selectedJobs: [job],
activeJob: job,
activeJobID: job.id,
});
},
setSelectedJobs(jobs) {
this.selectedJobs = jobs;
this.activeJob = jobs[jobs.length-1]; // Last-selected is the active one.
const activeJob =jobs[jobs.length-1]; // Last-selected is the active one.
this.$patch({
selectedJobs: jobs,
activeJob: activeJob,
activeJobID: activeJob.id,
});
},
deselectAllJobs() {
this.selectedJobs = [];
this.activeJob = null;
this.$patch({
selectedJobs: [],
activeJob: null,
activeJobID: "",
});
},
/**

View File

@ -0,0 +1,91 @@
import { defineStore } from 'pinia'
import * as API from '@/manager-api';
import { apiClient } from '@/stores/api-query-count';
const jobsAPI = new API.JobsApi(apiClient);
// 'use' prefix is idiomatic for Pinia stores.
// See https://pinia.vuejs.org/core-concepts/
export const useTasks = defineStore('tasks', {
state: () => ({
/** @type {API.Task[]} */
selectedTasks: [],
/** @type {API.Task} */
activeTask: null,
/**
* ID of the active task. Easier to query than `activeTask ? activeTask.id : ""`.
* @type {string}
*/
activeTaskID: "",
}),
getters: {
numSelected() {
return this.selectedTasks.length;
},
canCancel() {
return this._anyTaskWithStatus(["queued", "active", "soft-failed"])
},
canRequeue() {
return this._anyTaskWithStatus(["canceled", "completed", "failed"])
},
},
actions: {
// Selection of tasks.
setSelectedTask(task) {
this.$patch({
selectedTasks: [task],
activeTask: task,
activeTaskID: task.id,
});
},
setSelectedTasks(tasks) {
const activeTask =tasks[tasks.length-1]; // Last-selected is the active one.
this.$patch({
selectedTasks: tasks,
activeTask: activeTask,
activeTaskID: activeTask.id,
});
},
deselectAllTasks() {
this.$patch({
selectedTasks: [],
activeTask: null,
activeTaskID: "",
});
},
/**
* Actions on the selected tasks.
*
* All the action functions return a promise that resolves when the action has been performed.
*
* 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("cancel-requested"); },
requeueTasks() { return this._setTaskStatus("requeued"); },
// Internal methods.
/**
*
* @param {string[]} statuses
* @returns bool indicating whether there is a selected task with any of the given statuses.
*/
_anyTaskWithStatus(statuses) {
return this.selectedTasks.reduce((foundTask, task) => (foundTask || statuses.includes(task.status)), false);
},
/**
* Transition the selected task(s) to the new status.
* @param {string} newStatus
* @returns a Promise for the API request.
*/
_setTaskStatus(newStatus) {
const statuschange = new API.TaskStatusChange(newStatus, "requested from web interface");
return jobsAPI.setTaskStatus(this.activeTask.id, statuschange);
},
},
})