OpenC3/cosmos

View on GitHub
openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-limitsmonitor/src/tools/LimitsMonitor/LimitsControl.vue

Summary

Maintainability
Test Coverage
<!--
# 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>
    <v-card class="pa-5">
      <v-row class="ma-1">
        <v-text-field
          dense
          outlined
          readonly
          hide-details
          label="Overall Limits State"
          :prepend-inner-icon="astroIcon"
          :value="overallStateFormatted"
          :class="textFieldClass"
          style="margin-right: 10px; max-width: 280px"
          data-test="overall-state"
        />
        <v-text-field
          dense
          outlined
          readonly
          hide-details
          label="Current Limits Set"
          :value="currentLimitsSet"
          style="max-width: 200px"
          data-test="limits-set"
        />
      </v-row>

      <v-row data-test="limits-row" class="my-0 ml-1 mr-1">
        <div class="pa-1 mt-1 mr-2 label" style="width: 170px">Timestamp</div>
        <div class="pa-1 mt-1 mr-2 label" style="width: 200px">Item Name</div>
        <div class="pa-1 mt-1 mr-2 label" style="width: 200px">Value</div>
        <div class="pa-1 mt-1 mr-2 label" style="width: 180px">Limits Bar</div>
        <div class="pa-1 mt-1 mr-2 label">Controls</div>
      </v-row>
      <div v-for="(item, index) in items" :key="item.key">
        <v-row data-test="limits-row" class="my-0 ml-1 mr-1">
          <div class="pa-1 mt-1 mr-2 label" style="width: 170px">
            {{ item.timestamp }}
          </div>
          <labelvaluelimitsbar-widget
            v-if="item.limits"
            :parameters="item.parameters"
            :settings="widgetSettings"
          />
          <labelvalue-widget
            v-else
            :parameters="item.parameters"
            :settings="widgetSettings"
          />
          <v-tooltip bottom>
            <template v-slot:activator="{ on, attrs }">
              <v-btn
                icon
                class="mr-2"
                @click="ignorePacket(item.key)"
                v-bind="attrs"
                v-on="on"
              >
                <v-icon> mdi-close-circle-multiple </v-icon>
              </v-btn>
            </template>
            <span>Ignore Entire Packet</span>
          </v-tooltip>
          <v-tooltip bottom>
            <template v-slot:activator="{ on, attrs }">
              <v-btn
                icon
                class="mr-2"
                @click="ignoreItem(item.key)"
                v-bind="attrs"
                v-on="on"
              >
                <v-icon> mdi-close-circle </v-icon>
              </v-btn>
            </template>
            <span>Ignore Item</span>
          </v-tooltip>
          <v-tooltip bottom>
            <template v-slot:activator="{ on, attrs }">
              <v-btn
                icon
                class="mr-2"
                @click="removeItem(item.key)"
                v-bind="attrs"
                v-on="on"
              >
                <v-icon> mdi-eye-off </v-icon>
              </v-btn>
            </template>
            <span>Temporarily Hide Item</span>
          </v-tooltip>
        </v-row>
        <v-divider v-if="index < items.length" :key="index" />
      </div>
      <div class="footer">
        Note: Timestamp is "now" for items currently out of limits when the page
        is loaded.
      </div>
    </v-card>
    <v-dialog v-model="ignoredItemsDialog" max-width="600">
      <v-divider v-if="index < items.length - 1" :key="index" />
      <v-card>
        <v-system-bar>
          <v-spacer />
          <span>Ignored Items</span>
          <v-spacer />
        </v-system-bar>
        <v-card-text>
          <div class="my-2">
            <div v-for="(item, index) in ignoredFormatted" :key="index">
              <v-row class="ma-1">
                <span class="font-weight-black"> {{ item }} </span>
                <v-spacer />
                <v-btn
                  @click="restoreItem(index)"
                  small
                  icon
                  :data-test="`remove-ignore-${index}`"
                >
                  <v-icon> mdi-delete </v-icon>
                </v-btn>
              </v-row>
              <v-divider
                v-if="index < ignoredFormatted.length - 1"
                :key="index"
              />
            </div>
            <v-divider v-if="index < items.length - 1" :key="index" />
          </div>
        </v-card-text>
        <v-card-actions>
          <v-btn outlined @click="clearAll"> Clear All </v-btn>
          <v-spacer />
          <v-btn
            @click="ignoredItemsDialog = false"
            class="mx-2"
            color="primary"
          >
            Ok
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </div>
</template>

