openc3-cosmos-init/plugins/packages/openc3-tool-common/src/components/Openc3Screen.vue
<!--
# Copyright 2022 Ball Aerospace & Technologies Corp.
# All Rights Reserved.
#
# This program is free software; you can modify and/or redistribute it
# under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation; version 3 with
# attribution addendums as found in the LICENSE.txt
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# Modified by OpenC3, Inc.
# All changes Copyright 2024, OpenC3, Inc.
# All Rights Reserved
#
# This file may also be used under the terms of a commercial license
# if purchased from OpenC3, Inc.
#
# This screen expects to be inside of two parent elements.
# An overall item, and then a container for all screens
-->
<template>
<div :style="computedStyle" ref="bar">
<v-card :min-height="height" :min-width="width" style="cursor: default">
<v-system-bar>
<div v-show="errors.length !== 0">
<v-tooltip top>
<template v-slot:activator="{ on, attrs }">
<div v-on="on" v-bind="attrs">
<v-icon
data-test="error-graph-icon"
@click="errorDialog = true"
>
mdi-alert
</v-icon>
</div>
</template>
<span> Errors </span>
</v-tooltip>
</div>
<v-tooltip top>
<template v-slot:activator="{ on, attrs }">
<div v-on="on" v-bind="attrs">
<v-icon data-test="edit-screen-icon" @click="openEdit">
mdi-pencil
</v-icon>
</div>
</template>
<span> Edit Screen </span>
</v-tooltip>
<v-tooltip top v-if="!fixFloated">
<template v-slot:activator="{ on, attrs }">
<div v-on="on" v-bind="attrs">
<v-icon data-test="float-screen-icon" @click="floatScreen">
{{ floated ? 'mdi-balloon' : 'mdi-view-grid-outline' }}
</v-icon>
</div>
</template>
<span> {{ floated ? 'Unfloat Screen' : 'Float Screen' }} </span>
</v-tooltip>
<v-tooltip top v-if="floated">
<template v-slot:activator="{ on, attrs }">
<div v-on="on" v-bind="attrs">
<v-icon data-test="up-screen-icon" @click="upScreen">
mdi-arrow-up
</v-icon>
</div>
</template>
<span> Move Screen Up </span>
</v-tooltip>
<v-tooltip top v-if="floated && zIndex > minZ">
<template v-slot:activator="{ on, attrs }">
<div v-on="on" v-bind="attrs">
<v-icon data-test="down-screen-icon" @click="downScreen">
mdi-arrow-down
</v-icon>
</div>
</template>
<span> Move Screen Down </span>
</v-tooltip>
<v-spacer />
<span>{{ target }} {{ screen }}</span>
<v-spacer />
<v-tooltip top>
<template v-slot:activator="{ on, attrs }">
<div v-on="on" v-bind="attrs">
<v-icon
data-test="minimize-screen-icon"
@click="minMaxTransition"
v-show="expand"
>
mdi-window-minimize
</v-icon>
<v-icon
data-test="maximize-screen-icon"
@click="minMaxTransition"
v-show="!expand"
>
mdi-window-maximize
</v-icon>
</div>
</template>
<span v-show="expand"> Minimize Screen </span>
<span v-show="!expand"> Maximize Screen </span>
</v-tooltip>
<v-tooltip v-if="showClose" top>
<template v-slot:activator="{ on, attrs }">
<div v-on="on" v-bind="attrs">
<v-icon
data-test="close-screen-icon"
@click="$emit('close-screen')"
>
mdi-close-box
</v-icon>
</div>
</template>
<span> Close Screen </span>
</v-tooltip>
</v-system-bar>
<v-expand-transition v-if="!editDialog">
<div
class="pa-1"
style="position: relative"
ref="screen"
v-show="expand"
>
<v-overlay
style="pointer-events: none"
:value="errors.length !== 0"
opacity="0.8"
absolute
/>
<vertical-widget
:key="screenKey"
:widgets="layoutStack[0].widgets"
v-on="$listeners"
/>
</div>
</v-expand-transition>
</v-card>
<edit-screen-dialog
v-if="editDialog"
v-model="editDialog"
:target="target"
:screen="screen"
:definition="currentDefinition"
:keywords="keywords"
:errors="errors"
@save="saveEdit($event)"
@cancel="cancelEdit()"
@delete="deleteScreen()"
/>
<!-- Error dialog -->
<v-dialog v-model="errorDialog" max-width="600">
<v-system-bar>
<v-spacer />
<span> Screen: {{ target }} {{ screen }} Errors </span>
<v-spacer />
</v-system-bar>
<v-card>
<v-textarea class="errors" readonly rows="13" :value="error" />
</v-card>
</v-dialog>
</div>
</template>
<script>
import Api from '../services/api'
import { ConfigParserService } from '../services/config-parser'
import { OpenC3Api } from '../services/openc3-api'
import Vue from 'vue'
import upperFirst from 'lodash/upperFirst'
import camelCase from 'lodash/camelCase'
import EditScreenDialog from './EditScreenDialog'
const MAX_ERRORS = 20
// Globally register all XxxWidget.vue components
const requireComponent = require.context(
// The relative path of the components folder
'@openc3/tool-common/src/components/widgets',
// Whether or not to look in subfolders
false,
// The regular expression used to match base component filenames
/[A-Z][a-z]+Widget\.vue$/,
)
requireComponent.keys().forEach((filename) => {
// Get component config
const componentConfig = requireComponent(filename)
// Get PascalCase name of component
const componentName = upperFirst(
camelCase(
// Gets the filename regardless of folder depth
filename
.split('/')
.pop()
.replace(/\.\w+$/, ''),
),
)
// Register component globally
Vue.component(
componentName,
// Look for the component options on `.default`, which will
// exist if the component was exported with `export default`,
// otherwise fall back to module's root.
componentConfig.default || componentConfig,
)
})
export default {
components: {
EditScreenDialog,
},
props: {
target: {
type: String,
default: '',
},
screen: {
type: String,
default: '',
},
definition: {
type: String,
default: '',
},
keywords: {
type: Array,
default: () => [],
},
initialFloated: {
type: Boolean,
default: false,
},
initialTop: {
type: Number,
default: 0,
},
initialLeft: {
type: Number,
default: 0,
},
initialZ: {
type: Number,
default: 0,
},
minZ: {
type: Number,
default: 0,
},
fixFloated: {
type: Boolean,
default: false,
},
count: {
type: Number,
default: 0,
},
showClose: {
type: Boolean,
default: true,
},
timeZone: {
type: String,
default: 'local',
},
},
data() {
return {
api: null,
backup: '',
currentDefinition: this.definition,
editDialog: false,
expand: true,
configParser: null,
configError: false,
currentLayout: null,
layoutStack: [],
namedWidgets: {},
dynamicWidgets: [],
width: null,
height: null,
staleTime: 30,
cacheTimeout: 0.1,
globalSettings: [],
substitute: false,
original_target_name: null,
force_substitute: false,
pollingPeriod: 1,
errors: [],
errorDialog: false,
screenKey: null,
dragX: 0,
dragY: 0,
floated: this.initialFloated,
top: this.initialTop,
left: this.initialLeft,
zIndex: this.initialZ,
changeCounter: 0,
screenItems: [],
screenValues: {},
updateCounter: 0,
}
},
watch: {
count: {
handler(newValue, oldValue) {
this.currentDefinition = this.definition
this.rerender()
},
},
},
computed: {
error: function () {
if (this.errorDialog && this.errors.length > 0) {
let messages = new Set()
let result = ''
for (const error of this.errors) {
if (messages.has(error.message)) {
continue
}
let msg = `${error.time}: (${error.type}) ${error.message}\n`
result += msg
messages.add(error.message)
}
return result
}
return null
},
computedStyle() {
let style = {}
if (this.floated) {
style['position'] = 'absolute'
style['top'] = this.top + 'px'
style['left'] = this.left + 'px'
}
return style
},
},
// Called when an error from any descendent component is captured
// We need this because an error can occur from any of the children
// in the widget stack and are typically thrown on create()
errorCaptured(err, vm, info) {
if (this.errors.length < MAX_ERRORS) {
if (err.usage) {
this.errors.push({
type: 'usage',
message: err.message,
usage: err.usage,
line: err.line,
lineNumber: err.lineNumber,
time: new Date().getTime(),
})
} else {
this.errors.push({
type: 'error',
message: err,
time: new Date().getTime(),
})
}
this.configError = true
}
return false
},
created() {
this.api = new OpenC3Api()
this.configParser = new ConfigParserService()
this.parseDefinition()
this.screenKey = Math.floor(Math.random() * 1000000)
},
mounted() {
this.updateRefreshInterval()
if (this.floated) {
this.$refs.bar.onmousedown = this.dragMouseDown
this.$refs.bar.parentElement.parentElement.style =
'z-index: ' + this.zIndex
}
},
destroyed() {
if (this.updater != null) {
clearInterval(this.updater)
this.updater = null
}
},
methods: {
// These are API methods that ButtonWidget uses to open and close screens
open(target, screen) {
this.$parent.showScreen(target, screen)
},
close(target, screen) {
this.$parent.closeScreenByName(target, screen)
},
closeAll() {
this.$parent.closeAll()
},
clearErrors: function () {
this.errors = []
this.configError = false
},
updateRefreshInterval: function () {
let refreshInterval = this.pollingPeriod * 1000
if (this.updater) {
clearInterval(this.updater)
}
this.updater = setInterval(() => {
this.update()
}, refreshInterval)
},
parseDefinition: function () {
// Each time we start over and parse the screen definition
this.clearErrors()
this.namedWidgets = {}
this.layoutStack = []
this.dynamicWidgets = []
// Every screen starts with a VerticalWidget
this.layoutStack.push({
type: 'VerticalWidget',
parameters: [],
widgets: [],
})
this.currentLayout = this.layoutStack[this.layoutStack.length - 1]
this.configParser.parse_string(
this.currentDefinition,
'',
false,
true,
(keyword, parameters, line, lineNumber) => {
if (keyword) {
switch (keyword) {
case 'SCREEN':
this.configParser.verify_num_parameters(
3,
4,
`${keyword} <Width or AUTO> <Height or AUTO> <Polling Period>`,
)
this.width = parseInt(parameters[0])
this.height = parseInt(parameters[1])
this.pollingPeriod = parseFloat(parameters[2])
break
case 'END':
this.configParser.verify_num_parameters(0, 0, `${keyword}`)
this.layoutStack.pop()
this.currentLayout =
this.layoutStack[this.layoutStack.length - 1]
break
case 'STALE_TIME':
this.configParser.verify_num_parameters(
1,
1,
`${keyword} <Time (s)>`,
)
this.staleTime = parseInt(parameters[0])
break
case 'SETTING':
case 'SUBSETTING':
const widget =
this.currentLayout.widgets[
this.currentLayout.widgets.length - 1
] ?? this.currentLayout
widget.settings.push(parameters)
break
case 'GLOBAL_SETTING':
case 'GLOBAL_SUBSETTING':
this.globalSettings.push(parameters)
break
default:
this.processWidget(keyword, parameters, line, lineNumber)
break
} // switch keyword
} // if keyword
},
)
// This can happen if there is a typo in a layout widget with a corresponding END
if (typeof this.layoutStack[0] === 'undefined') {
let names = []
let lines = []
for (const widget of this.dynamicWidgets) {
names.push(widget.name)
lines.push(widget.lineNumber)
}
// Warn about any of the Dynamic widgets we found .. they could be typos
if (this.errors.length < MAX_ERRORS) {
this.errors.push({
type: 'usage',
message: `Unknown widget! Are these widgets: ${names.join(',')}?`,
lineNumber: lines.join(','),
time: new Date().getTime(),
})
this.configError = true
}
// Create a simple VerticalWidget to replace the bad widget so
// the layout stack can successfully unwind
this.layoutStack[0] = {
type: 'VerticalWidget',
parameters: [],
widgets: [],
}
} else {
this.applyGlobalSettings(this.layoutStack[0].widgets)
}
},
// Called by button scripts to get named widgets
getNamedWidget: function (name) {
return this.namedWidgets[name]
},
// TODO: Deprecate underscores used to match OpenC3 API rather than Javascript convention?
get_named_widget: function (name) {
return this.namedWidgets[name]
},
// Called by named widgets to register with the screen
setNamedWidget: function (name, widget) {
this.namedWidgets[name] = widget
},
openEdit: function () {
// Make a copy in case they edit and cancel
this.backup = this.currentDefinition.repeat(1)
this.editDialog = true
},
upScreen: function () {
this.zIndex += 1
this.$refs.bar.parentElement.parentElement.style =
'z-index: ' + this.zIndex
this.$emit('drag-screen', [
this.floated,
this.top,
this.left,
this.zIndex,
])
},
downScreen: function () {
if (this.zIndex > this.minZ) {
this.zIndex -= 1
this.$refs.bar.parentElement.parentElement.style =
'z-index: ' + this.zIndex
this.$emit('drag-screen', [
this.floated,
this.top,
this.left,
this.zIndex,
])
}
},
floatScreen: function () {
if (this.floated) {
this.$refs.bar.onmousedown = null
this.$refs.bar.parentElement.parentElement.style = 'z-index: 0'
this.floated = false
this.$emit('unfloat-screen', [
this.floated,
this.top,
this.left,
this.zIndex,
])
} else {
let bodyRect =
this.$refs.bar.parentElement.parentElement.parentElement.getBoundingClientRect()
let elemRect = this.$refs.bar.getBoundingClientRect()
this.top = elemRect.top - bodyRect.top - 5
this.left = elemRect.left - bodyRect.left - 5
this.$refs.bar.onmousedown = this.dragMouseDown
this.$refs.bar.parentElement.parentElement.style =
'z-index: ' + this.zIndex
this.floated = true
this.$emit('float-screen', [
this.floated,
this.top,
this.left,
this.zIndex,
])
}
},
dragMouseDown: function (e) {
e = e || window.event
e.preventDefault()
// get the mouse cursor position at startup:
this.dragX = e.clientX
this.dragY = e.clientY
document.onmouseup = this.closeDragElement
// call a function whenever the cursor moves:
document.onmousemove = this.elementDrag
},
elementDrag: function (e) {
e = e || window.event
e.preventDefault()
// calculate the new cursor position:
let xOffset = this.dragX - e.clientX
let yOffset = this.dragY - e.clientY
this.dragX = e.clientX
this.dragY = e.clientY
// set the element's new position:
this.top = this.$refs.bar.offsetTop - yOffset
this.left = this.$refs.bar.offsetLeft - xOffset
this.$emit('drag-screen', [
this.floated,
this.top,
this.left,
this.zIndex,
])
},
closeDragElement: function () {
// stop moving when mouse button is released:
document.onmouseup = null
document.onmousemove = null
},
rerender: function () {
this.parseDefinition()
this.updateRefreshInterval()
// Force re-render
this.screenKey = Math.floor(Math.random() * 1000000)
// After re-render clear any errors
this.$nextTick(function () {
this.clearErrors()
this.$emit('edit-screen')
})
},
cancelEdit: function () {
this.file = null
this.editDialog = false
// Restore the backup since we cancelled
this.currentDefinition = this.backup
this.rerender()
},
saveEdit: function (definition) {
this.editDialog = false
this.currentDefinition = definition
this.rerender()
this.$nextTick(function () {
Api.post(
'/openc3-api/screen/',
{
data: {
scope: window.openc3Scope,
target: this.target,
screen: this.screen,
text: this.currentDefinition,
},
},
0,
)
})
},
deleteScreen: function () {
this.editDialog = false
Api.delete(`/openc3-api/screen/${this.target}/${this.screen}`).then(
(response) => {
this.$emit('delete-screen')
},
)
},
minMaxTransition: function () {
this.expand = !this.expand
this.$emit('min-max-screen')
},
processWidget: function (keyword, parameters, line, lineNumber) {
var widgetName = null
if (keyword === 'NAMED_WIDGET') {
this.configParser.verify_num_parameters(
2,
null,
`${keyword} <Widget Name> <Widget Type> <Widget Settings... (optional)>`,
)
widgetName = parameters[0].toUpperCase()
keyword = parameters[1].toUpperCase()
parameters = parameters.slice(2, parameters.length)
}
const componentName =
keyword.charAt(0).toUpperCase() +
keyword.slice(1).toLowerCase() +
'Widget'
let settings = []
if (widgetName !== null) {
// Push a reference to the screen so the layout can register when it is created
// We do this because the widget isn't actually created until
// the layout happens with <component :is='type'>
settings.push(['NAMED_WIDGET', widgetName, this])
}
// If this is a layout widget we add it to the layoutStack and reset the currentLayout
if (
keyword === 'VERTICAL' ||
keyword === 'VERTICALBOX' ||
keyword === 'HORIZONTAL' ||
keyword === 'HORIZONTALBOX' ||
keyword === 'MATRIXBYCOLUMNS' ||
keyword === 'TABBOOK' ||
keyword === 'TABITEM' ||
keyword === 'CANVAS' ||
keyword === 'RADIOGROUP' ||
keyword === 'SCROLLWINDOW'
) {
const layout = {
type: componentName,
parameters: parameters,
settings: settings,
widgets: [],
}
this.layoutStack.push(layout)
this.currentLayout.widgets.push(layout)
this.currentLayout = layout
} else {
// Give all the widgets a reference to this screen
// Use settings so we don't break existing custom widgets
settings.push(['__SCREEN__', this])
if (Vue.options.components[componentName]) {
this.currentLayout.widgets.push({
type: componentName,
target: this.target,
parameters: parameters,
settings: settings,
line: line,
lineNumber: lineNumber,
})
} else {
let widget = {
type: 'DynamicWidget',
target: this.target,
parameters: parameters,
settings: settings,
name: componentName,
line: line,
lineNumber: lineNumber,
}
this.currentLayout.widgets.push(widget)
this.dynamicWidgets.push(widget)
}
}
},
applyGlobalSettings: function (widgets) {
widgets.forEach((widget) => {
this.globalSettings.forEach((setting) => {
// widget.type is already the full camelcase widget name like LabelWidget
// so we have to lower case both and tack on 'widget' to compare
if (
widget.type.toLowerCase() ===
setting[0].toLowerCase() + 'widget'
) {
widget.settings.push(setting.slice(1))
}
})
// Recursively apply to all widgets contained in layouts
if (widget.widgets) {
this.applyGlobalSettings(widget.widgets)
}
})
},
update: function () {
if (this.screenItems.length !== 0 && this.configError === false) {
this.api
.get_tlm_values(this.screenItems, this.staleTime, this.cacheTimeout)
.then((data) => {
this.clearErrors()
this.updateValues(data)
})
.catch((error) => {
let message = JSON.stringify(error, null, 2)
// Anything other than 'no response received' which means the API server is down
// is an error the user needs to fix so don't request values until they do
if (!message.includes('no response received')) {
this.configError = true
}
if (
!this.errors.find((existing) => {
return existing.message === message
})
) {
if (this.errors.length < MAX_ERRORS) {
this.errors.push({
type: 'error',
message: message,
time: new Date().getTime(),
})
}
}
})
}
},
updateValues: function (values) {
this.updateCounter += 1
for (let i = 0; i < values.length; i++) {
values[i].push(this.updateCounter)
Vue.set(this.screenValues, this.screenItems[i], values[i])
}
},
addItem: function (valueId) {
this.screenItems.push(valueId)
Vue.set(this.screenValues, valueId, [null, null, 0])
},
deleteItem: function (valueId) {
let index = this.screenItems.indexOf(valueId)
this.screenItems.splice(index, 1)
},
},
}
</script>
<style scoped>
.errors {
padding-top: 0px;
margin-top: 0px;
}
.v-textarea :deep(textarea) {
padding: 5px;
}
</style>