openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-packetviewer/src/tools/PacketViewer/PacketViewer.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.
-->
<template>
<div>
<top-bar :menus="menus" :title="title" />
<v-card>
<div style="padding: 10px">
<target-packet-item-chooser
:initial-target-name="this.$route.params.target"
:initial-packet-name="this.$route.params.packet"
@on-set="packetChanged($event)"
/>
</div>
<v-card-title>
Items
<v-spacer />
<v-text-field
v-model="search"
label="Search"
prepend-inner-icon="mdi-magnify"
clearable
outlined
dense
single-line
hide-details
class="search"
data-test="search"
/>
</v-card-title>
<v-data-table
:headers="headers"
:items="rows"
:search="search"
:custom-filter="filter"
:custom-sort="tableSort"
:items-per-page="itemsPerPage"
@update:items-per-page="itemsPerPage = $event"
:footer-props="{
itemsPerPageOptions: [10, 20, 50, 100, 500, 1000],
showFirstLastPage: true,
firstIcon: 'mdi-page-first',
lastIcon: 'mdi-page-last',
prevIcon: 'mdi-chevron-left',
nextIcon: 'mdi-chevron-right',
}"
multi-sort
dense
>
<template v-slot:item.name="{ item }">
<div @contextmenu="(event) => showContextMenu(event, item)">
<v-tooltip bottom :key="`${item.name}-${isPinned(item.name)}`">
<template v-slot:activator="{ on, attrs }">
<v-icon
v-if="isPinned(item.name)"
v-bind="attrs"
v-on="on"
class="pin-item"
>
mdi-pin
</v-icon>
</template>
<span
>Pinned items remain at the top.<br />Right click to
unpin.</span
>
</v-tooltip>
{{ item.name }}<span v-if="item.derived"> *</span>
</div>
</template>
<template v-slot:item.value="{ item }">
<value-widget
:key="item.name"
:value="item.value"
:limits-state="item.limitsState"
:counter="item.counter"
:parameters="[targetName, packetName, item.name]"
:settings="[['WIDTH', '100%']]"
:time-zone="timeZone"
/>
</template>
<template v-slot:footer.prepend>
<v-tooltip top close-delay="2000">
<template v-slot:activator="{ on, attrs }">
<v-icon v-bind="attrs" v-on="on" class="info-tooltip">
mdi-information-variant-circle
</v-icon>
</template>
<span>
Name with * indicates
<a
href="/tools/staticdocs/docs/configuration/telemetry#derived-items"
>DERIVED</a
> item<br />
Right click name to pin item<br />
Right click value for details / graph
</span>
</v-tooltip>
</template>
</v-data-table>
</v-card>
<v-dialog
v-model="optionsDialog"
@keydown.esc="optionsDialog = false"
max-width="300"
>
<v-card>
<v-system-bar>
<v-spacer />
<span>Options</span>
<v-spacer />
</v-system-bar>
<v-card-text>
<div class="pa-3">
<v-text-field
min="0"
max="10000"
step="100"
type="number"
label="Refresh Interval (ms)"
:value="refreshInterval"
@change="refreshInterval = $event"
data-test="refresh-interval"
/>
</div>
<div class="pa-3">
<v-text-field
min="1"
max="10000"
step="1"
type="number"
label="Time at which to mark data Stale (seconds)"
:value="staleLimit"
@change="staleLimit = parseInt($event)"
data-test="stale-limit"
/>
</div>
</v-card-text>
</v-card>
</v-dialog>
<!-- Note we're using v-if here so it gets re-created each time and refreshes the list -->
<open-config-dialog
v-if="showOpenConfig"
v-model="showOpenConfig"
:configKey="configKey"
@success="openConfiguration"
/>
<!-- Note we're using v-if here so it gets re-created each time and refreshes the list -->
<save-config-dialog
v-if="showSaveConfig"
v-model="showSaveConfig"
:configKey="configKey"
@success="saveConfiguration"
/>
<v-menu
v-model="contextMenuShown"
:position-x="x"
:position-y="y"
absolute
offset-y
>
<v-list>
<v-list-item
v-for="(item, index) in contextMenuOptions"
:key="index"
@click.stop="item.action"
>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
<script>
import { OpenC3Api } from '@openc3/tool-common/src/services/openc3-api'
import ValueWidget from '@openc3/tool-common/src/components/widgets/ValueWidget'
import TargetPacketItemChooser from '@openc3/tool-common/src/components/TargetPacketItemChooser'
import TopBar from '@openc3/tool-common/src/components/TopBar'
import Config from '@openc3/tool-common/src/components/config/Config'
import OpenConfigDialog from '@openc3/tool-common/src/components/config/OpenConfigDialog'
import SaveConfigDialog from '@openc3/tool-common/src/components/config/SaveConfigDialog'
// Used in the menu and openConfiguration lookup
const valueTypeToRadioGroup = {
WITH_UNITS: 'Formatted Items with Units',
FORMATTED: 'Formatted Items',
CONVERTED: 'Converted Items',
RAW: 'Raw Items',
}
export default {
components: {
TargetPacketItemChooser,
ValueWidget,
TopBar,
OpenConfigDialog,
SaveConfigDialog,
},
mixins: [Config],
data() {
return {
title: 'Packet Viewer',
configKey: 'packet_viewer',
showOpenConfig: false,
showSaveConfig: false,
timeZone: 'local',
search: '',
data: [],
headers: [
{ text: 'Name', value: 'name', align: 'end' },
{ text: 'Value', value: 'value' },
],
optionsDialog: false,
showIgnored: false,
derivedLast: false,
ignoredItems: [],
derivedItems: [],
menus: [
{
label: 'File',
items: [
{
label: 'Options',
icon: 'mdi-cog',
command: () => {
this.optionsDialog = true
},
},
{
divider: true,
},
{
label: 'Open Configuration',
icon: 'mdi-folder-open',
command: () => {
this.showOpenConfig = true
},
},
{
label: 'Save Configuration',
icon: 'mdi-content-save',
command: () => {
this.showSaveConfig = true
},
},
{
label: 'Reset Configuration',
icon: 'mdi-monitor-shimmer',
command: () => {
this.resetConfig()
this.resetConfigBase()
},
},
],
},
{
label: 'View',
radioGroup: 'Formatted Items with Units', // Default radio selected
items: [
{
label: 'Show Ignored Items',
checkbox: true,
checked: false,
command: (item) => {
this.showIgnored = item.checked
},
},
{
label: 'Display DERIVED Last',
checkbox: true,
checked: false,
command: (item) => {
this.derivedLast = item.checked
},
},
{
divider: true,
},
{
label: valueTypeToRadioGroup['WITH_UNITS'],
radio: true,
command: () => {
this.valueType = 'WITH_UNITS'
},
},
{
label: valueTypeToRadioGroup['FORMATTED'],
radio: true,
command: () => {
this.valueType = 'FORMATTED'
},
},
{
label: valueTypeToRadioGroup['CONVERTED'],
radio: true,
command: () => {
this.valueType = 'CONVERTED'
},
},
{
label: valueTypeToRadioGroup['RAW'],
radio: true,
command: () => {
this.valueType = 'RAW'
},
},
],
},
],
updater: null,
counter: 0,
targetName: '',
packetName: '',
valueType: 'WITH_UNITS',
refreshInterval: 1000,
staleLimit: 30,
rows: [],
menuItems: [],
itemsPerPage: 20,
api: null,
pinnedItems: [],
contextMenuShown: false,
itemName: '',
x: 0,
y: 0,
}
},
watch: {
showIgnored: function () {
this.saveDefaultConfig(this.currentConfig)
},
derivedLast: function () {
this.saveDefaultConfig(this.currentConfig)
},
valueType: function () {
this.saveDefaultConfig(this.currentConfig)
},
// Create a watcher on refreshInterval so we can change the updater
refreshInterval: function () {
this.changeUpdater(false)
this.saveDefaultConfig(this.currentConfig)
},
staleLimit: function () {
this.saveDefaultConfig(this.currentConfig)
},
itemsPerPage: function () {
this.saveDefaultConfig(this.currentConfig)
},
pinnedItems: function () {
this.saveDefaultConfig(this.currentConfig)
},
},
computed: {
currentConfig: function () {
return {
target: this.targetName,
packet: this.packetName,
refreshInterval: this.refreshInterval,
staleLimit: this.staleLimit,
showIgnored: this.showIgnored,
derivedLast: this.derivedLast,
valueType: this.valueType,
itemsPerPage: this.itemsPerPage,
pinnedItems: this.pinnedItems,
}
},
contextMenuOptions: function () {
let options = []
if (this.isPinned(this.itemName)) {
options.push({
title: 'Unpin Item',
action: () => {
this.contextMenuShown = false
this.pinnedItems = this.pinnedItems.filter(
(item) =>
!(
item.target === this.targetName &&
item.packet === this.packetName &&
item.item === this.itemName
),
)
},
})
} else {
options.push({
title: 'Pin Item',
action: () => {
this.contextMenuShown = false
this.pinnedItems.push({
target: this.targetName,
packet: this.packetName,
item: this.itemName,
})
},
})
}
return options
},
},
created() {
this.api = new OpenC3Api()
this.api
.get_setting('time_zone')
.then((response) => {
if (response) {
this.timeZone = response
}
})
.catch((error) => {
// Do nothing
})
// Called like /tools/packetviewer?config=temps
if (this.$route.query && this.$route.query.config) {
this.openConfiguration(this.$route.query.config, true) // routed
this.changeUpdater(true)
} else {
// Merge default config into the currentConfig in case default isn't yet defined
let config = { ...this.currentConfig, ...this.loadDefaultConfig() }
this.applyConfig(config)
// If we're passed in the route then manually call packetChanged to update
if (this.$route.params.target && this.$route.params.packet) {
// Initial position of chooser should be correct so call packetChanged for it
this.packetChanged({
targetName: this.$route.params.target.toUpperCase(),
packetName: this.$route.params.packet.toUpperCase(),
})
} else {
if (config.target && config.packet) {
// Chooser probably won't be at the right packet so need to refresh
this.$router.push({
name: 'PackerViewer',
params: {
target: config.target,
packet: config.packet,
},
})
this.$router.go()
}
}
this.changeUpdater(true)
}
},
beforeDestroy() {
if (this.updater != null) {
clearInterval(this.updater)
this.updater = null
}
},
methods: {
showContextMenu(e, item) {
e.preventDefault()
this.itemName = item.name
this.contextMenuShown = false
this.x = e.clientX
this.y = e.clientY
this.$nextTick(() => {
this.contextMenuShown = true
})
},
isPinned(name) {
return this.pinnedItems.find(
(item) =>
item.target === this.targetName &&
item.packet === this.packetName &&
item.item === name,
)
},
filter(value, search, _item) {
if (this.isPinned(value)) {
return true
} else {
return value.toString().indexOf(search.toUpperCase()) >= 0
}
},
tableSort(items, index, isDesc) {
// for each item in index, sort by that index
index.forEach((idx, i) => {
items.sort((a, b) => {
let aValue = a[idx]
let bValue = b[idx]
// For values just convert to float before sorting
// this will lead to a bunch of NaNs for non-numeric values
// but that's fine since they'll sort to the end
if (idx === 'value') {
aValue = parseFloat(aValue)
bValue = parseFloat(bValue)
}
if (aValue < bValue) {
return isDesc[i] ? 1 : -1
}
if (aValue > bValue) {
return isDesc[i] ? -1 : 1
}
return 0
})
})
// Finally sort the pinned values to the top
items.sort((a, b) => {
if (this.isPinned(a.name)) {
return -1
}
return 0
})
return items
},
packetChanged(event) {
this.api.get_target(event.targetName).then((target) => {
this.ignoredItems = target.ignored_items
})
this.api
.get_packet_derived_items(event.targetName, event.packetName)
.then((derived) => {
this.derivedItems = derived
})
this.targetName = event.targetName
this.packetName = event.packetName
if (
this.$route.params.target !== event.targetName ||
this.$route.params.packet !== event.packetName
) {
this.saveDefaultConfig(this.currentConfig)
this.$router.push({
name: 'PackerViewer',
params: {
target: this.targetName,
packet: this.packetName,
},
})
}
this.changeUpdater(true)
},
changeUpdater(clearExisting) {
if (this.updater != null) {
clearInterval(this.updater)
this.updater = null
}
if (clearExisting) {
this.rows = []
}
this.updater = setInterval(() => {
this.api
.get_tlm_packet(
this.targetName,
this.packetName,
this.valueType,
this.staleLimit,
)
.then((data) => {
// Make sure data isn't null or undefined. Note this is the only valid use of == or !=
if (data != null) {
this.counter += 1
let derived = []
let other = []
data.forEach((value) => {
if (!this.showIgnored && this.ignoredItems.includes(value[0])) {
return
}
if (this.derivedItems.includes(value[0])) {
derived.push({
name: value[0],
value: value[1],
limitsState: value[2],
derived: true,
counter: this.counter,
})
} else {
other.push({
name: value[0],
value: value[1],
limitsState: value[2],
derived: false,
counter: this.counter,
})
}
})
if (this.derivedLast) {
this.rows = other.concat(derived)
} else {
this.rows = derived.concat(other)
}
}
})
// Catch errors but just log to the console
// We don't clear the updater because errors can happen on upgrade
// and we want to continue updating once the new plugin comes online
.catch((error) => {
// eslint-disable-next-line
console.log(error)
})
}, this.refreshInterval)
},
resetConfig: function () {
this.targetName = ''
this.packetName = ''
this.refreshInterval = 1000
this.staleLimit = 30
this.showIgnored = false
this.derivedLast = false
this.valueType = 'WITH_UNITS'
this.itemsPerPage = 20
this.pinnedItems = []
},
applyConfig: function (config) {
this.targetName = config.target
this.packetName = config.packet
this.refreshInterval = config.refreshInterval || 1000
this.staleLimit = config.staleLimit || 30
this.showIgnored = config.showIgnored || false
this.menus[1].items[0].checked = this.showIgnored
this.derivedLast = config.derivedLast || false
this.menus[1].items[1].checked = this.derivedLast
this.valueType = config.valueType || 'WITH_UNITS'
this.menus[1].radioGroup = valueTypeToRadioGroup[this.valueType]
this.itemsPerPage = config.itemsPerPage || 20
this.pinnedItems = config.pinnedItems || []
},
openConfiguration: function (name, routed = false) {
this.openConfigBase(name, routed, async (config) => {
this.applyConfig(config)
this.saveDefaultConfig(config)
if (
this.$route.params.target !== config.target ||
this.$route.params.packet !== config.packet ||
this.$route.query.config !== name
) {
// Need full refresh since chooser won't be on the right packet
this.$router.push({
name: 'PackerViewer',
params: {
target: config.target,
packet: config.packet,
},
query: {
config: name,
},
})
this.$router.go()
}
})
},
saveConfiguration: function (name) {
this.saveConfigBase(name, this.currentConfig)
},
},
}
</script>
<style scoped>
.v-tooltip__content {
pointer-events: initial;
}
a {
color: var(--button-color-background-primary-default);
}
.pin-item {
float: left;
}
.info-tooltip {
margin-left: 10px;
}
</style>