Virtual media page

Adds page to manage virtual media devices. User selects file
and pushes 'start' button to establish websocket connection.
nbdServerService added to provide ability for user to navigate
away from the page and return with the ability to see the current
active sessions.

Currently only supports 1 Virtual Media device.

Resolves openbmc/phosphor-webui#40

Tested: uploaded ubuntu image file, started the connection  and
  mounted virtual media device from host console. Able to see Ubuntu
  image file. Also uploaded file and navigated away from the page,
  checking that the websocket remained open and was sending /
  recieving messages.  Finally, tested that when connection
  was stopped, 'USB disconnect' log was present in host console.

Change-Id: Ia3155d27cbcfef94c2753dde1303a151e08847cc
Signed-off-by: beccabroek <beccabroek@gmail.com>
Signed-off-by: Gunnar Mills <gmills@us.ibm.com>
Signed-off-by: Derick Montague <derick.montague@ibm.com>
diff --git a/app/assets/images/crit-x-black.svg b/app/assets/images/crit-x-black.svg
new file mode 100644
index 0000000..e6b37ef
--- /dev/null
+++ b/app/assets/images/crit-x-black.svg
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 19.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 27.7 28" style="enable-background:new 0 0 27.7 28;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:none;stroke:#000000;stroke-width:3;stroke-miterlimit:10;}
+</style>
+<g id="Layer_1">
+	<path class="st0" d="M26.5,26.5C23.1,23.2,1.2,1.2,1.2,1.2"/>
+	<path class="st0" d="M26.5,1.2C23.1,4.5,1.2,26.5,1.2,26.5"/>
+</g>
+<g id="Layer_2">
+</g>
+</svg>
diff --git a/app/common/directives/app-navigation.html b/app/common/directives/app-navigation.html
index e54b236..f69bfc7 100644
--- a/app/common/directives/app-navigation.html
+++ b/app/common/directives/app-navigation.html
@@ -89,19 +89,21 @@
       <a href="#/server-control/remote-console" tabindex="14" ng-click="closeSubnav()">Serial over LAN console</a></li>
     <li ng-class="{'active': (path == '/server-control/kvm')}">
       <a href="#/server-control/kvm" tabindex="15" ng-click="closeSubnav()">KVM</a></li>
+    <li ng-class="{'active': (path == '/configuration' || path == '/configuration/virtual-media')}">
+      <a href="#/configuration/virtual-media" tabindex="16" ng-click="closeSubnav()">Virtual Media</a></li>
   </ul>
   <ul class="nav__second-level btn-firmware" ng-style="navStyle" ng-class="{opened: (showSubMenu && firstLevel == 'configuration')}">
     <li ng-class="{'active': (path == '/configuration' || path == '/configuration/network')}">
-      <a href="#/configuration/network" tabindex="16" ng-click="closeSubnav()">Network settings</a></li>
+      <a href="#/configuration/network" tabindex="17" ng-click="closeSubnav()">Network settings</a></li>
     <li ng-class="{'active': (path == '/configuration' || path == '/configuration/snmp')}">
-      <a href="#/configuration/snmp" tabindex="17" ng-click="closeSubnav()">SNMP settings</a></li>
+      <a href="#/configuration/snmp" tabindex="18" ng-click="closeSubnav()">SNMP settings</a></li>
     <li ng-class="{'active': (path == '/configuration' || path == '/configuration/firmware')}">
-      <a href="#/configuration/firmware" tabindex="18" ng-click="closeSubnav()">Firmware</a></li>
+      <a href="#/configuration/firmware" tabindex="19" ng-click="closeSubnav()">Firmware</a></li>
     <li ng-class="{'active': (path == '/configuration' || path == '/configuration/date-time')}">
-      <a href="#/configuration/date-time" tabindex="19" ng-click="closeSubnav()">Date and time settings</a></li>
+      <a href="#/configuration/date-time" tabindex="20" ng-click="closeSubnav()">Date and time settings</a></li>
   </ul>
   <ul class="nav__second-level btn-users" ng-style="navStyle" ng-class="{opened: (showSubMenu && firstLevel == 'users')}">
     <li ng-class="{'active': (path == '/users' || path == '/users/manage-accounts')}">
