OpenC3/cosmos

View on GitHub
openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-dataviewer/src/tools/DataViewer/DataViewer.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>
    <top-bar :menus="menus" :title="title" />
    <v-card>
      <v-expansion-panels v-model="panel" style="margin-bottom: 5px">
        <v-expansion-panel>
          <v-expansion-panel-header
            style="z-index: 1"
          ></v-expansion-panel-header>
          <v-expansion-panel-content>
            <v-row dense>
              <v-col>
                <v-text-field
                  v-model="startDate"
                  label="Start Date"
                  type="date"
                  :rules="[rules.required]"
                  data-test="start-date"
                />
              </v-col>
              <v-col>
                <v-text-field
                  v-model="startTime"
                  label="Start Time"
                  type="time"
                  step="1"
                  :rules="[rules.required]"
                  data-test="start-time"
                />
              </v-col>
              <v-col>
                <v-text-field
                  v-model="endDate"
                  label="End Date"
                  type="date"
                  :rules="endTime ? [rules.required] : []"
                  data-test="end-date"
                />
              </v-col>
              <v-col>
                <v-text-field
                  v-model="endTime"
                  label="End Time"
                  type="time"
                  step="1"
                  :rules="endDate ? [rules.required] : []"
                  data-test="end-time"
                />
              </v-col>
              <v-col cols="auto" class="pt-4">
                <v-btn
                  v-if="running"
                  color="primary"
                  width="100"
                  data-test="stop-button"
                  @click="stop"
                >
                  Stop
                </v-btn>
                <v-btn
                  v-else
                  :disabled="!canStart"
                  color="primary"
                  width="100"
                  class="pulse-button"
                  data-test="start-button"
                  @click="start"
                >
                  Start
                </v-btn>
              </v-col>
            </v-row>
          </v-expansion-panel-content>
        </v-expansion-panel>
      </v-expansion-panels>
      <div class="mb-3" v-show="warning || error || connectionFailure">
        <v-alert type="warning" v-model="warning" dismissible>
          {{ warningText }}
        </v-alert>
        <v-alert type="error" v-model="error" dismissible>
          {{ errorText }}
        </v-alert>
        <v-alert type="error" v-model="connectionFailure">
          OpenC3 backend connection failed.
        </v-alert>
      </div>
      <v-tabs ref="tabs" v-model="curTab">
        <v-tab
          v-for="(tab, index) in config.tabs"
          :key="index"
          @contextmenu="(event) => tabMenu(event, index)"
          data-test="tab"
        >
          {{ tab.tabName }}
        </v-tab>
        <v-tooltip bottom>
          <template v-slot:activator="{ on, attrs }">
            <v-btn
              icon
              class="mt-2 ml-2"
              @click="addTab"
              v-bind="attrs"
              v-on="on"
              :class="config.tabs.length === 0 ? 'pulse-button' : ''"
              data-test="new-tab"
            >
              <v-icon>mdi-tab-plus</v-icon>
            </v-btn>
          </template>
          <span>Add Component</span>
        </v-tooltip>
      </v-tabs>
      <v-tabs-items v-model="curTab">
        <v-tab-item v-for="(tab, index) in config.tabs" :key="tab.ref" eager>
          <keep-alive>
            <v-card flat>
              <v-divider />
              <v-card-title class="pa-3">
                <span v-text="tab.name" />
                <v-spacer />
                <v-tooltip bottom>
                  <template v-slot:activator="{ on, attrs }">
                    <v-btn
                      icon
                      @click="() => deleteComponent(index)"
                      v-bind="attrs"
                      v-on="on"
                      data-test="delete-component"
                    >
                      <v-icon>mdi-delete</v-icon>
                    </v-btn>
                  </template>
                  <span>Remove Component</span>
                </v-tooltip>
              </v-card-title>
              <component
                v-on="$listeners"
                :is="tab.type"
                :name="tab.component"
                :ref="tab.ref"
                :config="tab.config"
                :packets="tab.packets"
                @config="(config) => (tab.config = config)"
              />
              <v-card-text v-if="receivedPackets.length === 0">
                No data! Make sure to hit the START button!
              </v-card-text>
            </v-card></keep-alive
          >
        </v-tab-item>
      </v-tabs-items>
      <v-card v-if="!config.tabs.length">
        <v-card-title>You're not viewing any packets</v-card-title>
        <v-card-text>Click the new tab icon to start.</v-card-text>
      </v-card>
    </v-card>
    <!-- Dialogs for opening and saving configs -->
    <open-config-dialog
      v-if="openConfig"
      v-model="openConfig"
      :configKey="configKey"
      @success="openConfiguration"
    />
    <save-config-dialog
      v-if="saveConfig"
      v-model="saveConfig"
      :configKey="configKey"
      @success="saveConfiguration"
    />
    <!-- Dialog for renaming a new tab -->
    <v-dialog v-model="tabNameDialog" width="600">
      <v-card>
        <v-system-bar>
          <v-spacer />
          <span> DataViewer: Rename Tab</span>
          <v-spacer />
        </v-system-bar>
        <v-card-text>
          <v-text-field
            v-model="newTabName"
            label="Tab name"
            data-test="rename-tab-input"
          />
        </v-card-text>
        <v-card-actions>
          <v-spacer />
          <v-btn
            outlined
            class="mx-2"
            data-test="cancel-rename"
            @click="cancelTabRename"
          >
            Cancel
          </v-btn>
          <v-btn
            color="primary"
            class="mx-2"
            data-test="rename"
            :disabled="!newTabName"
            @click="renameTab"
          >
            Rename
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
    <!-- Menu for right clicking on a tab -->
    <v-menu
      v-model="showTabMenu"
      :position-x="tabMenuX"
      :position-y="tabMenuY"
      absolute
      offset-y
    >
      <v-list>
        <v-list-item data-test="context-menu-rename">
          <v-list-item-title style="cursor: pointer" @click="openTabNameDialog">
            Rename
          </v-list-item-title>
        </v-list-item>
      </v-list>
    </v-menu>
    <!-- Dialog for adding a new component to a tab -->
    <add-component-dialog
      :components="components"
      v-if="showAddComponentDialog"
      v-model="showAddComponentDialog"
      @add="addComponent"
      @cancel="cancelAddComponent"
    />
  </div>
