Web: add task table
This commit is contained in:
parent
03d6f99c3d
commit
4ebf4f31f9
@ -8,7 +8,8 @@
|
|||||||
<jobs-table ref="jobsTable" @selectedJobChange="onSelectedJobChanged" />
|
<jobs-table ref="jobsTable" @selectedJobChange="onSelectedJobChanged" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
<job-details :jobData="selectedJob" />
|
<job-details :jobData="jobs.activeJob" />
|
||||||
|
<tasks-table ref="tasksTable" :jobID="jobs.activeJobID" @selectedTaskChange="onSelectedTaskChanged" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-3">
|
<div class="col-3">
|
||||||
<task-details />
|
<task-details />
|
||||||
@ -24,6 +25,7 @@
|
|||||||
import * as urls from '@/urls'
|
import * as urls from '@/urls'
|
||||||
import * as API from '@/manager-api';
|
import * as API from '@/manager-api';
|
||||||
import { useJobs } from '@/stores/jobs';
|
import { useJobs } from '@/stores/jobs';
|
||||||
|
import { useTasks } from '@/stores/tasks';
|
||||||
import { useNotifs } from '@/stores/notifications';
|
import { useNotifs } from '@/stores/notifications';
|
||||||
import { apiClient } from '@/stores/api-query-count';
|
import { apiClient } from '@/stores/api-query-count';
|
||||||
|
|
||||||
@ -31,6 +33,7 @@ import ApiSpinner from '@/components/ApiSpinner.vue'
|
|||||||
import JobsTable from '@/components/JobsTable.vue'
|
import JobsTable from '@/components/JobsTable.vue'
|
||||||
import JobDetails from '@/components/JobDetails.vue'
|
import JobDetails from '@/components/JobDetails.vue'
|
||||||
import TaskDetails from '@/components/TaskDetails.vue'
|
import TaskDetails from '@/components/TaskDetails.vue'
|
||||||
|
import TasksTable from '@/components/TasksTable.vue'
|
||||||
import UpdateListener from '@/components/UpdateListener.vue'
|
import UpdateListener from '@/components/UpdateListener.vue'
|
||||||
|
|
||||||
const DEFAULT_FLAMENCO_NAME = "Flamenco";
|
const DEFAULT_FLAMENCO_NAME = "Flamenco";
|
||||||
@ -39,13 +42,14 @@ const DEFAULT_FLAMENCO_VERSION = "unknown";
|
|||||||
export default {
|
export default {
|
||||||
name: 'App',
|
name: 'App',
|
||||||
components: {
|
components: {
|
||||||
ApiSpinner, JobsTable, JobDetails, TaskDetails, UpdateListener,
|
ApiSpinner, JobsTable, JobDetails, TaskDetails, TasksTable, UpdateListener,
|
||||||
},
|
},
|
||||||
data: () => ({
|
data: () => ({
|
||||||
websocketURL: urls.ws(),
|
websocketURL: urls.ws(),
|
||||||
messages: [],
|
messages: [],
|
||||||
|
|
||||||
jobs: useJobs(),
|
jobs: useJobs(),
|
||||||
|
tasks: useTasks(),
|
||||||
notifs: useNotifs(),
|
notifs: useNotifs(),
|
||||||
|
|
||||||
flamencoName: DEFAULT_FLAMENCO_NAME,
|
flamencoName: DEFAULT_FLAMENCO_NAME,
|
||||||
@ -55,9 +59,6 @@ export default {
|
|||||||
window.app = this;
|
window.app = this;
|
||||||
this.fetchManagerInfo();
|
this.fetchManagerInfo();
|
||||||
},
|
},
|
||||||
computed: {
|
|
||||||
selectedJob() { return this.jobs ? this.jobs.activeJob : null; },
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
// UI component event handlers:
|
// UI component event handlers:
|
||||||
onSelectedJobChanged(jobSummary) {
|
onSelectedJobChanged(jobSummary) {
|
||||||
@ -74,6 +75,21 @@ export default {
|
|||||||
this.$refs.jobsTable.processJobUpdate(job);
|
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) {
|
sendMessage(message) {
|
||||||
this.$refs.jobsListener.sendBroadcastMessage("typer", message);
|
this.$refs.jobsListener.sendBroadcastMessage("typer", message);
|
||||||
},
|
},
|
||||||
@ -91,8 +107,8 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
console.warn("App: this.$refs.jobsTable is", this.$refs.jobsTable);
|
console.warn("App: this.$refs.jobsTable is", this.$refs.jobsTable);
|
||||||
}
|
}
|
||||||
const selectedJob = this.selectedJob;
|
const activeJob = this.jobs.activeJob;
|
||||||
if (selectedJob && selectedJob.id == jobUpdate.id) {
|
if (activeJob && activeJob.id == jobUpdate.id) {
|
||||||
this.onSelectedJobChanged(jobUpdate);
|
this.onSelectedJobChanged(jobUpdate);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -113,6 +129,7 @@ export default {
|
|||||||
// SocketIO connection event handlers:
|
// SocketIO connection event handlers:
|
||||||
onSIOReconnected() {
|
onSIOReconnected() {
|
||||||
this.$refs.jobsTable.onReconnected();
|
this.$refs.jobsTable.onReconnected();
|
||||||
|
this.$refs.tasksTable.onReconnected();
|
||||||
this.fetchManagerInfo();
|
this.fetchManagerInfo();
|
||||||
},
|
},
|
||||||
onSIODisconnected(reason) {
|
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/
|
// See https://pinia.vuejs.org/core-concepts/
|
||||||
export const useJobs = defineStore('jobs', {
|
export const useJobs = defineStore('jobs', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
/** @type API.Job[] */
|
/** @type {API.Job[]} */
|
||||||
selectedJobs: [],
|
selectedJobs: [],
|
||||||
/** @type API.Job */
|
/** @type {API.Job} */
|
||||||
activeJob: null,
|
activeJob: null,
|
||||||
|
/**
|
||||||
|
* ID of the active job. Easier to query than `activeJob ? activeJob.id : ""`.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
activeJobID: "",
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
numSelected() {
|
numSelected() {
|
||||||
@ -32,16 +37,26 @@ export const useJobs = defineStore('jobs', {
|
|||||||
actions: {
|
actions: {
|
||||||
// Selection of jobs.
|
// Selection of jobs.
|
||||||
setSelectedJob(job) {
|
setSelectedJob(job) {
|
||||||
this.selectedJobs = [job];
|
this.$patch({
|
||||||
this.activeJob = job;
|
selectedJobs: [job],
|
||||||
|
activeJob: job,
|
||||||
|
activeJobID: job.id,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
setSelectedJobs(jobs) {
|
setSelectedJobs(jobs) {
|
||||||
this.selectedJobs = jobs;
|
const activeJob =jobs[jobs.length-1]; // Last-selected is the active one.
|
||||||
this.activeJob = jobs[jobs.length-1]; // Last-selected is the active one.
|
this.$patch({
|
||||||
|
selectedJobs: jobs,
|
||||||
|
activeJob: activeJob,
|
||||||
|
activeJobID: activeJob.id,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
deselectAllJobs() {
|
deselectAllJobs() {
|
||||||
this.selectedJobs = [];
|
this.$patch({
|
||||||
this.activeJob = null;
|
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