Move Virtual Media files and route

"Virtual media" was already part of the "Server control" submenu but
the files were at app/configuration and the route was /configuration.

Marta Mazur did a similar file and route move here as part of
supporting multiple Virtual Media endpoints:
https://gerrit.openbmc-project.xyz/c/openbmc/phosphor-webui/+/25218

Tested: Built and loaded on a Witherspoon. The VM route is now:
        #/server-control/virtual-media. Was able to use the VM
        panel.
Change-Id: Iec46a8ece7ddfbc356bc1e71875def70c4418027
Signed-off-by: Gunnar Mills <gmills@us.ibm.com>
diff --git a/app/server-control/controllers/virtual-media-controller.html b/app/server-control/controllers/virtual-media-controller.html
new file mode 100644
index 0000000..e756986
--- /dev/null
+++ b/app/server-control/controllers/virtual-media-controller.html
@@ -0,0 +1,38 @@
+<primary>
+  <h1>Virtual media</h1>
+  <p class="vm__page-description"  ng-if="devices.length >= 1">Specify image file location to start session.</p>
+  <p ng-if="devices.length < 1">
+    There are no Virtual Media devices available.
+  </primary>
+  <div ng-repeat="device in devices track by $index" class="vm__upload">
+    <h2 class="h3">{{device.deviceName}}</h2>
+    <div class="vm__upload-chooser">
+      <!-- name and error message -->
+      <div class="vm__upload-content">
+        <div class="vm__upload-controls">
+          <!-- Button -->
+          <label class="vm__upload-choose-label">
+            <input id="file-upload" type="file" file="device.file" class="hide" ng-disabled="device.isActive"/>
+            <span class="vm__upload-choose-button btn btn-secondary" ng-class="{disabled:device.isActive}">Choose file</span>
+          </label>
+          <div class="vm__upload-name">
+            <span ng-if="!device.file">No file selected</span>
+            <span ng-if="device.file.name !== undefined">{{device.file.name}}</span>
+            <span class="icon__exit" ng-if="device.file && !device.isActive" ng-click="resetFile($index);">
+              <icon file="icon-close.svg"></icon>
+            </span>
+          </div>
+        </div>
+        <div class="vm__active-text vm__active-border" ng-if="device.isActive">
+          <span>Active Session</span>
+        </div>
+        <div class="vm__active-text vm__error-border" ng-if="device.hasError">
+          <span>Error in connecting to the selected file</span>
+        </div>
+      </div>
+      <div class="vm__upload-start">
+        <input type="button" ng-value="device.isActive ? 'Stop' : 'Start'" ng-class="{disabled:!device.file}" class="btn btn-primary" ng-click="device.isActive? stopVM($index) : startVM($index)"/>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/app/server-control/controllers/virtual-media-controller.js b/app/server-control/controllers/virtual-media-controller.js
new file mode 100644
index 0000000..19c7e73
--- /dev/null
+++ b/app/server-control/controllers/virtual-media-controller.js
@@ -0,0 +1,391 @@
+/**
+ * Controller for virtual-media
+ *
+ * @module app/serverControl
+ * @exports virtualMediaController
+ * @name virtualMediaController
+ */
+
+window.angular && (function(angular) {
+  'use strict';
+
+  angular.module('app.serverControl').controller('virtualMediaController', [
+    '$scope', 'APIUtils', 'toastService', 'dataService', 'nbdServerService',
+    function($scope, APIUtils, toastService, dataService, nbdServerService) {
+      $scope.devices = [];
+
+      // Only one Virtual Media WebSocket device is currently available.
+      // Path is /vm/0/0.
+      // TODO: Support more than 1 VM device, when backend support is added.
+      var vmDevice = {};
+      // Hardcode to 0 since /vm/0/0. Last 0 is the device ID.
+      // To support more than 1 device ID, replace with a call to get the
+      // device IDs and names.
+      vmDevice.id = 0;
+      vmDevice.deviceName = 'Virtual media device';
+      findExistingConnection(vmDevice);
+      $scope.devices.push(vmDevice);
+
+      $scope.startVM = function(index) {
+        $scope.devices[index].isActive = true;
+        var file = $scope.devices[index].file;
+        var id = $scope.devices[index].id;
+        var host = dataService.getHost().replace('https://', '');
+        var server = new NBDServer('wss://' + host + '/vm/0/' + id, file, id);
+        $scope.devices[index].nbdServer = server;
+        nbdServerService.addConnection(id, server, file);
+        server.start();
+      };
+      $scope.stopVM = function(index) {
+        $scope.devices[index].isActive = false;
+        var server = $scope.devices[index].nbdServer;
+        server.stop();
+      };
+
+      $scope.resetFile = function(index) {
+        document.getElementById('file-upload').value = '';
+        $scope.devices[index].file = '';
+      };
+
+      function findExistingConnection(vmDevice) {
+        // Checks with existing connections kept in nbdServerService for an open
+        // Websocket connection.
+        var existingConnectionsMap = nbdServerService.getExistingConnections();
+        if (existingConnectionsMap.hasOwnProperty(vmDevice.id)) {
+          // Open ws will have a ready state of 1
+          if (existingConnectionsMap[vmDevice.id].server.ws.readyState === 1) {
+            vmDevice.isActive = true;
+            vmDevice.file = existingConnectionsMap[vmDevice.id].file;
+            vmDevice.nbdServer = existingConnectionsMap[vmDevice.id].server;
+          }
+        }
+        return vmDevice;
+      }
+    }
+  ]);
+})(angular);
+
+/* 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;
+
+function NBDServer(endpoint, file, id) {
+  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);
+    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.stop = function() {
+    this.ws.close();
+    this.state = NBD_STATE_UNKNOWN;
+  };
+
+  this._on_ws_error = function(ev) {
+    console.log('vm/0/' + id + 'error: ' + ev);
+  };
+
+  this._on_ws_close = function(ev) {
+    console.log(
+        'vm/0/' + id + ' closed with code: ' + ev.code +
+        ' reason: ' + ev.reason);
+  };
+
+  /* websocket event handlers */
+  this._on_ws_open = function(ev) {
+    console.log('vm/0/' + id + ' 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 {
+      var 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;
+        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 resp = new ArrayBuffer(20);
+        var view = new DataView(resp, 0, 20);
+        view.setUint32(0, 0x0003e889);
+        view.setUint32(4, 0x045565a9);
+        view.setUint32(8, opt);
+        view.setUint32(12, NBD_REP_ERR_UNSUP);
+        view.setUint32(16, 0);
+        this.ws.send(resp);
+    }
+
+    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);
+    }
+
+    return consumed;
+  };
+
+  this._handle_cmd_read = function(req) {
+    var offset;
+
+    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(req) {
+    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/app/server-control/index.js b/app/server-control/index.js
index 1b8aad5..8bf03b4 100644
--- a/app/server-control/index.js
+++ b/app/server-control/index.js
@@ -53,6 +53,12 @@
                 'controller': 'kvmController',
                 authenticated: true
               })
