Web: introduce VueRouter and split up into more components

Most of the code moved from `App.vue` to `views/JobsView.vue`.
Notification bar has its own component, and there are placeholder
"views" for Workers and Settings pages.

There is still some clunky handling of updates via SocketIO, as those
are a mix of job-specific and global (like SocketIO reconnection
events). The advantage of the current approach is that SocketIO
connections are closed when you leave the Jobs page, and reopened when
you enter the Workers page. My gut feeling says this is nice because it
ensures that all SocketIO connection-specific things are cleaned up when
you navigate.
This commit is contained in:
Sybren A. Stüvel 2022-05-06 16:46:51 +02:00
parent d673da7a0c
commit af39414a06
8 changed files with 268 additions and 130 deletions

View File

@ -3,9 +3,15 @@
<a href="/" class="navbar-brand">{{ flamencoName }}</a> <a href="/" class="navbar-brand">{{ flamencoName }}</a>
<nav> <nav>
<ul> <ul>
<li><a href="/">Jobs</a></li> <li>
<li><a href="/">Workers</a></li> <router-link to="/">Jobs</router-link>
<li><a href="/">Settings</a></li> </li>
<li>
<router-link to="/workers">Workers</router-link>
</li>
<li>
<router-link to="/settings">Settings</router-link>
</li>
</ul> </ul>
</nav> </nav>
<api-spinner /> <api-spinner />
@ -13,39 +19,14 @@
version: {{ flamencoVersion }} version: {{ flamencoVersion }}
</span> </span>
</header> </header>
<div class="col col-1"> <router-view></router-view>
<jobs-table ref="jobsTable" @selectedJobChange="onSelectedJobChanged" />
</div>
<div class="col col-2">
<job-details :jobData="jobs.activeJob" />
<tasks-table v-if="jobs.activeJobID" ref="tasksTable" :jobID="jobs.activeJobID"
@selectedTaskChange="onSelectedTaskChanged" />
</div>
<div class="col col-3">
<task-details :taskData="tasks.activeTask" />
</div>
<footer>
<span class='notifications' v-if="notifs.last">{{ notifs.last.msg }}</span>
<update-listener ref="updateListener" :websocketURL="websocketURL" :subscribedJob="jobs.activeJobID"
@jobUpdate="onSioJobUpdate" @taskUpdate="onSioTaskUpdate" @message="onChatMessage"
@sioReconnected="onSIOReconnected" @sioDisconnected="onSIODisconnected" />
</footer>
</template> </template>
<script> <script>
import * as urls from '@/urls'
import * as API from '@/manager-api'; 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'; import { apiClient } from '@/stores/api-query-count';
import ApiSpinner from '@/components/ApiSpinner.vue' 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"; const DEFAULT_FLAMENCO_NAME = "Flamenco";
const DEFAULT_FLAMENCO_VERSION = "unknown"; const DEFAULT_FLAMENCO_VERSION = "unknown";
@ -53,16 +34,9 @@ const DEFAULT_FLAMENCO_VERSION = "unknown";
export default { export default {
name: 'App', name: 'App',
components: { components: {
ApiSpinner, JobsTable, JobDetails, TaskDetails, TasksTable, UpdateListener, ApiSpinner,
}, },
data: () => ({ data: () => ({
websocketURL: urls.ws(),
messages: [],
jobs: useJobs(),
tasks: useTasks(),
notifs: useNotifs(),
flamencoName: DEFAULT_FLAMENCO_NAME, flamencoName: DEFAULT_FLAMENCO_NAME,
flamencoVersion: DEFAULT_FLAMENCO_VERSION, flamencoVersion: DEFAULT_FLAMENCO_VERSION,
}), }),
@ -71,99 +45,7 @@ export default {
this.fetchManagerInfo(); this.fetchManagerInfo();
}, },
methods: { methods: {
// onSelectedJobChanged is called whenever the selected job changes; this is // TODO: also call this when SocketIO reconnects.
// both when another job is selected and when the selected job itself gets
// 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) {
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.
if (this.$refs.tasksTable)
this.$refs.tasksTable.processTaskUpdate(task);
});
},
sendMessage(message) {
this.$refs.jobsListener.sendBroadcastMessage("typer", message);
},
// SocketIO data event handlers:
onSioJobUpdate(jobUpdate) {
if (!jobUpdate.previous_status)
return this.onJobNew(jobUpdate);
return this.onJobUpdate(jobUpdate);
},
onJobUpdate(jobUpdate) {
// this.messages.push(`Job update: ${jobUpdate.id} (${jobUpdate.previous_status} ${jobUpdate.status})`);
if (this.$refs.jobsTable) {
this.$refs.jobsTable.processJobUpdate(jobUpdate);
} else {
console.warn("App: this.$refs.jobsTable is", this.$refs.jobsTable);
}
if (this.jobs.activeJobID == jobUpdate.id) {
this.onSelectedJobChanged(jobUpdate);
}
},
onJobNew(jobUpdate) {
if (!this.$refs.jobsTable) {
console.warn("App: this.$refs.jobsTable is", this.$refs.jobsTable);
return;
}
// this.messages.push(`New job: ${jobUpdate.id} (${jobUpdate.status})`);
this.$refs.jobsTable.processNewJob(jobUpdate);
},
/**
* Event handler for SocketIO task updates.
* @param {API.SocketIOTaskUpdate} taskUpdate
*/
onSioTaskUpdate(taskUpdate) {
if (this.$refs.tasksTable)
this.$refs.tasksTable.processTaskUpdate(taskUpdate);
if (this.tasks.activeTaskID == taskUpdate.id)
this.onSelectedTaskChanged(taskUpdate);
},
onChatMessage(message) {
console.log("chat message received:", message);
this.messages.push(`${message.text}`);
},
// SocketIO connection event handlers:
onSIOReconnected() {
this.$refs.jobsTable.onReconnected();
if (this.$refs.tasksTable)
this.$refs.tasksTable.onReconnected();
this.fetchManagerInfo();
},
onSIODisconnected(reason) {
this.flamencoName = DEFAULT_FLAMENCO_NAME;
this.flamencoVersion = DEFAULT_FLAMENCO_VERSION;
this.jobs.deselectAllJobs();
},
fetchManagerInfo() { fetchManagerInfo() {
const metaAPI = new API.MetaApi(apiClient); const metaAPI = new API.MetaApi(apiClient);
metaAPI.getVersion().then((version) => { metaAPI.getVersion().then((version) => {

View File

@ -0,0 +1,14 @@
<template>
<span class='notifications' v-if="notifs.last">{{ notifs.last.msg }}</span>
</template>
<script>
import { useNotifs } from '@/stores/notifications';
export default {
name: 'NotificationBar',
data: () => ({
notifs: useNotifs(),
}),
}
</script>

View File

@ -2,6 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import App from '@/App.vue' import App from '@/App.vue'
import router from '@/router/index'
// Ensure Tabulator can find `luxon`, which it needs for sorting by // Ensure Tabulator can find `luxon`, which it needs for sorting by
// date/time/datetime. // date/time/datetime.
@ -15,4 +16,5 @@ const app = createApp(App)
const pinia = createPinia() const pinia = createPinia()
app.use(pinia) app.use(pinia)
app.use(router)
app.mount('#app') app.mount('#app')

View File

@ -0,0 +1,27 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'jobs',
// route level code-splitting
// this generates a separate chunk (ViewName.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/JobsView.vue')
},
{
path: '/workers',
name: 'workers',
component: () => import('../views/WorkersView.vue')
},
{
path: '/settings',
name: 'settings',
component: () => import('../views/SettingsView.vue')
}
]
})
export default router

View File

@ -1,6 +1,7 @@
let url = new URL(window.location.href); let url = new URL(window.location.href);
url.port = "8080"; url.port = "8080";
url.pathname = "/";
const flamencoAPIURL = url.href; const flamencoAPIURL = url.href;
url.protocol = "ws:"; url.protocol = "ws:";

View File

@ -0,0 +1,139 @@
<template>
<div class="col col-1">
<jobs-table ref="jobsTable" @selectedJobChange="onSelectedJobChanged" />
</div>
<div class="col col-2">
<job-details :jobData="jobs.activeJob" />
<tasks-table v-if="jobs.activeJobID" ref="tasksTable" :jobID="jobs.activeJobID"
@selectedTaskChange="onSelectedTaskChanged" />
</div>
<div class="col col-3">
<task-details :taskData="tasks.activeTask" />
</div>
<footer>
<notification-bar />
<update-listener ref="updateListener" :websocketURL="websocketURL" :subscribedJob="jobs.activeJobID"
@jobUpdate="onSioJobUpdate" @taskUpdate="onSioTaskUpdate" @message="onChatMessage"
@sioReconnected="onSIOReconnected" @sioDisconnected="onSIODisconnected" />
</footer>
</template>
<script>
import * as urls from '@/urls'
import * as API from '@/manager-api';
import { useJobs } from '@/stores/jobs';
import { useTasks } from '@/stores/tasks';
import { apiClient } from '@/stores/api-query-count';
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 NotificationBar from '@/components/NotificationBar.vue'
import UpdateListener from '@/components/UpdateListener.vue'
export default {
name: 'JobsView',
components: {
JobsTable, JobDetails, TaskDetails, TasksTable, NotificationBar, UpdateListener,
},
data: () => ({
websocketURL: urls.ws(),
messages: [],
jobs: useJobs(),
tasks: useTasks(),
}),
mounted() {
window.jobsView = this;
},
methods: {
// onSelectedJobChanged is called whenever the selected job changes; this is
// both when another job is selected and when the selected job itself gets
// 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) {
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.
if (this.$refs.tasksTable)
this.$refs.tasksTable.processTaskUpdate(task);
});
},
// SocketIO data event handlers:
onSioJobUpdate(jobUpdate) {
if (!jobUpdate.previous_status)
return this.onJobNew(jobUpdate);
return this.onJobUpdate(jobUpdate);
},
onJobUpdate(jobUpdate) {
// this.messages.push(`Job update: ${jobUpdate.id} (${jobUpdate.previous_status} ${jobUpdate.status})`);
if (this.$refs.jobsTable) {
this.$refs.jobsTable.processJobUpdate(jobUpdate);
} else {
console.warn("App: this.$refs.jobsTable is", this.$refs.jobsTable);
}
if (this.jobs.activeJobID == jobUpdate.id) {
this.onSelectedJobChanged(jobUpdate);
}
},
onJobNew(jobUpdate) {
if (!this.$refs.jobsTable) {
console.warn("App: this.$refs.jobsTable is", this.$refs.jobsTable);
return;
}
// this.messages.push(`New job: ${jobUpdate.id} (${jobUpdate.status})`);
this.$refs.jobsTable.processNewJob(jobUpdate);
},
/**
* Event handler for SocketIO task updates.
* @param {API.SocketIOTaskUpdate} taskUpdate
*/
onSioTaskUpdate(taskUpdate) {
if (this.$refs.tasksTable)
this.$refs.tasksTable.processTaskUpdate(taskUpdate);
if (this.tasks.activeTaskID == taskUpdate.id)
this.onSelectedTaskChanged(taskUpdate);
},
onChatMessage(message) {
console.log("chat message received:", message);
this.messages.push(`${message.text}`);
},
// SocketIO connection event handlers:
onSIOReconnected() {
this.$refs.jobsTable.onReconnected();
if (this.$refs.tasksTable)
this.$refs.tasksTable.onReconnected();
},
onSIODisconnected(reason) {
this.jobs.deselectAllJobs();
},
},
}
</script>

