OpenC3/cosmos

View on GitHub
openc3-cosmos-init/plugins/packages/openc3-tool-common/src/tools/base/components/Notifications.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 2023, 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-overlay :value="showNotificationPane" class="overlay" />
    <v-menu
      v-model="showNotificationPane"
      transition="slide-y-transition"
      :close-on-content-click="false"
      :nudge-width="340"
      offset-y
      :nudge-bottom="20"
    >
      <template v-slot:activator="{ on, attrs }">
        <rux-monitoring-icon
          v-bind="attrs"
          v-on="on"
          class="rux-icon"
          :icon="notificationVsAlert"
          label="Notifications"
          :sublabel="activeScripts"
          :status="iconStatus"
          :notifications="unreadNotifications.length"
        ></rux-monitoring-icon>
      </template>

      <!-- Notifications list -->
      <v-card>
        <v-card-title>
          Notifications
          <v-spacer />
          <v-tooltip top open-delay="350">
            <template v-slot:activator="{ on, attrs }">
              <v-btn
                icon
                v-bind="attrs"
                v-on="on"
                class="ml-1"
                @click="clearNotifications"
                data-test="clear-notifications"
              >
                <v-icon> mdi-close-box-multiple </v-icon>
              </v-btn>
            </template>
            <span>Clear all</span>
          </v-tooltip>
          <v-btn
            icon
            @click="toggleSettingsDialog"
            class="ml-1"
            data-test="notification-settings"
          >
            <v-icon> $astro-settings </v-icon>
          </v-btn>
        </v-card-title>
        <v-card-text v-if="notifications.length === 0">
          No notifications
        </v-card-text>
        <v-list
          v-else
          two-line
          width="420"
          max-height="80vh"
          class="overflow-y-auto"
          data-test="notification-list"
        >
          <template v-for="(notification, index) in notificationList">
            <template v-if="notification.header">
              <v-divider v-if="index !== 0" :key="index" class="mb-2" />
              <v-subheader :key="notification.header">
                {{ notification.header }}
              </v-subheader>
            </template>

            <v-list-item
              v-else
              :key="`notification-${index}`"
              @click="openDialog(notification)"
              class="pl-2"
            >
              <v-badge left inline color="transparent">
                <v-list-item-content class="pt-0 pb-0">
                  <v-list-item-title
                    :class="{
                      'text--secondary': notification.read,
                      'text-wrap': true,
                    }"
                  >
                    {{ notification.message }}
                  </v-list-item-title>
                  <v-list-item-subtitle>
                    {{ notification.time | shortDateTime }}
                  </v-list-item-subtitle>
                  <div style="height: 20px" />
                </v-list-item-content>
                <template v-slot:badge>
                  <rux-status
                    :status="getStatus(notification.level)"
                  ></rux-status>
                </template>
              </v-badge>
            </v-list-item>
          </template>
        </v-list>
      </v-card>
    </v-menu>

    <!-- Dialog for viewing full notification -->
    <v-dialog v-model="notificationDialog" width="600">
      <v-card>
        <v-card-title>
          {{ selectedNotification.message }}
          <v-spacer />
          <astro-status-indicator
            :status="selectedNotification.level || 'INFO'"
          />
        </v-card-title>
        <v-card-subtitle>
          {{ selectedNotification.time | shortDateTime }}
        </v-card-subtitle>
        <v-divider />
        <v-card-actions>
          <v-btn
            v-if="selectedNotification.url"
            color="primary"
            text
            @click="navigate(selectedNotification.url)"
          >
            Open
            <v-icon right> mdi-open-in-new </v-icon>
          </v-btn>
          <v-btn color="primary" text @click="notificationDialog = false">
            Close
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>

    <!-- Dialog for changing notification settings -->
    <v-dialog v-model="settingsDialog" width="600">
      <v-card>
        <v-card-title> Notification settings </v-card-title>
        <v-card-text>
          <v-switch v-model="showToast" label="Show toasts" />
        </v-card-text>
        <v-divider />
        <v-card-actions>
          <v-btn color="primary" text @click="toggleSettingsDialog">
            Close
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </div>
</template>

