OpenC3/cosmos

View on GitHub
openc3-cosmos-init/plugins/packages/openc3-tool-common/src/tools/admin/tabs/PluginsTab.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-row no-gutters align="center" class="px-2">
      <v-col>
        <v-file-input
          v-model="file"
          show-size
          accept=".gem"
          class="mx-2"
          label="Click to install new plugin .gem (NOT upgrade)"
          ref="fileInput"
          @change="fileChange()"
          @mousedown="fileMousedown()"
        />
      </v-col>
      <v-col class="ml-4 mr-2" cols="4">
        <rux-progress :value="progress"></rux-progress>
      </v-col>
      <!-- <v-col align="right">
        <v-btn
          @click="showDownloadDialog = true"
          class="mx-2"
          data-test="download-plugin"
          :disabled="file !== null"
        >
          <v-icon left>mdi-cloud-download</v-icon>
          <span> Download </span>
        </v-btn>
      </v-col> -->
    </v-row>
    <v-row no-gutters class="px-4" style="margin-top: 10px">
      <v-col>
        <v-checkbox
          v-model="showDefaultTools"
          label="Show Default Tools"
          class="mt-0"
          data-test="show-default-tools"
        />
      </v-col>
      <v-col align="right" class="mr-2">
        <div>* indicates a modified plugin</div>
        <div>Click target link to download modifications</div>
      </v-col>
    </v-row>
    <v-divider />
    <!-- TODO This alert shows both success and failure. Make consistent with rest of OpenC3. -->
    <v-alert
      dismissible
      transition="scale-transition"
      :type="alertType"
      v-model="showAlert"
      data-test="plugin-alert"
      >{{ alert }}</v-alert
    >
    <v-list
      class="list"
      v-if="Object.keys(processes).length > 0"
      data-test="process-list"
    >
      <v-row no-gutters class="px-4"
        ><v-col class="text-h6">Process List</v-col>
        <v-col align="right">
          <!-- See openc3/lib/openc3/utilities/process_manager.rb CLEANUP_CYCLE_SECONDS -->
          <div>Showing last 10 min of activity</div>
        </v-col>
      </v-row>
      <div v-for="process in processes" :key="process.name">
        <v-list-item>
          <v-list-item-content>
            <v-list-item-title>
              <span
                :class="process.state.toLowerCase()"
                v-text="
                  `Processing ${process.process_type}: ${process.detail} - ${process.state}`
                "
              />
            </v-list-item-title>
            <v-list-item-subtitle>
              <span v-text="' Updated At: ' + formatDate(process.updated_at)"
            /></v-list-item-subtitle>
          </v-list-item-content>
          <v-list-item-icon>
            <div v-if="process.state === 'Running'">
              <v-progress-circular indeterminate color="primary" />
            </div>
            <v-tooltip v-else bottom>
              <template v-slot:activator="{ on, attrs }">
                <v-icon
                  @click="showOutput(process)"
                  v-bind="attrs"
                  v-on="on"
                  data-test="show-output"
                >
                  mdi-eye
                </v-icon>
              </template>
              <span>Show Output</span>
            </v-tooltip>
          </v-list-item-icon>
        </v-list-item>
        <v-divider />
      </div>
    </v-list>
    <v-list class="list" data-test="plugin-list">
      <v-row class="px-4"><v-col class="text-h6">Plugin List</v-col></v-row>
      <div v-for="(plugin, index) in shownPlugins" :key="index">
        <v-list-item>
          <v-list-item-content>
            <v-list-item-title
              ><span v-if="isModified(plugin)">* </span
              >{{ plugin }}</v-list-item-title
            >
            <v-list-item-subtitle v-if="pluginTargets(plugin).length !== 0">
              <span
                v-for="(target, index) in pluginTargets(plugin)"
                :key="index"
              >
                <a
                  v-if="target.modified"
                  @click.prevent="downloadTarget(target.name)"
                  >{{ target.name }}
                </a>
                <span v-else>{{ target.name }} </span>
              </span>
            </v-list-item-subtitle>
          </v-list-item-content>
          <v-list-item-icon>
            <div class="mx-3">
              <v-tooltip bottom>
                <template v-slot:activator="{ on, attrs }">
                  <v-icon
                    @click="downloadPlugin(plugin)"
                    v-bind="attrs"
                    v-on="on"
                    data-test="download-plugin"
                  >
                    mdi-download
                  </v-icon>
                </template>
                <span>Download Plugin</span>
              </v-tooltip>
            </div>
            <div class="mx-3">
              <v-tooltip bottom>
                <template v-slot:activator="{ on, attrs }">
                  <v-icon
                    @click="editPlugin(plugin)"
                    v-bind="attrs"
                    v-on="on"
                    data-test="edit-plugin"
                  >
                    mdi-pencil
                  </v-icon>
                </template>
                <span>Edit Plugin Details</span>
              </v-tooltip>
            </div>
            <div class="mx-3">
              <v-tooltip bottom>
                <template v-slot:activator="{ on, attrs }">
                  <v-icon
                    @click="upgradePlugin(plugin)"
                    v-bind="attrs"
                    v-on="on"
                    data-test="upgrade-plugin"
                  >
                    mdi-update
                  </v-icon>
                </template>
                <span>Upgrade Plugin</span>
              </v-tooltip>
            </div>
            <div class="mx-3">
              <v-tooltip bottom>
                <template v-slot:activator="{ on, attrs }">
                  <v-icon
                    @click="deletePrompt(plugin)"
                    v-bind="attrs"
                    v-on="on"
                    data-test="delete-plugin"
                  >
                    mdi-delete
                  </v-icon>
                </template>
                <span>Delete Plugin</span>
              </v-tooltip>
            </div>
          </v-list-item-icon>
        </v-list-item>
        <v-divider v-if="index < plugins.length - 1" :key="index" />
      </div>
    </v-list>
    <plugin-dialog
      v-if="showPluginDialog"
      v-model="showPluginDialog"
      :pluginName="pluginName"
      :variables="variables"
      :pluginTxt="pluginTxt"
      :existingPluginTxt="existingPluginTxt"
      @submit="pluginCallback"
    />
    <modified-plugin-dialog
      v-if="showModifiedPluginDialog"
      v-model="showModifiedPluginDialog"
      :pluginName="currentPlugin"
      :targets="pluginTargets(currentPlugin)"
      :pluginDelete="pluginDelete"
      @submit="modifiedSubmit"
    />
    <!-- <download-dialog v-model="showDownloadDialog" /> -->
    <simple-text-dialog
      v-model="showProcessOutput"
      title="Process Output"
      :text="processOutput"
    />
  </div>
