Commit 366dfd9b authored by Matteo Ferrari's avatar Matteo Ferrari
Browse files

Addh Extra

parent 2fe387c0
Pipeline #2864540 passed with stages
in 11 minutes and 50 seconds
......@@ -68,6 +68,7 @@
"openid-client": "^4.2.1",
"passport": "^0.4.1",
"pug": "^3.0.0",
"redim-dim": "git+https://gitlab.cern.ch/ntof/daq/redim-dim.js.git",
"superagent": "^6.1.0",
"tmp": "^0.2.1"
},
......
......@@ -8,6 +8,7 @@ const
ews = require('express-ws'),
DimonServer = require('./DimonServer'),
{ DisProxy, DnsProxy } = require('redim-dim'),
logger = require('./httpLogger'),
auth = require('./auth');
......@@ -58,6 +59,9 @@ class Server {
this.router.use('/admin', this.runAuth.bind(this, [ 'admin' ]));
}
DnsProxy.register(this.router);
DisProxy.register(this.router);
/* NOTE: declare your additional endpoints here */
this.dimonService = new DimonServer(config.dimon);
this.dimonService.register(this.router);
......
......@@ -29,6 +29,7 @@
"jquery": "^3.5.1",
"lodash": "^4.17.20",
"moment": "^2.29.1",
"redim-client": "git+https://gitlab.cern.ch/ntof/daq/redim-client.js.git",
"vue": "^2.6.12",
"vue-router": "^3.4.8",
"vue-simple-calendar": "^5.0.0",
......
......@@ -7,6 +7,7 @@ BaseWebApp(:title="TITLE" :version="VERSION")
BaseSideBarItem(v-for='z in zones' :key='z.name' :isActive='z.name === selectedZone'
@click.native='selectZone(z.name)')
| {{ z.name }}
BaseSideBarItem(@click.native='goToAddhExtra()' :isActive='isExtraDataSelected') Addh Extra
</template>
<script>
......
......@@ -18,7 +18,14 @@ const component = Vue.extend({
data() { return { TITLE, VERSION }; },
computed: {
...mapState({ selectedZone: 'zone' }),
...mapState('monitoring', [ 'menus', 'zones' ])
...mapState('monitoring', [ 'menus', 'zones' ]),
/**
* @this {Instance}
* @returns {boolean}
*/
isExtraDataSelected() {
return this.$route.path === '/addh-extra';
}
},
/**
* @this {Instance}
......@@ -39,6 +46,13 @@ const component = Vue.extend({
query.zone = zone;
this.$router.push({ path: '/', query }).catch(noop);
}
},
/**
* @param {string} zone
*/
goToAddhExtra() {
const query = omit(this.$route.query, [ 'zone' ]); // Remove zone
this.$router.push({ path: '/addh-extra', query }).catch(noop);
}
}
});
......
/* NOTE: declare app level scss here */
.highlightOnFocus:focus-within {
outline: none;
box-shadow: 0 1rem 3rem rgba(204, 229, 255, 0.75) !important;
}
.highlightOnFocus:focus-within .keyHint, .keyHintNavBar {
position: relative;
top: -2px;
animation: fadein 0.7s linear;
display: initial !important;
}
.keyHint {
display: none;
}
.keyHintHeader {
font-size: 1rem;
font-weight: 400;
position: relative;
top: -3px;
}
@keyframes fadein {
0% { opacity:0; visibility:visible; }
100% { opacity:1; }
}
\ No newline at end of file
<template lang="pug">
BaseCard(tabIndex='0').highlightOnFocus
template(v-slot:header)
.d-flex.justify-content-between
div #[h5 Addh Extra Sources for {{ zone }}]
.ml-auto.clickable(@click='setEdit(true)' v-if='isConnected && !inEdit')
kbd.keyHint.mr-2(v-if="showKeyHints") E
i.fa.fa-cog
.ml-auto.clickable(@click='setEdit(false)' v-if='isConnected && inEdit')
kbd.keyHint.mr-2(v-if="showKeyHints") Esc
i.fa.fa-times
li(v-if='!isConnected' key='1')
div.text-muted Connecting to {{ zone }} ADDH Service...
li(v-show='isConnected' key='2')
BaseModalLoader(:isLoading="loading || isSendingCommand")
strong XML Dataset/Parameters: Single Value
table.table.table-sm.table-hover.mt-2.text-center
thead
tr.table-head #[th From Service] #[th From Name] #[th Destination] #[th.w-10(v-if="inEdit") Actions]
tbody
tr(v-if="singleEditData.length === 0 && !inEdit")
td(colspan="3") No sources defined.
tr(:key="data.id" v-for="data in singleEditData"
:class="{ 'table-warning': hasMissingFields(data) }")
td
BaseInput(v-model='data.fromService' :inEdit='inEdit' :noEditIcon='true')
td
BaseInput(v-model='data.fromName' :inEdit='inEdit' :noEditIcon='true')
td
BaseInput(v-model='data.destination' :inEdit='inEdit' :noEditIcon='true')
td(v-if="inEdit")
button.btn.btn-danger.x-rm(type='button' @click="removeSource(data.id)")
i.fa.fa-times
tr(v-if="inEdit")
td(colspan="3")
td(v-if="inEdit")
button.btn.btn-success.x-add(type='button' @click="addSource(DataSourceType.SINGLE_DATA)")
i.fa.fa-plus
strong XML Dataset/Parameters: All Values
table.table.table-sm.table-hover.mt-2.text-center
thead
tr.table-head #[th From Service] #[th Destination Prefix] #[th.w-10(v-if="inEdit") Actions]
tbody
tr(v-if="allEditData.length === 0 && !inEdit")
td(colspan="2") No sources defined.
tr(:key="data.id" v-for="data in allEditData"
:class="{ 'table-warning': hasMissingFields(data) }")
td
BaseInput(v-model='data.fromService' :inEdit='inEdit' :noEditIcon='true')
td
BaseInput(v-model='data.destinationPrefix' :inEdit='inEdit' :noEditIcon='true')
td(v-if="inEdit")
button.btn.btn-danger.x-rm(type='button' @click="removeSource(data.id)")
i.fa.fa-times
tr(v-if="inEdit")
td(colspan="2")
td(v-if="inEdit")
button.btn.btn-success.x-add(type='button' @click="addSource(DataSourceType.ALL_DATA)")
i.fa.fa-plus
strong Basic DIM Service
table.table.table-sm.table-hover.mt-2.text-center
thead
tr.table-head #[th From Service] #[th Destination] #[th.w-10(v-if="inEdit") Actions]
tbody
tr(v-if="valueEditData.length === 0 && !inEdit")
td(colspan="2") No sources defined.
tr(:key="data.id" v-for="data in valueEditData"
:class="{ 'table-warning': hasMissingFields(data) }")
td
BaseInput(v-model='data.fromService' :inEdit='inEdit' :noEditIcon='true')
td
BaseInput(v-model='data.destination' :inEdit='inEdit' :noEditIcon='true')
td(v-if="inEdit")
button.btn.btn-danger.x-rm(type='button' @click="removeSource(data.id)")
i.fa.fa-times
tr(v-if="inEdit")
td(colspan="2")
td(v-if="inEdit")
button.btn.btn-success.x-add(type='button' @click="addSource(DataSourceType.VALUE)")
i.fa.fa-plus
// Submit
BaseAnimationBlock(anim='height' :appear='false')
.row.mt-2.justify-content-end(v-show='inEdit')
.col-auto
button.btn.btn-primary.mx-1.x-submit(type='submit' @click='submit()')
kbd.keyHint.mr-2(v-if="showKeyHints") Ctrl+S
| Submit
</template>
<script>
export { default } from '../AddhExtra/AddhExtra.vue.js';
</script>
<style scoped>
.w-10 {
width: 10%;
}
</style>
/* eslint-disable max-lines */
/* eslint-disable vue/one-component-per-file */
// @ts-check
import Vue from "vue";
import { assign, cloneDeep, filter, forEach, get, isArray, isEmpty, isNil, values } from 'lodash';
import {
BaseKeyboardEventMixin as KeyboardEventMixin,
BaseLogger as logger } from '@cern/base-vue';
import { ParamBuilderMixin, UrlUtilitiesMixin, errorPrefix } from '../../utilities';
import { DicXmlCmd, DicXmlValue, xml } from "redim-client";
import { mapState } from "vuex";
const wrapError = errorPrefix.bind(null, 'Addh: ');
/** @enum {number} */
const DataSourceType = {
UNKNOWN: -1,
SINGLE_DATA: 0,
ALL_DATA: 1,
VALUE: 2
};
class ExtraDataSource {
/**
* @param {number} index
*/
constructor(index) {
this.id = index;
this.type = DataSourceType.UNKNOWN;
this.fromService = "";
this.fromName = "";
this.destination = "";
this.destinationPrefix = "";
}
toXmlJS() {
if (this.type === DataSourceType.SINGLE_DATA) {
return { '$': {
fromService: this.fromService, fromName: this.fromName, destination: this.destination
} };
}
if (this.type === DataSourceType.ALL_DATA) {
return { '$': {
fromService: this.fromService, destinationPrefix: this.destinationPrefix
} };
}
if (this.type === DataSourceType.VALUE) {
return { '$': {
fromService: this.fromService, destination: this.destination
} };
}
}
}
/**
* @typedef {import('redim-client').XmlJsObject } XmlJsObject
*
* @typedef {V.Instance<typeof component> &
* V.Instance<typeof UrlUtilitiesMixin> &
* V.Instance<typeof ParamBuilderMixin> &
* V.Instance<ReturnType<KeyboardEventMixin>>} Instance
*/
const component = /** @type {V.Constructor<any, any>} */ (Vue).extend({
name: 'AddhExtra',
mixins: [
KeyboardEventMixin({ local: true, checkOnInputs: true }),
UrlUtilitiesMixin,
ParamBuilderMixin
],
props: {
zone: { type: String, default: '' },
dns: { type: String, default: '' }
},
/**
* @return {{
* client?: DicXmlValue | null,
* loading: boolean,
* inEdit: boolean
* originalData: Record<number, ExtraDataSource>,
* editData: Record<number, ExtraDataSource>,
* index: number,
* DataSourceType: typeof DataSourceType,
* isSendingCommand: boolean
* }}
*/
data() {
return {
client: null, loading: false, inEdit: false,
originalData: {}, editData: {}, index: 0,
DataSourceType: DataSourceType, isSendingCommand: false
};
},
computed: {
...mapState('ui', [ 'showKeyHints' ]),
/**
* @this {Instance}
* @returns {boolean}
*/
isConnected() {
return !this.loading;
},
/**
* @this {Instance}
* @returns {Array<ExtraDataSource>}
*/
singleEditData() {
return filter(values(this.editData), (d) => d.type === DataSourceType.SINGLE_DATA);
},
/**
* @this {Instance}
* @returns {Array<ExtraDataSource>}
*/
allEditData() {
return filter(values(this.editData), (d) => d.type === DataSourceType.ALL_DATA);
},
/**
* @this {Instance}
* @returns {Array<ExtraDataSource>}
*/
valueEditData() {
return filter(values(this.editData), (d) => d.type === DataSourceType.VALUE);
}
},
watch: {
/** @this {Instance} */
dns() { this.monitor(); }
},
/** @this {Instance} */
mounted() {
this.onKey('e', () => this.setEdit(true));
this.onKey('esc', () => this.setEdit(false));
this.onKey('ctrl-s-keydown', this.submit);
this.monitor();
},
/** @this {Instance} */
beforeDestroy() {
this.close();
},
methods: {
/**
* @this {Instance}
* @param {boolean} value
*/
setEdit(value/*: any */) {
this.inEdit = value;
// Reset editData with original data if exit from edit mode
if (!value)
this.editData = cloneDeep(this.originalData);
},
/** @this {Instance} */
close() {
if (this.client) {
this.client.removeListener('value', this.handleValue);
this.client.removeListener('error', this.handleError);
this.client.close();
}
this.client = null;
},
/** @this {Instance} */
async monitor() {
this.close();
// @ts-ignore: data is set
assign(this, this.$options.data());
this.loading = true;
this.client = new DicXmlValue('ADDH/Extra',
{ proxy: this.currentUrl() }, this.dnsUrl(this.dns));
this.client.on('error', this.handleError);
this.client.on('value', this.handleValue);
this.client.promise().catch(this.handleError);
},
/**
* @this {Instance}
* @param {any} value
*/
handleValue(value /*: any */) {
if (!value) { return; }
// Force inEdit false
this.setEdit(false);
// Clear data
this.originalData = {};
this.editData = {};
this.index = 0; // Code used for refs
try {
const dataJs = xml.toJs(value);
// Process data array ( could be SINGLE_DATA or ALL_DATA item)
let dataArray = get(dataJs, 'data');
if (!isArray(dataArray)) dataArray = [ dataArray ];
forEach(dataArray, (data) => {
const attrObj = get(data, '$');
if (isNil(attrObj)) return;
const newExtra = new ExtraDataSource(this.index++);
assign(newExtra, attrObj);
if (isEmpty(newExtra.fromService)) { return; }
if (!isEmpty(newExtra.fromName) && !isEmpty(newExtra.destination)) {
newExtra.type = DataSourceType.SINGLE_DATA;
}
else if (!isEmpty(newExtra.destinationPrefix)) {
newExtra.type = DataSourceType.ALL_DATA;
}
Vue.set(this.originalData, newExtra.id, newExtra);
});
// Process value array (could be valueData only)
let valueArray = get(dataJs, 'value');
if (!isArray(valueArray)) valueArray = [ valueArray ];
forEach(valueArray, (value) => {
const attrObj = get(value, '$');
if (isNil(attrObj)) return;
const newExtra = new ExtraDataSource(this.index++);
assign(newExtra, attrObj);
newExtra.type = DataSourceType.VALUE;
Vue.set(this.originalData, newExtra.id, newExtra);
});
this.editData = cloneDeep(this.originalData);
}
catch (err) { logger.error(err); }
finally {
this.loading = false;
}
},
/**
* @this {Instance}
* @param {any} err
*/
handleError(err) {
logger.error(err);
},
/**
* @this {Instance}
* @param {Array<ExtraDataSource>} array
* @param {number} id
*/
removeSource(id) {
Vue.delete(this.editData, id);
},
/**
* @this {Instance}
* @param {DataSourceType} type
*/
addSource(type) {
const newExtra = new ExtraDataSource(this.index++);
newExtra.type = type;
Vue.set(this.editData, newExtra.id, newExtra);
},
/**
* @this {Instance}
* @param {ExtraDataSource} data
*/
hasMissingFields(data) {
if (data.type === DataSourceType.UNKNOWN || isEmpty(data.fromService)) {
return true;
}
switch (data.type) {
case DataSourceType.SINGLE_DATA:
return isEmpty(data.fromName) || isEmpty(data.destination);
case DataSourceType.ALL_DATA:
return isEmpty(data.destinationPrefix);
case DataSourceType.VALUE:
return isEmpty(data.destination);
}
},
/** @this {Instance} */
keepFocus() {
this.$nextTick(() => {
/** @type HTMLElement */(this.$el).focus();
});
},
/**
* @this {Instance}
* @param {KeyboardEvent | null} event
*/
async submit(event /*: KeyboardEvent */) {
if (event) {
event.preventDefault(); /* prevent ctrl-s to open dialog */
if (!this.inEdit) { return; } // Keyboard Shortcut Guard
}
// Build the extraObj
/** @type {XmlJsObject} */
const extraObj = { data: [], value: [] };
// Iterate over editData
forEach(this.editData, (data) => {
if (this.hasMissingFields(data)) { return; }
if (data.type === DataSourceType.VALUE)
extraObj.value.push(data.toXmlJS());
else
extraObj.data.push(data.toXmlJS());
});
this.changeExtra(extraObj);
},
/**
* @this {Instance}
* @param {any} extraObj
*/
changeExtra(extraObj) {
this.wrapCmd(this.extraCmd, extraObj);
},
/**
* @this {Instance}
* @param {any} extraObj
*/
extraCmd(extraObj) {
return DicXmlCmd.invoke('ADDH/Command',
{ command: { '$': { name: 'extra' }, extra: extraObj } },
{ proxy: this.currentUrl() }, this.dnsUrl(this.dns))
.catch(wrapError);
},
/**
* @this {Instance}
* @param {function(any): Promise<void>} cmd
* @param {any} args
*/
wrapCmd(cmd, ...args) {
this.isSendingCommand = true;
// @ts-ignore
cmd.call(this, ...args)
.catch((/** @type any */err) => logger.error(err))
.finally(() => {
this.isSendingCommand = false;
this.setEdit(false);
this.keepFocus();
});
}
}
});
export default component;
<template lang="pug">
BaseAnimationGroup(:appear='true')
.row(key='addhExtra')
.col-12.col-xl-6(v-for="zone in zones" ).mb-2
AddhExtra(:zone='zone.name' :dns='zone.dns')
</template>
<script>
export { default } from './AddhExtraLayout.vue.js';
</script>
// @ts-check
import Vue from "vue";
import { mapState } from 'vuex';
import AddhExtra from './AddhExtra.vue';
/**
* @typedef {V.Instance<typeof component>} Instance
*/
const component = Vue.extend({
name: 'AddhExtraLayout',
components: { AddhExtra },
computed: {
...mapState('monitoring', [ 'zones' ])
}
});
export default component;
......@@ -4,6 +4,7 @@ import Router from 'vue-router';
import store from './store';
import ServerList from './components/ServerList.vue';
import AddhExtraLayout from './components/AddhExtra/AddhExtraLayout.vue';
Vue.use(Router);
......@@ -14,6 +15,11 @@ const router = new Router({
component: ServerList,
meta: { navbar: false }
},
{
path: '/addh-extra', name: 'AddhExtra',
component: AddhExtraLayout,
meta: { navbar: false }
},
{ path: '/index.html', redirect: '/', meta: { navbar: false } }
]
......
/* eslint-disable vue/one-component-per-file */
// @ts-check
import Vue from 'vue';