+              .when('/server-control/virtual-media', {
+                'template':
+                    require('./controllers/virtual-media-controller.html'),
+                'controller': 'virtualMediaController',
+                authenticated: true
+              })
               .when('/server-control', {
                 'template':
                     require('./controllers/power-operations-controller.html'),
diff --git a/app/server-control/styles/index.scss b/app/server-control/styles/index.scss
index 7171a7e..cd91860 100644
--- a/app/server-control/styles/index.scss
+++ b/app/server-control/styles/index.scss
@@ -3,3 +3,4 @@
 @import "./remote-console.scss";
 @import "./server-led.scss";
 @import "./kvm.scss";
+@import "./virtual-media.scss";
diff --git a/app/server-control/styles/virtual-media.scss b/app/server-control/styles/virtual-media.scss
new file mode 100644
index 0000000..4070e34
--- /dev/null
+++ b/app/server-control/styles/virtual-media.scss
@@ -0,0 +1,82 @@
+.vm__page-description {
+  margin-top: 1em;
+  margin-bottom: 3em;
+}
+
+.vm__upload {
+  align-items: center;
+  margin-bottom: 2em;
+}
+
+.vm__upload-chooser {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: baseline;
+  margin: 0.7em 0.7em 0.7em 0;
+}
+.vm__upload-choose-label {
+  flex-grow: 0;
+  flex-shrink: 0;
+  flex-basis: auto;
+  margin-right: 4px;
+}
+
+.vm__upload-choose-button {
+  padding: 0.5em 0.75em;
+  font-size: 1rem;
+  background-color: $primary-light;
+  min-width: 3em;
+}
+
+.vm__upload-content {
+  flex: 1 0 220px;
+  max-width: 640px;
+
+  .icon__exit {
+    float: right;
+    cursor: pointer;
+    width: 0.75em;
+    height: 0.75em;
+    margin: 0 1em 0.3em 0.3em;
+  }
+}
+
+.vm__upload-controls {
+  display: flex;
+  align-items: center;
+}
+
+.vm__upload-name {
+  flex: 1 0 220px;
+  background-color: $background-03;
+  padding: 0.5em;
+}
+
+.vm__error-border,
+.vm__active-border {
+  border-top: 2px solid $status-error;
+  margin-top: 4px;
+  padding-top: 4px;
+}
+.vm__active-border {
+  border-color: $status-ok;
+}
+
+.vm__active-text {
+  color: $status-ok;
+  font-size: 0.8rem;
+  margin-top: 4px;
+}
+
+.vm__upload-start {
+  flex-grow: 0;
+  flex-shrink: 0;
+  flex-basis: 100%;
+  margin-top: 1em;
+
+  @media screen and (min-width: 760px) {
+    flex-basis: auto;
+    margin-top: 0;
+    margin-left: 0.75em;
+  }
+}