From 4ebf4f31f91d6e938eac12d7a79fa3e32296fa70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Fri, 29 Apr 2022 13:11:19 +0200 Subject: [PATCH] Web: add task table --- web/app/src/App.vue | 31 ++++- web/app/src/components/TaskActionsBar.vue | 49 +++++++ web/app/src/components/TasksTable.vue | 148 ++++++++++++++++++++++ web/app/src/stores/jobs.js | 31 +++-- web/app/src/stores/tasks.js | 91 +++++++++++++ 5 files changed, 335 insertions(+), 15 deletions(-) create mode 100644 web/app/src/components/TaskActionsBar.vue create mode 100644 web/app/src/components/TasksTable.vue create mode 100644 web/app/src/stores/tasks.js diff --git a/web/app/src/App.vue b/web/app/src/App.vue index 67b51727..2646ef5e 100644 --- a/web/app/src/App.vue +++ b/web/app/src/App.vue @@ -8,7 +8,8 @@
- + +
@@ -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) { diff --git a/web/app/src/components/TaskActionsBar.vue b/web/app/src/components/TaskActionsBar.vue new file mode 100644 index 00000000..1092da91 --- /dev/null +++ b/web/app/src/components/TaskActionsBar.vue @@ -0,0 +1,49 @@ + + + diff --git a/web/app/src/components/TasksTable.vue b/web/app/src/components/TasksTable.vue new file mode 100644 index 00000000..7926192b --- /dev/null +++ b/web/app/src/components/TasksTable.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/web/app/src/stores/jobs.js b/web/app/src/stores/jobs.js index 55bc16eb..7f8daee8 100644 --- a/web/app/src/stores/jobs.js +++ b/web/app/src/stores/jobs.js @@ -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: "", + }); }, /** diff --git a/web/app/src/stores/tasks.js b/web/app/src/stores/tasks.js new file mode 100644 index 00000000..09b4d727 --- /dev/null +++ b/web/app/src/stores/tasks.js @@ -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); + }, + }, +})