Web: add task table
This commit is contained in:
parent
03d6f99c3d
commit
4ebf4f31f9
@ -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) {
|
||||
|
49
web/app/src/components/TaskActionsBar.vue
Normal file
49
web/app/src/components/TaskActionsBar.vue
Normal 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>
|
148
web/app/src/components/TasksTable.vue
Normal file
148
web/app/src/components/TasksTable.vue
Normal 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>
|
@ -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: "",
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
|
91
web/app/src/stores/tasks.js
Normal file
91
web/app/src/stores/tasks.js
Normal 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);
|
||||
},
|
||||
},
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user