From 621cadb9db1b2e5c6b4223e22ffb68029fe7880f Mon Sep 17 00:00:00 2001 From: Andrey Yatskov Date: Fri, 17 Jun 2016 21:55:47 +0400 Subject: [PATCH] ADMINUI-2311 Volumes UI --- less/__vms-list.less | 7 +- less/app.less | 1 + less/vm.less | 5 +- less/volumes.less | 27 ++ lib/adminui.js | 39 ++- lib/auth.js | 10 +- lib/sdc-clients.js | 4 + lib/volumes.js | 98 ++++++ www/js/components/error-alert.jsx | 28 +- www/js/components/filter-form.jsx | 77 ++++- www/js/components/ip.jsx | 2 +- www/js/components/localnav.jsx | 15 +- www/js/components/multi-nic-config.jsx | 4 +- www/js/components/pages/user/index.jsx | 1 - www/js/components/pages/vm/index.jsx | 3 +- www/js/components/pages/volumes/index.jsx | 89 ++++++ www/js/components/pages/volumes/list.jsx | 233 ++++++++++++++ www/js/components/pages/volumes/networks.jsx | 75 +++++ .../components/pages/volumes/volume-form.jsx | 285 ++++++++++++++++++ www/js/components/pages/volumes/volume.jsx | 208 +++++++++++++ www/js/components/user-link.jsx | 31 +- www/js/models/network.js | 3 +- www/js/models/packages.js | 7 +- www/js/models/user.js | 53 ++-- www/js/models/volume.js | 8 + www/js/models/volumes.js | 9 + www/js/router.js | 25 +- 27 files changed, 1251 insertions(+), 96 deletions(-) create mode 100644 less/volumes.less create mode 100644 lib/volumes.js create mode 100644 www/js/components/pages/volumes/index.jsx create mode 100644 www/js/components/pages/volumes/list.jsx create mode 100644 www/js/components/pages/volumes/networks.jsx create mode 100644 www/js/components/pages/volumes/volume-form.jsx create mode 100644 www/js/components/pages/volumes/volume.jsx create mode 100644 www/js/models/volume.js create mode 100644 www/js/models/volumes.js diff --git a/less/__vms-list.less b/less/__vms-list.less index 16e6bbcb..956b4df4 100644 --- a/less/__vms-list.less +++ b/less/__vms-list.less @@ -42,7 +42,8 @@ } .__vms-list { - .vms-list-header { + .vms-list-header, + .list-header { .make-row(0); background: @state-info-bg; .select { @@ -139,7 +140,7 @@ text-transform: uppercase; &.stopped { background-color: @gray-light; color: #fff; } &.failed { background-color: @brand-danger; color: #fff; } - &.running { background-color: @brand-success; color: #fff; } - &.destroyed { background-color: @gray-dark; color: #fff; } + &.running, &.ready { background-color: @brand-success; color: #fff; } + &.destroyed, &.unknown { background-color: @gray-dark; color: #fff; } } } diff --git a/less/app.less b/less/app.less index 117bbe94..03395b88 100644 --- a/less/app.less +++ b/less/app.less @@ -39,6 +39,7 @@ @import "vm.less"; @import "vms.less"; +@import "volumes.less"; @import "image.less"; @import "settings.less"; diff --git a/less/vm.less b/less/vm.less index 66a12fb8..fe86f879 100644 --- a/less/vm.less +++ b/less/vm.less @@ -49,7 +49,7 @@ section.fwrules { .dropdown-menu .delete { color: red; } .resource-status { - .running { background: @brand-success; } + .running, .ready { background: @brand-success; } .failed { background: @brand-danger; } .stopped { background: @gray-light; } } @@ -123,7 +123,8 @@ section.fwrules { .server-uuid, .owner-uuid, .billing-id, - .image-uuid { + .image-uuid, + .volume-uuid { .uuid; .pull-right; } diff --git a/less/volumes.less b/less/volumes.less new file mode 100644 index 00000000..eb5598de --- /dev/null +++ b/less/volumes.less @@ -0,0 +1,27 @@ + +@import "vm"; +@import "networks"; +@import "__vms-list"; + +.volumes-list { + .__vms-list; + td.status {width: 90px;} + a.delete {color: @state-danger-text; text-decoration: none; font-weight: bold; visibility: hidden; float: right;} + tr:hover .actions a, .actions:hover a {visibility: visible;} +} + +#page-volume { + #page-vm; +} +.networks-list { + #page-networks > .networks-list; +} + +#page-volume-form { + .help-block { + margin-bottom: 0; + } + .control-label.required { + font-weight: 600; + } +} diff --git a/lib/adminui.js b/lib/adminui.js index 1492d673..6bfaf560 100644 --- a/lib/adminui.js +++ b/lib/adminui.js @@ -38,6 +38,13 @@ function resume(req, res, next) { next(); } +function checkVolApiConfig(req, res, next) { + if (req.sdc[req.dc].hasOwnProperty('volapi')) { + return next(); + } + next(new restify.ServiceUnavailableError('Cannot connect to VOLAPI service. Please verify configuration.')); +} + function setUfds(req, res, next) { var trySetUfds = function () { if (req.ufdsMaster.connected) { @@ -176,8 +183,6 @@ ADMINUI.prototype.createServer = function () { }); }); - - server.use(sdcClients.handler); server.use((function attachOtherClients(req, res, next) { @@ -283,6 +288,32 @@ ADMINUI.prototype.createServer = function () { auth.requireRole('operators'), vms.del); + var volumes = require('./volumes'); + server.get('/api/volumes', + auth.requireAuth, + auth.requireRole('operators'), + checkVolApiConfig, + volumes.list); + + server.get('/api/volumes/:uuid', + auth.requireAuth, + auth.requireRole('operators'), + checkVolApiConfig, + volumes.get); + + server.post('/api/volumes', + auth.requireAuth, + auth.requireRole('operators'), + checkVolApiConfig, + resume, + bodyParser, + volumes.create); + + server.del('/api/volumes/:uuid', + auth.requireAuth, + auth.requireRole('operators'), + checkVolApiConfig, + volumes.del); var jobs = require('./jobs'); server.get('/api/jobs', auth.requireAuth, jobs.listJobs); @@ -323,8 +354,6 @@ ADMINUI.prototype.createServer = function () { images.aclAction); - - var packages = require('./packages'); server.get('/api/packages', auth.requireAuth, packages.list); server.get('/api/packages/:uuid', auth.requireAuth, packages.get); @@ -909,7 +938,7 @@ ADMINUI.prototype.createServer = function () { } var method = req.method; var body = - !(method === 'POST' && route.name === 'authenticate') && + !(method === 'POST' && route && route.name === 'authenticate') && !(method === 'GET' && Math.floor(res.statusCode/100) === 2); auditLogger({ log: req.log.child({ component: 'audit', route: route && route.name }, true), diff --git a/lib/auth.js b/lib/auth.js index 762e1cd2..804abba3 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -18,7 +18,8 @@ var LOCKED_TIME = 4 * 60 * 60; var userLockTimes = {}; var userLoginAttempts = {}; -var MANTA_ENALBED = require('../etc/config.json')['manta'] || false; +var config = require('../etc/config.json'); +var MANTA_ENABLED = config.manta || false; var Auth = module.exports = {}; Auth.optionalAuth = function optionalAuth(req, res, next) { @@ -189,10 +190,12 @@ Auth.authenticate = function authenticate(req, res, next) { return next(new restify.ConflictError('User does not have permission to use Operations Portal.')); } + var isVolapiEnabled = !!(config.datacenters[req.dc] && config.datacenters[req.dc].volapi); req.sessions.create({ roles: user.groups(), user: user.uuid, - manta: MANTA_ENALBED + manta: MANTA_ENABLED, + volapiEnabled: isVolapiEnabled }, function (err2, token) { if (err2) { req.log.info(err2, 'error creating session'); @@ -203,7 +206,8 @@ Auth.authenticate = function authenticate(req, res, next) { user: user, token: token, adminUuid: req.adminUuid, - manta: MANTA_ENALBED, + manta: MANTA_ENABLED, + volapiEnabled: isVolapiEnabled, dc: req.dc, roles: user.groups() }); diff --git a/lib/sdc-clients.js b/lib/sdc-clients.js index a0a893e7..377de3ba 100644 --- a/lib/sdc-clients.js +++ b/lib/sdc-clients.js @@ -76,6 +76,10 @@ module.exports.createClients = function (config) { clients[dc].imgapi = new IMGAPI(cfg.imgapi); clients[dc].papi = new SDC.PAPI(cfg.papi); + if (cfg.volapi) { + clients[dc].volapi = restify.createJsonClient(cfg.volapi); + } + cfg.moray.no_count = true; clients[dc].moray = createMorayClient({ diff --git a/lib/volumes.js b/lib/volumes.js new file mode 100644 index 00000000..39b82f05 --- /dev/null +++ b/lib/volumes.js @@ -0,0 +1,98 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright (c) 2016, Joyent, Inc. + */ + +var sprintf = require('util').format; + +var getVolumeParams = function (params) { + var query = params.query || []; + + if (params.type && params.type.length) { + query.push(sprintf('(type=*%s*)', params.type)); + delete params.type; + } + + if (params.state && params.state.length) { + query.push(sprintf('(state=%s)', params.state)); + delete params.state; + } + + if (params.tag && params.tag.length) { + query.push(sprintf('(tag=%s)', params.tag)); + delete params.tag; + } + + if (query.length) { + params.filter = '(&' + query.join('') + ')'; + } + + return params; +}; + +function list(req, res, next) { + var params = getVolumeParams(req.params); + var opts = { + path: '/volumes', + query: params + }; + req.sdc[req.dc].volapi.get(opts, function (err, _req, _res, volumes) { + if (err) { + req.log.error(err, 'Error retrieving volumes'); + return next(err); + } + if (params.uuid && params.uuid.length === 36) { + volumes = volumes.filter(function (volume) { + return volume.uuid === params.uuid; + }); + } + res.send(volumes); + return next(); + }); +} + +function get(req, res, next) { + req.sdc[req.dc].volapi.get('/volumes/' + req.params.uuid, function (err, _req, _res, volume) { + if (err) { + req.log.error(err, 'Error retrieving volume'); + return next(err); + } + res.send(volume); + return next(); + }); +} + +function create(req, res, next) { + req.log.info(req.body, 'VOLAPI Create Request'); + req.sdc[req.dc].volapi.post('/volumes', req.body, function (err, _req, _res, volume) { + if (err) { + req.log.error(err, 'Error creating volume'); + return next(err); + } + res.send(volume); + return next(); + }); +} + +function del(req, res, next) { + req.sdc[req.dc].volapi.del('/volumes/' + req.params.uuid, function (err, _req, _res, volume) { + if (err) { + req.log.error(err, 'Error deleting volume'); + return next(err); + } + res.send(volume); + return next(); + }); +} + +module.exports = { + list: list, + get: get, + create: create, + del: del +}; diff --git a/www/js/components/error-alert.jsx b/www/js/components/error-alert.jsx index 6cc37976..f3a8aa55 100644 --- a/www/js/components/error-alert.jsx +++ b/www/js/components/error-alert.jsx @@ -5,30 +5,32 @@ */ /* - * Copyright (c) 2014, Joyent, Inc. + * Copyright (c) 2016, Joyent, Inc. */ -"use strict"; +'use strict'; var React = require('react'); var ErrorAlert = React.createClass({ - propTypes: { - error: React.PropTypes.object - }, - render: function() { + render: function () { var error = this.props.error; - - if (!error || !error.code) { - return
; + if (!error) { + return
; } + var errorMessage = error.message || (error.errors ? null : error); + return (
{ - error.message ?
{error.message}
: '' + errorMessage ?
{errorMessage}
: '' } { - error.errors && error.errors.length ? : '' + error.errors && error.errors.length ? : '' }
); } diff --git a/www/js/components/filter-form.jsx b/www/js/components/filter-form.jsx index a5f4ce28..671a6dca 100644 --- a/www/js/components/filter-form.jsx +++ b/www/js/components/filter-form.jsx @@ -8,13 +8,27 @@ var utils = require('../lib/utils'); var NicTags = require('../models/nictags'); var FabricVlans = require('../models/fabrics-vlans'); var INPUT_TYPES = {}; +var DEFAULT_WIDTH = { + uuid: 4, + owner_uuid: 4, + alias: 3, + name: 3, + package_name: 3, + state: 3, + tag: 3, + type: 3, + volume_state: 3, + ip: 2, + nic_tag: 2, + vlan_id: 2 +}; INPUT_TYPES.uuid = React.createClass({ onChange: function (e) { this.props.onChange('uuid', e.target.value); }, render: function () { - return (
+ return (
); @@ -26,7 +40,7 @@ INPUT_TYPES.alias = React.createClass({ this.props.onChange('alias', e.target.value); }, render: function () { - return (
+ return (
); @@ -42,7 +56,7 @@ INPUT_TYPES.package_name = React.createClass({ this.typeahead.remove(); }, render: function () { - return (
+ return (
); @@ -54,20 +68,43 @@ INPUT_TYPES.name = React.createClass({ this.props.onChange('name', e.target.value); }, render: function () { - return (
+ return (
); } }); +INPUT_TYPES.tag = React.createClass({ + onChange: function (e) { + this.props.onChange('tag', e.target.value); + }, + render: function () { + return (
+ + +
); + } +}); + +INPUT_TYPES.type = React.createClass({ + onChange: function (e) { + this.props.onChange('type', e.target.value); + }, + render: function () { + return (
+ + +
); + } +}); INPUT_TYPES.ip = React.createClass({ onChange: function (e) { this.props.onChange('ip', e.target.value); }, render: function () { - return (
+ return (
); @@ -106,7 +143,7 @@ INPUT_TYPES.state = React.createClass({ this.props.onChange('state', e.target.value); }, render: function () { - return (
+ return (
+ + + + +
); + } +}); + INPUT_TYPES.nic_tag = React.createClass({ getInitialState: function () { return { @@ -138,7 +191,7 @@ INPUT_TYPES.nic_tag = React.createClass({ }); }, render: function () { - return (
+ return (
@@ -197,7 +250,8 @@ var FilterForm = React.createClass({ return { values: this.props.initialParams || {}, types: this.props.types || Object.keys(INPUT_TYPES), - buttonTitle: this.props.buttonTitle || 'Search' + buttonTitle: this.props.buttonTitle || 'Search', + typeWidth: this.props.typeWidth || {} }; }, _onChange: function (prop, value) { @@ -215,10 +269,13 @@ var FilterForm = React.createClass({ if (InputType) { var value = this.state.values[type] || ''; var data = type === 'vlan_id' ? {owner_uuid : self.state.values.owner_uuid} : null; + var width = this.state.typeWidth[type] || DEFAULT_WIDTH[type]; if (type === 'package_name') { value = this.state.values['billing_id'] || ''; + } else if (type === 'volume_state') { + value = this.state.values['state'] || ''; } - return ; + return ; } return; }, this); diff --git a/www/js/components/ip.jsx b/www/js/components/ip.jsx index 84db6837..b23be1ef 100644 --- a/www/js/components/ip.jsx +++ b/www/js/components/ip.jsx @@ -33,7 +33,7 @@ var Ip = React.createClass({ isLoaded: true, error: error || '' }); - } + }; if (ipParts.length === 4 && ipParts[3].length) { this.setState({ isLoaded: false diff --git a/www/js/components/localnav.jsx b/www/js/components/localnav.jsx index 8a03e57e..de57b239 100644 --- a/www/js/components/localnav.jsx +++ b/www/js/components/localnav.jsx @@ -20,7 +20,7 @@ var SecondaryNav = React.createClass({ handleMenuSelect: React.PropTypes.func.isRequired, active: React.PropTypes.string }, - _clickedMenuItem: function(e) { + _clickedMenuItem: function (e) { if (e.metaKey || e.ctrlKey) { return; } @@ -37,12 +37,12 @@ var SecondaryNav = React.createClass({ return; } }, - _classesFor: function(v) { + _classesFor: function (v) { return cx({ active: (this.props.active === v) }); }, - mantaNav: function() { + mantaNav: function () { if (adminui.state.get('manta')) { return [
  • Storage (Manta)
  • , @@ -50,17 +50,22 @@ var SecondaryNav = React.createClass({ ]; } }, - render: function() { + render: function () { return (
    • Compute
    • Dashboard
    • Virtual Machines
    • + + {adminui.state.get('volapiEnabled') && +
    • Volumes
    • + } +
    • Servers
    • Images
    • Packages
    • - { this.mantaNav() } + {this.mantaNav()}
    • Infrastructure
    • Networking
    • diff --git a/www/js/components/multi-nic-config.jsx b/www/js/components/multi-nic-config.jsx index 666a9788..cac78704 100644 --- a/www/js/components/multi-nic-config.jsx +++ b/www/js/components/multi-nic-config.jsx @@ -83,7 +83,7 @@ var MultipleNicConfigComponent = React.createClass({ selectedIps[currentNic.ip] = true; } result[uuid] = currentNic; - }, this) + }, this); this.setState({nics: result, selectedIps: selectedIps}, function () { if (this.props.onChange) { this.props.onChange(); @@ -120,7 +120,7 @@ var MultipleNicConfigComponent = React.createClass({ var nic = this.state.nics[uuid]; var filter = function (network) { return network.uuid === nic.network_uuid; - } + }; if (networks.some(filter) || networkPools.some(filter)) { nics[uuid] = nic; } diff --git a/www/js/components/pages/user/index.jsx b/www/js/components/pages/user/index.jsx index 003f9063..47767583 100644 --- a/www/js/components/pages/user/index.jsx +++ b/www/js/components/pages/user/index.jsx @@ -94,7 +94,6 @@ var PageUser = React.createClass({ }); this.fetchUser(); } - console.log('componentWillReceiveProps', props); }, componentDidMount: function () { diff --git a/www/js/components/pages/vm/index.jsx b/www/js/components/pages/vm/index.jsx index b62c9516..b3765ac9 100644 --- a/www/js/components/pages/vm/index.jsx +++ b/www/js/components/pages/vm/index.jsx @@ -185,8 +185,7 @@ var VMPage = React.createClass({ {vm.alias || vm.uuid} - {vm.uuid} - + {vm.uuid} diff --git a/www/js/components/pages/volumes/index.jsx b/www/js/components/pages/volumes/index.jsx new file mode 100644 index 00000000..af160b2c --- /dev/null +++ b/www/js/components/pages/volumes/index.jsx @@ -0,0 +1,89 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright (c) 2016, Joyent, Inc. + */ + +var React = require('react'); +var app = require('../../../adminui'); +var api = require('../../../request'); +var utils = require('../../../lib/utils'); + +var VolumesList = require('./list'); +var FilterForm = require('./../../filter-form'); + +var Volumes = require('../../../models/volumes'); +var Volume = require('../../../models/volume'); + +var VolumesPage = React.createClass({ + statics: { + sidebar: 'volumes', + url: function () { + var url = 'volumes'; + return location.pathname === '/volumes' ? (url + location.search || '') : url; + } + }, + getInitialState: function () { + return { + filterTypes: ['uuid', 'name', 'owner_uuid'], + typeWidth: {uuid: 3}, + form: true + }; + }, + componentWillMount: function () { + this.collection = new Volumes(); + this.query(this.props.params); + app.vent.trigger('settitle', 'volumes'); + }, + showForm: function () { + app.vent.trigger('showcomponent', 'volume-form'); + }, + render: function () { + var state = this.state; + return ( +
      +
      +

      Volumes + {app.user.role('operators') ? +
      + +
      : null + } +

      +
      + +
      +
      +
      + +
      +
      +
      + +
      +
      +
      + +
      +
      +
      +
      + ); + }, + + query: function (params) { + this.collection.params = utils.getVmSearchParams(params); + this.collection.fetch({ + reset: true + }); + } +}); + +module.exports = VolumesPage; diff --git a/www/js/components/pages/volumes/list.jsx b/www/js/components/pages/volumes/list.jsx new file mode 100644 index 00000000..76220fb4 --- /dev/null +++ b/www/js/components/pages/volumes/list.jsx @@ -0,0 +1,233 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright (c) 2016, Joyent, Inc. + */ + +var React = require('react'); +var _ = require('underscore'); +var adminui = require('../../../adminui'); +var utils = require('../../../lib/utils'); +var UserLink = require('../../user-link'); +var User = require('../../../models/user'); +var BB = require('../../bb'); +var PaginationView = require('../../../views/pagination'); +var JSONExport = require('../../json-export'); +var Volumes = require('../../../models/volumes'); +var Promise = require('promise'); + +var NOT_AVAILABLE = 'N/A'; +var NOT_EXPORTED_OWNER_FIELDS = [ + 'controls', + 'memberof', + 'dn', + 'pwdchangedtime', + 'pwdendtime', + 'registered_developer', + 'approved_for_provisioning', + 'memberof', + 'objectclass' +]; +var usersCache = {}; + +var fetchOwner = function (owner_uuid) { + return new Promise(function (resolve) { + if (usersCache[owner_uuid]) { + resolve(usersCache[owner_uuid]); + return; + } + + var user = new User({uuid: owner_uuid}); + user.fetch().done(function () { + usersCache[owner_uuid] = user; + resolve(user); + }).fail(function () { + resolve({}); + }); + }); +}; + +var VolumesList = React.createClass({ + getInitialState: function () { + var state = { + volumes: this.props.volumes || new Volumes() + }; + state.loading = !state.volumes.fetched; + return state; + }, + componentDidMount: function () { + var volumes = this.state.volumes; + volumes.on('reset', this.onSync, this); + volumes.on('add', this.onSync, this); + volumes.on('remove', this.onSync, this); + volumes.on('error', function (model, xhr) { + var errorDetails = ''; + if (xhr.statusText === 'timeout') { + errorDetails = 'Connection timed out - please try again.'; + } else { + try { + errorDetails = JSON.parse(xhr.responseText).message; + } catch (ex) { + errorDetails = ex; + } + } + adminui.vent.trigger('notification', { + level: 'error', + message: 'Failed to fetch volumes: ' + errorDetails + }); + this.setState({loading: false}); + }, this); + }, + onSync: function () { + var self = this; + var volumes = this.state.volumes; + var owners = _.uniq(_.pluck(volumes.toJSON(), 'owner_uuid')); + Promise.all(owners.map(fetchOwner)).done(function (owners) { + self.setState({volumes: volumes, owners: owners, loading: false}); + }); + }, + showVolume: function (volume) { + adminui.vent.trigger('showcomponent', 'volume', {volume: volume}); + }, + navigateToOwnerDetailsPage: function (user) { + adminui.vent.trigger('showcomponent', 'user', {user: user}); + }, + deleteVolume: function (model, event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + var volume = model.toJSON(); + var confirm = window.confirm( + _.str.sprintf('Are you sure you want to delete the volume "%s" ?', volume.name) + ); + if (confirm) { + var self = this; + model.set({'deleting': true}); + this.setState({volumes: self.state.volumes}); + model.destroy({contentType: 'application/json', data: JSON.stringify(volume), wait: true}).done(function () { + var notifyMsg = _.str.sprintf('Volume %s has been deleted successfully.', volume.name); + adminui.vent.trigger('notification', { + level: 'success', + message: notifyMsg + }); + }).fail(function (error) { + model.set({'deleting': false}); + self.setState({volumes: self.state.volumes}); + adminui.vent.trigger('notification', { + level: 'error', + message: error.responseData.message + }); + }); + } + }, + renderVolume: function (model) { + var volume = model.toJSON(); + var state = volume.state || null; + + var actions = volume.deleting ? + ( +
      Deleting
      + ) : + ( + Delete + ); + + return ( + + {state ? + + {state} + : null + } + + {volume.name || volume.uuid} + {volume.uuid} + + + {volume.reserved_size / 1024} GB + + + {volume.type} + + +
      + +
      + + {this.props.showActions && adminui.user.role('operators') && actions} + + ); + }, + handleExport: function () { + var state = this.state; + var volumes = state.volumes.toJSON(); + var owners = state.owners; + var volumesGroupedByOwner = _.groupBy(volumes, function (volume) { + return volume.owner_uuid; + }); + var exported = owners.map(function (owner) { + owner = owner.toJSON(); + if (owner.cn !== NOT_AVAILABLE) { + NOT_EXPORTED_OWNER_FIELDS.forEach(function (key) { + delete owner[key]; + }); + owner.created_at = new Date(Number(owner.created_at)); + owner.updated_at = new Date(Number(owner.updated_at)); + } + owner.volumes = volumesGroupedByOwner[owner.uuid]; + return owner; + }); + this.setState({exported: exported}); + }, + handleCloseExport: function () { + this.setState({exported: false}); + }, + render: function () { + var volumes = this.state.volumes; + if (this.state.loading) { + return ( +
      +
      Retrieving Volumes
      +
      + ); + } + + if (!volumes.length) { + return ( +
      +
      No Volumes were found matching specified criteria
      +
      + ); + } + + return ( +
      +
      +
      + Showing {volumes.length} of {volumes.objectCount} Volumes
      +
      + +
      + + {volumes.map(this.renderVolume, this)} +
      + + { + this.state.exported ?
      + +
      : null + } +
      + ); + } +}); + +module.exports = VolumesList; diff --git a/www/js/components/pages/volumes/networks.jsx b/www/js/components/pages/volumes/networks.jsx new file mode 100644 index 00000000..5704776c --- /dev/null +++ b/www/js/components/pages/volumes/networks.jsx @@ -0,0 +1,75 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright (c) 2016, Joyent, Inc. + */ + +var React = require('react'); +var NetworksList = require('../networking/networks-list'); +var Networks = require('../../../models/networks'); + +var VolumeNetworksList = React.createClass({ + propTypes: { + networks: React.PropTypes.array.isRequired + }, + getInitialState: function () { + return { + loading: true, + networkFilters: this.props.owner ? {provisionable_by: this.props.owner} : {} + }; + }, + componentDidMount: function () { + var networks = new Networks(); + var self = this; + var req = networks.fetch({params: this.state.networkFilters}); + req.fail(function (xhr) { + self.setState({ + loading: false, + error: xhr.responseText + }); + }); + + req.done(function () { + networks.reset(networks.fullCollection.filter(function (network) { + return self.props.networks.indexOf(network.get('uuid')) !== -1; + })); + self.setState({ + error: null, + loading: false, + networks: networks + }); + }); + }, + render: function () { + if (this.state.loading) { + return
      +
      + Fetching Networks +
      +
      ; + } + + if (this.state.error) { + return
      +
      +

      Failed to retrieve networks.

      +

      {this.state.error}

      +
      +
      ; + } + + return
      +

      Networks

      + +
      ; + } +}); + +module.exports = VolumeNetworksList; + diff --git a/www/js/components/pages/volumes/volume-form.jsx b/www/js/components/pages/volumes/volume-form.jsx new file mode 100644 index 00000000..b2d9cbb1 --- /dev/null +++ b/www/js/components/pages/volumes/volume-form.jsx @@ -0,0 +1,285 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright (c) 2016, Joyent, Inc. + */ + +var React = require('react'); +var Chosen = require('react-chosen'); +var _ = require('underscore'); +var PropTypes = React.PropTypes; + +var app = require('../../../adminui'); +var api = require('../../../request'); +var UserInput = require('../../../views/typeahead-user'); +var ServerTypeahead = require('../../server-typeahead'); +var Networks = require('../../../models/networks'); +var ErrorAlert = require('../../error-alert'); +var Packages = require('../../../models/packages'); + +var defaultHandler = function () { + return app.vent.trigger('showcomponent', 'volumes'); +}; + +var VolumeForm = React.createClass({ + propTypes: { + handleSave: PropTypes.func, + handleCancel: PropTypes.func + }, + statics: { + sidebar: 'volumes' + }, + getDefaultProps: function () { + return { + handleCancel: defaultHandler, + handleSave: defaultHandler, + cancelButton: 'Back to Volumes', + creating: false + }; + }, + getInitialState: function () { + return { + userNetworks: {}, + networks: [], + packages: [], + type: 'tritonnfs', + sizeMeasure: 'MB' + }; + }, + componentDidMount: function () { + var self = this; + var packages = new Packages(); + packages.fetch({ + params: { + name: 'sdc_volume_nfs*', + active: true + } + }).done(function () { + self.setState({packages: packages}); + }).fail(function (xhr) { + self.setState({error: xhr.responseText}); + }); + this.networks = new Networks(); + this.userInput = new UserInput({ + accountsOnly: true, + el: React.findDOMNode(self.refs.owner) + }); + + this.userInput.render(); + this.userInput.on('selected', self.onSelectUser, self); + }, + onSelectUser: function () { + var userId = this.userInput.selectedUser && this.userInput.selectedUser.id; + userId = userId && userId.length === 36 ? userId : null; + var ownerUuid = this.state.owner_uuid; + this.setState({ + loading: true, + owner_uuid: userId, + networks: [] + }); + + if (userId && userId !== ownerUuid) { + var self = this; + if (this.state.userNetworks[userId]) { + return self.setState({ + error: null, + loading: false, + networks: self.state.userNetworks[userId] + }); + } + var req = this.networks.fetch({params: { + provisionable_by: userId, + fabric: true + }}); + req.fail(function (xhr) { + self.setState({ + loading: false, + error: xhr.responseText + }); + }); + + req.done(function () { + self.state.userNetworks[userId] = self.networks; + self.setState({ + error: null, + loading: false, + userNetworks: self.state.userNetworks + }); + }); + } + }, + onSelectNetwork: function (e, data) { + if (data.selected) { + this.state.networks.push(data.selected); + } else if (data.deselected) { + this.state.networks = _.without(this.state.networks, data.deselected) + } + this.setState({networks: this.state.networks}); + }, + renderNetworkSelect: function () { + var networks = this.networks.fullCollection.toJSON(); + return ( + + + { + networks.map(function (network) { + return (); + }) + } + + ); + }, + onSelectPackage: function (e, data) { + this.setState({size: data.selected}); + }, + renderPackageSelect: function () { + var packages = this.state.packages.toJSON(); + return ( + + + { + packages.map(function (pkg) { + return (); + }) + } + + ); + }, + render: function () { + var state = this.state; + return ( +
      +
      +

      Provision Volume

      +
      +
      +
      + {state.error && } + +
      + +
      + +
      +
      + +
      + +
      + +
      +
      + +
      + +
      + {state.packages.length ? this.renderPackageSelect() : null} +
      +
      + +
      + +
      + +
      +
      + +
      + +
      + + Server to provision volume to (optional) +
      +
      + + {state.owner_uuid ? +
      + + +
      + {state.loading ? +
      + Retrieving Networks +
      : this.renderNetworkSelect() + } +
      +
      : null + } + +
      +
      + {state.creating ?
      Creating volume
      : +
      + + +
      + } +
      +
      + +
      +
      + ); + }, + _handleCancel: function () { + this.props.handleCancel(); + }, + _handleSave: function (e) { + e.preventDefault(); + var state = this.state; + var data = { + name: state.name, + type: state.type, + owner_uuid: state.owner_uuid, + networks: state.networks + }; + if (state.size) { + data.size = state.size + state.sizeMeasure; + } + if (state.server) { + data.server_uuid = state.server; + } + + this.setState({error: null, creating: true}); + api.post('/api/volumes').send(data).end(function (res) { + if (res.error) { + this.setState({error: res.body, creating: false}); + return; + } + if (res.ok) { + app.vent.trigger('notification', { + level: 'success', + message: _.str.sprintf('Volume %s has been created successfully', state.name) + }); + this.props.handleSave(state); + } + }.bind(this)); + }, + _handleChangeName: function (e) { + this.setState({name: e.target.value}); + }, + _handleChangeServer: function (server) { + this.setState({server: server}); + }, + _handleChangeSize: function (e) { + this.setState({size: e.target.value}); + }, + _handleChangeSizeMeasure: function (e) { + this.setState({sizeMeasure: e.target.value}); + } +}); + +module.exports = VolumeForm; diff --git a/www/js/components/pages/volumes/volume.jsx b/www/js/components/pages/volumes/volume.jsx new file mode 100644 index 00000000..3f687712 --- /dev/null +++ b/www/js/components/pages/volumes/volume.jsx @@ -0,0 +1,208 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright (c) 2016, Joyent, Inc. + */ + +'use strict'; + +var React = require('react'); +var moment = require('moment'); +var adminui = require('../../../adminui'); +var VolumeModel = require('../../../models/volume'); +var Metadata = require('./../vm/metadata'); +var VolumeNetworks = require('./networks'); +var UserTile = require('../../user-tile'); + +var VolumePage = React.createClass({ + statics: { + sidebar: 'volumes', + url: function (props) { + var data = props.uuid || props.volume; + + if (data && typeof data === 'object') { + data = data.get('uuid'); + } + + return _.str.sprintf('/volumes/%s', data); + } + }, + getInitialState: function () { + var volume = this.props.volume; + var isVolumeObject = volume && typeof volume === 'object'; + return { + volume: isVolumeObject ? volume : new VolumeModel({uuid: this.props.uuid || volume}), + loading: !isVolumeObject + }; + }, + componentDidMount: function () { + if (this.state.loading) { + this.fetchVolume(); + } + }, + fetchVolume: function () { + var req = this.state.volume.fetch(); + req.fail(function (xhr) { + this.setState({ + loading: false, + error: xhr.responseText, + volume: this.state.volume + }); + }.bind(this)); + + req.done(function () { + this.setState({ + error: null, + loading: false, + volume: this.state.volume + }); + }.bind(this)); + }, + navigateToVmPage: function (e) { + var vmUuid = e && e.target && e.target.getAttribute('data-vm-uuid'); + if (e.metaKey || e.ctrlKey || !vmUuid) { + return; + } + e.preventDefault(); + adminui.router.showVm(vmUuid); + }, + render: function () { + if (this.state.loading) { + return
      +
      + Fetching Volume +
      +
      ; + } + + if (this.state.error) { + return
      +
      +

      Volume can not be retrieved

      +

      {this.state.error}

      +
      +
      ; + } + + if (!this.state.volume) { + return
      +
      +

      Volume Not Found

      +
      +

      The Volume with ID {this.props.uuid} can not be found.

      +
      ; + } + + var volume = this.state.volume.toJSON(); + var state = volume.state || null; + + return
      +
      + {state &&
      {state}
      } +

      + {volume.name || volume.uuid}  + {volume.uuid} +

      +
      +
      +
      +
      + + + + + + + {volume.reserved_size ? + + + + + : null + } + + + + + + + {volume.filesystem_path ? + + + + + : null + } + + + + + + + {volume.vm_uuid ? + + + + + : null + } +
      Name + + {volume.name || volume.uuid} + + {volume.uuid} +
      Size{volume.reserved_size / 1024} GB
      Type + {volume.type} +
      FS Path{volume.filesystem_path}
      Created + + {volume.create_timestamp && moment(volume.create_timestamp).utc().format('D MMMM, YYYY HH:mm:ss z')} + +
      Virtual Machine + {volume.vm_uuid} +
      +
      +
      +
      + +
      +
      + +
      +
      +
      +
      + + {volume.networks && volume.networks.length ? +
      +
      +
      +
      + +
      +
      +
      +
      : null + } + + {volume.tags && Object.keys(volume.tags).length ? +
      +
      +
      +

      Tags

      +
      +
      +
      +
      : null + } + +
      ; + } +}); + +module.exports = VolumePage; diff --git a/www/js/components/user-link.jsx b/www/js/components/user-link.jsx index 351b76ee..e37ee3f1 100644 --- a/www/js/components/user-link.jsx +++ b/www/js/components/user-link.jsx @@ -18,21 +18,23 @@ var UserLink = React.createClass({ 'userUuid': React.PropTypes.string.isRequired, 'handleClick': React.PropTypes.func }, - getInitialState: function() { + getInitialState: function () { return { - user: {}, - loaded: false + user: this.props.user || {}, + loaded: !!this.props.user }; }, - componentDidMount: function() { - var user = this.user = new User({uuid: this.props.userUuid}); - var self = this; - var req = this.user.fetch(); - req.done(function() { - self.setState({user: user, loaded: true}); - }); + componentDidMount: function () { + var user = this.user = this.props.user || new User({uuid: this.props.userUuid}); + if (!this.props.user) { + var self = this; + var req = this.user.fetch(); + req.done(function () { + self.setState({user: user, loaded: true}); + }); + } }, - handleClick: function(e) { + handleClick: function (e) { if (e.metaKey || e.ctrlKey) { return; } @@ -41,21 +43,22 @@ var UserLink = React.createClass({ this.props.handleClick(this.user); } }, - render: function() { + render: function () { var userIcon = this.props.icon ? : null; if (this.state.loaded) { var user = this.state.user.toJSON(); + user.cn = user.cn || user.givenname + ' ' + user.sn; + var company = this.props.company && user.company && user.company.length ?
      { this.props.icon ? : null}
      {user.company}
      : null; - return
      { userIcon } - {user.cn} + {user.cn} { company }
      ; } else { diff --git a/www/js/models/network.js b/www/js/models/network.js index 9e7e9574..554b4c42 100644 --- a/www/js/models/network.js +++ b/www/js/models/network.js @@ -8,7 +8,6 @@ * Copyright (c) 2015, Joyent, Inc. */ -var Backbone = require('backbone'); var Model = require('./model'); var api = require('../request'); @@ -18,5 +17,5 @@ module.exports = Model.extend({ update: function(attrs, cb) { api.put(this.url()).send(attrs).end(cb); - }, + } }); diff --git a/www/js/models/packages.js b/www/js/models/packages.js index 66df7489..9aba4f22 100644 --- a/www/js/models/packages.js +++ b/www/js/models/packages.js @@ -5,18 +5,17 @@ */ /* - * Copyright (c) 2014, Joyent, Inc. + * Copyright (c) 2016, Joyent, Inc. */ -var Backbone = require('backbone'); var Package = require('./package'); var Collection = require('./collection'); var Packages = Collection.extend({ model: Package, url: '/api/packages', - fetchActive: function() { - return this.fetch({params:{active: true}}); + fetchActive: function () { + return this.fetch({params: {active: true}}); } }); diff --git a/www/js/models/user.js b/www/js/models/user.js index 633b7357..94c56789 100644 --- a/www/js/models/user.js +++ b/www/js/models/user.js @@ -5,10 +5,10 @@ */ /* - * Copyright (c) 2014, Joyent, Inc. + * Copyright (c) 2016, Joyent, Inc. */ -"use strict"; +'use strict'; /** * models/user */ @@ -19,7 +19,7 @@ var api = require('../request'); var User = module.exports = Model.extend({ - url: function() { + url: function () { if (this.get('uuid') && this.get('account')) { return _.str.sprintf('/api/users/%s/%s', this.get('account'), this.get('uuid')); } @@ -30,54 +30,53 @@ var User = module.exports = Model.extend({ }, urlRoot: '/api/users', idAttribute: 'uuid', - parse: function(resp) { + parse: function (resp) { var data = Model.prototype.parse.apply(this, arguments); data.groups = this.parseGroups(data.memberof); return data; }, - parseGroups: function(memberof) { - return _.map(memberof, function(dn) { + parseGroups: function (memberof) { + return _.map(memberof, function (dn) { var p = dn.match(/^cn=(\w+)/); return p[1]; }); }, - authenticated: function() { + authenticated: function () { return window.localStorage.getItem('api-token') !== null; }, - getToken: function() { + getToken: function () { return window.localStorage.getItem('api-token'); }, - getAdminUuid: function() { + getAdminUuid: function () { return window.localStorage.getItem('admin-uuid'); }, - role: function(r) { + role: function (r) { return this.getRoles().indexOf(r) >= 0; }, - getRoles: function() { + getRoles: function () { var roles = window.localStorage.getItem('user-roles'); return JSON.parse(roles); }, - getManta: function() { - var m = window.localStorage.getItem('manta'); - if (m === "true") { - return true; - } else { - return false; - } + getManta: function () { + return window.localStorage.getItem('manta') === 'true'; }, - getDatacenter: function() { + isVolapiEnabled: function () { + return window.localStorage.getItem('volapi-enabled') === 'true'; + }, + + getDatacenter: function () { return window.localStorage.getItem('dc'); }, - authenticate: function(user, pass) { + authenticate: function (user, pass) { var self = this; if (user.length === 0 || pass.length === 0) { @@ -90,12 +89,11 @@ var User = module.exports = Model.extend({ password: pass }; - var req = api.post("/api/auth"); + var req = api.post('/api/auth'); req.timeout(10000); req.send(authData); - req.end(function(err, res) { + req.end(function (err, res) { if (err) { - console.log('auth error', err); if (err.timeout) { self.trigger('error', 'Server Error (request timeout)'); } @@ -109,7 +107,7 @@ var User = module.exports = Model.extend({ window.localStorage.setItem('dc', data.dc); window.localStorage.setItem('admin-uuid', data.adminUuid); window.localStorage.setItem('manta', data.manta); - + window.localStorage.setItem('volapi-enabled', data.volapiEnabled); window.localStorage.setItem('user-roles', JSON.stringify(data.roles)); window.localStorage.setItem('user-uuid', data.user.uuid); window.localStorage.setItem('user-login', data.user.login); @@ -117,6 +115,7 @@ var User = module.exports = Model.extend({ self.trigger('authenticated', { user: self, adminUuid: data.adminUuid, + volapiEnabled: data.volapiEnabled, manta: data.manta, dc: data.dc }); @@ -126,9 +125,9 @@ var User = module.exports = Model.extend({ }); }, - signout: function() { + signout: function () { var self = this; - api.del('/api/auth').end(function(res) { + api.del('/api/auth').end(function (res) { if (res.ok) { window.localStorage.removeItem('api-token'); window.localStorage.removeItem('user-roles'); @@ -173,7 +172,7 @@ var User = module.exports = Model.extend({ } }); -User.currentUser = function() { +User.currentUser = function () { var roles = []; try { roles = JSON.parse(window.localStorage.getItem('user-roles')); diff --git a/www/js/models/volume.js b/www/js/models/volume.js new file mode 100644 index 00000000..9b3c3336 --- /dev/null +++ b/www/js/models/volume.js @@ -0,0 +1,8 @@ +'use strict'; + +var Model = require('./model'); + +module.exports = Model.extend({ + urlRoot: '/api/volumes', + idAttribute: 'uuid' +}); diff --git a/www/js/models/volumes.js b/www/js/models/volumes.js new file mode 100644 index 00000000..fee33802 --- /dev/null +++ b/www/js/models/volumes.js @@ -0,0 +1,9 @@ +'use strict'; + +var Volume = require('./volume'); +var PageableCollection = require('./pageableCollection'); + +module.exports = PageableCollection.extend({ + model: Volume, + url: '/api/volumes' +}); diff --git a/www/js/router.js b/www/js/router.js index f4dc51f4..1b04f00f 100644 --- a/www/js/router.js +++ b/www/js/router.js @@ -29,12 +29,15 @@ var Components = { 'alarms': require('./components/pages/alarms'), 'vm': require('./components/pages/vm'), 'vms': require('./components/pages/vms'), + 'volumes': require('./components/pages/volumes'), + 'volume': require('./components/pages/volumes/volume'), + 'volume-form': require('./components/pages/volumes/volume-form'), 'settings': require('./components/pages/settings'), 'user': require('./components/pages/user'), 'images': require('./components/pages/images'), 'manta/agents': require('./components/pages/manta/agents'), 'dashboard': require('./components/pages/dashboard'), - 'vlan-form': require('./components/pages/networking/fabric-vlan-form.jsx') + 'vlan-form': require('./components/pages/networking/fabric-vlan-form') }; Object.keys(Components).forEach(function (k) { @@ -85,6 +88,8 @@ module.exports = Backbone.Marionette.AppRouter.extend({ 'vms': 'showVms', 'vms/:uuid(/)': 'showVm', 'vms2/:uuid(/)': 'showVm2', + 'volumes': 'showVolumes', + 'volumes/:uuid(/)': 'showVolume', 'users/:uuid(/)': 'showUser', 'users/:uuid/:tab(/)': 'showUser', 'users/:account/:uuid/:tab(/)': 'showUser', @@ -132,6 +137,7 @@ module.exports = Backbone.Marionette.AppRouter.extend({ this.state.set({ manta: this.user.getManta(), + volapiEnabled: this.user.isVolapiEnabled(), datacenter: this.user.getDatacenter() }); }, @@ -145,7 +151,7 @@ module.exports = Backbone.Marionette.AppRouter.extend({ $.ajax({ type: 'GET', timeout: 15000, - url: '/api/auth', + url: '/api/auth' }).fail(function (xhr, t, m) { reject(); self.showSignin(); @@ -390,6 +396,21 @@ module.exports = Backbone.Marionette.AppRouter.extend({ }.bind(this)); }, + showVolumes: function (args) { + if (typeof args === 'string') { + args = this.parseURI(args); + } + this.authenticated().then(function () { + this.presentComponent('volumes', args ? {params: args} : {}); + }.bind(this)); + }, + + showVolume: function (uuid) { + this.authenticated().then(function () { + this.presentComponent('volume', uuid ? {uuid: uuid} : {}); + }.bind(this)); + }, + showNetworking: function (tab, querystring) { this.authenticated().then(function () {