remove dependency on vue-router

only a small subset of vue-router is used, and integration is
complicated by each route having its own EventSource request.
Implementing the routing directly allows simplification of
the EventSource logic.

Another motivating factor is that the vue-router packages in
debian have been unreliable, making the dependence on vue-router
a hinderance for packaging laminar in debian.
pull/141/head
Oliver Giles 3 years ago
parent c140fb51eb
commit 907f3926ce

@ -85,8 +85,6 @@ add_custom_command(OUTPUT index_html_size.h
# Download 3rd-party frontend JS libs...
file(DOWNLOAD https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.12/vue.min.js
js/vue.min.js EXPECTED_MD5 fb192338844efe86ec759a40152fcb8e)
file(DOWNLOAD https://cdnjs.cloudflare.com/ajax/libs/vue-router/3.4.8/vue-router.min.js
js/vue-router.min.js EXPECTED_MD5 5f51d4dbbf68fd6725956a5a2b865f3b)
file(DOWNLOAD https://raw.githubusercontent.com/drudru/ansi_up/v1.3.0/ansi_up.js
js/ansi_up.js EXPECTED_MD5 158566dc1ff8f2804de972f7e841e2f6)
file(DOWNLOAD https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.2/Chart.min.js

@ -11,7 +11,6 @@
<link rel="manifest" href="/manifest.webmanifest">
<title>Laminar</title>
<script src="js/vue.min.js"></script>
<script src="js/vue-router.min.js"></script>
<script src="js/ansi_up.js"></script>
<script src="js/Chart.min.js"></script>
<script src="js/app.js" defer></script>
@ -115,13 +114,13 @@
<template id="job"><div id="page-job-main">
<div style="padding: 15px;">
<h2>{{$route.params.name}}</h2>
<h2>{{route.params.name}}</h2>
<div v-html="description"></div>
<dl>
<dt>Last Successful Run</dt>
<dd><router-link v-if="lastSuccess" :to="'/jobs/'+$route.params.name+'/'+lastSuccess.number">#{{lastSuccess.number}}</router-link> {{lastSuccess?' - at '+formatDate(lastSuccess.started):'never'}}</dd>
<dd><router-link v-if="lastSuccess" :to="'/jobs/'+route.params.name+'/'+lastSuccess.number">#{{lastSuccess.number}}</router-link> {{lastSuccess?' - at '+formatDate(lastSuccess.started):'never'}}</dd>
<dt>Last Failed Run</dt>
<dd><router-link v-if="lastFailed" :to="'/jobs/'+$route.params.name+'/'+lastFailed.number">#{{lastFailed.number}}</router-link> {{lastFailed?' - at '+formatDate(lastFailed.started):'never'}}</dd>
<dd><router-link v-if="lastFailed" :to="'/jobs/'+route.params.name+'/'+lastFailed.number">#{{lastFailed.number}}</router-link> {{lastFailed?' - at '+formatDate(lastFailed.started):'never'}}</dd>
</dl>
</div>
<div style="display: grid; justify-content: center; padding: 15px;">
@ -142,7 +141,7 @@
</tr>
<tr v-for="job in jobsRunning.concat(jobsRecent)" track-by="$index">
<td style="width:1px"><span v-html="runIcon(job.result)"></span></td>
<td><router-link :to="'/jobs/'+$route.params.name+'/'+job.number">#{{job.number}}</router-link></td>
<td><router-link :to="'/jobs/'+route.params.name+'/'+job.number">#{{job.number}}</router-link></td>
<td class="text-center">{{formatDate(job.started)}}</td>
<td class="text-center">{{formatDuration(job.started, job.completed)}}</td>
<td class="text-center vp-sm-hide">{{job.reason}}</td>
@ -159,10 +158,10 @@
<template id="run"><div style="display: grid; grid-template-rows: auto 1fr">
<div style="padding: 15px">
<div style="display: grid; grid-template-columns: auto 25px auto auto 1fr 400px; gap: 5px; align-items: center">
<h2 style="white-space: nowrap"><span v-html="runIcon(job.result)"></span> {{$route.params.name}} #{{$route.params.number}}</h2>
<h2 style="white-space: nowrap"><span v-html="runIcon(job.result)"></span> {{route.params.name}} #{{route.params.number}}</h2>
<span></span>
<router-link :disabled="$route.params.number == 1" :to="'/jobs/'+$route.params.name+'/'+($route.params.number-1)" tag="button">&laquo;</router-link>
<router-link :disabled="$route.params.number == latestNum" :to="'/jobs/'+$route.params.name+'/'+(parseInt($route.params.number)+1)" tag="button">&raquo;</router-link>
<router-link :disabled="route.params.number == 1" :to="'/jobs/'+route.params.name+'/'+(route.params.number-1)" tag="button">&laquo;</router-link>
<router-link :disabled="route.params.number == latestNum" :to="'/jobs/'+route.params.name+'/'+(parseInt(route.params.number)+1)" tag="button">&raquo;</router-link>
<span></span>
<div class="progress" v-show="job.result == 'running'">
<div class="progress-bar" :class="{overtime:job.overtime,indeterminate:!job.etc}" :style="job.etc && {width:job.progress+'%'}"></div>
@ -200,7 +199,7 @@
</router-link>
<div id="nav-top-links" style="display: grid; grid-auto-flow: column; justify-content: start; gap: 15px; padding: 0 15px; align-items: center; font-size: 16px;">
<router-link to="/jobs">Jobs</router-link>
<router-link v-for="(crumb,i) in _route.path.slice(1).split('/').slice(1,-1)" :to="_route.path.split('/').slice(0,i+3).join('/')">{{crumb}}</router-link>
<router-link v-for="(crumb,i) in route.path.slice(1).split('/').slice(1,-1)" :to="route.path.split('/').slice(0,i+3).join('/')">{{crumb}}</router-link>
</div>
<div></div>
<span class="version">{{version}}</span>

@ -18,86 +18,6 @@ Vue.filter('iecFileSize', bytes => {
['B', 'KiB', 'MiB', 'GiB', 'TiB'][exp];
});
// Mixin handling retrieving dynamic updates from the backend
Vue.mixin((() => {
const setupEventSource = (to, query, next, comp) => {
const es = new EventSource(document.head.baseURI + to.path.substr(1) + query);
es.comp = comp; // When reconnecting, we already have a component. Usually this will be null.
es.to = to; // Save a ref, needed for adding query params for pagination.
es.onmessage = function(msg) {
msg = JSON.parse(msg.data);
// "status" is the first message the server always delivers.
// Use this to confirm the navigation. The component is not
// created until next() is called, so creating a reference
// for other message types must be deferred. There are some extra
// subtle checks here. If this eventsource already has a component,
// then this is not the first time the status message has been
// received. If the frontend requests an update, the status message
// should not be handled here, but treated the same as any other
// message. An exception is if the connection has been lost - in
// that case we should treat this as a "first-time" status message.
// !this.comp.es is used to test this condition.
if (msg.type === 'status' && (!this.comp || !this.comp.es)) {
next(comp => {
// Set up bidirectional reference
// 1. needed to reference the component for other msg types
this.comp = comp;
// 2. needed to close the ws on navigation away
comp.es = this;
comp.esReconnectInterval = 500;
// Update html and nav titles
document.title = comp.$root.title = msg.title;
comp.$root.version = msg.version;
// Calculate clock offset (used by ProgressUpdater)
comp.$root.clockSkew = msg.time - Math.floor((new Date()).getTime()/1000);
comp.$root.connected = true;
// Component-specific callback handler
comp[msg.type](msg.data, to.params);
});
} else {
// at this point, the component must be defined
if (!this.comp)
return console.error("Page component was undefined");
else {
this.comp.$root.connected = true;
this.comp.$root.showNotify(msg.type, msg.data);
if(typeof this.comp[msg.type] === 'function')
this.comp[msg.type](msg.data);
}
}
}
es.onerror = function(e) {
this.comp.$root.connected = false;
setTimeout(() => {
// Recrate the EventSource, passing in the existing component
this.comp.es = setupEventSource(to, query, null, this.comp);
}, this.comp.esReconnectInterval);
if(this.comp.esReconnectInterval < 7500)
this.comp.esReconnectInterval *= 1.5;
this.close();
}
return es;
}
return {
beforeRouteEnter(to, from, next) {
setupEventSource(to, '', (fn) => { next(fn); });
},
beforeRouteUpdate(to, from, next) {
this.es.close();
setupEventSource(to, '', (fn) => { fn(this); next(); });
},
beforeRouteLeave(to, from, next) {
this.es.close();
next();
},
methods: {
query(q) {
this.es.close();
setupEventSource(this.es.to, '?' + Object.entries(q).map(([k,v])=>`${k}=${v}`).join('&'), fn => fn(this));
}
}
};
})());
// Mixin for periodically updating a progress bar
Vue.mixin({
@ -577,6 +497,7 @@ const Job = templateId => {
let chtBt = null;
return {
template: templateId,
props: ['route'],
data: () => state,
methods: {
status: function(msg) {
@ -632,6 +553,9 @@ const Job = templateId => {
state.sort.field = field;
}
this.query(state.sort)
},
query: function(q) {
this.$root.$emit('navigate', q);
}
}
};
@ -674,9 +598,11 @@ const Run = templateId => {
return {
template: templateId,
data: () => state,
props: ['route'],
methods: {
status: function(data, params) {
status: function(data) {
// Check for the /latest endpoint
const params = this._props.route.params;
if(params.number === 'latest')
return this.$router.replace('/jobs/' + params.name + '/' + data.latestNum);
@ -719,6 +645,115 @@ const Run = templateId => {
};
};
Vue.component('RouterLink', {
name: 'router-link',
props: {
to: { type: String },
tag: { type: String, default: 'a' }
},
template: `<component :is="tag" @click="navigate" :href="to"><slot></slot></component>`,
methods: {
navigate: function(e) {
e.preventDefault();
history.pushState(null, null, this.to);
this.$root.$emit('navigate');
}
}
});
Vue.component('RouterView', (() => {
const routes = [
{ path: /^$/, component: Home('#home') },
{ path: /^jobs$/, component: All('#jobs') },
{ path: /^wallboard$/, component: All('#wallboard') },
{ path: /^jobs\/(?<name>[^\/]+)$/, component: Job('#job') },
{ path: /^jobs\/(?<name>[^\/]+)\/(?<number>\d+)$/, component: Run('#run') }
];
const resolveRoute = path => {
for(i in routes) {
const r = routes[i].path.exec(path);
if(r)
return [routes[i].component, r.groups];
}
}
let eventSource = null;
const setupEventSource = (view, query) => {
// drop any existing event source
if(eventSource)
eventSource.close();
const path = (location.origin+location.pathname).substr(document.head.baseURI.length);
const search = query ? '?' + Object.entries(query).map(([k,v])=>`${k}=${v}`).join('&') : '';
eventSource = new EventSource(document.head.baseURI + path + search);
eventSource.reconnectInterval = 500;
eventSource.onmessage = msg => {
msg = JSON.parse(msg.data);
if(msg.type === 'status') {
// Event source is connected. Update static data
document.title = view.$root.title = msg.title;
view.$root.version = msg.version;
// Calculate clock offset (used by ProgressUpdater)
view.$root.clockSkew = msg.time - Math.floor((new Date()).getTime()/1000);
view.$root.connected = true;
[view.currentView, route.params] = resolveRoute(path);
// the component won't be instantiated until nextTick
view.$nextTick(() => {
// component is ready, update it with the data from the eventsource
eventSource.comp = view.$children[0];
// and finally run the component handler
eventSource.comp[msg.type](msg.data);
});
} else {
console.log('another msg!')
console.log(msg)
// at this point, the component must be defined
if (!eventSource.comp)
return console.error("Page component was undefined");
view.$root.connected = true;
view.$root.showNotify(msg.type, msg.data);
if(typeof eventSource.comp[msg.type] === 'function')
eventSource.comp[msg.type](msg.data);
}
}
eventSource.onerror = err => {
let ri = eventSource.reconnectInterval;
view.$root.connected = false;
setTimeout(() => {
setupEventSource(view);
if(ri < 7500)
ri *= 1.5;
eventSource.reconnectInterval = ri
}, ri);
eventSource.close();
}
};
let route = {};
return {
name: 'router-view',
template: `<component :is="currentView" :route="route"></component>`,
data: () => ({
currentView: routes[0].component, // default to home
route: route
}),
created: function() {
this.$root.$on('navigate', query => {
setupEventSource(this, query);
});
window.addEventListener('popstate', () => {
this.$root.$emit('navigate');
});
// initial navigation
this.$root.$emit('navigate');
}
};
})());
new Vue({
el: '#app',
data: {
@ -726,7 +761,8 @@ new Vue({
version: '',
clockSkew: 0,
connected: false,
notify: 'localStorage' in window && localStorage.getItem('showNotifications') == 1
notify: 'localStorage' in window && localStorage.getItem('showNotifications') == 1,
route: { path: '', params: {} }
},
computed: {
supportsNotifications: () =>
@ -748,16 +784,5 @@ new Vue({
},
watch: {
notify: e => localStorage.setItem('showNotifications', e ? 1 : 0)
},
router: new VueRouter({
mode: 'history',
base: document.head.baseURI.substr(location.origin.length),
routes: [
{ path: '/', component: Home('#home') },
{ path: '/jobs', component: All('#jobs') },
{ path: '/wallboard', component: All('#wallboard') },
{ path: '/jobs/:name', component: Job('#job') },
{ path: '/jobs/:name/:number', component: Run('#run') }
],
}),
}
});

Loading…
Cancel
Save