<script>
import { formatDistanceToNow } from 'date-fns'
import {
  AstroStatusColors,
  UnknownToAstroStatus,
} from '../../../components/icons'
import { highestLevel, orderByLevel, groupByLevel } from '../util/AstroStatus'
import Cable from '../../../services/cable.js'
import Api from '../../../services/api'

const NOTIFICATION_HISTORY_MAX_LENGTH = 1000

export default {
  props: {
    size: {
      type: [String, Number],
      default: 26,
    },
  },
  data: function () {
    return {
      AstroStatusColors,
      alerts: [],
      cable: new Cable(),
      scriptCable: new Cable('/script-api/cable'),
      subscription: null,
      scriptSubscription: null,
      numScripts: 0,
      notifications: [],
      showNotificationPane: false,
      toastNotification: {},
      notificationDialog: false,
      selectedNotification: {},
      settingsDialog: false,
      showToast: true,
    }
  },
  computed: {
    activeScripts: function () {
      return `Scripts: ${this.numScripts}`
    },
    notificationVsAlert: function () {
      // TODO: Determine if this is a notification or alert
      return 'notifications'
      // return 'warning'
    },
    iconStatus: function () {
      if (this.unreadNotifications.length === 0) {
        return 'off'
      }
      const levels = this.unreadNotifications
        .map((notification) => notification.level)
        .filter((val, index, self) => {
          return self.indexOf(val) === index // Unique values
        })
      return UnknownToAstroStatus[highestLevel(levels)]
    },
    readNotifications: function () {
      return this.notifications
        .filter((notification) => notification.read)
        .sort((a, b) => b.time - a.time)
    },
    unreadNotifications: function () {
      return this.notifications
        .filter((notification) => !notification.read)
        .sort((a, b) => b.time - a.time)
    },
    unreadCount: function () {
      return this.unreadNotifications.length
    },
    notificationList: function () {
      const groups = groupByLevel(this.unreadNotifications)
      let result = orderByLevel(Object.keys(groups), (k) => k).flatMap(
        (level) => {
          const header = {
            header: level.charAt(0).toUpperCase() + level.slice(1),
          }
          return [header, ...groups[level]]
        },
      )
      if (this.readNotifications.length) {
        result = result.concat([{ header: 'Read' }, ...this.readNotifications])
      }
      return result
    },
  },
  watch: {
    showNotificationPane: function (val) {
      if (!val) {
        if (this.selectedNotification.message) {
          this.notificationDialog = false
          this.selectedNotification = {}
        } else {
          this.markAllAsRead()
        }
      }
    },
    showToast: function (val) {
      localStorage.notoast = !val
    },
  },
  created: function () {
    this.showToast = localStorage.notoast === 'false'
    this.subscribe()
    // TODO How does this get updated after initialization
    this.alerts = this.$store.state.notifyHistory
    // Get the initial number of running scripts
    Api.get('/script-api/running-script').then((response) => {
      this.numScripts = response.data.length
    })
  },
  destroyed: function () {
    if (this.subscription) {
      this.subscription.unsubscribe()
    }
    if (this.scriptSubscription) {
      this.scriptSubscription.unsubscribe()
    }
    this.cable.disconnect()
    this.scriptCable.disconnect()
  },
  methods: {
    getStatus: function (level) {
      return UnknownToAstroStatus[level]
    },
    markAllAsRead: function () {
      this.notifications.forEach((notification) => {
        notification.read = true
        if (
          !localStorage.lastReadNotification ||
          localStorage.lastReadNotification < notification.msg_id
        ) {
          localStorage.lastReadNotification = notification.msg_id
        }
      })
    },
    clearNotifications: function () {
      this.markAllAsRead()
      this.notifications = []
      localStorage.notificationStreamOffset = localStorage.lastReadNotification
      this.showNotificationPane = false
    },
    toggleNotificationPane: function () {
      this.showNotificationPane = !this.showNotificationPane
    },
    toggleSettingsDialog: function () {
      this.settingsDialog = !this.settingsDialog
    },
    openDialog: function (notification, clearToast = false) {
      notification.read = true
      if (
        !localStorage.lastReadNotification ||
        localStorage.lastReadNotification < notification.msg_id
      ) {
        localStorage.lastReadNotification = notification.msg_id
      }
      this.selectedNotification = notification
      this.notificationDialog = true
    },
    navigate: function (url) {
      window.open(url, '_blank')
    },
    subscribe: function () {
      this.cable
        .createSubscription(
          'MessagesChannel',
          window.openc3Scope,
          {
            received: (data) => this.receiveMessage(data),
          },
          {
            start_offset:
              localStorage.notificationStreamOffset ||
              localStorage.lastReadNotification,
            types: ['notification', 'alert', 'ephemeral'],
          },
        )
        .then((subscription) => {
          this.subscription = subscription
        })
      this.scriptCable
        .createSubscription('AllScriptsChannel', window.openc3Scope, {
          received: (data) => this.receiveScript(data),
        })
        .then((subscription) => {
          this.scriptSubscription = subscription
        })
    },
    receiveMessage: function (parsed) {
      this.cable.recordPing()

      // Cut down if we're being flooded
      if (parsed.length > NOTIFICATION_HISTORY_MAX_LENGTH) {
        parsed.splice(0, parsed.length - NOTIFICATION_HISTORY_MAX_LENGTH)
      }

      // Filter out ephemeral
      let ephemeral = parsed.filter(
        (someobject) => someobject.type === 'ephemeral',
      )
      if (ephemeral && ephemeral.length > 0) {
        // Remove ephemeral from parsed
        parsed = parsed.filter((someobject) => someobject.type !== 'ephemeral')
        // Emit the ephemeral
        ephemeral.forEach((notification) => {
          this.$emit('ephemeral', notification)
        })
      }

      let foundToast = false
      parsed.forEach((notification) => {
        notification.read =
          notification.msg_id <= localStorage.lastReadNotification
        notification.level = notification.level || 'INFO'
        if (
          !notification.read && // Don't toast read notifications
          ['FATAL', 'ERROR', 'WARN'].includes(notification.level) // Toast for these statuses
        ) {
          foundToast = true
          this.toastNotification = notification
        }
      })

      if (this.showToast && foundToast) {
        let duration = 5000
        if (['FATAL', 'ERROR'].includes(this.toastNotification.level)) {
          duration = null
        }

        // Notify takes a minute to be ready on app load
        if (this.$notify) {
          this.$notify[this.toastNotification.level]({
            ...this.toastNotification,
            type: 'notification',
            duration: duration,
            saveToHistory: false,
          })
        }
      }

      if (
        this.notifications.length + parsed.length >
        NOTIFICATION_HISTORY_MAX_LENGTH
      ) {
        this.notifications.splice(
          0,
          this.notifications.length +
            parsed.length -
            NOTIFICATION_HISTORY_MAX_LENGTH,
        )
      }
      this.notifications = this.notifications.concat(parsed)
    },
    receiveScript: function (data) {
      this.cable.recordPing()
      this.numScripts = data['active_scripts']
    },
  },
  filters: {
    shortDateTime: function (nsec) {
      if (!nsec) return ''
      const date = new Date(nsec / 1_000_000)
      return formatDistanceToNow(date, { addSuffix: true })
    },
  },
}
</script>

<style scoped>
.v-subheader {
  height: 28px;
}
.v-badge {
  width: 100%;
}
.overlay {
  height: 100vh;
  width: 100vw;
}
</style>