</template>

<script>
import { OpenC3Api } from '@openc3/tool-common/src/services/openc3-api'
import Api from '@openc3/tool-common/src/services/api'
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'
import Cable from '@openc3/tool-common/src/services/cable.js'
import TopBar from '@openc3/tool-common/src/components/TopBar'
import TimeFilters from '@openc3/tool-common/src/tools/base/util/timeFilters.js'

import AddComponentDialog from '@/tools/DataViewer/AddComponentDialog'
// DynamicComponent is how we load custom user components
import DynamicComponent from '@/tools/DataViewer/DynamicComponent'
// Import the built-in DataViewer components
import DumpComponent from '@/tools/DataViewer/DumpComponent'
import ValueComponent from '@/tools/DataViewer/ValueComponent'

export default {
  components: {
    AddComponentDialog,
    OpenConfigDialog,
    SaveConfigDialog,
    DynamicComponent,
    DumpComponent,
    ValueComponent,
    TopBar,
  },
  mixins: [Config, TimeFilters],
  data() {
    return {
      title: 'Data Viewer',
      configKey: 'data_viewer',
      api: null,
      timeZone: 'local',
      // Initialize with all built-in components
      components: [
        {
          label: 'COSMOS Packet Raw/Decom',
          value: 'DumpComponent',
          items: false,
        },
        {
          label: 'COSMOS Item Value',
          value: 'ValueComponent',
          items: true,
        },
      ],
      counter: 0,
      panel: 0,
      componentType: null,
      componentName: null,
      openConfig: false,
      saveConfig: false,
      cable: new Cable(),
      subscription: null,
      startDate: null,
      startTime: null,
      endDate: null,
      endTime: null,
      rules: {
        required: (value) => !!value || 'Required',
      },
      autoStart: false,
      canStart: false,
      running: false,
      curTab: null,
      receivedPackets: {},
      menus: [
        {
          label: 'File',
          items: [
            {
              label: 'Open Configuration',
              icon: 'mdi-folder-open',
              command: () => {
                this.openConfig = true
              },
            },
            {
              label: 'Save Configuration',
              icon: 'mdi-content-save',
              command: () => {
                this.saveConfig = true
              },
            },
            {
              label: 'Reset Configuration',
              icon: 'mdi-monitor-shimmer',
              command: () => {
                this.resetConfig()
                this.resetConfigBase()
              },
            },
          ],
        },
      ],
      warning: false,
      warningText: '',
      error: false,
      errorText: '',
      connectionFailure: false,
      config: {
        tabs: [],
      },
      tabNameDialog: false,
      newTabName: '',
      showTabMenu: false,
      tabMenuX: 0,
      tabMenuY: 0,
      showAddComponentDialog: false,
    }
  },
  computed: {
    startEndTime: function () {
      let startTemp = null
      let endTemp = null
      try {
        if (this.timeZone === 'local') {
          startTemp = new Date(this.startDate + ' ' + this.startTime)
          if (this.endDate !== null && this.endTime !== null) {
            endTemp = new Date(this.endDate + ' ' + this.endTime)
          }
        } else {
          startTemp = new Date(this.startDate + ' ' + this.startTime + 'Z')
          if (this.endDate !== null && this.endTime !== null) {
            endTemp = new Date(this.endDate + ' ' + this.endTime + 'Z')
          }
        }
      } catch (e) {
        return
      }
      return {
        start_time: startTemp.getTime() * 1_000_000,
        end_time: endTemp ? endTemp.getTime() * 1_000_000 : null,
      }
    },
    allPackets: function () {
      return this.config.tabs.flatMap((tab) => {
        return tab.packets
      })
    },
  },
  watch: {
    'config.tabs.length': function () {
      this.resizeTabs()
    },
    // canStart is set by the subscription when it connects.
    // We set autoStart to true during mounted() when loading from
    // a route or a previous saved configuration.
    canStart: function (newVal, _) {
      if (newVal === true && this.autoStart) {
        this.start()
      }
    },
    config: {
      handler: function () {
        this.saveDefaultConfig(this.config)
      },
      deep: true,
    },
  },
  async created() {
    this.api = new OpenC3Api()
    await this.api
      .get_setting('time_zone')
      .then((response) => {
        if (response) {
          this.timeZone = response
        }
      })
      .catch((error) => {
        // Do nothing
      })
    let now = new Date()
    this.startDate = this.formatDate(now, this.timeZone)
    this.startTime = this.formatTimeHMS(now, this.timeZone)

    // Determine if there are any user added widgets
    Api.get('/openc3-api/widgets').then((response) => {
      response.data.forEach((widget) => {
        // Only list the ones following the naming convention DataviewerxxxxxWidget
        const found = widget.match(/DATAVIEWER([A-Z]+)/)
        if (found) {
          Api.get(`/openc3-api/widgets/${widget}`).then((response) => {
            let label = response.data.label
            let items = response.data.items
            if (label === null) {
              label = response.data.name.slice(10)
              label = label.charAt(0) + label.slice(1).toLowerCase()
            }
            this.components.push({
              label: label,
              value: found[0],
              items: items,
            })
          })
        }
      })
    })
    this.subscribe()
  },
  mounted: function () {
    // Called like /tools/dataviewer?config=config
    if (this.$route.query && this.$route.query.config) {
      this.autoStart = true
      this.openConfiguration(this.$route.query.config, true) // routed
    } else {
      let config = this.loadDefaultConfig()
      // Only apply the config if it's not an empty object (config does not exist)
      if (JSON.stringify(config) !== '{}') {
        this.autoStart = true
        this.config = config
      }
    }
  },
  destroyed: function () {
    if (this.subscription) {
      this.subscription.unsubscribe()
    }
    this.cable.disconnect()
  },
  methods: {
    packetTitle: function (packet) {
      if (packet.itemName !== undefined) {
        return `${packet.targetName} ${packet.packetName} ${packet.itemName}`
      } else {
        return `${packet.targetName} ${packet.packetName} [ ${packet.mode} ]`
      }
    },
    resizeTabs: function () {
      if (this.$refs.tabs) this.$refs.tabs.onResize()
    },
    start: function () {
      this.autoStart = false
      // Check for a future start time
      if (this.startEndTime.start_time > new Date().getTime() * 1_000_000) {
        this.warningText = 'Start date/time is in the future!'
        this.warning = true
        return
      }
      // Check for an empty time period
      if (this.startEndTime.start_time === this.startEndTime.end_time) {
        this.warningText = 'Start date/time is equal to end date/time!'
        this.warning = true
        return
      }
      // Check for a future End Time
      if (this.startEndTime.end_time) {
        this.warningText =
          'Note: End date/time is greater than current date/time. Data will continue to stream in real-time until ' +
          this.endDate +
          ' ' +
          this.endTime +
          ' is reached.'
        this.warning = true
      }
      this.running = true
      this.addToSubscription()
    },
    stop: function () {
      this.running = false
      this.removeFromSubscription()
    },
    subscribe: function () {
      this.cable
        .createSubscription('StreamingChannel', window.openc3Scope, {
          received: (data) => this.received(data),
          connected: () => {
            this.canStart = true
            this.connectionFailure = false
          },
          disconnected: () => {
            this.stop()
            this.canStart = false
            this.warningText = 'OpenC3 backend connection disconnected.'
            this.warning = true
            this.connectionFailure = true
          },
          rejected: () => {
            this.warningText = 'OpenC3 backend connection rejected.'
            this.warning = true
          },
        })
        .then((subscription) => {
          this.subscription = subscription
          if (this.running) this.addToSubscription()
        })
    },
    addToSubscription: function (packets) {
      packets = packets || this.allPackets

      let itemBased = []
      let packetBased = []
      packets.forEach((packet) => {
        if (packet.itemName !== undefined) {
          itemBased.push(packet)
        } else {
          packetBased.push(packet)
        }
      })

      if (itemBased.length > 0) {
        // Add the items to the subscription
        OpenC3Auth.updateToken(OpenC3Auth.defaultMinValidity).then(
          (refreshed) => {
            if (refreshed) {
              OpenC3Auth.setTokens()
            }
            this.subscription.perform('add', {
              scope: window.openc3Scope,
              token: localStorage.openc3Token,
              items: itemBased.map(this.itemSubscriptionKey),
              ...this.startEndTime,
            })
          },
        )
      }

      if (packetBased.length > 0) {
        // Group by mode
        const modeGroups = packetBased.reduce((groups, packet) => {
          if (groups[packet.mode]) {
            groups[packet.mode].push(packet)
          } else {
            groups[packet.mode] = [packet]
          }
          return groups
        }, {})
        Object.keys(modeGroups).forEach((mode) => {
          // This eliminates duplicates by converted to Set and back to Array
          modeGroups[mode] = [...new Set(modeGroups[mode])]
        })
        OpenC3Auth.updateToken(OpenC3Auth.defaultMinValidity).then(
          (refreshed) => {
            if (refreshed) {
              OpenC3Auth.setTokens()
            }
            Object.keys(modeGroups).forEach((mode) => {
              this.subscription.perform('add', {
                scope: window.openc3Scope,
                token: localStorage.openc3Token,
                packets: modeGroups[mode].map(this.subscriptionKey),
                ...this.startEndTime,
              })
            })
          },
        )
      }
    },
    removeFromSubscription: function (packets) {
      packets = packets || this.allPackets

      let itemBased = []
      let packetBased = []
      packets.forEach((packet) => {
        if (packet.itemName !== undefined) {
          itemBased.push(packet)
        } else {
          packetBased.push(packet)
        }
      })

      if (itemBased.length > 0) {
        this.subscription.perform('remove', {
          scope: window.openc3Scope,
          token: localStorage.openc3Token,
          items: itemBased.map(this.itemSubscriptionKey),
        })
      }
      if (packetBased.length > 0) {
        this.subscription.perform('remove', {
          scope: window.openc3Scope,
          token: localStorage.openc3Token,
          packets: packetBased.map(this.subscriptionKey),
        })
      }
    },
    received: function (parsed) {
      this.cable.recordPing()
      if (parsed['error']) {
        this.errorText = parsed['error']
        this.error = true
        return
      }
      if (!parsed.length) {
        return
      }

      // Iterate over the parsed data and send it to the appropriate component

      // If the parsed data has a __type of ITEMS, we're dealing with an array
      // of items with attributes named after the item. We need to filter
      // the items per tab and send them to the appropriate component.
      if (parsed[0].__type === 'ITEMS') {
        this.config.tabs.forEach((tab, i) => {
          // Skip tabs without items
          if (!tab.items) return

          let keys = tab.packets.map((itemConfig) => this.itemKey(itemConfig))
          let filtered = parsed
            .map((item) => {
              let newItem = {}
              // These fields are always in the item subscription
              newItem['__type'] = item['__type']
              newItem['__time'] = item['__time']
              let found = false
              keys.forEach((key) => {
                if (item[key]) {
                  found = true
                  newItem[key] = item[key]
                }
              })
              if (found) {
                return newItem
              } else {
                return
              }
            })
            .filter(Boolean) // Remove undefined items
          if (
            filtered &&
            typeof this.$refs[tab.ref][0].receive === 'function'
          ) {
            this.$refs[tab.ref][0].receive(filtered)
          }
        })
      } else {
        const groupedPackets = parsed.reduce((groups, packet) => {
          if (groups[packet.__packet]) {
            groups[packet.__packet].push(packet)
          } else {
            groups[packet.__packet] = [packet]
          }
          return groups
        }, {})
        this.config.tabs.forEach((tab, i) => {
          // Skip tabs with items
          if (tab.items) return
          tab.packets.forEach((packetConfig) => {
            let packetName = this.packetKey(packetConfig)
            this.receivedPackets[packetName] = true
            if (
              groupedPackets[packetName] &&
              typeof this.$refs[tab.ref][0].receive === 'function'
            ) {
              this.$refs[tab.ref][0].receive(groupedPackets[packetName])
            }
          })
        })
        this.receivedPackets = { ...this.receivedPackets }
      }
    },
    packetKey: function (packet) {
      let key = packet.mode + '__'
      if (packet.cmdOrTlm === 'TLM') {
        key += 'TLM'
      } else {
        key += 'CMD'
      }
      key += `__${packet.targetName}__${packet.packetName}`
      if (packet.mode === 'DECOM') key += `__${packet.valueType}`
      return key
    },
    itemKey: function (item) {
      let key = item.mode + '__'
      if (item.cmdOrTlm === 'TLM') {
        key += 'TLM'
      } else {
        key += 'CMD'
      }
      key += `__${item.targetName}__${item.packetName}__${item.itemName}`
      if (item.mode === 'DECOM') key += `__${item.valueType}`
      return key
    },
    // Maybe combine subscriptionKey with itemSubscriptionKey
    subscriptionKey: function (packet) {
      const cmdOrTlm = packet.cmdOrTlm.toUpperCase()
      let key = `${packet.mode}__${cmdOrTlm}__${packet.targetName}__${packet.packetName}`
      if (packet.mode === 'DECOM') key += `__${packet.valueType}`
      return key
    },
    itemSubscriptionKey: function (item) {
      let key = `DECOM__TLM__${item.targetName}__${item.packetName}__${item.itemName}__${item.valueType}`
      return key
    },
    resetConfig: function () {
      this.stop()
      this.receivedPackets = {}
      this.config.tabs = []
    },
    openConfiguration: function (name, routed = false) {
      this.openConfigBase(name, routed, (config) => {
        this.stop()
        this.receivedPackets = {}
        this.config = config
        // Only call start() if autoStart is false like during a reload.
        // Otherwise we might call start before the subscription is valid.
        // See watch on canStart for more info.
        if (this.autoStart === false) {
          this.start()
        }
      })
    },
    saveConfiguration: function (name) {
      this.saveConfigBase(name, this.config)
    },
    addTab: function () {
      this.cancelTabRename()
      this.showAddComponentDialog = true
    },
    cancelTabRename: function () {
      this.tabNameDialog = false
      this.newTabName = ''
    },
    tabMenu: function (event, index) {
      this.curTab = index
      event.preventDefault()
      this.showTabMenu = false
      this.tabMenuX = event.clientX
      this.tabMenuY = event.clientY
      this.$nextTick(() => {
        this.showTabMenu = true
      })
    },
    openTabNameDialog: function () {
      this.newTabName = this.config.tabs[this.curTab].tabName
      this.tabNameDialog = true
    },
    renameTab: function () {
      this.config.tabs[this.curTab].tabName = this.newTabName
      this.tabNameDialog = false
    },
    packetSelected: function (event) {
      this.newPacket = {
        target: event.targetName,
        packet: event.packetName,
        cmdOrTlm: this.newPacketCmdOrTlm,
      }
    },
    addComponent: function (event) {
      // Built-in components are just themselves
      let type = event.component.value
      let component = event.component.value
      if (event.component.value.includes('DATAVIEWER')) {
        // Dynamic widgets use the DynamicComponent
        type = 'DynamicComponent'
        let name =
          event.component.value.charAt(0).toUpperCase() +
          event.component.value.slice(1).toLowerCase()
        component = `${name}Widget`
      }
      this.config.tabs.push({
        name: event.component.label,
        // Most tabs only have 1 packet so it's a good way to name them
        tabName: this.packetTitle(event.packets[0]),
        packets: [...event.packets], // Make a copy
        type: type,
        component: component,
        items: event.component.items,
        config: { timeZone: this.timeZone },
        ref: Date.now(),
      })
      this.curTab = this.config.tabs.length - 1

      if (this.running) {
        this.addToSubscription(event.packets)
      }
      this.cancelAddComponent()
    },
    cancelAddComponent: function () {
      this.showAddComponentDialog = false
    },
    deleteComponent: function (tabIndex) {
      // Check for item based components first
      if (this.config.tabs[tabIndex].packets[0].itemName !== undefined) {
        // Get the list of items the other tabs are using
        let itemsInUse = []
        this.config.tabs.forEach((tab, i) => {
          // Skip tabs without items
          if (!tab.items) return

          if (i !== tabIndex) {
            itemsInUse = itemsInUse.concat(tab.packets.map(this.itemKey))
          }
        })
        // Filter out any items that are in use
        let filtered = this.config.tabs[tabIndex].packets.filter(
          (packet) => itemsInUse.indexOf(this.itemKey(packet)) === -1,
        )
        if (filtered.length > 0) {
          this.removeFromSubscription(filtered)
        }
      } else {
        // Get the list of packets the other tabs are using
        let packetsInUse = []
        this.config.tabs.forEach((tab, i) => {
          // Skip tabs with items
          if (tab.items) return

          if (i !== tabIndex) {
            packetsInUse = packetsInUse.concat(tab.packets.map(this.packetKey))
          }
        })
        // Filter out any packets that are in use
        let filtered = this.config.tabs[tabIndex].packets.filter(
          (packet) => packetsInUse.indexOf(this.packetKey(packet)) === -1,
        )
        if (filtered.length > 0) {
          this.removeFromSubscription(filtered)
        }
      }
      this.config.tabs.splice(tabIndex, 1)
    },
  },
}
</script>

<style scoped>
.v-expansion-panel-content {
  .container {
    margin: 0px;
  }
}
.v-expansion-panel-header {
  min-height: 10px;
  padding: 5px;
}
/* Add some juice to the START button to indicate it needs to be pressed */
.pulse-button {
  animation: pulse 2s infinite;
}
@keyframes pulse {
  0% {
    -webkit-box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.4);
  }
  70% {
    -webkit-box-shadow: 0 0 0 10px rgba(255, 255, 255, 0);
  }
  100% {
    -webkit-box-shadow: 0 0 0 0 rgba(255, 255, 255, 0);
  }
}

.text-component-missing-name {
  font-family: 'Courier New', Courier, monospace;
}

.v-tabs-items {
  overflow: visible;
}
</style>