diff options
author | Gunnar Mills <gmills@us.ibm.com> | 2019-09-19 14:12:16 -0500 |
---|---|---|
committer | Gunnar Mills <gmills@us.ibm.com> | 2019-09-24 14:16:14 +0000 |
commit | 418db63c77aad03fe3401c7acd9f9792fab96a68 (patch) | |
tree | 79f995939cf688fe3d45c9b9b48931d7ca0afea8 /app/server-control | |
parent | 5ccf9e36c1da9b6396288c3cfe8d32dddfbb1a10 (diff) | |
download | phosphor-webui-418db63c77aad03fe3401c7acd9f9792fab96a68.tar.gz phosphor-webui-418db63c77aad03fe3401c7acd9f9792fab96a68.zip |
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>
Diffstat (limited to 'app/server-control')
-rw-r--r-- | app/server-control/controllers/virtual-media-controller.html | 38 | ||||
-rw-r--r-- | app/server-control/controllers/virtual-media-controller.js | 391 | ||||
-rw-r--r-- | app/server-control/index.js | 6 | ||||
-rw-r--r-- | app/server-control/styles/index.scss | 1 | ||||
-rw-r--r-- | app/server-control/styles/virtual-media.scss | 82 |
5 files changed, 518 insertions, 0 deletions
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 @@ window.angular && (function(angular) { '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; + } +} |