-      <a href="#/users/manage-accounts" tabindex="20" ng-click="closeSubnav()">Manage user account</a></li>
+      <a href="#/users/manage-accounts" tabindex="21" ng-click="closeSubnav()">Manage user accounts</a></li>
   </ul>
 </nav>
diff --git a/app/common/services/nbdServerService.js b/app/common/services/nbdServerService.js
new file mode 100644
index 0000000..835054c
--- /dev/null
+++ b/app/common/services/nbdServerService.js
@@ -0,0 +1,26 @@
+/**
+ * Network block device (NBD) Server service. Keeps all NBD connections.
+ *
+ * @module app/common/services/nbdServerService
+ * @exports nbdServerService
+ * @name nbdServerService
+
+ */
+
+window.angular && (function(angular) {
+  'use strict';
+
+  angular.module('app.common.services').service('nbdServerService', [
+    'Constants',
+    function(Constants) {
+      this.nbdServerMap = {};
+
+      this.addConnection = function(index, nbdServer, file) {
+        this.nbdServerMap[index] = {'server': nbdServer, 'file': file};
+      };
+      this.getExistingConnections = function(index) {
+        return this.nbdServerMap;
+      }
+    }
+  ]);
+})(window.angular);
diff --git a/app/common/styles/base/buttons.scss b/app/common/styles/base/buttons.scss
index 70b70cc..541d15e 100644
--- a/app/common/styles/base/buttons.scss
+++ b/app/common/styles/base/buttons.scss
@@ -1,9 +1,11 @@
-button, .button, .submit {
+button,
+.button,
+.submit {
   font-size: 1em;
   @include fontFamilyBold;
   text-transform: none;
   border-radius: 3px;
-  padding: .5rem 2rem .5rem;
+  padding: 0.5rem 2rem 0.5rem;
   height: auto;
   border: 0;
   overflow: hidden;
@@ -11,7 +13,12 @@
     cursor: pointer;
   }
   &.disabled {
+    pointer-events: none;
     color: $btn__disabled-txt;
+    background-color: $btn__disabled-bg;
+    border-color: $btn__disabled-bg;
+    border-style: solid;
+    border-width: 2px;
     &:hover {
       cursor: default;
       background: transparent;
@@ -36,14 +43,15 @@
       cursor: default;
     }
   }
-  i { //button symbol
+  i {
+    //button symbol
     font-style: normal;
     text-transform: none;
     font-size: 1.5em;
     transform: rotate(80deg);
     display: inline-block;
   }
-  img{
+  img {
     width: 18px;
     height: 18px;
     display: inline-block;
@@ -61,14 +69,18 @@
     @include fastTransition-all;
   }
   &.disabled {
-    border: 2px solid $lightgrey;
-    background: $btn__disabled-bg;
-    @include fastTransition-all;
+    pointer-events: none;
+    color: $btn__disabled-txt;
+    background-color: $btn__disabled-bg;
+    border-color: $btn__disabled-bg;
+    border-style: solid;
+    border-width: 2px;
     &:hover {
       background: $btn__disabled-bg;
     }
   }
-  i { // button symbol
+  i {
+    // button symbol
     font-style: normal;
     font-weight: 400;
     text-transform: none;
@@ -77,11 +89,11 @@
     display: inline-block;
     vertical-align: middle;
   }
-  img{
+  img {
     width: 18px;
     height: 18px;
     display: inline-block;
-    margin-right: .5em;
+    margin-right: 0.5em;
     margin-top: -3px;
   }
 }
@@ -94,7 +106,7 @@
     text-decoration: underline;
   }
   &:before {
-    content: '';
+    content: "";
     position: absolute;
     left: 0;
     top: 0px;
@@ -105,14 +117,14 @@
     border-top: 4px solid $black;
   }
   &:after {
-    content: '\2794';
+    content: "\2794";
     position: absolute;
     transform: rotate(-45deg);
-    font-size: .9em;
+    font-size: 0.9em;
     font-weight: 700;
     vertical-align: middle;
     display: inline-block;
     left: 11px;
     top: 0px;
   }
-}
\ No newline at end of file
+}
diff --git a/app/configuration/controllers/virtual-media-controller.html b/app/configuration/controllers/virtual-media-controller.html
new file mode 100644
index 0000000..22c4bf8
--- /dev/null
+++ b/app/configuration/controllers/virtual-media-controller.html
@@ -0,0 +1,36 @@
+<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 button 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);"></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="button btn-primary" ng-click="device.isActive? stopVM($index) : startVM($index)"/>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/app/configuration/controllers/virtual-media-controller.js b/app/configuration/controllers/virtual-media-controller.js
new file mode 100644
index 0000000..24e945a
--- /dev/null
+++ b/app/configuration/controllers/virtual-media-controller.js
@@ -0,0 +1,391 @@
+/**
+ * Controller for virtual-media
+ *
+ * @module app/configuration
+ * @exports virtualMediaController
+ * @name virtualMediaController
+ */
+
+window.angular && (function(angular) {
+  'use strict';
+
+  angular.module('app.configuration').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/configuration/index.js b/app/configuration/index.js
index b418295..db35ce6 100644
--- a/app/configuration/index.js
+++ b/app/configuration/index.js
@@ -35,6 +35,12 @@
                 'controller': 'snmpController',
                 authenticated: true
               })