<script>
import { OpenC3Api } from '@openc3/tool-common/src/services/openc3-api'
import Cable from '@openc3/tool-common/src/services/cable.js'
import LabelvalueWidget from '@openc3/tool-common/src/components/widgets/LabelvalueWidget'
import LabelvaluelimitsbarWidget from '@openc3/tool-common/src/components/widgets/LabelvaluelimitsbarWidget'
import Vue from 'vue'
import TimeFilters from '@openc3/tool-common/src/tools/base/util/timeFilters.js'

export default {
  components: {
    LabelvalueWidget,
    LabelvaluelimitsbarWidget,
  },
  props: {
    value: {
      type: Array,
      default: () => [],
    },
    timeZone: {
      type: String,
      default: 'local',
    },
  },
  mixins: [TimeFilters],
  data() {
    return {
      api: null,
      cable: new Cable(),
      ignored: [],
      ignoredItemsDialog: false,
      overallState: 'GREEN',
      currentLimitsSet: '',
      items: [],
      itemList: [],
      screenItems: [],
      screenValues: {},
      updateCounter: 0,
      widgetSettings: [
        ['WIDTH', '580px'], // Total of three subwidgets
        ['0', 'WIDTH', '200px'],
        ['1', 'WIDTH', '200px'],
        ['2', 'WIDTH', '180px'],
        ['__SCREEN__', this],
      ],
    }
  },
  computed: {
    textFieldClass() {
      if (this.overallState) {
        return `textfield-${this.overallState.toLowerCase()}`
      } else {
        return ''
      }
    },
    overallStateFormatted() {
      if (this.ignored.length === 0) {
        return this.overallState
      } else {
        return `${this.overallState} (Some items ignored)`
      }
    },
    ignoredFormatted() {
      return this.ignored.map((x) => x.split('__').join(' '))
    },
    astroIcon() {
      switch (this.overallState) {
        case 'GREEN':
          return '$vuetify.icons.astro-status-normal'
        case 'YELLOW':
          return '$vuetify.icons.astro-status-caution'
        case 'RED':
          return '$vuetify.icons.astro-status-critical'
        case 'BLUE':
          // This one is a little weird but it matches our color scheme
          return '$vuetify.icons.astro-status-standby'
        default:
          return null
      }
    },
  },
  created() {
    this.api = new OpenC3Api()
    // Value is passed in as the list of ignored items
    for (let item of this.value) {
      if (item.match(/.+__.+__.+/)) {
        // TARGET__PACKET__ITEM
        this.ignoreItem(item, true)
      } else {
        // TARGET__PACKET
        this.ignorePacket(item, true)
      }
    }
    this.updateOutOfLimits()
    this.getCurrentLimitsSet()
    this.currentSetRefreshInterval = setInterval(
      this.getCurrentLimitsSet,
      10 * 1000,
    )

    this.cable
      .createSubscription('LimitsEventsChannel', window.openc3Scope, {
        received: (parsed) => {
          this.cable.recordPing()
          this.handleMessages(parsed)
        },
      })
      .then((limitsSubscription) => {
        this.limitsSubscription = limitsSubscription
      })
    this.cable
      .createSubscription('ConfigEventsChannel', window.openc3Scope, {
        received: (data) => {
          this.cable.recordPing()
          const parsed = JSON.parse(data)
          this.handleConfigEvents(parsed)
        },
      })
      .then((configSubscription) => {
        this.configSubscription = configSubscription
      })
  },
  mounted() {
    this.updater = setInterval(() => {
      this.update()
    }, 1000)
  },
  destroyed() {
    if (this.updater != null) {
      clearInterval(this.updater)
      this.updater = null
    }
    if (this.limitsSubscription) {
      this.limitsSubscription.unsubscribe()
    }
    if (this.configSubscription) {
      this.configSubscription.unsubscribe()
    }
    this.cable.disconnect()
  },
  methods: {
    getCurrentLimitsSet: function () {
      this.api.get_limits_set().then((result) => {
        this.currentLimitsSet = result
      })
    },
    updateOutOfLimits() {
      this.items = []
      this.itemList = []

      this.api.get_out_of_limits().then((items) => {
        for (const item of items) {
          let itemName = item.join('__')
          // Skip ignored
          if (this.ignored.find((ignored) => itemName.includes(ignored))) {
            continue
          }

          this.itemList.push(itemName)
          let itemInfo = {
            key: item.slice(0, 3).join('__'),
            parameters: item.slice(0, 3),
            timestamp: this.formatDateTime(new Date(), this.timeZone),
          }
          if (item[3].includes('YELLOW') && this.overallState !== 'RED') {
            this.overallState = 'YELLOW'
          }
          if (item[3].includes('RED')) {
            this.overallState = 'RED'
          }
          if (item[3] == 'YELLOW' || item[3] == 'RED') {
            itemInfo['limits'] = false
          } else {
            itemInfo['limits'] = true
          }
          this.items.push(itemInfo)
        }
        this.calcOverallState()
      })
    },
    calcOverallState() {
      let overall = 'GREEN'
      for (let item of this.itemList) {
        if (this.ignored.find((ignored) => item.includes(ignored))) {
          continue
        }

        if (item.includes('YELLOW') && overall !== 'RED') {
          overall = 'YELLOW'
        }
        if (item.includes('RED')) {
          overall = 'RED'
          break
        }
      }
      this.overallState = overall
    },
    ignorePacket(item, noUpdate) {
      let [target_name, packet_name, item_name] = item.split('__')
      let newIgnored = `${target_name}__${packet_name}`
      this.ignored.push(newIgnored)
      noUpdate || this.updateIgnored()

      while (true) {
        let index = this.itemList.findIndex((item) => item.includes(newIgnored))
        if (index === -1) {
          break
        } else {
          let underIndex = this.itemList[index].lastIndexOf('__')
          this.removeItem(this.itemList[index].substring(0, underIndex))
        }
      }
      this.calcOverallState()
    },
    ignoreItem(item, noUpdate) {
      this.ignored.push(item)
      noUpdate || this.updateIgnored()
      this.removeItem(item)
      this.calcOverallState()
    },
    restoreItem(index) {
      this.ignored.splice(index, 1)
      this.updateIgnored()
      this.updateOutOfLimits()
    },
    removeItem(item) {
      const index = this.itemList.findIndex((arrayItem) =>
        arrayItem.includes(item),
      )
      this.items.splice(index, 1)
      this.itemList.splice(index, 1)
    },
    clearAll() {
      this.ignored = []
      this.updateIgnored()
      this.updateOutOfLimits()
    },
    updateIgnored() {
      this.$emit('input', this.ignored)
    },
    handleConfigEvents(config) {
      for (let event of config) {
        // When a target is deleted we refresh the list of items
        if (event['kind'] === 'deleted' && event['type'] === 'target') {
          this.updateOutOfLimits()
        }
      }
    },
    handleMessages(messages) {
      for (let message of messages) {
        message = JSON.parse(message['event'])

        // We only want to handle LIMITS_CHANGE messages
        // NOTE: The channel also sends LIMITS_SETTINGS and LIMITS_SET messages
        if (message.type != 'LIMITS_CHANGE') {
          continue
        }

        let itemName = `${message.target_name}__${message.packet_name}__${message.item_name}`
        const index = this.itemList.findIndex((arrayItem) =>
          arrayItem.includes(itemName),
        )
        // If we find an existing item we update the state and re-calc overall state
        if (index !== -1) {
          this.itemList[index] = `${itemName}__${message.new_limits_state}`
          this.calcOverallState()
          continue
        }
        // Skip ignored items
        if (this.ignored.find((ignored) => itemName.includes(ignored))) {
          continue
        }
        // Only process items who have gone out of limits
        if (
          message.new_limits_state &&
          !(
            message.new_limits_state.includes('YELLOW') ||
            message.new_limits_state.includes('RED')
          )
        ) {
          continue
        }
        let itemInfo = {
          key: itemName,
          timestamp: this.formatNanoseconds(message.time_nsec, this.timeZone),
          parameters: [
            message.target_name,
            message.packet_name,
            message.item_name,
          ],
        }
        if (
          message.new_limits_state == 'YELLOW' ||
          message.new_limits_state == 'RED'
        ) {
          itemInfo['limits'] = false
        } else {
          itemInfo['limits'] = true
        }
        this.itemList.push(`${itemName}__${message.new_limits_state}`)
        this.items.push(itemInfo)
        this.calcOverallState()
      }
    },
    update() {
      if (this.screenItems.length !== 0) {
        this.api.get_tlm_values(this.screenItems).then((data) => {
          this.updateValues(data)
        })
      }
    },
    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)
    },

    // Menu options
    showIgnored() {
      this.ignoredItemsDialog = true
    },
  },
}
</script>

<style scoped>
.footer {
  padding-top: 5px;
}
.v-input {
  background-color: var(--color-background-base-default);
}
/* TODO: Color the border */
.textfield-green :deep(.v-text-field__slot) input,
.textfield-green :deep(.v-text-field__slot) label {
  color: rgb(0, 200, 0);
}

.textfield-yellow :deep(.v-text-field__slot) input,
.textfield-yellow :deep(.v-text-field__slot) label {
  color: rgb(255, 220, 0);
}

.textfield-red :deep(.v-text-field__slot) input,
.textfield-red :deep(.v-text-field__slot) label {
  color: rgb(255, 45, 45);
}
</style>