Add VirtualMedia page

More info: https://github.com/openbmc/webui-vue/issues/7

Signed-off-by: Mateusz Gapski <mateuszx.gapski@intel.com>
Change-Id: I68f2074e77301c68c425f1e661988c751224b713
diff --git a/src/components/AppNavigation/AppNavigation.vue b/src/components/AppNavigation/AppNavigation.vue
index 5101d82..ef689d5 100644
--- a/src/components/AppNavigation/AppNavigation.vue
+++ b/src/components/AppNavigation/AppNavigation.vue
@@ -84,6 +84,9 @@
               >
                 {{ $t('appNavigation.serverPowerOperations') }}
               </b-nav-item>
+              <b-nav-item to="/control/virtual-media">
+                {{ $t('appNavigation.virtualMedia') }}
+              </b-nav-item>
             </b-collapse>
           </li>
 
diff --git a/src/locales/en-US.json b/src/locales/en-US.json
index 97f4578..01c3879 100644
--- a/src/locales/en-US.json
+++ b/src/locales/en-US.json
@@ -97,7 +97,8 @@
     "serverLed": "@:appPageTitle.serverLed",
     "serverPowerOperations": "@:appPageTitle.serverPowerOperations",
     "snmpSettings": "@:appPageTitle.snmpSettings",
-    "sslCertificates": "@:appPageTitle.sslCertificates"
+    "sslCertificates": "@:appPageTitle.sslCertificates",
+    "virtualMedia": "@:appPageTitle.virtualMedia"
   },
   "appPageTitle": {
     "changePassword": "Change password",
@@ -120,7 +121,8 @@
     "serverPowerOperations": "Server power operations",
     "snmpSettings": "SNMP settings",
     "sslCertificates": "SSL certificates",
-    "unauthorized": "Unauthorized"
+    "unauthorized": "Unauthorized",
+    "virtualMedia": "Virtual Media"
   },
   "pageChangePassword": {
     "changePassword": "Change password",
@@ -625,6 +627,18 @@
   "pageUnauthorized": {
     "description": "The attempted action is not accessible from the logged in account. Contact your system administrator to check your privilege role."
   },
+  "pageVirtualMedia": {
+    "configureConnection": "Configure Connection",
+    "virtualMediaSubTitleFirst": "Save image in a web browser",
+    "virtualMediaSubTitleSecond": "Load image from external server",
+    "defaultDeviceName": "Virtual media device",
+    "toast": {
+      "errorReadingFile": "Error reading file. Closing server.",
+      "serverRunning": "Server running",
+      "serverClosedSuccessfully": "Server closed successfully",
+      "serverClosedWithErrors": "Server closed with errors"
+    }
+  },
   "countries": {
     "AF": "Afghanistan",
     "AL": "Albania",
diff --git a/src/router/index.js b/src/router/index.js
index d3bf742..5db985c 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -176,6 +176,14 @@
         }
       },
       {
+        path: '/control/virtual-media',
+        name: 'virtual-media',
+        component: () => import('@/views/Control/VirtualMedia'),
+        meta: {
+          title: 'appPageTitle.virtualMedia'
+        }
+      },
+      {
         path: '/unauthorized',
         name: 'unauthorized',
         component: Unauthorized,
diff --git a/src/store/index.js b/src/store/index.js
index 2e7c97a..3844511 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -24,6 +24,7 @@
 
 import WebSocketPlugin from './plugins/WebSocketPlugin';
 import DateTimeStore from './modules/Configuration/DateTimeSettingsStore';
+import VirtualMediaStore from './modules/Control/VirtualMediaStore';
 
 Vue.use(Vuex);
 
@@ -52,7 +53,8 @@
     fan: FanStore,
     chassis: ChassisStore,
     bmc: BmcStore,
-    processors: ProcessorStore
+    processors: ProcessorStore,
+    virtualMedia: VirtualMediaStore
   },
   plugins: [WebSocketPlugin]
 });