</template>

<script>
import { toDate, format } from 'date-fns'
import Api from '../../../services/api'
// import DownloadDialog from '../DownloadDialog'
import PluginDialog from '../PluginDialog'
import ModifiedPluginDialog from '../ModifiedPluginDialog'
import SimpleTextDialog from '../../../components/SimpleTextDialog'

export default {
  components: {
    // DownloadDialog,
    PluginDialog,
    ModifiedPluginDialog,
    SimpleTextDialog,
  },
  data() {
    return {
      file: null,
      currentPlugin: null,
      plugins: [],
      targets: [],
      processes: {},
      alert: '',
      alertType: 'success',
      showAlert: false,
      pluginName: null,
      variables: {},
      pluginTxt: '',
      pluginHashTmp: null,
      existingPluginTxt: null,
      showDownloadDialog: false,
      showProcessOutput: false,
      processOutput: '',
      showPluginDialog: false,
      showModifiedPluginDialog: false,
      showDefaultTools: false,
      progress: 0,
      pluginDelete: false,
      // When updating update local_mode.rb, local_mode.py, plugins.spec.ts
      defaultPlugins: [
        'openc3-cosmos-tool-admin',
        'openc3-cosmos-tool-bucketexplorer',
        'openc3-cosmos-tool-cmdsender',
        'openc3-cosmos-tool-cmdhistory', // Enterprise only
        'openc3-cosmos-tool-cmdtlmserver',
        'openc3-cosmos-tool-dataextractor',
        'openc3-cosmos-tool-dataviewer',
        'openc3-cosmos-tool-docs',
        'openc3-cosmos-tool-handbooks',
        'openc3-cosmos-tool-iframe',
        'openc3-cosmos-tool-limitsmonitor',
        'openc3-cosmos-tool-packetviewer',
        'openc3-cosmos-tool-scriptrunner',
        'openc3-cosmos-tool-tablemanager',
        'openc3-cosmos-tool-tlmgrapher',
        'openc3-cosmos-tool-tlmviewer',
        'openc3-cosmos-enterprise-tool-admin', // Enterprise only
        'openc3-cosmos-tool-autonomic', // Enterprise only
        'openc3-cosmos-tool-calendar', // Enterprise only
        'openc3-cosmos-tool-grafana', // Enterprise only
        'openc3-enterprise-tool-base', // Enterprise only
        'openc3-tool-base',
      ],
    }
  },
  computed: {
    shownPlugins() {
      let result = []
      for (let plugin of this.plugins) {
        let pluginNameFirst = plugin.split('__')[0]
        let pluginNameSplit = pluginNameFirst.split('-')
        pluginNameSplit = pluginNameSplit.slice(0, -1)
        let pluginName = pluginNameSplit.join('-')
        if (
          !this.defaultPlugins.includes(pluginName) ||
          this.showDefaultTools
        ) {
          result.push(plugin)
        }
      }
      return result
    },
    pluginTargets() {
      return (plugin) => {
        let result = []
        for (const target in this.targets) {
          if (this.targets[target]['plugin'] === plugin) {
            result.push(this.targets[target])
          }
        }
        return result
      }
    },
    isModified() {
      return (plugin) => {
        let result = false
        for (const target in this.targets) {
          if (
            this.targets[target]['plugin'] === plugin &&
            this.targets[target]['modified'] === true
          ) {
            result = true
            break
          }
        }
        return result
      }
    },
  },
  mounted() {
    this.update()
    this.updateProcesses()
  },
  methods: {
    showOutput: function (process) {
      this.processOutput = process.output
      this.showProcessOutput = true
    },
    update: function () {
      Api.get('/openc3-api/plugins').then((response) => {
        this.plugins = response.data
      })
      Api.get('/openc3-api/targets_modified').then((response) => {
        this.targets = response.data
      })
    },
    updateProcesses: function () {
      Api.get('openc3-api/process_status/plugin_?substr=true').then(
        (response) => {
          this.processes = response.data
          if (Object.keys(this.processes).length > 0) {
            setTimeout(() => {
              this.updateProcesses()
              this.update()
            }, 5000)
          }
        }
      )
    },
    formatDate(nanoSecs) {
      return format(
        toDate(parseInt(nanoSecs) / 1_000_000),
        'yyyy-MM-dd HH:mm:ss.SSS'
      )
    },
    upload: function (existing = null) {
      const method = existing ? 'put' : 'post'
      const path = existing
        ? `/openc3-api/plugins/${existing}`
        : '/openc3-api/plugins'
      const formData = new FormData()
      formData.append('plugin', this.file, this.file.name)
      let self = this
      const promise = Api[method](path, {
        data: formData,
        headers: { 'Content-Type': 'multipart/form-data' },
        onUploadProgress: function (progressEvent) {
          var percentCompleted = Math.round(
            (progressEvent.loaded * 100) / progressEvent.total
          )
          self.progress = percentCompleted
        },
      })
      promise
        .then((response) => {
          this.progress = 100
          this.alert = 'Uploaded file'
          this.alertType = 'success'
          this.showAlert = true
          setTimeout(() => {
            this.showAlert = false
          }, 5000)
          this.update()
          let existingPluginTxt = null
          if (response.data.existing_plugin_txt_lines !== undefined) {
            existingPluginTxt =
              response.data.existing_plugin_txt_lines.join('\n')
          }
          let pluginTxt = response.data.plugin_txt_lines.join('\n')
          ;(this.pluginName = response.data.name),
            (this.variables = response.data.variables),
            (this.pluginTxt = pluginTxt),
            (this.existingPluginTxt = existingPluginTxt)
          this.showPluginDialog = true
          this.file = undefined
        })
        .catch((error) => {
          this.currentPlugin = null
          this.file = undefined
        })
    },
    pluginCallback: function (pluginHash) {
      this.showPluginDialog = false
      if (this.currentPlugin !== null) {
        pluginHash['name'] = this.currentPlugin
      }
      this.pluginHashTmp = pluginHash
      if (this.isModified(this.currentPlugin)) {
        this.pluginDelete = false
        this.showModifiedPluginDialog = true
      } else {
        this.pluginInstall()
      }
    },
    modifiedSubmit: async function (deleteModified) {
      if (deleteModified === true) {
        for (let target of this.pluginTargets(this.currentPlugin)) {
          if (target.modified == true) {
            await Api.post(`/openc3-api/targets/${target.name}/delete_modified`)
          }
        }
      }
      if (this.pluginDelete) {
        this.deletePlugin(this.currentPlugin)
      } else {
        this.pluginInstall()
      }
    },
    pluginInstall: function () {
      Api.post(`/openc3-api/plugins/install/${this.pluginName}`, {
        data: {
          plugin_hash: JSON.stringify(this.pluginHashTmp),
        },
      }).then((response) => {
        this.alert = `Started installing plugin ${this.pluginName} ...`
        this.alertType = 'success'
        this.showAlert = true
        this.currentPlugin = null
        this.file = undefined
        this.variables = {}
        this.pluginTxt = ''
        this.existingPluginTxt = null
        setTimeout(() => {
          this.showAlert = false
          this.updateProcesses()
        }, 5000)
        this.update()
      })
    },
    downloadTarget: function (name) {
      Api.post(`/openc3-api/targets/${name}/download`).then((response) => {
        // Decode Base64 string
        const decodedData = window.atob(response.data.contents)
        // Create UNIT8ARRAY of size same as row data length
        const uInt8Array = new Uint8Array(decodedData.length)
        // Insert all character code into uInt8Array
        for (let i = 0; i < decodedData.length; ++i) {
          uInt8Array[i] = decodedData.charCodeAt(i)
        }
        const blob = new Blob([uInt8Array], { type: 'application/zip' })
        const link = document.createElement('a')
        link.href = URL.createObjectURL(blob)
        link.setAttribute('download', response.data.filename)
        link.click()
      })
    },
    downloadPlugin: function (plugin) {
      Api.post(`/openc3-api/packages/${plugin}/download`).then((response) => {
        // Decode Base64 string
        const decodedData = window.atob(response.data.contents)
        // Create UNIT8ARRAY of size same as row data length
        const uInt8Array = new Uint8Array(decodedData.length)
        // Insert all character code into uInt8Array
        for (let i = 0; i < decodedData.length; ++i) {
          uInt8Array[i] = decodedData.charCodeAt(i)
        }
        const blob = new Blob([uInt8Array], { type: 'application/zip' })
        const link = document.createElement('a')
        link.href = URL.createObjectURL(blob)
        link.setAttribute('download', response.data.filename)
        link.click()
      })
    },
    editPlugin: function (plugin) {
      Api.get(`/openc3-api/plugins/${plugin}`).then((response) => {
        let existingPluginTxt = null
        if (response.data.existing_plugin_txt_lines !== undefined) {
          existingPluginTxt = response.data.existing_plugin_txt_lines.join('\n')
        }
        let pluginTxt = response.data.plugin_txt_lines.join('\n')
        ;(this.pluginName = response.data.name),
          (this.variables = response.data.variables),
          (this.pluginTxt = pluginTxt),
          (this.existingPluginTxt = existingPluginTxt)
        this.showPluginDialog = true
      })
    },
    deletePrompt: function (plugin) {
      this.$dialog
        .confirm(`Are you sure you want to remove: ${plugin}`, {
          okText: 'Delete',
          cancelText: 'Cancel',
        })
        .then((dialog) => {
          if (this.isModified(plugin)) {
            this.currentPlugin = plugin
            this.pluginDelete = true
            this.showModifiedPluginDialog = true
          } else {
            this.deletePlugin(plugin)
          }
        })
    },
    deletePlugin: function (plugin) {
      this.alert = `Removing plugin ${plugin} ...`
      this.alertType = 'success'
      this.showAlert = true
      Api.delete(`/openc3-api/plugins/${plugin}`).then((response) => {
        setTimeout(() => {
          this.showAlert = false
          this.updateProcesses()
        }, 5000)
      })
      this.update()
    },
    upgradePlugin(plugin) {
      this.file = undefined
      this.currentPlugin = plugin
      this.$refs.fileInput.$refs.input.click()
      this.progress = 0
    },
    fileMousedown() {
      this.currentPlugin = null
      this.progress = 0
    },
    fileChange() {
      if (this.file !== undefined) {
        if (this.currentPlugin !== null) {
          if (
            this.file.name.split('.gem')[0] ==
            this.currentPlugin.split('.gem')[0]
          ) {
            this.$dialog
              .confirm(
                `The new gem ${this.file.name} appears to be identical to the existing ${this.currentPlugin}. Install?`,
                {
                  okText: 'Ok',
                  cancelText: 'Cancel',
                }
              )
              .then(() => {
                this.upload(this.currentPlugin)
              })
              .catch((error) => {
                // do nothing
              })
          } else {
            // Split up the gem name to determine if this is an upgrade
            // or mistakenly trying to install a different gem
            // Gems are named like openc3-cosmos-demo-5.3.2.gem or
            // openc3-cosmos-pw-test-1.0.0.20230213074527.gem
            // So split on - and match everything until the first .
            let parts = this.file.name.split('-')
            let i = parts.findIndex((x) => x.includes('.'))
            let newName = parts.slice(0, i).join('-')
            parts = this.currentPlugin.split('-')
            i = parts.findIndex((x) => x.includes('.'))
            let existingName = parts.slice(0, i).join('-')
            if (newName !== existingName) {
              this.$dialog
                .confirm(
                  `The new gem base name ${newName} doesn't match the existing ${existingName}. Install?`,
                  {
                    okText: 'Ok',
                    cancelText: 'Cancel',
                  }
                )
                .then(() => {
                  this.upload(this.currentPlugin)
                })
            } else {
              this.upload(this.currentPlugin)
            }
          }
        } else {
          this.upload()
        }
      }
    },
  },
}
</script>

<style scoped>
.crashed {
  color: red;
}
.list {
  background-color: var(--color-background-surface-default) !important;
}
</style>