View File

@ -0,0 +1,28 @@
<template>
<div class="col col-1">
Settings
</div>
<div class="col col-2">
More settings
</div>
<div class="col col-3">
Completely different settings
</div>
<footer>
No footer here.
</footer>
</template>
<script>
export default {
name: 'SettingsView',
components: {},
data: () => ({
}),
mounted() {
window.settingsView = this;
},
methods: {
},
}
</script>

View File

@ -0,0 +1,45 @@
<template>
<div class="col col-1">
Workers
</div>
<div class="col col-2">
Worker Details
</div>
<div class="col col-3">
Column 3
</div>
<footer>
<notification-bar />
<update-listener ref="updateListener" :websocketURL="websocketURL" @sioReconnected="onSIOReconnected"
@sioDisconnected="onSIODisconnected" />
</footer>
</template>
<script>
import * as urls from '@/urls'
import * as API from '@/manager-api';
import { apiClient } from '@/stores/api-query-count';
import NotificationBar from '@/components/NotificationBar.vue'
import UpdateListener from '@/components/UpdateListener.vue'
export default {
name: 'WorkersView',
components: {
NotificationBar, UpdateListener,
},
data: () => ({
websocketURL: urls.ws(),
}),
mounted() {
window.workersView = this;
},
methods: {
// SocketIO connection event handlers:
onSIOReconnected() {
},
onSIODisconnected(reason) {
},
},
}
</script>