diff --git a/src/store/modules/Control/ControlStore.js b/src/store/modules/Control/ControlStore.js
index 82dbdcc..c06ff4f 100644
--- a/src/store/modules/Control/ControlStore.js
+++ b/src/store/modules/Control/ControlStore.js
@@ -1,4 +1,4 @@
-import api from '../../api';
+import api from '@/store/api';
 import i18n from '../../../i18n';
 
 /**
diff --git a/src/store/modules/Control/VirtualMediaStore.js b/src/store/modules/Control/VirtualMediaStore.js
new file mode 100644
index 0000000..e01cfce
--- /dev/null
+++ b/src/store/modules/Control/VirtualMediaStore.js
@@ -0,0 +1,80 @@
+import api from '../../api';
+import i18n from '@/i18n';
+
+const VirtualMediaStore = {
+  namespaced: true,
+  state: {
+    proxyDevices: [],
+    legacyDevices: [],
+    connections: []
+  },
+  getters: {
+    proxyDevices: state => state.proxyDevices,
+    legacyDevices: state => state.legacyDevices
+  },
+  mutations: {
+    setProxyDevicesData: (state, deviceData) =>
+      (state.proxyDevices = deviceData),
+    setLegacyDevicesData: (state, deviceData) =>
+      (state.legacyDevices = deviceData)
+  },
+  actions: {
+    async getData({ commit }) {
+      const virtualMediaListEnabled =
+        process.env.VUE_APP_VIRTUAL_MEDIA_LIST_ENABLED === 'true'
+          ? true
+          : false;
+      if (!virtualMediaListEnabled) {
+        const device = {
+          id: i18n.t('pageVirtualMedia.defaultDeviceName'),
+          websocket: '/vm/0/0',
+          file: null,
+          transferProtocolType: 'OEM',
+          isActive: false
+        };
+        commit('setProxyDevicesData', [device]);
+        return;
+      }
+
+      return await api
+        .get('/redfish/v1/Managers/bmc/VirtualMedia')
+        .then(response =>
+          response.data.Members.map(virtualMedia => virtualMedia['@odata.id'])
+        )
+        .then(devices => api.all(devices.map(device => api.get(device))))
+        .then(devices => {
+          const deviceData = devices.map(device => {
+            return {
+              id: device.data?.Id,
+              transferProtocolType: device.data?.TransferProtocolType,
+              websocket: device.data?.Oem?.OpenBMC?.WebSocketEndpoint
+            };
+          });
+          const proxyDevices = deviceData
+            .filter(d => d.transferProtocolType === 'OEM')
+            .map(device => {
+              return {
+                ...device,
+                file: null,
+                isActive: false
+              };
+            });
+          const legacyDevices = deviceData
+            .filter(d => !d.transferProtocolType)
+            .map(device => {
+              return {
+                ...device,
+                address: ''
+              };
+            });
+          commit('setProxyDevicesData', proxyDevices);
+          commit('setLegacyDevicesData', legacyDevices);
+        })
+        .catch(error => {
+          console.log('Virtual Media:', error);
+        });
+    }
+  }
+};
+
+export default VirtualMediaStore;
diff --git a/src/utilities/NBDServer.js b/src/utilities/NBDServer.js
new file mode 100644
index 0000000..7c0419a
--- /dev/null
+++ b/src/utilities/NBDServer.js
@@ -0,0 +1,290 @@
+/* handshake flags */
+const NBD_FLAG_FIXED_NEWSTYLE = 0x1;
+const NBD_FLAG_NO_ZEROES = 0x2;
+
+/* transmission flags */
+const NBD_FLAG_HAS_FLAGS = 0x1;
+const NBD_FLAG_READ_ONLY = 0x2;
+
+/* option negotiation */
+const NBD_OPT_EXPORT_NAME = 0x1;
+const NBD_REP_FLAG_ERROR = 0x1 << 31;
+const NBD_REP_ERR_UNSUP = NBD_REP_FLAG_ERROR | 1;
+
+/* command definitions */
+const NBD_CMD_READ = 0;
+const NBD_CMD_WRITE = 1;
+const NBD_CMD_DISC = 2;
+const NBD_CMD_TRIM = 4;
+
+/* errno */
+const EPERM = 1;
+const EIO = 5;
+const EINVAL = 22;
+const ENOSPC = 28;
+
+/* internal object state */
+const NBD_STATE_UNKNOWN = 1;
+const NBD_STATE_OPEN = 2;
+const NBD_STATE_WAIT_CFLAGS = 3;
+const NBD_STATE_WAIT_OPTION = 4;
+const NBD_STATE_TRANSMISSION = 5;
+
+export default class NBDServer {
+  constructor(endpoint, file, id, token) {
+    this.socketStarted = () => {};
+    this.socketClosed = () => {};
+    this.errorReadingFile = () => {};
+    this.file = file;
+    this.id = id;
+    this.endpoint = endpoint;
+    this.ws = null;
+    this.state = NBD_STATE_UNKNOWN;
+    this.msgbuf = null;
+    this.start = function() {
+      this.ws = new WebSocket(this.endpoint, [token]);
+      this.state = NBD_STATE_OPEN;
+      this.ws.binaryType = 'arraybuffer';
+      this.ws.onmessage = this._on_ws_message.bind(this);
+      this.ws.onopen = this._on_ws_open.bind(this);
+      this.ws.onclose = this._on_ws_close.bind(this);
+      this.ws.onerror = this._on_ws_error.bind(this);
+      this.socketStarted();
+    };
+    this.stop = function() {
+      if (this.ws.readyState == 1) {
+        this.ws.close();
+        this.state = NBD_STATE_UNKNOWN;
+      }
+    };
+    this._on_ws_error = function(ev) {
+      console.log(`${endpoint} error: ${ev.error}`);
+      console.log(JSON.stringify(ev));
+    };
+    this._on_ws_close = function(ev) {
+      console.log(
+        `${endpoint} closed with code: ${ev.code} + reason: ${ev.reason}`
+      );
+      console.log(JSON.stringify(ev));
+      this.socketClosed(ev.code);
+    };
+    /* websocket event handlers */
+    this._on_ws_open = function() {
+      console.log(endpoint + ' opened');
+      this.client = {
+        flags: 0
+      };
+      this._negotiate();
+    };
+    this._on_ws_message = function(ev) {
+      var data = ev.data;
+      if (this.msgbuf == null) {
+        this.msgbuf = data;
+      } else {
+        const tmp = new Uint8Array(this.msgbuf.byteLength + data.byteLength);
+        tmp.set(new Uint8Array(this.msgbuf), 0);
+        tmp.set(new Uint8Array(data), this.msgbuf.byteLength);
+        this.msgbuf = tmp.buffer;
+      }
+      for (;;) {
+        var handler = this.recv_handlers[this.state];
+        if (!handler) {
+          console.log('no handler for state ' + this.state);
+          this.stop();
+          break;
+        }
+        var consumed = handler(this.msgbuf);
+        if (consumed < 0) {
+          console.log(
+            'handler[state=' + this.state + '] returned error ' + consumed
+          );
+          this.stop();
+          break;
+        }
+        if (consumed == 0) {
+          break;
+        }
+        if (consumed > 0) {
+          if (consumed == this.msgbuf.byteLength) {
+            this.msgbuf = null;
+            break;
+          }
+          this.msgbuf = this.msgbuf.slice(consumed);
+        }
+      }
+    };
+    this._negotiate = function() {
+      var buf = new ArrayBuffer(18);
+      var data = new DataView(buf, 0, 18);
+      /* NBD magic: NBDMAGIC */
+      data.setUint32(0, 0x4e42444d);
+      data.setUint32(4, 0x41474943);
+      /* newstyle negotiation: IHAVEOPT */
+      data.setUint32(8, 0x49484156);
+      data.setUint32(12, 0x454f5054);
+      /* flags: fixed newstyle negotiation, no padding */
+      data.setUint16(16, NBD_FLAG_FIXED_NEWSTYLE | NBD_FLAG_NO_ZEROES);
+      this.state = NBD_STATE_WAIT_CFLAGS;
+      this.ws.send(buf);
+    };
+    /* handlers */
+    this._handle_cflags = function(buf) {
+      if (buf.byteLength < 4) {
+        return 0;
+      }
+      var data = new DataView(buf, 0, 4);
+      this.client.flags = data.getUint32(0);
+      this.state = NBD_STATE_WAIT_OPTION;
+      return 4;
+    };
+    this._handle_option = function(buf) {
+      if (buf.byteLength < 16) return 0;
+      var data = new DataView(buf, 0, 16);
+      if (data.getUint32(0) != 0x49484156 || data.getUint32(4) != 0x454f5054) {
+        console.log('invalid option magic');
+        return -1;
+      }
+      var opt = data.getUint32(8);
+      var len = data.getUint32(12);
+      if (buf.byteLength < 16 + len) {
+        return 0;
+      }
+      switch (opt) {
+        case NBD_OPT_EXPORT_NAME:
+          var n = 10;
+          if (!(this.client.flags & NBD_FLAG_NO_ZEROES)) n += 124;
+          var resp = new ArrayBuffer(n);
+          var view = new DataView(resp, 0, 10);
+          /* export size. */
+          var size = this.file.size;
+          // eslint-disable-next-line prettier/prettier
+          view.setUint32(0, Math.floor(size / (2 ** 32)));
+          view.setUint32(4, size & 0xffffffff);
+          /* transmission flags: read-only */
+          view.setUint16(8, NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY);
+          this.ws.send(resp);
+          this.state = NBD_STATE_TRANSMISSION;
+          break;
+        default:
+          console.log('handle_option: Unsupported option: ' + opt);
+          /* reject other options */
+          var resp1 = new ArrayBuffer(20);
+          var view1 = new DataView(resp1, 0, 20);
+          view1.setUint32(0, 0x0003e889);
+          view1.setUint32(4, 0x045565a9);
+          view1.setUint32(8, opt);
+          view1.setUint32(12, NBD_REP_ERR_UNSUP);
+          view1.setUint32(16, 0);
+          this.ws.send(resp1);
+      }
+      return 16 + len;
+    };
+    this._create_cmd_response = function(req, rc, data = null) {
+      var len = 16;
+      if (data) len += data.byteLength;
+      var resp = new ArrayBuffer(len);
+      var view = new DataView(resp, 0, 16);
+      view.setUint32(0, 0x67446698);
+      view.setUint32(4, rc);
+      view.setUint32(8, req.handle_msB);
+      view.setUint32(12, req.handle_lsB);
+      if (data) new Uint8Array(resp, 16).set(new Uint8Array(data));
+      return resp;
+    };
+    this._handle_cmd = function(buf) {
+      if (buf.byteLength < 28) {
+        return 0;
+      }
+      var view = new DataView(buf, 0, 28);
+      if (view.getUint32(0) != 0x25609513) {
+        console.log('invalid request magic');
+        return -1;
+      }
+      var req = {
+        flags: view.getUint16(4),
+        type: view.getUint16(6),
+        handle_msB: view.getUint32(8),
+        handle_lsB: view.getUint32(12),
+        offset_msB: view.getUint32(16),
+        offset_lsB: view.getUint32(20),
+        length: view.getUint32(24)
+      };
+      /* we don't support writes, so nothing needs the data at present */
+      /* req.data = buf.slice(28); */
+      var err = 0;
+      var consumed = 28;
+      /* the command handlers return 0 on success, and send their
+       * own response. Otherwise, a non-zero error code will be
+       * used as a simple error response
+       */
+      switch (req.type) {
+        case NBD_CMD_READ:
+          err = this._handle_cmd_read(req);
+          break;
+        case NBD_CMD_DISC:
+          err = this._handle_cmd_disconnect(req);
+          break;
+        case NBD_CMD_WRITE:
+          /* we also need length bytes of data to consume a write
+           * request */
+          if (buf.byteLength < 28 + req.length) {
+            return 0;
+          }
+          consumed += req.length;
+          err = EPERM;
+          break;
+        case NBD_CMD_TRIM:
+          err = EPERM;
+          break;
+        default:
+          console.log('invalid command 0x' + req.type.toString(16));
+          err = EINVAL;
+      }
+      if (err) {
+        console.log('error handle_cmd: ' + err);
+        var resp = this._create_cmd_response(req, err);
+        this.ws.send(resp);
+        if (err == ENOSPC) {
+          this.errorReadingFile();
+          this.stop();
+        }
+      }
+      return consumed;
+    };
+    this._handle_cmd_read = function(req) {
+      var offset;
+      // eslint-disable-next-line prettier/prettier
+      offset = (req.offset_msB * 2 ** 32) + req.offset_lsB;
+      if (offset > Number.MAX_SAFE_INTEGER) return ENOSPC;
+      if (offset + req.length > Number.MAX_SAFE_INTEGER) return ENOSPC;
+      if (offset + req.length > file.size) return ENOSPC;
+      var blob = this.file.slice(offset, offset + req.length);
+      var reader = new FileReader();
+
+      reader.onload = function(ev) {
+        var reader = ev.target;
+        if (reader.readyState != FileReader.DONE) return;
+        var resp = this._create_cmd_response(req, 0, reader.result);
+        this.ws.send(resp);
+      }.bind(this);
+
+      reader.onerror = function(ev) {
+        var reader = ev.target;
+        console.log('error reading file: ' + reader.error);
+        var resp = this._create_cmd_response(req, EIO);
+        this.ws.send(resp);
+      }.bind(this);
+      reader.readAsArrayBuffer(blob);
+      return 0;
+    };
+    this._handle_cmd_disconnect = function() {
+      this.stop();
+      return 0;
+    };
+    this.recv_handlers = Object.freeze({
+      [NBD_STATE_WAIT_CFLAGS]: this._handle_cflags.bind(this),
+      [NBD_STATE_WAIT_OPTION]: this._handle_option.bind(this),
+      [NBD_STATE_TRANSMISSION]: this._handle_cmd.bind(this)
+    });
+  }
+}
diff --git a/src/views/Control/VirtualMedia/VirtualMedia.vue b/src/views/Control/VirtualMedia/VirtualMedia.vue
new file mode 100644
index 0000000..a9a575d
--- /dev/null
+++ b/src/views/Control/VirtualMedia/VirtualMedia.vue
@@ -0,0 +1,153 @@
+<template>
+  <b-container fluid="xl">
+    <page-title />
+    <b-row class="mb-4">
+      <b-col md="12">
+        <page-section
+          :section-title="$t('pageVirtualMedia.virtualMediaSubTitleFirst')"
+        >
+          <b-row>
+            <b-col v-for="(dev, $index) in proxyDevices" :key="$index" md="6">
+              <b-form-group
+                :label="dev.id"
+                :label-for="dev.id"
+                label-class="bold"
+              >
+                <b-form-file
+                  v-show="!dev.isActive"
+                  :id="dev.id"
+                  v-model="dev.file"
+                />
+                <p v-if="dev.isActive">{{ dev.file.name }}</p>
+              </b-form-group>
+              <b-button
+                v-if="!dev.isActive"
+                variant="primary"
+                :disabled="!dev.file"
+                @click="startVM(dev)"
+              >
+                {{ 'Start' }}
+              </b-button>
+              <b-button
+                v-if="dev.isActive"
+                variant="primary"
+                :disabled="!dev.file"
+                @click="stopVM(dev)"
+              >
+                {{ 'Stop' }}
+              </b-button>
+            </b-col>
+          </b-row>
+        </page-section>
+      </b-col>
+    </b-row>
+    <b-row v-if="loadImageFromExternalServer" class="mb-4">
+      <b-col md="12">
+        <page-section
+          :section-title="$t('pageVirtualMedia.virtualMediaSubTitleSecond')"
+        >
+          <b-row>
+            <b-col
+              v-for="(device, $index) in legacyDevices"
+              :key="$index"
+              md="6"
+            >
+              <b-form-group
+                :label="device.id"
+                :label-for="device.id"
+                label-class="bold"
+              >
+                <b-button variant="primary" @click="configureConnection()">
+                  {{ $t('pageVirtualMedia.configureConnection') }}
+                </b-button>
+
+                <b-button
+                  variant="primary"
+                  class="float-right"
+                  :disabled="!device.address"
+                  @click="startLegacy(device)"
+                >
+                  {{ 'Start' }}
+                </b-button>
+              </b-form-group>
+            </b-col>
+          </b-row>
+        </page-section>
+      </b-col>
+    </b-row>
+  </b-container>
+</template>
+
+<script>
+import PageTitle from '@/components/Global/PageTitle';
+import PageSection from '@/components/Global/PageSection';
+import BVToastMixin from '@/components/Mixins/BVToastMixin';
+import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin';
+import NbdServer from '@/utilities/NBDServer';
+
+export default {
+  name: 'VirtualMedia',
+  components: { PageTitle, PageSection },
+  mixins: [BVToastMixin, LoadingBarMixin],
+  data() {
+    return {
+      loadImageFromExternalServer:
+        process.env.VUE_APP_VIRTUAL_MEDIA_LIST_ENABLED === 'true' ? true : false
+    };
+  },
+  computed: {
+    proxyDevices() {
+      return this.$store.getters['virtualMedia/proxyDevices'];
+    },
+    legacyDevices() {
+      return this.$store.getters['virtualMedia/legacyDevices'];
+    }
+  },
+  created() {
+    if (this.proxyDevices.length > 0 || this.legacyDevices.length > 0) return;
+    this.startLoader();
+    this.$store
+      .dispatch('virtualMedia/getData')
+      .finally(() => this.endLoader());
+  },
+  methods: {
+    startVM(device) {
+      const token = this.$store.getters['authentication/token'];
+      device.nbd = new NbdServer(
+        `wss://${window.location.host}${device.websocket}`,
+        device.file,
+        device.id,
+        token
+      );
+      device.nbd.socketStarted = () =>
+        this.successToast(this.$t('pageVirtualMedia.toast.serverRunning'));
+      device.nbd.errorReadingFile = () =>
+        this.errorToast(this.$t('pageVirtualMedia.toast.errorReadingFile'));
+      device.nbd.socketClosed = code => {
+        if (code === 1000)
+          this.successToast(
+            this.$t('pageVirtualMedia.toast.serverClosedSuccessfully')
+          );
+        else
+          this.errorToast(
+            this.$t('pageVirtualMedia.toast.serverClosedWithErrors')
+          );
+        device.file = null;
+        device.isActive = false;
+      };
+
+      device.nbd.start();
+      device.isActive = true;
+    },
+    stopVM(device) {
+      device.nbd.stop();
+    },
+    startLegacy() {
+      console.log('starting legacy...');
+    },
+    configureConnection() {
+      this.warningToast('This option is unavialable. We are working on it.');
+    }
+  }
+};
+</script>
diff --git a/src/views/Control/VirtualMedia/index.js b/src/views/Control/VirtualMedia/index.js
new file mode 100644
index 0000000..4573e86
--- /dev/null
+++ b/src/views/Control/VirtualMedia/index.js
@@ -0,0 +1,2 @@
+import VirtualMedia from './VirtualMedia.vue';
+export default VirtualMedia;