+              .when('/configuration/virtual-media', {
+                'template':
+                    require('./controllers/virtual-media-controller.html'),
+                'controller': 'virtualMediaController',
+                authenticated: true
+              })
               .when('/configuration/firmware', {
                 'template': require('./controllers/firmware-controller.html'),
                 'controller': 'firmwareController',
diff --git a/app/configuration/styles/index.scss b/app/configuration/styles/index.scss
index e532583..f035580 100644
--- a/app/configuration/styles/index.scss
+++ b/app/configuration/styles/index.scss
@@ -2,3 +2,4 @@
 @import "./snmp.scss";
 @import "./date-time.scss";
 @import "./firmware.scss";
+@import "./virtual-media.scss";
diff --git a/app/configuration/styles/virtual-media.scss b/app/configuration/styles/virtual-media.scss
new file mode 100644
index 0000000..f7d75b8
--- /dev/null
+++ b/app/configuration/styles/virtual-media.scss
@@ -0,0 +1,86 @@
+.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: $white;
+  min-width: 3em;
+  &.disabled {
+    background-color: $btn__disabled-bg;
+  }
+}
+
+.vm__upload-content {
+  flex: 1 0 220px;
+  max-width: 640px;
+
+  .icon__exit {
+    float: right;
+    cursor: pointer;
+    width: 0.75em;
+    height: 0.75em;
+    margin: 0.3em 1em 0.3em 0.3em;
+    background-image: url(../assets/images/crit-x-black.svg);
+  }
+}
+
+.vm__upload-controls {
+  display: flex;
+  align-items: center;
+}
+
+.vm__upload-name {
+  flex: 1 0 220px;
+  background-color: $medgrey;
+  padding: 0.5em;
+}
+
+.vm__error-border,
+.vm__active-border {
+  border-top: 2px solid $error-color;
+  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;
+  }
+}
diff --git a/app/index.js b/app/index.js
index a0dde4d..3fbf64f 100644
--- a/app/index.js
+++ b/app/index.js
@@ -35,6 +35,7 @@
 import api_utils from './common/services/api-utils.js';
 import userModel from './common/services/userModel.js';
 import apiInterceptor from './common/services/apiInterceptor.js';
+import nbdServerService from './common/services/nbdServerService.js';
 
 import filters_index from './common/filters/index.js';
 
@@ -85,6 +86,7 @@
 import network_controller from './configuration/controllers/network-controller.js';
 import snmp_controller from './configuration/controllers/snmp-controller.js';
 import firmware_controller from './configuration/controllers/firmware-controller.js';
+import vm_controller from './configuration/controllers/virtual-media-controller.js';
 
 import users_index from './users/index.js';
 import user_accounts_controller from './users/controllers/user-accounts-controller.js';