Add Firmware page

Adds ability to upload a fimware image by local workstation
or TFTP. Also adds ability to reboot BMC from the backup image.

- Add route definition, component view, and store for
  Firmware page
- Get ActiveSoftwareImage location at /redfish/v1/Managers/bmc
- Get backup by checking for an image id that is not the same as
  the active image /redfish/v1/UpdateService/FirmwareInventory
- Switch running firmware image by making PATCH request to
  /redfish/v1/Managers/bmc

Signed-off-by: Yoshie Muranaka <yoshiemuranaka@gmail.com>
Change-Id: I04450e5a170d374122908c4c0349ba3b6e93ed2c
diff --git a/src/views/Configuration/Firmware/Firmware.vue b/src/views/Configuration/Firmware/Firmware.vue
new file mode 100644
index 0000000..248b0ab
--- /dev/null
+++ b/src/views/Configuration/Firmware/Firmware.vue
@@ -0,0 +1,401 @@
+<template>
+  <b-container fluid="xl">
+    <page-title :description="$t('pageFirmware.pageDescription')" />
+    <!-- Operation in progress alert -->
+    <alert v-if="isOperationInProgress" variant="info" class="mb-5">
+      <p>
+        {{ $t('pageFirmware.alert.operationInProgress') }}
+      </p>
+    </alert>
+    <!-- Shutdown server warning alert -->
+    <alert v-else-if="!isHostOff" variant="warning" class="mb-5">
+      <p class="font-weight-bold mb-1">
+        {{ $t('pageFirmware.alert.serverShutdownRequiredBeforeUpdate') }}
+      </p>
+      {{ $t('pageFirmware.alert.serverShutdownRequiredInfo') }}
+      <template v-slot:action>
+        <b-btn variant="link" class="text-nowrap" @click="onClickShutDown">
+          {{ $t('pageFirmware.alert.shutDownServer') }}
+        </b-btn>
+      </template>
+    </alert>
+    <b-row class="mb-4">
+      <!-- Firmware on system -->
+      <b-col md="10" lg="12" xl="8" class="pr-xl-4">
+        <page-section :section-title="$t('pageFirmware.firmwareOnSystem')">
+          <b-card-group deck>
+            <!-- Current FW -->
+            <b-card header-bg-variant="success">
+              <template v-slot:header>
+                <dl class="mb-0">
+                  <dt>{{ $t('pageFirmware.current') }}</dt>
+                  <dd class="mb-0">{{ systemFirmwareVersion }}</dd>
+                </dl>
+              </template>
+              <b-row>
+                <b-col xs="6">
+                  <dl class="my-0">
+                    <dt>{{ $t('pageFirmware.bmcStatus') }}</dt>
+                    <dd>{{ $t('pageFirmware.running') }}</dd>
+                  </dl>
+                </b-col>
+                <b-col xs="6">
+                  <dl class="my-0">
+                    <dt>{{ $t('pageFirmware.hostStatus') }}</dt>
+                    <dd v-if="hostStatus === 'on'">
+                      {{ $t('global.status.on') }}
+                    </dd>
+                    <dd v-else-if="hostStatus === 'off'">
+                      {{ $t('global.status.off') }}
+                    </dd>
+                    <dd v-else>
+                      {{ $t('global.status.notAvailable') }}
+                    </dd>
+                  </dl>
+                </b-col>
+              </b-row>
+            </b-card>
+
+            <!-- Backup FW -->
+            <b-card>
+              <template v-slot:header>
+                <dl class="mb-0">
+                  <dt>{{ $t('pageFirmware.backup') }}</dt>
+                  <dd class="mb-0">{{ backupFirmwareVersion }}</dd>
+                </dl>
+              </template>
+              <b-row>
+                <b-col xs="6">
+                  <dl class="my-0">
+                    <dt>{{ $t('pageFirmware.state') }}</dt>
+                    <dd>{{ backupFirmwareStatus }}</dd>
+                  </dl>
+                </b-col>
+              </b-row>
+            </b-card>
+          </b-card-group>
+        </page-section>
+
+        <!-- Change to backup image -->
+        <page-section :section-title="$t('pageFirmware.changeToBackupImage')">
+          <dl class="mb-5">
+            <dt>
+              {{ $t('pageFirmware.backupImage') }}
+            </dt>
+            <dd>{{ backupFirmwareVersion }}</dd>
+          </dl>
+          <b-btn
+            v-b-modal.modal-reboot-backup
+            type="button"
+            variant="primary"
+            :disabled="isPageDisabled || !isRebootFromBackupAvailable"
+          >
+            {{ $t('pageFirmware.changeAndRebootBmc') }}
+          </b-btn>
+        </page-section>
+      </b-col>
+
+      <!-- Update code -->
+      <b-col sm="8" xl="4" class="update-code pl-xl-4">
+        <page-section :section-title="$t('pageFirmware.updateCode')">
+          <b-form @submit.prevent="onSubmitUpload">
+            <b-form-group
+              :label="$t('pageFirmware.form.uploadLocation')"
+              :disabled="isPageDisabled"
+            >
+              <b-form-radio v-model="isWorkstationSelected" :value="true">
+                {{ $t('pageFirmware.form.workstation') }}
+              </b-form-radio>
+              <b-form-radio v-model="isWorkstationSelected" :value="false">
+                {{ $t('pageFirmware.form.tftpServer') }}
+              </b-form-radio>
+            </b-form-group>
+
+            <!-- Workstation Upload -->
+            <template v-if="isWorkstationSelected">
+              <b-form-group
+                :label="$t('pageFirmware.form.imageFile')"
+                label-for="image-file"
+              >
+                <b-form-text id="image-file-help-block">
+                  {{ $t('pageFirmware.form.onlyTarFilesAccepted') }}
+                </b-form-text>
+                <b-form-file
+                  id="image-file"
+                  v-model="file"
+                  accept=".tar"
+                  aria-describedby="image-file-help-block"
+                  :disabled="isPageDisabled"
+                  :state="getValidationState($v.file)"
+                  @input="$v.file.$touch()"
+                />
+                <b-form-invalid-feedback role="alert">
+                  {{ $t('global.form.required') }}
+                </b-form-invalid-feedback>
+              </b-form-group>
+            </template>
+
+            <!-- TFTP Server Upload -->
+            <template v-else>
+              <b-form-group
+                :label="$t('pageFirmware.form.tftpServerIpAddress')"
+                label-for="tftp-ip"
+              >
+                <b-form-input
+                  id="tftp-id"
+                  v-model="tftpIpAddress"
+                  type="text"
+                  :browse-text="$t('global.fileUpload.browseText')"
+                  :drop-placeholder="$t('global.fileUpload.dropPlaceholder')"
+                  :placeholder="$t('global.fileUpload.placeholder')"
+                  :state="getValidationState($v.tftpIpAddress)"
+                  :disabled="isPageDisabled"
+                  @input="$v.tftpIpAddress.$touch()"
+                />
+                <b-form-invalid-feedback role="alert">
+                  {{ $t('global.form.fieldRequired') }}
+                </b-form-invalid-feedback>
+              </b-form-group>
+              <b-form-group
+                :label="$t('pageFirmware.form.imageFileName')"
+                label-for="tftp-file-name"
+              >
+                <b-form-input
+                  id="tftp-file-name"
+                  v-model="tftpFileName"
+                  type="text"
+                  :state="getValidationState($v.tftpFileName)"
+                  :disabled="isPageDisabled"
+                  @input="$v.tftpFileName.$touch()"
+                />
+                <b-form-invalid-feedback role="alert">
+                  {{ $t('global.form.fieldRequired') }}
+                </b-form-invalid-feedback>
+              </b-form-group>
+            </template>
+
+            <!-- Info alert -->
+            <alert variant="info" class="mt-4 mb-5">
+              <p class="font-weight-bold mb-1">
+                {{ $t('pageFirmware.alert.updateProcess') }}
+              </p>
+              <p>{{ $t('pageFirmware.alert.updateProcessInfo') }}</p>
+            </alert>
+            <b-form-group>
+              <b-btn type="submit" variant="primary" :disabled="isPageDisabled">
+                {{ $t('pageFirmware.form.uploadAndRebootBmc') }}
+              </b-btn>
+            </b-form-group>
+          </b-form>
+        </page-section>
+      </b-col>
+    </b-row>
+
+    <!-- Modals -->
+    <modal-upload @ok="uploadFirmware" />
+    <modal-reboot-backup
+      :current="currentFirmwareVersion"
+      :backup="backupFirmwareVersion"
+      @ok="rebootFromBackup"
+    />
+  </b-container>
+</template>
+
+<script>
+import { requiredIf } from 'vuelidate/lib/validators';
+import { mapGetters } from 'vuex';
+
+import PageSection from '@/components/Global/PageSection';
+import PageTitle from '@/components/Global/PageTitle';
+import Alert from '@/components/Global/Alert';
+import ModalUpload from './FirmwareModalUpload';
+import ModalRebootBackup from './FirmwareModalRebootBackup';
+
+import VuelidateMixin from '@/components/Mixins/VuelidateMixin.js';
+import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin';
+import BVToastMixin from '@/components/Mixins/BVToastMixin';
+
+export default {
+  name: 'Firmware',
+  components: {
+    Alert,
+    ModalRebootBackup,
+    ModalUpload,
+    PageSection,
+    PageTitle
+  },
+  mixins: [BVToastMixin, LoadingBarMixin, VuelidateMixin],
+  data() {
+    return {
+      isWorkstationSelected: true,
+      file: null,
+      tftpIpAddress: null,
+      tftpFileName: null,
+      timeoutId: null
+    };
+  },
+  computed: {
+    hostStatus() {
+      return this.$store.getters['global/hostStatus'];
+    },
+    isHostOff() {
+      return this.hostStatus === 'off' ? true : false;
+    },
+    isOperationInProgress() {
+      return this.$store.getters['controls/isOperationInProgress'];
+    },
+    ...mapGetters('firmware', [
+      'backupFirmwareStatus',
+      'backupFirmwareVersion',
+      'isRebootFromBackupAvailable',
+      'systemFirmwareVersion'
+    ]),
+    isPageDisabled() {
+      return !this.isHostOff || this.loading || this.isOperationInProgress;
+    }
+  },
+  watch: {
+    isWorkstationSelected: function() {
+      this.$v.$reset();
+      this.file = null;
+      this.tftpIpAddress = null;
+      this.tftpFileName = null;
+    }
+  },
+  created() {
+    this.startLoader();
+    this.$store.dispatch('firmware/getUpdateServiceApplyTime');
+    Promise.all([
+      this.$store.dispatch('global/getHostStatus'),
+      this.$store.dispatch('firmware/getSystemFirwareVersion')
+    ]).finally(() => this.endLoader());
+  },
+  beforeRouteLeave(to, from, next) {
+    this.hideLoader();
+    this.clearRebootTimeout();
+    next();
+  },
+  validations() {
+    return {
+      file: {
+        required: requiredIf(function() {
+          return this.isWorkstationSelected;
+        })
+      },
+      tftpIpAddress: {
+        required: requiredIf(function() {
+          return !this.isWorkstationSelected;
+        })
+      },
+      tftpFileName: {
+        required: requiredIf(function() {
+          return !this.isWorkstationSelected;
+        })
+      }
+    };
+  },
+  methods: {
+    uploadFirmware() {
+      const startTime = this.$options.filters.formatTime(new Date());
+      this.setRebootTimeout(360000); //6 minute timeout
+      this.infoToast(
+        this.$t('pageFirmware.toast.infoUploadStartTimeMessage', { startTime }),
+        this.$t('pageFirmware.toast.infoUploadStartTimeTitle')
+      );
+      if (this.isWorkstationSelected) {
+        this.dispatchWorkstationUpload();
+      } else {
+        this.dispatchTftpUpload();
+      }
+    },
+    dispatchWorkstationUpload() {
+      this.$store
+        .dispatch('firmware/uploadFirmware', this.file)
+        .then(success =>
+          this.infoToast(
+            success,
+            this.$t('pageFirmware.toast.successUploadTitle')
+          )
+        )
+        .catch(({ message }) => {
+          this.errorToast(message);
+          this.clearRebootTimeout();
+        });
+    },
+    dispatchTftpUpload() {
+      const data = {
+        address: this.tftpIpAddress,
+        filename: this.tftpFileName
+      };
+      this.$store
+        .dispatch('firmware/uploadFirmwareTFTP', data)
+        .then(success =>
+          this.infoToast(
+            success,
+            this.$t('pageFirmware.toast.successUploadTitle')
+          )
+        )
+        .catch(({ message }) => {
+          this.errorToast(message);
+          this.clearRebootTimeout();
+        });
+    },
+    rebootFromBackup() {
+      this.setRebootTimeout();
+      this.$store
+        .dispatch('firmware/switchFirmwareAndReboot')
+        .then(success =>
+          this.infoToast(success, this.$t('global.status.success'))
+        )
+        .catch(({ message }) => {
+          this.errorToast(message);
+          this.clearRebootTimeout();
+        });
+    },
+    setRebootTimeout(timeoutMs = 60000) {
+      // Set a timeout to disable page interactions while
+      // an upload or BMC reboot is in progress
+      this.startLoader();
+      this.timeoutId = setTimeout(() => {
+        this.endLoader();
+        this.infoToast(
+          this.$t('pageFirmware.toast.infoRefreshApplicationMessage'),
+          this.$t('pageFirmware.toast.infoRefreshApplicationTitle')
+        );
+      }, timeoutMs);
+    },
+    clearRebootTimeout() {
+      if (this.timeoutId) {
+        clearTimeout(this.timeoutId);
+        this.endLoader();
+      }
+    },
+    onSubmitUpload() {
+      this.$v.$touch();
+      if (this.$v.$invalid) return;
+      this.$bvModal.show('modal-upload');
+    },
+    onClickShutDown() {
+      this.$bvModal
+        .msgBoxConfirm(this.$t('pageFirmware.modal.serverShutdownMessage'), {
+          title: this.$t('pageFirmware.modal.serverShutdownWillCauseOutage'),
+          okTitle: this.$t('pageFirmware.modal.shutDownServer'),
+          okVariant: 'danger'
+        })
+        .then(shutdownConfirmed => {
+          if (shutdownConfirmed)
+            this.$store.dispatch('controls/hostSoftPowerOff');
+        });
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.update-code {
+  border-left: none;
+  @include media-breakpoint-up(xl) {
+    border-left: 1px solid gray('300');
+  }
+}
+</style>
diff --git a/src/views/Configuration/Firmware/FirmwareModalRebootBackup.vue b/src/views/Configuration/Firmware/FirmwareModalRebootBackup.vue
new file mode 100644
index 0000000..a8fb3ad
--- /dev/null
+++ b/src/views/Configuration/Firmware/FirmwareModalRebootBackup.vue
@@ -0,0 +1,33 @@
+<template>
+  <b-modal
+    id="modal-reboot-backup"
+    :ok-title="$t('pageFirmware.modal.rebootFromBackup.primaryAction')"
+    :title="$t('pageFirmware.modal.rebootFromBackup.title')"
+    @ok="$emit('ok')"
+  >
+    <p>
+      {{ $t('pageFirmware.modal.rebootFromBackup.message1', { backup }) }}
+    </p>
+    <p>
+      {{ $t('pageFirmware.modal.rebootFromBackup.message2', { current }) }}
+    </p>
+    <p class="font-weight-bold">
+      {{ $t('pageFirmware.modal.rebootFromBackup.message3', { backup }) }}
+    </p>
+  </b-modal>
+</template>
+
+<script>
+export default {
+  props: {
+    current: {
+      type: String,
+      required: true
+    },
+    backup: {
+      type: String,
+      required: true
+    }
+  }
+};
+</script>
diff --git a/src/views/Configuration/Firmware/FirmwareModalUpload.vue b/src/views/Configuration/Firmware/FirmwareModalUpload.vue
new file mode 100644
index 0000000..d092bec
--- /dev/null
+++ b/src/views/Configuration/Firmware/FirmwareModalUpload.vue
@@ -0,0 +1,18 @@
+<template>
+  <b-modal
+    id="modal-upload"
+    :title="$t('pageFirmware.modal.uploadAndReboot.title')"
+    :ok-title="$t('pageFirmware.modal.uploadAndReboot.primaryAction')"
+    @ok="$emit('ok')"
+  >
+    <p>
+      {{ $t('pageFirmware.modal.uploadAndReboot.message1') }}
+    </p>
+    <p>
+      {{ $t('pageFirmware.modal.uploadAndReboot.message2') }}
+    </p>
+    <p class="font-weight-bold">
+      {{ $t('pageFirmware.modal.uploadAndReboot.message3') }}
+    </p>
+  </b-modal>
+</template>
diff --git a/src/views/Configuration/Firmware/index.js b/src/views/Configuration/Firmware/index.js
new file mode 100644
index 0000000..ad15cc0
--- /dev/null
+++ b/src/views/Configuration/Firmware/index.js
@@ -0,0 +1,2 @@
+import Firmware from './Firmware.vue';
+export default Firmware;