Compare commits

...

75 Commits

Author SHA1 Message Date
Julien Fontanet
4bcb65c518 5.2.0 2016-09-09 16:31:24 +02:00
Olivier Lambert
25361fa7eb feat(changelog): add IP management feature in changelog 2016-09-09 16:30:13 +02:00
Pierre Donias
889a265000 feat(settings/ips): new page to manage IP pools (#1418)
Fixes #1350, fixes #988 and fixes #240.
2016-09-09 16:19:26 +02:00
Olivier Lambert
3122f6dcd5 feat(changelog): update changelog for 5.2.0 2016-09-09 14:21:54 +02:00
Olivier Lambert
16aa2e8085 bug(vmConsole): re-display header if VM goes from running to halted state. Fixes #1485 2016-09-08 16:29:56 +02:00
Julien Fontanet
074d51a670 fix(xo/deleteVms): delete disks as well
Fixes #1484
2016-09-08 15:06:42 +02:00
Greenkeeper
2122a79132 chore(package): update chartist-plugin-legend to version 0.5.0 (#1482)
https://greenkeeper.io/
2016-09-08 11:01:50 +02:00
Ronan Abhamon
26dbc585ba feat(vm-import): supports ova import (#709) (#1361)
Fixes #709
2016-09-08 10:15:44 +02:00
Greenkeeper
4b3cfbd424 chore(package): update modular-css to version 0.27.1 (#1478)
https://greenkeeper.io/
2016-09-08 09:55:38 +02:00
Julien Fontanet
035191a2cc feat(xo/installAllPatchesOnPool): better pool-wide patching (#1476)
Fixes #1392
2016-09-07 17:55:02 +02:00
Olivier Lambert
06a40180a1 fix(vm): do not display VDI or VIF disconnect but when VM is not running. Fixes #1470 and fixes #1468 2016-09-05 16:59:21 +02:00
Julien Fontanet
aaf4c5dff7 chore(Vm): move isRunning into utils/isVmRunning 2016-09-05 16:40:13 +02:00
Olivier Lambert
0c83bc2b0e feat(logview): standardize and improve the settings/log view. Fixes #1467 2016-09-05 16:10:12 +02:00
Julien Fontanet
2d412fd8db fix(scheduling): month and day display
There were issues with some timezones.

Fixes #1404 & fixes #1438.
2016-09-01 11:47:50 -03:00
Julien Fontanet
443e2bec25 chore(NewVm#_getIsoPredicate): memoise selector 2016-09-01 10:57:13 -03:00
Olivier Lambert
d5e1323d82 feat(newVif): select management network by default when adding a vif. Fixes #1425 2016-09-01 15:34:49 +02:00
Julien Fontanet
7f0b77cc89 chore(package): update chartist-plugin-legend to version 0.4.0 (#1450)
https://greenkeeper.io/
2016-08-31 10:32:50 +02:00
greenkeeperio-bot
0169cff66c chore(package): update chartist-plugin-legend to version 0.4.0
https://greenkeeper.io/
2016-08-31 10:17:48 +02:00
Olivier Lambert
0fd1424a41 fix(newVm): check pool object for ISO selector when creating a VM from selfservice. Fixes #1448 2016-08-30 21:26:59 +02:00
Julien Fontanet
6280d56f32 chore(xo): use resolveId() (only) where it makes sense 2016-08-26 16:18:03 -04:00
Julien Fontanet
9f2a77872f fix(xo/deleteUser): dont attempt to display error when cancelled
Fixes vatesfr/xo-web#1439
2016-08-26 14:38:02 -04:00
Pierre Donias
b571c18e9a feat(host): indicate pool master in multiple places (#1423)
Fixes #1407
2016-08-25 12:33:55 -04:00
Greenkeeper
49863d6e4d Update standard to version 8.0.0 🚀 (#1435)
https://greenkeeper.io/
2016-08-24 13:00:00 -04:00
Julien Fontanet
48cc7bb647 5.1.9 2016-08-22 14:02:40 -04:00
Pierre Donias
442d42d8dc fix(settings/logs): show params in a modal (#1424) 2016-08-19 16:09:20 +02:00
Olivier Lambert
9501ebacfc feat(menu): add warning icon when disconnected 2016-08-19 13:52:31 +02:00
Pierre Donias
23f9fa46f8 fix(home/host-item): do not show pool name if not enough permissions (#1421) 2016-08-19 13:40:55 +02:00
Julien Fontanet
1bd0f37fd4 feat(Menu): display when disconnected
Fixes vatesfr/xo-web#1417
2016-08-19 12:27:48 +02:00
Pierre Donias
ed74ded923 feat(settings/logs): display parameters (#1420) 2016-08-19 10:06:45 +02:00
Olivier Lambert
b732410b74 feat(vm and home): add tooltip to OS icon. Fixes #1416 2016-08-18 14:12:46 +02:00
Olivier Lambert
a51f2b7fcf fix(newvm): check if ssh keys object exists 2016-08-18 13:59:08 +02:00
Olivier Lambert
fe12bbb60d fix(sr): container var check if not defined 2016-08-18 13:51:37 +02:00
Olivier Lambert
8882df7939 5.1.8 2016-08-17 11:07:30 +02:00
Olivier Lambert
185a554cd9 fix(newVm): fix wrong ISO SR predicate. Fixes #1415 2016-08-17 11:06:35 +02:00
Olivier Lambert
230e0dc2a5 5.1.7 2016-08-16 15:38:59 +02:00
Pierre Donias
f5b69fdfdc feat(vm/console): hide header and resize console (#1410)
Fix #1268
2016-08-16 14:49:44 +02:00
Greenkeeper
01dc0d8f1e chore(package): update modular-css to version 0.26.0 (#1385)
https://greenkeeper.io/
2016-08-16 12:45:29 +02:00
Greenkeeper
8035886a3c chore(package): update promise-toolbox to version 0.5.0 (#1409)
https://greenkeeper.io/
2016-08-16 12:22:45 +02:00
Olivier Lambert
0ab5f4b13f fix(host): wrong storage link. Fixes #1408 2016-08-16 11:16:56 +02:00
Pierre Donias
a1bc98def8 feat(host): redirect to home when host disappears (#1406) 2016-08-16 09:50:29 +02:00
Olivier Lambert
868cf6140b feat(settings): more tooltips for server connect/disconnect 2016-08-15 18:04:01 +02:00
Olivier Lambert
4b3473f480 feat(logstackmodal): use pre tag for stack trace 2016-08-15 17:52:04 +02:00
Olivier Lambert
7bc782cc62 feat(copiable): add tooltip on copiable component 2016-08-15 17:33:59 +02:00
Olivier Lambert
e625a53e4a fix(vm migration): allow target network without IPs. Fixes #1403 2016-08-15 15:20:59 +02:00
Olivier Lambert
b31185d96d fix(newVm): typo spotted by @Danp2 2016-08-15 14:07:12 +02:00
Olivier Lambert
09d75e972f feat(newVm): add missing tooltips. Fixes #1402 2016-08-15 11:44:36 +02:00
Olivier Lambert
f33568951b 5.1.6 2016-08-12 17:28:49 +02:00
Pierre Donias
8d8c442be5 feat(settings/logs): new view to display API logs (#1401)
Fix #1344
2016-08-12 17:27:50 +02:00
Olivier Lambert
f890b8ea7a feat(modal text): warns users about consequences of host eject 2016-08-11 21:13:32 +02:00
Pierre Donias
1b80b3929c feat(host): detach host from its pool (#1399)
Fixes #1395
2016-08-11 17:49:25 +02:00
Pierre Donias
4f946293f6 feat(pool): add host (#1398)
Fixes #1374
2016-08-11 17:05:41 +02:00
Olivier Lambert
36788cde2b feat(vm disk): add VBD connect for a running VM. Fixes #1397 2016-08-11 16:52:52 +02:00
Pierre Donias
1547c99e5a feat(new-vm): use saved SSH key in cloud config(#1394)
* feat(new-vm): use saved SSH key in cloud config. Fixes #1319
2016-08-11 13:32:54 +02:00
Olivier Lambert
5c9606dad8 feat(pool): improve pool view. Fixes #1393 2016-08-11 10:34:03 +02:00
Olivier Lambert
fdcb1dccf5 feat(pool): start to work on adding an existing host to a pool 2016-08-11 09:47:52 +02:00
Olivier Lambert
12812b8c23 5.1.5 2016-08-10 18:06:19 +02:00
Olivier Lambert
0098497255 fix(select): select color modified due to an update. Fixes #1391 2016-08-10 16:02:48 +02:00
Olivier Lambert
6562d2de7f feat(sr select): display space left on SR. Fixes #1358 2016-08-10 15:58:36 +02:00
Olivier Lambert
1f0e88cdb0 feat(backup): better tooltips. Fixes #1363 2016-08-10 14:17:27 +02:00
Olivier Lambert
197da91ef3 feat(vdi remove): add modal when removing a VDI. Fixes #1388 2016-08-10 13:39:13 +02:00
Olivier Lambert
cbd59789e2 fix(vm disks): _isFreeForWriting missing case. Fixes #1386 2016-08-10 13:13:17 +02:00
Olivier Lambert
190ecf3d74 fix(pool): pool name and description edition. Fixes #1390 2016-08-10 12:42:46 +02:00
Olivier Lambert
15b8f6bca2 feat(meter tooltips): add tooltips for meter object. Fixes #1387 2016-08-10 12:31:28 +02:00
Pierre Donias
5b406d731b fix(vm): select destination SR when at least one VDI is local (#1382)
* Fixes #1357 
* fix(vm): select destination SR when at least one VDI is local
* fix(vm): do not send map when not necessary
2016-08-09 17:03:08 +02:00
Olivier Lambert
4be9e67ac4 fix(metercss): remove useless and conflicting CSS styles 2016-08-09 10:31:03 +02:00
Olivier Lambert
d047421685 feat(updates): enhance update view. Also fixes #1341 2016-08-08 16:46:47 +02:00
Olivier Lambert
f6f415a421 fix(network): name instead of description 2016-08-08 14:55:15 +02:00
Pierre Donias
edfaaebac0 feat(dashboard/health): Storage table: BlockLink (SR) and Link (SR's pool)
Fixes #1381
2016-08-08 14:15:49 +02:00
Olivier Lambert
67df22a1bf feat(vmsnapshot): add snapshot export and copy. Fixes #1353 and #1336 2016-08-08 14:05:27 +02:00
Pierre Donias
7dc59a00f6 feat(pool): action button to create an SR (#1380)
Fixes #1372
2016-08-08 12:45:12 +02:00
Pierre Donias
6214fe4c2e feat(pool): action button to create a VM (#1379)
Fix #1373
2016-08-08 11:35:24 +02:00
Greenkeeper
21610c3e0a chore(package): update ava to version 0.16.0 (#1377)
https://greenkeeper.io/
2016-08-08 09:57:36 +02:00
Olivier Lambert
87550b0189 5.1.4 2016-08-07 19:35:37 +02:00
Ronan Abhamon
b7c42d0a08 fix(scheduling): range not working
Fixes #1376
2016-08-07 19:35:05 +02:00
Olivier Lambert
c15ad299ac fix(sparklines): smaller sparklines and removing useless dots 2016-08-05 14:39:07 +02:00
73 changed files with 2786 additions and 631 deletions

View File

@@ -1,5 +1,78 @@
# ChangeLog
## **5.2.0** (2016-09-09)
### Enhancements
- IP management [\#1350](https://github.com/vatesfr/xo-web/issues/1350), [\#988](https://github.com/vatesfr/xo-web/issues/988), [\#1427](https://github.com/vatesfr/xo-web/issues/1427) and [\#240](https://github.com/vatesfr/xo-web/issues/240)
- Update reverse proxy example [\#1474](https://github.com/vatesfr/xo-web/issues/1474)
- Improve log view [\#1467](https://github.com/vatesfr/xo-web/issues/1467)
- Backup Reports: e-mail subject [\#1463](https://github.com/vatesfr/xo-web/issues/1463)
- Backup Reports: report the error [\#1462](https://github.com/vatesfr/xo-web/issues/1462)
- Vif selector: select management network by default [\#1425](https://github.com/vatesfr/xo-web/issues/1425)
- Display when browser disconnected to server [\#1417](https://github.com/vatesfr/xo-web/issues/1417)
- Tooltip on OS icon in VM view [\#1416](https://github.com/vatesfr/xo-web/issues/1416)
- Display pool master [\#1407](https://github.com/vatesfr/xo-web/issues/1407)
- Missing tooltips in VM creation view [\#1402](https://github.com/vatesfr/xo-web/issues/1402)
- Handle VDB disconnect and connect [\#1397](https://github.com/vatesfr/xo-web/issues/1397)
- Eject host from a pool [\#1395](https://github.com/vatesfr/xo-web/issues/1395)
- Improve pool general view [\#1393](https://github.com/vatesfr/xo-web/issues/1393)
- Improve patching system [\#1392](https://github.com/vatesfr/xo-web/issues/1392)
- Pool name modification [\#1390](https://github.com/vatesfr/xo-web/issues/1390)
- Confirmation dialog before destroying VDIs [\#1388](https://github.com/vatesfr/xo-web/issues/1388)
- Tooltips for meter object [\#1387](https://github.com/vatesfr/xo-web/issues/1387)
- New Host assistant [\#1374](https://github.com/vatesfr/xo-web/issues/1374)
- New VM assistant [\#1373](https://github.com/vatesfr/xo-web/issues/1373)
- New SR assistant [\#1372](https://github.com/vatesfr/xo-web/issues/1372)
- Direct access to VDI listing from dashboard's SR usage breakdown [\#1371](https://github.com/vatesfr/xo-web/issues/1371)
- Can't set a network name at pool level [\#1368](https://github.com/vatesfr/xo-web/issues/1368)
- Change a few mouse over descriptions [\#1363](https://github.com/vatesfr/xo-web/issues/1363)
- Hide network install in VM create if template is HVM [\#1362](https://github.com/vatesfr/xo-web/issues/1362)
- SR space left during VM creation [\#1358](https://github.com/vatesfr/xo-web/issues/1358)
- Add destination SR on migration modal in VM view [\#1357](https://github.com/vatesfr/xo-web/issues/1357)
- Ability to create a new VM from a snapshot [\#1353](https://github.com/vatesfr/xo-web/issues/1353)
- Missing explanation/confirmation on Snapshot Page [\#1349](https://github.com/vatesfr/xo-web/issues/1349)
- Log view: expose API errors in the web UI [\#1344](https://github.com/vatesfr/xo-web/issues/1344)
- Registration on update page [\#1341](https://github.com/vatesfr/xo-web/issues/1341)
- Add export snapshot button [\#1336](https://github.com/vatesfr/xo-web/issues/1336)
- Use saved SSH keys in VM create CloudConfig [\#1319](https://github.com/vatesfr/xo-web/issues/1319)
- Collapse header in console view [\#1268](https://github.com/vatesfr/xo-web/issues/1268)
- Two max concurrent jobs in parallel [\#915](https://github.com/vatesfr/xo-web/issues/915)
- Handle OVA import via the web UI [\#709](https://github.com/vatesfr/xo-web/issues/709)
### Bug fixes
- Bug on VM console when header is hidden [\#1485](https://github.com/vatesfr/xo-web/issues/1485)
- Disks not removed when deleting multiple VMs [\#1484](https://github.com/vatesfr/xo-web/issues/1484)
- Do not display VDI disconnect button when a VM is not running [\#1470](https://github.com/vatesfr/xo-web/issues/1470)
- Do not display VIF disconnect button when a VM is not running [\#1468](https://github.com/vatesfr/xo-web/issues/1468)
- Error on migration if no default SR \(even when not used\) [\#1466](https://github.com/vatesfr/xo-web/issues/1466)
- DR issue while rotating old backup [\#1464](https://github.com/vatesfr/xo-web/issues/1464)
- Giving resource set to end-user ends with error [\#1448](https://github.com/vatesfr/xo-web/issues/1448)
- Error thrown when cancelling out of Delete User confirmation dialog [\#1439](https://github.com/vatesfr/xo-web/issues/1439)
- Wrong month label shown in Backup and Job scheduler [\#1438](https://github.com/vatesfr/xo-web/issues/1438)
- Bug on Self service creation/edition [\#1438](https://github.com/vatesfr/xo-web/issues/1428)
- ISO selection during VM create is not mounted after [\#1415](https://github.com/vatesfr/xo-web/issues/1415)
- Hosts general view: bad link for storage [\#1408](https://github.com/vatesfr/xo-web/issues/1408)
- Backup Schedule - "Month" and "Day of Week" display error [\#1404](https://github.com/vatesfr/xo-web/issues/1404)
- Migrate dialog doesn't present all available VIF's in new UI interface [\#1403](https://github.com/vatesfr/xo-web/issues/1403)
- NFS mount issues [\#1396](https://github.com/vatesfr/xo-web/issues/1396)
- Select component color [\#1391](https://github.com/vatesfr/xo-web/issues/1391)
- SR created with local path shouldn't be shared [\#1389](https://github.com/vatesfr/xo-web/issues/1389)
- Disks (VBD) are attached to VM in RO mode instead of RW even if RO is unchecked [\#1386](https://github.com/vatesfr/xo-web/issues/1386)
- Re-connection issues between server and XS hosts [\#1384](https://github.com/vatesfr/xo-web/issues/1384)
- Meter object style with Chrome 52 [\#1383](https://github.com/vatesfr/xo-web/issues/1383)
- Editing a rolling snapshot job seems to fail [\#1376](https://github.com/vatesfr/xo-web/issues/1376)
- Dashboard SR usage and total inverted [\#1370](https://github.com/vatesfr/xo-web/issues/1370)
- XenServer connection issue with host while using VGPUs [\#1369](https://github.com/vatesfr/xo-web/issues/1369)
- Job created with v4 are not correctly displayed in v5 [\#1366](https://github.com/vatesfr/xo-web/issues/1366)
- CPU accounting in resource set [\#1365](https://github.com/vatesfr/xo-web/issues/1365)
- Tooltip stay displayed when a button change state [\#1360](https://github.com/vatesfr/xo-web/issues/1360)
- Failure on host reboot [\#1351](https://github.com/vatesfr/xo-web/issues/1351)
- Editing Backup Jobs Without Compression, Slider Always Set To On [\#1339](https://github.com/vatesfr/xo-web/issues/1339)
- Month Selection on Backup Screen Wrong [\#1338](https://github.com/vatesfr/xo-web/issues/1338)
- Delta backup fail when removed VDIs [\#1333](https://github.com/vatesfr/xo-web/issues/1333)
## **5.1.0** (2016-07-26)
### Enhancements

View File

@@ -330,7 +330,6 @@ gulp.task(function server (done) {
address = '[' + address + ']'
}
/* jshint devel: true*/
console.log('Listening on http://' + address + ':' + port)
})
.on('error', done)

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "xo-web",
"version": "5.1.3",
"version": "5.2.0",
"license": "AGPL-3.0",
"description": "Web interface client for Xen-Orchestra",
"keywords": [
@@ -33,7 +33,7 @@
"devDependencies": {
"ansi_up": "^1.3.0",
"asap": "^2.0.4",
"ava": "^0.15.0",
"ava": "^0.16.0",
"babel-eslint": "^6.0.0",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-react-constant-elements": "^6.5.0",
@@ -48,7 +48,7 @@
"bootstrap": "github:twbs/bootstrap#v4-dev",
"browserify": "^13.0.0",
"bundle-collapser": "^1.2.1",
"chartist-plugin-legend": "^0.3.1",
"chartist-plugin-legend": "^0.5.0",
"chartist-plugin-tooltip": "0.0.11",
"classnames": "^2.2.3",
"connect": "^3.4.1",
@@ -57,6 +57,7 @@
"dependency-check": "^2.5.1",
"font-awesome": "^4.5.0",
"font-mfizz": "github:fizzed/font-mfizz",
"get-stream": "^2.3.0",
"ghooks": "^1.1.1",
"globby": "^6.0.0",
"gulp": "github:gulpjs/gulp#4.0",
@@ -70,17 +71,18 @@
"gulp-uglify": "^2.0.0",
"gulp-watch": "^4.3.5",
"human-format": "^0.6.0",
"is-ip": "^1.0.0",
"jsonrpc-websocket-client": "0.0.1-5",
"later": "^1.2.0",
"lodash": "^4.6.1",
"loose-envify": "^1.1.0",
"marked": "^0.3.5",
"modular-css": "^0.25.0",
"modular-css": "^0.27.1",
"moment": "^2.13.0",
"moment-timezone": "^0.5.4",
"notifyjs": "^2.0.1",
"novnc-node": "^0.5.3",
"promise-toolbox": "^0.4.0",
"promise-toolbox": "^0.5.0",
"random-password": "^0.1.2",
"react": "^15.0.0",
"react-addons-shallow-compare": "^15.1.0",
@@ -109,10 +111,12 @@
"redux-thunk": "^2.0.1",
"reselect": "^2.2.1",
"serve-static": "^1.10.2",
"standard": "^7.0.0",
"standard": "^8.0.0",
"superagent": "^2.0.0",
"tar-stream": "^1.5.2",
"vinyl": "^1.1.1",
"watchify": "^3.7.0",
"xml2js": "^0.4.17",
"xo-acl-resolver": "^0.2.1",
"xo-lib": "^0.8.0",
"xo-remote-parser": "^0.3"

View File

@@ -6,17 +6,21 @@ import Tooltip from 'tooltip'
import {
ButtonGroup
} from 'react-bootstrap-4/lib'
import {
noop
} from 'utils'
const ActionBar = ({ actions, param }) => (
<ButtonGroup>
{map(actions, ({ handler, handlerParam = param, label, icon }, index) => (
{map(actions, ({ handler, handlerParam = param, label, icon, redirectOnSuccess }, index) => (
<Tooltip key={index} content={_(label)}>
<ActionButton
key={index}
btnStyle='secondary'
handler={handler}
handler={handler || noop}
handlerParam={handlerParam}
icon={icon}
redirectOnSuccess={redirectOnSuccess}
size='large'
/>
</Tooltip>
@@ -28,7 +32,8 @@ ActionBar.propTypes = {
React.PropTypes.shape({
label: React.PropTypes.string.isRequired,
icon: React.PropTypes.string.isRequired,
handler: React.PropTypes.func
handler: React.PropTypes.func,
redirectOnSuccess: React.PropTypes.string
})
).isRequired,
display: React.PropTypes.oneOf(['icon', 'text', 'both'])

View File

@@ -1,10 +1,36 @@
import clone from 'lodash/clone'
import includes from 'lodash/includes'
import isArray from 'lodash/isArray'
import forEach from 'lodash/forEach'
import map from 'lodash/map'
import { Component } from 'react'
import getEventValue from './get-event-value'
import invoke from './invoke'
import shallowEqual from './shallow-equal'
const cowSet = (object, path, value, depth) => {
if (depth >= path.length) {
return value
}
object = clone(object)
const prop = path[depth]
object[prop] = cowSet(object[prop], path, value, depth + 1)
return object
}
const get = (object, path, depth) => {
if (depth >= path.length) {
return object
}
const prop = path[depth++]
return isArray(object) && prop === '*'
? map(object, value => get(value, path, depth))
: get(object[prop], path, depth)
}
export default class BaseComponent extends Component {
constructor (props, context) {
super(props, context)
@@ -24,7 +50,42 @@ export default class BaseComponent extends Component {
}
// See https://preactjs.com/guide/linked-state
linkState (name) {
linkState (name, targetPath) {
const key = targetPath
? `${name}##${targetPath}`
: name
let linkedState = this._linkedState
let cb
if (!linkedState) {
linkedState = this._linkedState = {}
} else if ((cb = linkedState[key])) {
return cb
}
let getValue
if (targetPath) {
const path = targetPath.split('.')
getValue = event => get(getEventValue(event), path, 0)
} else {
getValue = getEventValue
}
if (includes(name, '.')) {
const path = name.split('.')
return (linkedState[key] = event => {
this.setState(cowSet(this.state, path, getValue(event), 0))
})
}
return (linkedState[key] = event => {
this.setState({
[name]: getValue(event)
})
})
}
toggleState (name) {
let linkedState = this._linkedState
let cb
if (!linkedState) {
@@ -33,9 +94,16 @@ export default class BaseComponent extends Component {
return cb
}
return (linkedState[name] = event => {
if (includes(name, '.')) {
const path = name.split('.')
return (linkedState[path] = event => {
this.setState(cowSet(this.state, path, !get(this.state, path), 0))
})
}
return (linkedState[name] = () => {
this.setState({
[name]: getEventValue(event)
[name]: !this.state[name]
})
})
}

View File

@@ -1,5 +1,7 @@
import _ from 'intl'
import CopyToClipboard from 'react-copy-to-clipboard'
import classNames from 'classnames'
import Tooltip from 'tooltip'
import React, { createElement } from 'react'
import Icon from '../icon'
@@ -18,10 +20,12 @@ const Copiable = propTypes({
},
props.children,
' ',
<CopyToClipboard text={props.data || props.children}>
<button className={classNames('btn btn-sm btn-secondary', styles.button)}>
<Icon icon='clipboard' />
</button>
</CopyToClipboard>
<Tooltip content={_('copyToClipboard')}>
<CopyToClipboard text={props.data || props.children}>
<button className={classNames('btn btn-sm btn-secondary', styles.button)}>
<Icon icon='clipboard' />
</button>
</CopyToClipboard>
</Tooltip>
))
export { Copiable as default }

View File

@@ -15,6 +15,7 @@ import { formatSize } from './utils'
import { SizeInput } from './form'
import {
SelectHost,
SelectIp,
SelectNetwork,
SelectPool,
SelectRemote,
@@ -373,6 +374,7 @@ export class Select extends Editable {
const MAP_TYPE_SELECT = {
host: SelectHost,
ip: SelectIp,
network: SelectNetwork,
pool: SelectPool,
remote: SelectRemote,

View File

@@ -99,12 +99,9 @@ export class Range extends Component {
}
set value (value) {
const { onChange } = this.props
this.state.value = +value
if (onChange) {
onChange(value)
}
this.setState({
value: +value
})
}
_handleChange = event => {

View File

@@ -102,6 +102,7 @@ export default class Select extends Component {
return (
<ReactSelect
{...this.props}
backspaceToRemoveMessage=''
menuRenderer={this._renderMenu}
menuStyle={SELECT_MENU_STYLE}
style={SELECT_STYLE}

View File

@@ -1,4 +1,5 @@
import isEmpty from 'lodash/isEmpty'
import keys from 'lodash/keys'
import map from 'lodash/map'
import React from 'react'
import { Portal } from 'react-overlays'
@@ -19,7 +20,8 @@ import {
} from './selectors'
import {
getHostMissingPatches,
installAllHostPatches
installAllHostPatches,
installAllPatchesOnPool
} from './xo'
// ===================================================================
@@ -84,9 +86,17 @@ class HostsPatchesTable extends Component {
)
)
_installAllMissingPatches = () => (
Promise.all(map(this._getHosts(), this._installAllHostPatches))
)
_installAllMissingPatches = () => {
const pools = {}
forEach(this._getHosts(), host => {
pools[host.$pool] = true
})
return Promise.all(map(
keys(pools),
installAllPatchesOnPool
)).then(this._refreshMissingPatches)
}
_refreshHostMissingPatches = host => (
getHostMissingPatches(host).then(patches => {

View File

@@ -8,6 +8,9 @@ addLocaleData(reactIntlData)
// ===================================================================
export default {
statusConnecting: 'Connexion…',
statusDisconnected: 'Déconnecté',
editableLongClickPlaceholder: 'Clic long pour éditer',
editableClickPlaceholder: 'Cliquer pour éditer',

View File

@@ -1,4 +1,4 @@
// See http://momentjs.com/docs/#/use-it/browserify/
// See http://momentjs.com/docs/#/use-it/browserify/
import 'moment/locale/pt'
import reactIntlData from 'react-intl/locale-data/pt'

View File

@@ -5,6 +5,9 @@ var forEach = require('lodash/forEach')
var isString = require('lodash/isString')
var messages = {
statusConnecting: 'Connecting',
statusDisconnected: 'Disconnected',
editableLongClickPlaceholder: 'Long click to edit',
editableClickPlaceholder: 'Click to edit',
@@ -17,6 +20,12 @@ var messages = {
onError: 'On error',
successful: 'Successful',
// ----- Copiable component -----
copyToClipboard: 'Copy to clipboard',
// ----- Pills -----
pillMaster: 'Master',
// ----- Titles -----
homePage: 'Home',
homeVmPage: 'VMs',
@@ -39,6 +48,8 @@ var messages = {
settingsGroupsPage: 'Groups',
settingsAclsPage: 'ACLs',
settingsPluginsPage: 'Plugins',
settingsLogsPage: 'Logs',
settingsIpsPage: 'IPs',
aboutPage: 'About',
newMenu: 'New',
taskMenu: 'Tasks',
@@ -112,6 +123,7 @@ var messages = {
homeMore: 'More',
homeMigrateTo: 'Migrate to…',
homeMissingPaths: 'Missing patches',
homePoolMaster: 'Master:',
highAvailability: 'High Availability',
// ----- Forms -----
@@ -134,12 +146,14 @@ var messages = {
selectResourceSetsSr: 'Select SR(s)…',
selectResourceSetsNetwork: 'Select network(s)…',
selectResourceSetsVdi: 'Select disk(s)…',
selectSshKey: 'Select SSH key(s)…',
selectSrs: 'Select SR(s)…',
selectVms: 'Select VM(s)…',
selectVmTemplates: 'Select VM template(s)…',
selectTags: 'Select tag(s)…',
selectVdis: 'Select disk(s)…',
selectTimezone: 'Select timezone…',
selectIp: 'Select IP(s)...',
fillRequiredInformations: 'Fill required informations.',
fillOptionalInformations: 'Fill informations (optional)',
selectTableReset: 'Reset',
@@ -329,6 +343,9 @@ var messages = {
srForget: 'Forget this SR',
srRemoveButton: 'Remove this SR',
srNoVdis: 'No VDIs in this storage',
// ----- Pool general -----
poolRamUsage: '{used} used on {total}',
poolMaster: 'Master:',
// ----- Pool tabs -----
hostsTabName: 'Hosts',
// ----- Pool advanced tab -----
@@ -340,6 +357,7 @@ var messages = {
hostDescription: 'Description',
hostMemory: 'Memory',
noHost: 'No hosts',
memoryLeftTooltip: '{used}% used ({free} free)',
// ----- Pool network tab -----
poolNetworkNameLabel: 'Name',
poolNetworkDescription: 'Description',
@@ -400,6 +418,8 @@ var messages = {
pifStatusConnected: 'Connected',
pifStatusDisconnected: 'Disconnected',
pifNoInterface: 'No physical interface detected',
pifInUse: 'This interface is currently in use',
defaultLockingMode: 'Default locking mode',
// ----- Host storage tabs -----
addSrDeviceButton: 'Add a storage',
srNameLabel: 'Name',
@@ -476,6 +496,8 @@ var messages = {
ctrlAltDelButtonLabel: 'Ctrl+Alt+Del',
tipLabel: 'Tip:',
tipConsoleLabel: 'non-US keyboard could have issues with console: switch your own layout to US.',
hideHeaderTooltip: 'Hide infos',
showHeaderTooltip: 'Show infos',
// ----- VM disk tab -----
vdiAction: 'Action',
@@ -506,6 +528,10 @@ var messages = {
vifStatusDisconnected: 'Disconnected',
vifIpAddresses: 'IP addresses',
vifMacAutoGenerate: 'Auto-generated if empty',
vifAllowedIps: 'Allowed IPs',
vifNoIps: 'No IPs',
vifLockedNetwork: 'Network is locked',
vifLockedNetworkNoIps: 'Network is locked and no IPs are allowed for this interface',
// ----- VM snapshot tab -----
noSnapshots: 'No snapshots',
@@ -513,6 +539,8 @@ var messages = {
tipCreateSnapshotLabel: 'Just click on the snapshot button to create one!',
revertSnapshot: 'Revert VM to this snapshot',
deleteSnapshot: 'Remove this snapshot',
copySnapshot: 'Create a VM from this snapshot',
exportSnapshot: 'Export this snapshot',
snapshotDate: 'Creation date',
snapshotName: 'Name',
snapshotAction: 'Action',
@@ -618,6 +646,7 @@ var messages = {
alarmObject: 'Issue on',
alarmPool: 'Pool',
alarmRemoveAll: 'Remove all alarms',
spaceLeftTooltip: '{used}% used ({free} left)',
// ----- New VM -----
newVmCreateNewVmOn: 'Create a new VM on {select}',
@@ -664,6 +693,8 @@ var messages = {
newVmMultipleVmsPattern: 'Name pattern:',
newVmMultipleVmsPatternPlaceholder: 'e.g.: \\{name\\}_%',
newVmFirstIndex: 'First index:',
newVmNumberRecalculate: 'Recalculate VMs number',
newVmNameRefresh: 'Refresh VMs name',
// ----- Self -----
resourceSets: 'Resource sets',
@@ -695,7 +726,7 @@ var messages = {
usedResource: 'Used:',
// ---- VM import ---
importVmsList: 'Try dropping some backups here, or click to select backups to upload. Accept only .xva files.',
importVmsList: 'Try dropping some VMs files here, or click to select VMs to upload. Accept only .xva/.ova files.',
noSelectedVms: 'No selected VMs.',
vmImportToPool: 'To Pool:',
vmImportToSr: 'To SR:',
@@ -705,6 +736,17 @@ var messages = {
vmImportFailed: 'VM import failed',
startVmImport: 'Import starting…',
startVmExport: 'Export starting…',
nCpus: 'N CPUs',
vmMemory: 'Memory',
diskInfo: 'Disk {position} ({capacity})',
diskDescription: 'Disk description',
noDisks: 'No disks.',
noNetworks: 'No networks.',
networkInfo: 'Network {name}',
noVmImportErrorDescription: 'No description available',
vmImportError: 'Error:',
vmImportFileType: '{type} file:',
vmImportConfigAlert: 'Please to check and/or modify the VM configuration.',
// ---- Tasks ---
noTasks: 'No pending tasks',
@@ -724,7 +766,8 @@ var messages = {
lastBackupColumn: 'Last Backup',
availableBackupsColumn: 'Available Backups',
restoreColumn: 'Restore',
restoreTip: 'Restore VM',
restoreTip: 'View restore options',
displayBackup: 'Display backups',
importBackupTitle: 'Import VM',
importBackupMessage: 'Starting your backup import',
vmsToBackup: 'VMs to backup',
@@ -734,6 +777,8 @@ var messages = {
emergencyShutdownHostsModalMessage: 'Are you sure you want to shutdown {nHosts} Host{nHosts, plural, one {} other {s}}?',
stopHostModalTitle: 'Shutdown host',
stopHostModalMessage: 'This will shutdown your host. Do you want to continue?',
addHostModalTitle: 'Add host',
addHostModalMessage: 'Are you sure you want to add {host} to {pool}?',
restartHostModalTitle: 'Restart host',
restartHostModalMessage: 'This will restart your host. Do you want to continue?',
restartHostsAgentsModalTitle: 'Restart Host{nHosts, plural, one {} other {s}} agent{nHosts, plural, one {} other {s}}',
@@ -754,10 +799,10 @@ var messages = {
restartVmsModalMessage: 'Are you sure you want to restart {vms} VM{vms, plural, one {} other {s}}?',
snapshotVmsModalTitle: 'Snapshot VM{vms, plural, one {} other {s}}',
snapshotVmsModalMessage: 'Are you sure you want to snapshot {vms} VM{vms, plural, one {} other {s}}?',
deleteVmModalTitle: 'Delete VM',
deleteVmsModalTitle: 'Delete VM{vms, plural, one {} other {s}}',
deleteVmModalMessage: 'Are you sure you want to delete this VM? ALL VM DISKS WILL BE REMOVED',
deleteVmsModalMessage: 'Are you sure you want to delete {vms} VM{vms, plural, one {} other {s}}? ALL VM DISKS WILL BE REMOVED',
deleteVmModalTitle: 'Delete VM',
deleteVmModalMessage: 'Are you sure you want to delete this VM? ALL VM DISKS WILL BE REMOVED',
migrateVmModalTitle: 'Migrate VM',
migrateVmSelectHost: 'Select a destination host:',
migrateVmSelectMigrationNetwork: 'Select a migration network:',
@@ -773,6 +818,8 @@ var messages = {
migrateVmNetwork: 'Network',
migrateVmNoTargetHost: 'No target host',
migrateVmNoTargetHostMessage: 'A target host is required to migrate a VM',
deleteVdiModalTitle: 'Delete VDI',
deleteVdiModalMessage: 'Are you sure you want to delete this disk? ALL DATA ON THIS DISK WILL BE LOST',
revertVmModalTitle: 'Revert your VM',
revertVmModalMessage: 'You are about to revert your VM to the snapshot state. This operation is irreversible',
importBackupModalTitle: 'Import a {name} Backup',
@@ -797,6 +844,8 @@ var messages = {
serverPassword: 'Password',
serverAction: 'Action',
serverReadOnly: 'Read Only',
serverConnect: 'Connect server',
serverDisconnect: 'Disconnect server',
// ----- Copy VM -----
copyVm: 'Copy VM',
@@ -810,6 +859,11 @@ var messages = {
copyVmsNoTargetSr: 'No target SR',
copyVmsNoTargetSrMessage: 'A target SR is required to copy a VM',
// ----- Detach host -----
detachHostModalTitle: 'Detach host',
detachHostModalMessage: 'Are you sure you want to detach {host} from its pool? THIS WILL REMOVE ALL VMs ON ITS LOCAL STORAGE AND REBOOT THE HOST.',
detachHost: 'Detach',
// ----- Network -----
newNetworkCreate: 'Create network',
newNetworkInterface: 'Interface',
@@ -821,6 +875,12 @@ var messages = {
newNetworkDefaultMtu: 'Default: 1500',
deleteNetwork: 'Delete network',
deleteNetworkConfirm: 'Are you sure you want to delete this network?',
networkInUse: 'This network is currently in use',
// ----- Add host -----
addHostSelectHost: 'Host',
addHostNoHost: 'No host',
addHostNoHostMessage: 'No host selected to be added',
// ----- About View -----
xenOrchestra: 'Xen Orchestra',
@@ -856,13 +916,16 @@ var messages = {
registration: 'Registration',
trial: 'Trial',
settings: 'Settings',
proxySettings: 'Proxy settings',
update: 'Update',
refresh: 'Refresh',
upgrade: 'Upgrade',
noUpdaterCommunity: 'No updater available for Community Edition',
noUpdaterSubscribe: 'Please consider subscribe and try it with all features for free during 15 days on',
noUpdaterWarning: 'Manual update could break your current installation due to dependencies issues, do it with caution',
currentVersion: 'Current version:',
register: 'Register',
editRegistration: 'Edit registration',
trialRegistration: 'Please, take time to register in order to enjoy your trial.',
trialStartButton: 'Start trial',
trialAvailableUntil: 'You can use a trial version until {date, date, medium}. Upgrade your appliance to get it.',
@@ -918,7 +981,36 @@ var messages = {
deleteSshKeyConfirmMessage: 'Are you sure you want to delete the SSH key {title}?',
// ----- Usage -----
others: 'Others'
others: 'Others',
// ----- Logs -----
loadingLogs: 'Loading logs...',
logUser: 'User',
logMethod: 'Method',
logParams: 'Params',
logMessage: 'Message',
logStack: 'Stack trace',
logDisplayDetails: 'Display details',
logShowParams: 'ShowParams',
logTime: 'Date',
logNoStackTrace: 'No stack trace',
logNoParams: 'No params',
logDeleteAll: 'Delete all logs',
logDeleteAllTitle: 'Delete all logs',
logDeleteAllMessage: 'Are you sure you want to delete all the logs?',
// ----- IPs ------
ipPoolName: 'Name',
ipPoolIps: 'IPs',
ipPoolIpsPlaceholder: 'IPs (e.g.: 1.0.0.12-1.0.0.17;1.0.0.23)',
ipPoolNetworks: 'Networks',
ipsNoIpPool: 'No IP pools',
ipsCreate: 'Create',
ipsDeleteAllTitle: 'Delete all IP pools',
ipsDeleteAllMessage: 'Are you sure you want to delete all the IP pools?',
ipsVifs: 'VIFs',
ipsNotUsed: 'Not used'
}
forEach(messages, function (message, id) {
if (isString(message)) {

126
src/common/ip.js Normal file
View File

@@ -0,0 +1,126 @@
import forEachRight from 'lodash/forEachRight'
import forEach from 'lodash/forEach'
import isArray from 'lodash/isArray'
import isIp from 'is-ip'
import some from 'lodash/some'
export { isIp }
export const isIpV4 = isIp.v4
export const isIpV6 = isIp.v6
// Source: https://github.com/ezpaarse-project/ip-range-generator/blob/master/index.js
const ipv4 = /^(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(?:\.(?!$)|$)){4}$/
function ip2hex (ip) {
let parts = ip.split('.').map(str => parseInt(str, 10))
let n = 0
n += parts[3]
n += parts[2] * 256 // 2^8
n += parts[1] * 65536 // 2^16
n += parts[0] * 16777216 // 2^24
return n
}
function assertIpv4 (str, msg) {
if (!ipv4.test(str)) { throw new Error(msg) }
}
function *range (ip1, ip2) {
assertIpv4(ip1, 'argument "ip1" must be a valid IPv4 address')
assertIpv4(ip2, 'argument "ip2" must be a valid IPv4 address')
let hex = ip2hex(ip1)
let hex2 = ip2hex(ip2)
if (hex > hex2) {
let tmp = hex
hex = hex2
hex2 = tmp
}
for (let i = hex; i <= hex2; i++) {
yield `${(i >> 24) & 0xff}.${(i >> 16) & 0xff}.${(i >> 8) & 0xff}.${i & 0xff}`
}
}
// -----------------------------------------------------------------------------
export const getNextIpV4 = ip => {
const splitIp = ip.split('.')
if (splitIp.length !== 4 || some(splitIp, value => value < 0 || value > 255)) {
return
}
let index
forEachRight(splitIp, (value, i) => {
if (value < 255) {
index = i
return false
}
splitIp[i] = 1
})
if (index === 0 && +splitIp[0] === 255) {
return 0
}
splitIp[index]++
return splitIp.join('.')
}
export const formatIps = ips => {
if (!isArray(ips)) {
throw new Error('ips must be an array')
}
if (ips.length === 0) {
return []
}
const sortedIps = ips.sort((ip1, ip2) => {
const splitIp1 = ip1.split('.')
const splitIp2 = ip2.split('.')
if (splitIp1.length !== 4) {
return 1
}
if (splitIp2.length !== 4) {
return -1
}
return splitIp1[3] - splitIp2[3] +
(splitIp1[2] - splitIp2[2]) * 256 +
(splitIp1[1] - splitIp2[1]) * 256 * 256 +
(splitIp1[0] - splitIp2[0]) * 256 * 256 * 256
})
const range = { first: '', last: '' }
const formattedIps = []
let index = 0
forEach(sortedIps, ip => {
if (ip !== getNextIpV4(range.last)) {
if (range.first) {
formattedIps[index] = range.first === range.last ? range.first : { ...range }
index++
}
range.first = range.last = ip
} else {
range.last = ip
}
})
formattedIps[index] = range.first === range.last ? range.first : range
return formattedIps
}
export const parseIpPattern = pattern => {
const ips = []
forEach(pattern.split(';'), rawIpRange => {
const ipRange = rawIpRange.split('-')
if (ipRange.length < 2) {
ips.push(ipRange[0])
} else if (!isIpV4(ipRange[0]) || !isIpV4(ipRange[1])) {
ips.push(rawIpRange)
} else {
ips.push(...range(ipRange[0], ipRange[1]))
}
})
return ips
}

View File

@@ -102,6 +102,14 @@ export default class NoVnc extends Component {
this._clean()
}
componentWillReceiveProps (props) {
const rfb = this._rfb
if (rfb && this.props.scale !== props.scale) {
rfb.get_display().set_scale(props.scale || 1)
rfb.get_mouse().set_scale(props.scale || 1)
}
}
_focus = () => {
const rfb = this._rfb
if (rfb) {

View File

@@ -55,13 +55,12 @@ export const SrItem = propTypes({
let label = `${sr.name_label || sr.id}`
if (isSrWritable(sr)) {
label += ` (${formatSize(sr.size)})`
label += ` (${formatSize(sr.size - sr.physical_usage)} free)`
}
return (
<span>
<Icon icon='sr' /> {label}
{container && ` (${container.name_label || container.id})`}
</span>
)
}))
@@ -113,6 +112,16 @@ const xoItemToRender = {
<Icon icon='resource-set' /> {resourceSet.name}
</span>
),
sshKey: key => (
<span>
<Icon icon='ssh-key' /> {key.label}
</span>
),
ipPool: ipPool => (
<span>
<Icon icon='ip' /> {ipPool.name}
</span>
),
// XO objects.
pool: pool => (

View File

@@ -4,7 +4,7 @@ import later from 'later'
import map from 'lodash/map'
import React from 'react'
import sortedIndex from 'lodash/sortedIndex'
import { FormattedTime } from 'react-intl'
import { FormattedDate, FormattedTime } from 'react-intl'
import {
Tab,
Tabs
@@ -111,12 +111,12 @@ const TIME_FORMAT = {
// monthNum: [ 0 : 11 ]
const getMonthName = (monthNum) =>
<FormattedTime value={new Date(1970, monthNum)} month='long' />
<FormattedDate value={Date.UTC(1970, monthNum)} month='long' timeZone='UTC' />
// dayNum: [ 0 : 6 ]
const getDayName = (dayNum) =>
// January, 1970, 5th => Monday
<FormattedTime value={new Date(1970, 0, 4 + dayNum)} weekday='long' />
<FormattedDate value={Date.UTC(1970, 0, 4 + dayNum)} weekday='long' timeZone='UTC' />
// ===================================================================
@@ -280,14 +280,12 @@ class TimePicker extends Component {
}
_update (props) {
const { refs } = this
const { value, valueRenderer } = props
if (value.indexOf('/') === 1) {
this.setState({
activeKey: NAV_EVERY_N
})
refs.range.value = value.split('/')[1]
}, () => { this.refs.range.value = value.split('/')[1] })
} else {
this.setState({
activeKey: NAV_EACH_SELECTED,

View File

@@ -8,6 +8,8 @@ import groupBy from 'lodash/groupBy'
import keyBy from 'lodash/keyBy'
import keys from 'lodash/keys'
import map from 'lodash/map'
import mapValues from 'lodash/mapValues'
import pick from 'lodash/pick'
import sortBy from 'lodash/sortBy'
import store from 'store'
import { parse as parseRemote } from 'xo-remote-parser'
@@ -31,7 +33,9 @@ import {
} from './utils'
import {
isSrWritable,
subscribeCurrentUser,
subscribeGroups,
subscribeIpPools,
subscribeRemotes,
subscribeResourceSets,
subscribeRoles,
@@ -50,6 +54,32 @@ const getLabel = object =>
// ===================================================================
/*
* WITHOUT xoContainers :
*
* xoObjects: [
* { type: 'myType', id: 'abc', label: 'First object' },
* { type: 'myType', id: 'def', label: 'Second object' }
* ]
*
*
* WITH xoContainers :
*
* xoContainers: [
* { type: 'containerType', id: 'ghi', label: 'First container' },
* { type: 'containerType', id: 'jkl', label: 'Second container' }
* ]
*
* xoObjects: {
* ghi: [
* { type: 'objectType', id: 'mno', label: 'First object' }
* { type: 'objectType', id: 'pqr', label: 'Second object' }
* ],
* jkl: [
* { type: 'objectType', id: 'stu', label: 'Third object' }
* ]
* }
*/
@propTypes({
autoFocus: propTypes.bool,
clearable: propTypes.bool,
@@ -82,7 +112,7 @@ export class GenericSelect extends Component {
// Returns the values of the selected objects
// if they are contained in xoObjectsById.
return mapPlus(value, (value, push) => {
const o = xoObjectsById[value.value || value]
const o = xoObjectsById[value.value !== undefined ? value.value : value]
if (o) {
push(o)
@@ -96,11 +126,11 @@ export class GenericSelect extends Component {
// Supports id strings and objects.
_setValue (value, props = this.props) {
if (props.multi) {
return map(value, object => object.id || object)
return map(value, object => object.id !== undefined ? object.id : object)
}
return (value != null)
? value.id || value
? value.id !== undefined ? value.id : value
: ''
}
@@ -202,7 +232,7 @@ export class GenericSelect extends Component {
this.setState({
value: this._setValue(value)
}, onChange && (() => { onChange(this.value) }))
}, onChange && (() => onChange(this.value)))
}
// GroupBy: Display option with margin if not disabled and containers exists.
@@ -267,13 +297,28 @@ const makeSubscriptionSelect = (subscribe, props) => (
class extends Component {
constructor (props) {
super(props)
this.state = {
xoObjects: []
}
this._getFilteredXoObjects = createFilter(
this._getFilteredXoContainers = createFilter(
() => this.state.xoContainers,
() => this.props.containerPredicate
)
this._getFilteredXoObjects = createSelector(
() => this.state.xoObjects,
() => this.props.predicate
() => this.state.xoContainers && this._getFilteredXoContainers(),
() => this.props.predicate,
(xoObjects, xoContainers, predicate) => {
if (xoContainers == null) {
return filter(xoObjects, predicate)
} else {
// Filter xoObjects with `predicate`...
const filteredObjects = mapValues(xoObjects, xoObjectsGroup =>
filter(xoObjectsGroup, predicate)
)
// ...and keep only those whose xoContainer hasn't been filtered out
return pick(filteredObjects, map(xoContainers, container => container.id))
}
}
)
}
@@ -296,7 +341,7 @@ const makeSubscriptionSelect = (subscribe, props) => (
{...props}
{...this.props}
xoObjects={this._getFilteredXoObjects()}
xoContainers={this.state.xoContainers}
xoContainers={this.state.xoContainers && this._getFilteredXoContainers()}
/>
)
}
@@ -766,3 +811,61 @@ export class SelectResourceSetsNetwork extends Component {
)
}
}
// ===================================================================
export class SelectSshKey extends Component {
get value () {
return this.refs.select.value
}
set value (value) {
this.refs.select.value = value
}
componentWillMount () {
this.componentWillUnmount = subscribeCurrentUser(user => {
this.setState({
sshKeys: user && user.preferences && map(user.preferences.sshKeys, (key, id) => ({
id,
label: key.title,
type: 'sshKey'
}))
})
})
}
render () {
return (
<GenericSelect
ref='select'
placeholder={_('selectSshKey')}
{...this.props}
xoObjects={this.state.sshKeys || []}
/>
)
}
}
// ===================================================================
export const SelectIp = makeSubscriptionSelect(subscriber => {
const unsubscribeIpPools = subscribeIpPools(ipPools => {
const sortedIpPools = sortBy(ipPools, 'name')
const xoObjects = mapValues(
groupBy(sortedIpPools, 'id'),
ipPools => map(ipPools[0].addresses, (address, ip) => ({
...address,
id: ip,
label: ip
}))
)
const xoContainers = map(sortedIpPools, ipPool => ({
...ipPool,
type: 'ipPool'
}))
subscriber({ xoObjects, xoContainers })
})
return unsubscribeIpPools
}, { placeholder: _('selectIp') })

View File

@@ -212,6 +212,8 @@ const _getId = (state, { routeParams, id }) => routeParams
export const getLang = state => state.lang
export const getStatus = state => state.status
export const getUser = state => state.user
const _getPermissionsPredicate = invoke(() => {

View File

@@ -300,7 +300,7 @@ export default class SortedTable extends Component {
</thead>
<tbody>
{map(this._getVisibleItems(), (item, i) => {
const colums = map(props.columns, (column, key) => (
const columns = map(props.columns, (column, key) => (
<td key={key}>
{column.itemRenderer(item, userData)}
</td>
@@ -313,8 +313,8 @@ export default class SortedTable extends Component {
key={id}
tagName='tr'
to={isFunction(rowLink) ? rowLink(item, userData) : rowLink}
>{colums}</BlockLink>
: <tr key={id}>{colums}</tr>
>{columns}</BlockLink>
: <tr key={id}>{columns}</tr>
})}
</tbody>
</table>

View File

@@ -58,7 +58,7 @@ export default class Tags extends Component {
}
}}
onBlur={this._stopEdit}
></input>
/>
</span>
: []
}

View File

@@ -2,6 +2,7 @@ import * as actions from 'store/actions'
import escapeRegExp from 'lodash/escapeRegExp'
import every from 'lodash/every'
import forEach from 'lodash/forEach'
import getStream from 'get-stream'
import humanFormat from 'human-format'
import isArray from 'lodash/isArray'
import isEmpty from 'lodash/isEmpty'
@@ -13,6 +14,7 @@ import keys from 'lodash/keys'
import map from 'lodash/map'
import mapValues from 'lodash/mapValues'
import React from 'react'
import ReadableStream from 'readable-stream'
import replace from 'lodash/replace'
import store from 'store'
import { connect } from 'react-redux'
@@ -209,7 +211,7 @@ export const getXoaPlan = plan => {
export const mapPlus = (collection, cb) => {
const result = []
const push = ::result.push
forEach(collection, value => cb(value, push))
forEach(collection, (value, index) => cb(value, push, index))
return result
}
@@ -417,3 +419,36 @@ export function buildTemplate (pattern, rules) {
return isFunction(rule) ? rule(...params) : rule
})
}
// ===================================================================
export const streamToString = getStream
// ===================================================================
/* global FileReader */
// Creates a readable stream from a HTML file.
export const htmlFileToStream = file => {
const reader = new FileReader()
const stream = new ReadableStream()
let offset = 0
reader.onloadend = evt => {
stream.push(evt.target.result)
}
reader.onerror = error => {
stream.emit('error', error)
}
stream._read = function (size) {
if (offset >= file.size) {
stream.push(null)
} else {
reader.readAsBinaryString(file.slice(offset, offset + size))
offset += size
}
}
return stream
}

View File

@@ -1,8 +1,7 @@
import React from 'react'
import {
Sparklines,
SparklinesLine,
SparklinesSpots
SparklinesLine
} from 'react-sparklines'
import propTypes from './prop-types'
@@ -14,7 +13,7 @@ import {
const STYLE = {}
const WIDTH = 120
const HEIGHT = 40
const HEIGHT = 20
// ===================================================================
@@ -36,8 +35,7 @@ export const CpuSparkLines = propTypes({
return (
<Sparklines style={STYLE} data={computeArraysAvg(cpus)} max={100} min={0} width={WIDTH} height={HEIGHT}>
<SparklinesLine style={{ strokeWidth: 1, stroke: '#366e98', fill: '#366e98', fillOpacity: 0.5 }} color='#2598d9' />
<SparklinesSpots />
<SparklinesLine style={{ strokeWidth: 0.5, stroke: '#366e98', fill: '#366e98', fillOpacity: 0.5 }} color='#2598d9' />
</Sparklines>
)
})
@@ -53,8 +51,7 @@ export const MemorySparkLines = propTypes({
return (
<Sparklines style={STYLE} data={memoryUsed} max={memory[memory.length - 1]} min={0} width={WIDTH} height={HEIGHT}>
<SparklinesLine style={{ strokeWidth: 1, stroke: '#990822', fill: '#990822', fillOpacity: 0.5 }} color='#cc0066' />
<SparklinesSpots />
<SparklinesLine style={{ strokeWidth: 0.5, stroke: '#990822', fill: '#990822', fillOpacity: 0.5 }} color='#cc0066' />
</Sparklines>
)
})
@@ -70,8 +67,7 @@ export const XvdSparkLines = propTypes({
return (
<Sparklines style={STYLE} data={computeObjectsAvg(xvds)} min={0} width={WIDTH} height={HEIGHT}>
<SparklinesLine style={{ strokeWidth: 1, stroke: '#089944', fill: '#089944', fillOpacity: 0.5 }} color='#33cc33' />
<SparklinesSpots />
<SparklinesLine style={{ strokeWidth: 0.5, stroke: '#089944', fill: '#089944', fillOpacity: 0.5 }} color='#33cc33' />
</Sparklines>
)
})
@@ -87,8 +83,7 @@ export const VifSparkLines = propTypes({
return (
<Sparklines style={STYLE} data={computeObjectsAvg(vifs)} min={0} width={WIDTH} height={HEIGHT}>
<SparklinesLine style={{ strokeWidth: 1, stroke: '#eca649', fill: '#eca649', fillOpacity: 0.5 }} color='#ffd633' />
<SparklinesSpots />
<SparklinesLine style={{ strokeWidth: 0.5, stroke: '#eca649', fill: '#eca649', fillOpacity: 0.5 }} color='#ffd633' />
</Sparklines>
)
})
@@ -104,8 +99,7 @@ export const PifSparkLines = propTypes({
return (
<Sparklines style={STYLE} data={computeObjectsAvg(pifs)} min={0} width={WIDTH} height={HEIGHT}>
<SparklinesLine style={{ strokeWidth: 1, stroke: '#eca649', fill: '#eca649', fillOpacity: 0.5 }} color='#ffd633' />
<SparklinesSpots />
<SparklinesLine style={{ strokeWidth: 0.5, stroke: '#eca649', fill: '#eca649', fillOpacity: 0.5 }} color='#ffd633' />
</Sparklines>
)
})
@@ -121,8 +115,7 @@ export const LoadSparkLines = propTypes({
return (
<Sparklines style={STYLE} data={load} min={0} width={WIDTH} height={HEIGHT}>
<SparklinesLine style={{ strokeWidth: 1, stroke: '#33cc33', fill: '#33cc33', fillOpacity: 0.5 }} color='#33cc33' />
<SparklinesSpots />
<SparklinesLine style={{ strokeWidth: 0.5, stroke: '#33cc33', fill: '#33cc33', fillOpacity: 0.5 }} color='#33cc33' />
</Sparklines>
)
})

View File

@@ -0,0 +1,37 @@
import _ from 'intl'
import BaseComponent from 'base-component'
import every from 'lodash/every'
import React from 'react'
import SingleLineRow from 'single-line-row'
import { SelectHost } from 'select-objects'
import { Col } from 'grid'
import { connectStore } from 'utils'
import { createGetObjectsOfType } from 'selectors'
@connectStore(() => ({
hosts: createGetObjectsOfType('host')
}), { withRef: true })
export default class AddHostModal extends BaseComponent {
get value () {
return this.state
}
_hostPredicate = host =>
host.$pool !== this.props.pool.id &&
every(this.props.hosts, h => h.$pool !== host.$pool || h.id === host.id)
render () {
return <div>
<SingleLineRow>
<Col size={6}>{_('addHostSelectHost')}</Col>
<Col size={6}>
<SelectHost
onChange={this.linkState('host')}
predicate={this._hostPredicate}
value={this.state.host}
/>
</Col>
</SingleLineRow>
</div>
}
}

View File

@@ -39,8 +39,9 @@ export const XEN_DEFAULT_CPU_CAP = 0
// ===================================================================
export const isSrWritable = sr => sr.content_type !== 'iso' && sr.size > 0
export const isSrShared = sr => sr.$PBDs.length > 1
export const isSrWritable = sr => sr && sr.content_type !== 'iso' && sr.size > 0
export const isSrShared = sr => sr && sr.$PBDs.length > 1
export const isVmRunning = vm => vm && vm.power_state === 'Running'
// ===================================================================
@@ -49,6 +50,12 @@ export const signOut = () => {
window.location.reload(true)
}
export const connect = () => {
xo.open(createBackoff()).catch(error => {
logError(error, 'failed to connect to xo-server')
})
}
const xo = invoke(() => {
const token = cookies.get('token')
if (!token) {
@@ -60,13 +67,6 @@ const xo = invoke(() => {
credentials: { token }
})
const connect = () => {
xo.open(createBackoff()).catch(error => {
logError(error, 'failed to connect to xo-server')
})
}
connect()
xo.on('scheduledAttempt', ({ delay }) => {
console.warn('next attempt in %s ms', delay)
})
@@ -75,6 +75,7 @@ const xo = invoke(() => {
return xo
})
connect()
const _signIn = new Promise(resolve => xo.once('authenticated', resolve))
@@ -159,6 +160,12 @@ const createSubscription = cb => {
if (!isEqual(result, cache)) {
cache = result
/* FIXME: Edge case:
* 1) MyComponent has a subscription with subscribers[1]
* 2) subscribers[0] causes the MyComponent unmounting (and thus its unsubscription)
* When subscribers[1] will be executed, it will no longer exist,
* which will throw an error (Uncaught (in promise) TypeError: subscriber is not a function)
*/
forEach(subscribers, subscriber => {
subscriber(result)
})
@@ -206,6 +213,8 @@ export const subscribeJobs = createSubscription(() => _call('job.getAll'))
export const subscribeJobsLogs = createSubscription(() => _call('log.get', {namespace: 'jobs'}))
export const subscribeApiLogs = createSubscription(() => _call('log.get', {namespace: 'api'}))
export const subscribePermissions = createSubscription(() => _call('acl.getCurrentPermissions'))
export const subscribePlugins = createSubscription(() => _call('plugin.get'))
@@ -244,6 +253,8 @@ export const subscribeRoles = createSubscription(invoke(
sort => () => _call('role.getAll').then(sort)
))
export const subscribeIpPools = createSubscription(() => _call('ipPool.getAll'))
// System ============================================================
export const apiMethods = _call('system.getMethodsInfo')
@@ -277,26 +288,26 @@ export const addServer = (host, username, password) => (
)
)
export const editServer = ({ id }, { host, username, password, readOnly }) => (
_call('server.set', { id, host, username, password, readOnly })::tap(
export const editServer = (server, { host, username, password, readOnly }) => (
_call('server.set', { id: resolveId(server), host, username, password, readOnly })::tap(
subscribeServers.forceRefresh
)
)
export const connectServer = ({ id }) => (
_call('server.connect', { id })::tap(
export const connectServer = server => (
_call('server.connect', { id: resolveId(server) })::tap(
subscribeServers.forceRefresh
)
)
export const disconnectServer = ({ id }) => (
_call('server.disconnect', { id })::tap(
export const disconnectServer = server => (
_call('server.disconnect', { id: resolveId(server) })::tap(
subscribeServers.forceRefresh
)
)
export const removeServer = ({ id }) => (
_call('server.remove', { id })::tap(
export const removeServer = server => (
_call('server.remove', { id: resolveId(server) })::tap(
subscribeServers.forceRefresh
)
)
@@ -307,6 +318,42 @@ export const editPool = (pool, props) => (
_call('pool.set', { id: resolveId(pool), ...props })
)
import AddHostModalBody from './add-host-modal'
export const addHostToPool = (pool, host) => {
if (host) {
return confirm({
title: _('addHostModalTitle'),
body: _('addHostModalMessage', { pool: pool.name_label, host: host.name_label })
}).then(() =>
_call('pool.mergeInto', { source: host.$pool, target: pool.id, force: true })
)
}
return confirm({
title: _('addHostModalTitle'),
body: <AddHostModalBody pool={pool} />
}).then(
params => {
if (!params.host) {
error(_('addHostNoHost'), _('addHostNoHostMessage'))
return
}
_call('pool.mergeInto', { source: params.host.$pool, target: pool.id, force: true })
},
noop
)
}
export const detachHost = host => (
confirm({
icon: 'host-eject',
title: _('detachHostModalTitle'),
body: _('detachHostModalMessage', {host: <strong>{host.name_label}</strong>})
}).then(
() => _call('host.detach', { host: host.id })
)
)
// Host --------------------------------------------------------------
export const editHost = (host, props) => (
@@ -348,7 +395,7 @@ export const restartHostsAgents = hosts => {
title: _('restartHostsAgentsModalTitle', { nHosts }),
body: _('restartHostsAgentsModalMessage', { nHosts })
}).then(
() => map(hosts, host => restartHostAgent(host)),
() => map(hosts, restartHostAgent),
noop
)
}
@@ -413,6 +460,10 @@ export const installAllHostPatches = host => (
_call('host.installAllPatches', { host: resolveId(host) })
)
export const installAllPatchesOnPool = pool => (
_call('pool.installAllPatches', { pool: resolveId(pool) })
)
// VM ----------------------------------------------------------------
export const startVm = vm => (
@@ -651,7 +702,7 @@ export const deleteVms = vms => (
title: _('deleteVmsModalTitle', { vms: vms.length }),
body: _('deleteVmsModalMessage', { vms: vms.length })
}).then(
() => map(vms, vmId => _call('vm.delete', { id: vmId })),
() => map(vms, vmId => _call('vm.delete', { id: vmId, delete_disks: true })),
noop
)
)
@@ -682,12 +733,12 @@ export const fetchVmStats = (vm, granularity) => (
_call('vm.stats', { id: resolveId(vm), granularity })
)
export const importVm = (file, sr) => {
export const importVm = (file, type = 'xva', data = undefined, sr) => {
const { name } = file
info(_('startVmImport'), name)
return _call('vm.import', { sr }).then(({ $sendTo: url }) => {
return _call('vm.import', { type, data, sr: resolveId(sr) }).then(({ $sendTo: url }) => {
const req = request.post(url)
req.send(file)
@@ -701,9 +752,9 @@ export const importVm = (file, sr) => {
})
}
export const importVms = (files, sr) => (
Promise.all(map(files, file =>
importVm(file, sr).catch(noop)
export const importVms = (vms, sr) => (
Promise.all(map(vms, ({ file, type, data }) =>
importVm(file, type, data, sr).catch(noop)
))
)
@@ -759,7 +810,13 @@ export const editVdi = (vdi, props) => (
)
export const deleteVdi = vdi => (
_call('vdi.delete', { id: resolveId(vdi) })
confirm({
title: _('deleteVdiModalTitle'),
body: _('deleteVdiModalMessage')
}).then(
() => _call('vdi.delete', { id: resolveId(vdi) }),
noop
)
)
export const migrateVdi = (vdi, sr) => (
@@ -802,6 +859,10 @@ export const deleteVif = vif => (
_call('vif.delete', { id: resolveId(vif) })
)
export const setVif = (vif, { allowedIpv4Addresses, allowedIpv6Addresses }) => (
_call('vif.set', { id: resolveId(vif), allowedIpv4Addresses, allowedIpv6Addresses })
)
// Network -----------------------------------------------------------
export const editNetwork = (network, props) => (
@@ -1233,6 +1294,14 @@ export const deleteJobsLog = id => (
)
)
// Logs
export const deleteApiLog = id => (
_call('log.delete', {namespace: 'api', id})::tap(
subscribeApiLogs.forceRefresh
)
)
// Acls, users, groups ----------------------------------------------------------
export const addAcl = ({subject, object, action}) => (
@@ -1316,9 +1385,11 @@ export const deleteUser = user => (
confirm({
title: _('deleteUser'),
body: <p>{_('deleteUserConfirm')}</p>
}).then(() => _call('user.delete', resolveIds({id: user})))
::tap(subscribeUsers.forceRefresh)
::rethrow(err => error(_('deleteUser'), err.message || String(err)))
}).then(() =>
_call('user.delete', { id: resolveId(user) })
::tap(subscribeUsers.forceRefresh)
::rethrow(err => error(_('deleteUser'), err.message || String(err)))
)
)
export const editUser = (user, { email, password, permission }) => (
@@ -1347,8 +1418,16 @@ const _setUserPreferences = preferences => (
)
import NewSshKeyModalBody from './new-ssh-key-modal'
export const addSshKey = () => (
confirm({
export const addSshKey = key => {
const { preferences } = xo.user
const otherKeys = preferences && preferences.sshKeys || []
if (key) {
return _setUserPreferences({ sshKeys: [
...otherKeys,
key
]})
}
return confirm({
icon: 'ssh-key',
title: _('newSshKeyModalTitle'),
body: <NewSshKeyModalBody />
@@ -1358,8 +1437,6 @@ export const addSshKey = () => (
error(_('sshKeyErrorTitle'), _('sshKeyErrorMessage'))
return
}
const { preferences } = xo.user
const otherKeys = preferences && preferences.sshKeys || []
return _setUserPreferences({ sshKeys: [
...otherKeys,
newKey
@@ -1367,7 +1444,7 @@ export const addSshKey = () => (
},
noop
)
)
}
export const deleteSshKey = key => (
confirm({
@@ -1462,13 +1539,13 @@ export const setDefaultHomeFilter = (type, name) => {
// Jobs ----------------------------------------------------------
export const deleteJob = job => (
_call('job.delete', resolveIds({id: job}))::tap(
_call('job.delete', { id: resolveId(job) })::tap(
subscribeJobs.forceRefresh
)
)
export const deleteSchedule = schedule => (
_call('schedule.delete', resolveIds({id: schedule}))::tap(
_call('schedule.delete', { id: resolveIds(schedule) })::tap(
subscribeSchedules.forceRefresh
)
)
@@ -1484,3 +1561,25 @@ export const updateSchedule = ({ id, job: jobId, cron, enabled, name, timezone }
subscribeSchedules.forceRefresh
)
)
// IP pools --------------------------------------------------------------------
export const createIpPool = ({ name, ips, networks }) => {
const addresses = {}
forEach(ips, ip => { addresses[ip] = {} })
return _call('ipPool.create', { name, addresses, networks: resolveIds(networks) })::tap(
subscribeIpPools.forceRefresh
)
}
export const deleteIpPool = ipPool => (
_call('ipPool.delete', { id: resolveId(ipPool) })::tap(
subscribeIpPools.forceRefresh
)
)
export const setIpPool = (ipPool, { name, addresses, networks }) => (
_call('ipPool.set', { id: resolveId(ipPool), name, addresses, networks: resolveIds(networks) })::tap(
subscribeIpPools.forceRefresh
)
)

View File

@@ -1,9 +1,11 @@
import BaseComponent from 'base-component'
import every from 'lodash/every'
import forEach from 'lodash/forEach'
import find from 'lodash/find'
import map from 'lodash/map'
import mapValues from 'lodash/mapValues'
import React from 'react'
import store from 'store'
import _ from '../../intl'
import invoke from '../../invoke'
@@ -22,8 +24,12 @@ import {
import {
createGetObjectsOfType,
createPicker,
createSelector
createSelector,
getObject
} from '../../selectors'
import {
isSrShared
} from 'xo'
import { isSrWritable } from '../'
@@ -59,6 +65,7 @@ import styles from './index.css'
networks: getNetworks,
pifs: getPifs,
pools: getPools,
vbds: getVbds,
vdis: getVdis,
vifs: getVifs
}
@@ -85,7 +92,26 @@ export default class MigrateVmModalBody extends BaseComponent {
)
)
this._getNetworkPredicate = createSelector(
this._getTargetNetworkPredicate = createSelector(
createPicker(
() => this.props.pifs,
() => this.state.host.$PIFs
),
pifs => {
if (!pifs) {
return false
}
const networks = {}
forEach(pifs, pif => {
networks[pif.$network] = true
})
return network => networks[network.id]
}
)
this._getMigrationNetworkPredicate = createSelector(
createPicker(
() => this.props.pifs,
() => this.state.host.$PIFs
@@ -118,7 +144,12 @@ export default class MigrateVmModalBody extends BaseComponent {
}
}
_getObject (id) {
return getObject(store.getState(), id)
}
_selectHost = host => {
// No host selected
if (!host) {
this.setState({
host: undefined,
@@ -126,20 +157,40 @@ export default class MigrateVmModalBody extends BaseComponent {
})
return
}
const intraPool = this.props.vm.$pool === host.$pool
const { pools, vbds, vdis, vm } = this.props
const intraPool = vm.$pool === host.$pool
// Intra-pool
const defaultSr = pools[host.$pool].default_SR
if (intraPool) {
let doNotMigrateVdis
if (vm.$container === host.id) {
doNotMigrateVdis = true
} else {
const _doNotMigrateVdi = {}
forEach(vbds, vbd => {
if (vbd.VDI != null) {
_doNotMigrateVdi[vbd.VDI] = isSrShared(this._getObject(this._getObject(vbd.VDI).$SR))
}
})
doNotMigrateVdis = every(_doNotMigrateVdi)
}
this.setState({
doNotMigrateVdis,
host,
intraPool,
mapVdisSrs: undefined,
mapVdisSrs: doNotMigrateVdis ? undefined : mapValues(vdis, vdi => defaultSr),
mapVifsNetworks: undefined,
migrationNetwork: undefined
})
return
}
const { networks, pools, pifs, vdis, vifs } = this.props
// Inter-pool
const { networks, pifs, vifs } = this.props
const defaultMigrationNetworkId = find(pifs, pif => pif.$host === host.id && pif.management).$network
const defaultSr = pools[host.$pool].default_SR
const defaultNetwork = invoke(() => {
// First PIF with an IP.
@@ -158,6 +209,7 @@ export default class MigrateVmModalBody extends BaseComponent {
})
this.setState({
doNotMigrateVdis: false,
host,
intraPool,
mapVdisSrs: mapValues(vdis, vdi => defaultSr),
@@ -171,6 +223,7 @@ export default class MigrateVmModalBody extends BaseComponent {
render () {
const { vdis, vifs, networks } = this.props
const {
doNotMigrateVdis,
host,
intraPool,
mapVdisSrs,
@@ -190,6 +243,28 @@ export default class MigrateVmModalBody extends BaseComponent {
</Col>
</SingleLineRow>
</div>
{host && !doNotMigrateVdis && <div className={styles.groupBlock}>
<SingleLineRow>
<Col>{_('migrateVmSelectSrs')}</Col>
</SingleLineRow>
<br />
<SingleLineRow>
<Col size={6}><span className={styles.listTitle}>{_('migrateVmName')}</span></Col>
<Col size={6}><span className={styles.listTitle}>{_('migrateVmSr')}</span></Col>
</SingleLineRow>
{map(vdis, vdi => <div className={styles.listItem} key={vdi.id}>
<SingleLineRow>
<Col size={6}>{vdi.name_label}</Col>
<Col size={6}>
<SelectSr
onChange={sr => this.setState({ mapVdisSrs: { ...mapVdisSrs, [vdi.id]: sr.id } })}
predicate={this._getSrPredicate()}
value={mapVdisSrs[vdi.id]}
/>
</Col>
</SingleLineRow>
</div>)}
</div>}
{intraPool !== undefined &&
(!intraPool &&
<div>
@@ -199,34 +274,12 @@ export default class MigrateVmModalBody extends BaseComponent {
<Col size={6}>
<SelectNetwork
onChange={this._selectMigrationNetwork}
predicate={this._getNetworkPredicate()}
predicate={this._getMigrationNetworkPredicate()}
value={migrationNetworkId}
/>
</Col>
</SingleLineRow>
</div>
<div className={styles.groupBlock}>
<SingleLineRow>
<Col>{_('migrateVmSelectSrs')}</Col>
</SingleLineRow>
<br />
<SingleLineRow>
<Col size={6}><span className={styles.listTitle}>{_('migrateVmName')}</span></Col>
<Col size={6}><span className={styles.listTitle}>{_('migrateVmSr')}</span></Col>
</SingleLineRow>
{map(vdis, vdi => <div className={styles.listItem} key={vdi.id}>
<SingleLineRow>
<Col size={6}>{vdi.name_label}</Col>
<Col size={6}>
<SelectSr
onChange={sr => this.setState({ mapVdisSrs: { ...mapVdisSrs, [vdi.id]: sr.id } })}
predicate={this._getSrPredicate()}
value={mapVdisSrs[vdi.id]}
/>
</Col>
</SingleLineRow>
</div>)}
</div>
<div className={styles.groupBlock}>
<SingleLineRow>
<Col>{_('migrateVmSelectNetworks')}</Col>
@@ -242,7 +295,7 @@ export default class MigrateVmModalBody extends BaseComponent {
<Col size={6}>
<SelectNetwork
onChange={network => this.setState({ mapVifsNetworks: { ...mapVifsNetworks, [vif.id]: network.id } })}
predicate={this._getNetworkPredicate()}
predicate={this._getTargetNetworkPredicate()}
value={mapVifsNetworks[vif.id]}
/>
</Col>

View File

@@ -87,7 +87,26 @@ export default class MigrateVmsModalBody extends BaseComponent {
)
)
this._getNetworkPredicate = createSelector(
this._getTargetNetworkPredicate = createSelector(
createPicker(
() => this.props.pifs,
() => this.state.host.$PIFs
),
pifs => {
if (!pifs) {
return false
}
const networks = {}
forEach(pifs, pif => {
networks[pif.$network] = true
})
return network => networks[network.id]
}
)
this._getMigrationNetworkPredicate = createSelector(
createPicker(
() => this.props.pifs,
() => this.state.host.$PIFs
@@ -261,7 +280,7 @@ export default class MigrateVmsModalBody extends BaseComponent {
<Col size={6}>
<SelectNetwork
onChange={this._selectMigrationNetwork}
predicate={this._getNetworkPredicate()}
predicate={this._getMigrationNetworkPredicate()}
value={migrationNetworkId}
/>
</Col>
@@ -290,7 +309,7 @@ export default class MigrateVmsModalBody extends BaseComponent {
<SelectNetwork
disabled={smartVifMapping}
onChange={this._selectNetwork}
predicate={this._getNetworkPredicate()}
predicate={this._getTargetNetworkPredicate()}
value={networkId}
/>
</Col>

View File

@@ -116,6 +116,10 @@
@extend .fa;
@extend .fa-key;
}
&-ip {
@extend .fa;
@extend .fa-map-marker;
}
&-shown {
@extend .fa;
@@ -163,12 +167,20 @@
@extend .fa;
@extend .fa-link;
}
&-disconnect {
@extend .fa;
@extend .fa-chain-broken;
}
&-lock {
@extend .fa;
@extend .fa-lock;
}
&-unlock {
@extend .fa;
@extend .fa-unlock;
}
&-cpu {
@extend .fa;
@extend .fa-dashboard;
@@ -676,6 +688,10 @@
@extend .fa;
@extend .fa-puzzle-piece;
}
&-logs {
@extend .fa;
@extend .fa-list;
}
}
&-menu-about {
@extend .fa;

View File

@@ -72,17 +72,27 @@ $select-input-height: 40px; // Bootstrap input height
width: 100%;
}
.Select-value-label {
color: #373a3c;
}
.Select-control {
border-radius: unset;
}
// Disabled option style.
.Select-menu-outer {
.Select-option.is-disabled {
cursor: default;
font-weight: bold;
color: #777;
}
.Select-menu-outer .Select-option.is-disabled {
cursor: default;
font-weight: bold;
color: #777;
}
.Select-placeholder {
color: #999;
}
.Select--single > .Select-control .Select-value {
color: #333;
}
// COLORS ======================================================================

View File

@@ -6,10 +6,6 @@
// error for usage > 90%
meter {
/* Reset the default appearance */
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
/* For Firefox */
background: #EEE;
box-shadow: 0 2px 3px rgba(0,0,0,0.2) inset;

View File

@@ -137,7 +137,7 @@ export default class Restore extends Component {
{r.enabled && <span className='tag tag-success'>{_('remoteEnabled')}</span>}
{r.error && <span className='tag tag-danger'>{_('remoteError')}</span>}
<span className='pull-right'>
<ActionButton disabled={!r.enabled} icon='refresh' btnStyle='default' handler={this._list} handlerParam={r.id} />
<Tooltip content={_('displayBackup')}><ActionButton disabled={!r.enabled} icon='refresh' btnStyle='default' handler={this._list} handlerParam={r.id} /></Tooltip>
</span>
{r.backupInfoByVm && <div>
<br />

View File

@@ -2,9 +2,11 @@ import _ from 'intl'
import ActionRowButton from 'action-row-button'
import Icon from 'icon'
import isEmpty from 'lodash/isEmpty'
import Link from 'link'
import map from 'lodash/map'
import SortedTable from 'sorted-table'
import TabButton from 'tab-button'
import Tooltip from 'tooltip'
import Upgrade from 'xoa-upgrade'
import React, { Component } from 'react'
import { Card, CardHeader, CardBlock } from 'card'
@@ -25,7 +27,7 @@ import {
const SrColContainer = connectStore(() => ({
container: createGetObject()
}))(({ container }) => <span>{container.name_label}</span>)
}))(({ container }) => <Link to={`pools/${container.id}`}>{container.name_label}</Link>)
const VdiColSr = connectStore(() => ({
sr: createGetObject()
@@ -65,7 +67,10 @@ const SR_COLUMNS = [
},
{
name: _('srUsage'),
itemRenderer: sr => sr.size > 1 && <meter value={(sr.physical_usage / sr.size) * 100} min='0' max='100' optimum='40' low='80' high='90'></meter>,
itemRenderer: sr => sr.size > 1 &&
<Tooltip content={_('spaceLeftTooltip', {used: Math.round((sr.physical_usage / sr.size) * 100), free: formatSize(sr.size - sr.physical_usage)})}>
<meter value={(sr.physical_usage / sr.size) * 100} min='0' max='100' optimum='40' low='80' high='90' />
</Tooltip>,
sortCriteria: sr => sr.physical_usage / sr.size,
sortOrder: 'desc'
}
@@ -230,6 +235,8 @@ export default class Health extends Component {
)
)
_getSrUrl = sr => `srs/${sr.id}`
render () {
return process.env.XOA_PLAN > 3
? <Container>
@@ -244,7 +251,12 @@ export default class Health extends Component {
? <p className='text-xs-center'>{_('noSrs')}</p>
: <Row>
<Col>
<SortedTable collection={this.props.userSrs} columns={SR_COLUMNS} defaultColumn={4} />
<SortedTable
collection={this.props.userSrs}
columns={SR_COLUMNS}
defaultColumn={4}
rowLink={this._getSrUrl}
/>
</Col>
</Row>
}

View File

@@ -71,6 +71,8 @@ export default class HostItem extends Component {
<Ellipsis>
<Text value={host.name_label} onChange={this._setNameLabel} useLongClick />
</Ellipsis>
&nbsp;
{container && host.id === container.master && <span className='tag tag-pill tag-info'>{_('pillMaster')}</span>}
</EllipsisContainer>
</Col>
<Col mediumSize={4} className='hidden-md-down'>
@@ -110,10 +112,10 @@ export default class HostItem extends Component {
<Col largeSize={2} className='hidden-lg-down'>
<span className='tag tag-info tag-ip'>{host.address}</span>
</Col>
<Col mediumSize={2} className='hidden-sm-down'>
{container && <Col mediumSize={2} className='hidden-sm-down'>
<Link to={`/${container.type}s/${container.id}`}>{container.name_label}</Link>
</Col>
<Col mediumSize={1} className={styles.itemExpandRow}>
</Col>}
<Col mediumSize={1} offset={container ? undefined : 2} className={styles.itemExpandRow}>
<a className={styles.itemExpandButton}
onClick={this._toggleExpanded}>
<Icon icon='nav' fixedWidth />&nbsp;&nbsp;&nbsp;

View File

@@ -38,7 +38,6 @@
}
.itemExpanded {
padding-top: 0.4em;
color: #999;
font-size: 1em;
overflow: hidden;

View File

@@ -9,8 +9,8 @@ import SingleLineRow from 'single-line-row'
import size from 'lodash/size'
import Tags from 'tags'
import Tooltip from 'tooltip'
import { BlockLink } from 'link'
import { Row, Col } from 'grid'
import Link, { BlockLink } from 'link'
import { Col } from 'grid'
import { Text } from 'editable'
import {
addTag,
@@ -48,7 +48,8 @@ import styles from './index.css'
return {
hostMetrics: getHostMetrics,
missingPaths: getMissingPatches
missingPaths: getMissingPatches,
poolHosts: getPoolHosts
}
})
export default class PoolItem extends Component {
@@ -64,7 +65,7 @@ export default class PoolItem extends Component {
}
render () {
const { item: pool, expandAll, selected, hostMetrics } = this.props
const { item: pool, expandAll, selected, hostMetrics, poolHosts } = this.props
const { missingPatchCount } = this.state
return <div className={styles.item}>
<BlockLink to={`/pools/${pool.id}`}>
@@ -122,8 +123,8 @@ export default class PoolItem extends Component {
</SingleLineRow>
</BlockLink>
{(this.state.expanded || expandAll) &&
<Row>
<Col mediumSize={6} className={styles.itemExpanded}>
<SingleLineRow>
<Col mediumSize={3} className={styles.itemExpanded}>
<span>
{hostMetrics.count}x <Icon icon='host' />
{' '}
@@ -132,12 +133,17 @@ export default class PoolItem extends Component {
{formatSize(hostMetrics.memoryTotal)}
</span>
</Col>
<Col mediumSize={6}>
<Col mediumSize={4} className={styles.itemExpanded}>
<span>
{_('homePoolMaster')} <Link to={`/hosts/${pool.master}`}>{poolHosts && poolHosts[pool.master].name_label}</Link>
</span>
</Col>
<Col mediumSize={5}>
<span style={{fontSize: '1.4em'}}>
<Tags labels={pool.tags} onDelete={this._removeTag} onAdd={this._addTag} />
</span>
</Col>
</Row>
</SingleLineRow>
}
</div>
}

View File

@@ -107,7 +107,7 @@ export default class VmItem extends Component {
</span>
}
</span>
<Icon className='text-info' icon={vm.os_version && osFamily(vm.os_version.distro)} fixedWidth />
<Tooltip content={vm.os_version ? vm.os_version.name : _('unknownOsName')}><Icon className='text-info' icon={vm.os_version && osFamily(vm.os_version.distro)} fixedWidth /></Tooltip>
{' '}
<Ellipsis>
<Text value={vm.name_description} onChange={this._setNameDescription} placeholder={_('vmHomeDescriptionPlaceholder')} useLongClick />

View File

@@ -129,6 +129,10 @@ const isRunning = host => host && host.power_state === 'Running'
}
})
export default class Host extends Component {
static contextTypes = {
router: React.PropTypes.object
}
loop (host = this.props.host) {
if (this.cancel) {
this.cancel()
@@ -182,6 +186,10 @@ export default class Host extends Component {
}
const hostCur = this.props.host
if (hostCur && !hostNext) {
this.context.router.push('/')
}
if (!hostCur) {
this._getMissingPatches(hostNext)
}
@@ -234,7 +242,7 @@ export default class Host extends Component {
value={host.name_description}
onChange={this._setNameDescription}
/>
<span className='text-muted'> - <Link to={`/pools/${pool.id}`}>{pool.name_label}</Link></span>
{pool && <span className='text-muted'> - <Link to={`/pools/${pool.id}`}>{pool.name_label}</Link></span>}
</span>
</Col>
<Col mediumSize={6}>

View File

@@ -3,7 +3,7 @@ import Copiable from 'copiable'
import React from 'react'
import TabButton from 'tab-button'
import { Toggle } from 'form'
import { enableHost, disableHost, restartHost } from 'xo'
import { enableHost, detachHost, disableHost, restartHost } from 'xo'
import { FormattedRelative, FormattedTime } from 'react-intl'
import { Container, Row, Col } from 'grid'
@@ -39,6 +39,13 @@ export default ({
labelId='enableHostLabel'
/>
}
<TabButton
btnStyle='danger'
handler={detachHost}
handlerParam={host}
icon='host-eject'
labelId='detachHost'
/>
</Col>
</Row>
<Row>

View File

@@ -3,6 +3,7 @@ import Copiable from 'copiable'
import Icon from 'icon'
import map from 'lodash/map'
import React from 'react'
import store from 'store'
import Tags from 'tags'
import { addTag, removeTag } from 'xo'
import { BlockLink } from 'link'
@@ -10,6 +11,7 @@ import { Container, Row, Col } from 'grid'
import { FormattedRelative } from 'react-intl'
import { formatSize } from 'utils'
import Usage, { UsageElement } from 'usage'
import { getObject } from 'selectors'
import {
CpuSparkLines,
MemorySparkLines,
@@ -22,70 +24,78 @@ export default ({
host,
vmController,
vms
}) => <Container>
<br />
<Row className='text-xs-center'>
<Col mediumSize={3}>
<h2>{host.CPUs.cpu_count}x <Icon icon='cpu' size='lg' /></h2>
<BlockLink to={`/hosts/${host.id}/stats`}>{statsOverview && <CpuSparkLines data={statsOverview} />}</BlockLink>
</Col>
<Col mediumSize={3}>
<h2>{formatSize(host.memory.size)} <Icon icon='memory' size='lg' /></h2>
<BlockLink to={`/hosts/${host.id}/stats`}>{statsOverview && <MemorySparkLines data={statsOverview} />}</BlockLink>
</Col>
<Col mediumSize={3}>
<BlockLink to={`/hosts/${host.id}/network`}><h2>{host.$PIFs.length}x <Icon icon='network' size='lg' /></h2></BlockLink>
<BlockLink to={`/hosts/${host.id}/stats`}>{statsOverview && <PifSparkLines data={statsOverview} />}</BlockLink>
</Col>
<Col mediumSize={3}>
<BlockLink to={`/hosts/${host.id}/disks`}><h2>{host.$PBDs.length}x <Icon icon='disk' size='lg' /></h2></BlockLink>
<BlockLink to={`/hosts/${host.id}/stats`}>{statsOverview && <LoadSparkLines data={statsOverview} />}</BlockLink>
</Col>
</Row>
<br />
<Row className='text-xs-center'>
<Col mediumSize={3}>
<p className='text-xs-center'>{_('started', { ago: <FormattedRelative value={host.startTime * 1000} /> })}</p>
</Col>
<Col mediumSize={3}>
<p>{host.license_params.sku_marketing_name} {host.version} ({host.license_params.sku_type})</p>
</Col>
<Col mediumSize={3}>
<Copiable tagName='p'>
{host.address}
</Copiable>
</Col>
<Col mediumSize={3}>
<p>{host.bios_strings['system-manufacturer']} {host.bios_strings['system-product-name']}</p>
</Col>
</Row>
<Row>
<Col className='text-xs-center'>
<h5>RAM usage:</h5>
</Col>
</Row>
<Row>
<Col smallOffset={1} mediumSize={10}>
<Usage total={host.memory.size}>
<UsageElement
highlight
tooltip='XenServer'
value={vmController.memory.size}
/>
{map(vms, vm => <UsageElement
tooltip={vm.name_label}
key={vm.id}
value={vm.memory.size}
href={`#/vms/${vm.id}`}
/>)}
</Usage>
</Col>
</Row>
<Row>
<Col>
<h2 className='text-xs-center'>
<Tags labels={host.tags} onDelete={tag => removeTag(host.id, tag)} onAdd={tag => addTag(host.id, tag)} />
</h2>
</Col>
</Row>
</Container>
}) => {
const pool = getObject(store.getState(), host.$pool)
return <Container>
<br />
<Row className='text-xs-center'>
<Col mediumSize={3}>
<h2>{host.CPUs.cpu_count}x <Icon icon='cpu' size='lg' /></h2>
<BlockLink to={`/hosts/${host.id}/stats`}>{statsOverview && <CpuSparkLines data={statsOverview} />}</BlockLink>
</Col>
<Col mediumSize={3}>
<h2>{formatSize(host.memory.size)} <Icon icon='memory' size='lg' /></h2>
<BlockLink to={`/hosts/${host.id}/stats`}>{statsOverview && <MemorySparkLines data={statsOverview} />}</BlockLink>
</Col>
<Col mediumSize={3}>
<BlockLink to={`/hosts/${host.id}/network`}><h2>{host.$PIFs.length}x <Icon icon='network' size='lg' /></h2></BlockLink>
<BlockLink to={`/hosts/${host.id}/stats`}>{statsOverview && <PifSparkLines data={statsOverview} />}</BlockLink>
</Col>
<Col mediumSize={3}>
<BlockLink to={`/hosts/${host.id}/storage`}><h2>{host.$PBDs.length}x <Icon icon='disk' size='lg' /></h2></BlockLink>
<BlockLink to={`/hosts/${host.id}/stats`}>{statsOverview && <LoadSparkLines data={statsOverview} />}</BlockLink>
</Col>
</Row>
<br />
<Row className='text-xs-center'>
<Col mediumSize={3}>
<p className='text-xs-center'>{_('started', { ago: <FormattedRelative value={host.startTime * 1000} /> })}</p>
</Col>
<Col mediumSize={3}>
<p>{host.license_params.sku_marketing_name} {host.version} ({host.license_params.sku_type})</p>
</Col>
<Col mediumSize={3}>
<Copiable tagName='p'>
{host.address}
</Copiable>
</Col>
<Col mediumSize={3}>
<p>{host.bios_strings['system-manufacturer']} {host.bios_strings['system-product-name']}</p>
</Col>
</Row>
<Row>
<Col className='text-xs-center'>
<h5>RAM usage:</h5>
</Col>
</Row>
<Row>
<Col smallOffset={1} mediumSize={10}>
<Usage total={host.memory.size}>
<UsageElement
highlight
tooltip='XenServer'
value={vmController.memory.size}
/>
{map(vms, vm => <UsageElement
tooltip={vm.name_label}
key={vm.id}
value={vm.memory.size}
href={`#/vms/${vm.id}`}
/>)}
</Usage>
</Col>
</Row>
{pool && host.id === pool.master && <Row className='text-xs-center'>
<Col>
<h3><span className='tag tag-pill tag-info'>{_('pillMaster')}</span></h3>
</Col>
</Row>}
<Row>
<Col>
<h2 className='text-xs-center'>
<Tags labels={host.tags} onDelete={tag => removeTag(host.id, tag)} onAdd={tag => addTag(host.id, tag)} />
</h2>
</Col>
</Row>
</Container>
}

View File

@@ -3,15 +3,35 @@ import ActionRowButton from 'action-row-button'
import React from 'react'
import isEmpty from 'lodash/isEmpty'
import map from 'lodash/map'
import some from 'lodash/some'
import TabButton from 'tab-button'
import { connectPif, createNetwork, deletePif, disconnectPif } from 'xo'
import Tooltip from 'tooltip'
import { ButtonGroup } from 'react-bootstrap-4/lib'
import { Container, Row, Col } from 'grid'
import { Toggle } from 'form'
import { connectStore } from 'utils'
import { createGetObjectsOfType } from 'selectors'
import {
connectPif,
createNetwork,
deletePif,
disconnectPif,
editNetwork
} from 'xo'
export default ({
const _toggleDefaultLockingMode = (component, tooltip) => tooltip
? <Tooltip content={tooltip}>
{component}
</Tooltip>
: component
export default connectStore(() => ({
vifsByNetwork: createGetObjectsOfType('VIF').groupBy('$network')
}))(({
host,
networks,
pifs
pifs,
vifsByNetwork
}) => <Container>
<Row>
<Col className='text-xs-right'>
@@ -37,13 +57,16 @@ export default ({
<th>{_('pifAddressLabel')}</th>
<th>{_('pifMacLabel')}</th>
<th>{_('pifMtuLabel')}</th>
<th>{_('defaultLockingMode')}</th>
<th>{_('pifStatusLabel')}</th>
<th />
</tr>
</thead>
<tbody>
{map(pifs, pif =>
<tr key={pif.id}>
{map(pifs, pif => {
const pifInUse = some(vifsByNetwork[pif.$network], vif => vif.attached)
return <tr key={pif.id}>
<td>{pif.device}</td>
<td>{networks[pif.$network].name_label}</td>
<td>{pif.vlan === -1
@@ -53,6 +76,16 @@ export default ({
<td>{pif.ip} ({pif.mode})</td>
<td><pre>{pif.mac}</pre></td>
<td>{pif.mtu}</td>
<td className='text-xs-center'>
{_toggleDefaultLockingMode(
<Toggle
disabled={pifInUse}
onChange={() => editNetwork(pif.$network, { defaultIsLocked: !networks[pif.$network].defaultIsLocked })}
value={networks[pif.$network].defaultIsLocked}
/>,
pifInUse && _('pifInUse')
)}
</td>
<td>
{pif.attached
? <span className='tag tag-success'>
@@ -82,7 +115,7 @@ export default ({
</ButtonGroup>
</td>
</tr>
)}
})}
</tbody>
</table>
</span>
@@ -90,4 +123,4 @@ export default ({
}
</Col>
</Row>
</Container>
</Container>)

View File

@@ -3,6 +3,7 @@ import React from 'react'
import _ from 'intl'
import isEmpty from 'lodash/isEmpty'
import map from 'lodash/map'
import Tooltip from 'tooltip'
import { BlockLink } from 'link'
import { TabButtonLink } from 'tab-button'
import { formatSize } from 'utils'
@@ -51,7 +52,9 @@ export default ({
<td>{formatSize(sr.size)}</td>
<td>
{sr.size > 1 &&
<meter value={(sr.physical_usage / sr.size) * 100} min='0' max='100' optimum='40' low='80' high='90'></meter>
<Tooltip content={_('spaceLeftTooltip', {used: Math.round((sr.physical_usage / sr.size) * 100), free: formatSize(sr.size - sr.physical_usage)})}>
<meter value={(sr.physical_usage / sr.size) * 100} min='0' max='100' optimum='40' low='80' high='90' />
</Tooltip>
}
</td>
<td>

View File

@@ -114,9 +114,9 @@ export default class XoApp extends Component {
{blocked ? <XoaUpdates /> : this.props.children}
</div>
</div>
<TooltipViewer />
<Modal />
<Notification />
<TooltipViewer />
</div>
</IntlProvider>
}

View File

@@ -335,8 +335,8 @@ export default class Jobs extends Component {
<tr>
<th>{_('jobName')}</th>
<th>{_('jobAction')}</th>
<th></th>
<th></th>
<th />
<th />
</tr>
</thead>
<tbody>

View File

@@ -181,7 +181,7 @@ export default class Schedules extends Component {
<th>{_('job')}</th>
<th className='hidden-xs-down'>{_('jobScheduling')}</th>
<th className='hidden-xs-down'>{_('jobTimezone')}</th>
<th></th>
<th />
</tr>
</thead>
<tbody>

View File

@@ -11,6 +11,7 @@ import propTypes from 'prop-types'
import React, { Component } from 'react'
import renderXoItem from 'render-xo-item'
import SortedTable from 'sorted-table'
import Tooltip from 'tooltip'
import { alert, confirm } from 'modal'
import { connectStore } from 'utils'
import { createGetObject } from 'selectors'
@@ -89,7 +90,7 @@ const showCalls = log => alert(<span>{_('job')} {log.jobId}</span>, <Log log={lo
const LOG_COLUMNS = [
{
name: '',
itemRenderer: log => <ActionRowButton icon='preview' handler={showCalls} handlerParam={log} />
itemRenderer: log => <Tooltip content={_('logDisplayDetails')}><ActionRowButton icon='preview' handler={showCalls} handlerParam={log} /></Tooltip>
},
{
name: _('jobId'),

View File

@@ -9,13 +9,19 @@ import React from 'react'
import Tooltip from 'tooltip'
import { Button } from 'react-bootstrap-4/lib'
import { connectStore, noop, getXoaPlan } from 'utils'
import { signOut, subscribePermissions, subscribeResourceSets } from 'xo'
import { UpdateTag } from '../xoa-updates'
import {
connect,
signOut,
subscribePermissions,
subscribeResourceSets
} from 'xo'
import {
createFilter,
createGetObjectsOfType,
createSelector,
getLang,
getStatus,
getUser
} from 'selectors'
@@ -31,6 +37,7 @@ import styles from './index.css'
[ task => task.status === 'pending' ]
),
pools: createGetObjectsOfType('pool'),
status: getStatus,
user: getUser
}), {
withRef: true
@@ -88,11 +95,12 @@ export default class Menu extends Component {
}
render () {
const { nTasks, user } = this.props
const { nTasks, status, user } = this.props
const isAdmin = user && user.permission === 'admin'
const noOperatablePools = this._getNoOperatablePools()
const noResourceSets = isEmpty(this.state.resourceSets)
/* eslint-disable object-property-newline */
const items = [
{ to: '/home', icon: 'menu-home', label: 'homePage', subMenu: [
{ to: '/home?t=VM', icon: 'vm', label: 'homeVmPage' },
@@ -121,7 +129,9 @@ export default class Menu extends Component {
{ to: '/settings/groups', icon: 'menu-settings-groups', label: 'settingsGroupsPage' },
{ to: '/settings/acls', icon: 'menu-settings-acls', label: 'settingsAclsPage' },
{ to: '/settings/remotes', icon: 'menu-backup-remotes', label: 'backupRemotesPage' },
{ to: '/settings/plugins', icon: 'menu-settings-plugins', label: 'settingsPluginsPage' }
{ to: '/settings/plugins', icon: 'menu-settings-plugins', label: 'settingsPluginsPage' },
{ to: '/settings/logs', icon: 'menu-settings-logs', label: 'settingsLogsPage' },
{ to: '/settings/ips', icon: 'ip', label: 'settingsIpsPage' }
]},
{ to: '/jobs/overview', icon: 'menu-jobs', label: 'jobsPage', subMenu: [
{ to: '/jobs/overview', icon: 'menu-jobs-overview', label: 'jobsOverviewPage' },
@@ -137,6 +147,7 @@ export default class Menu extends Component {
!noOperatablePools && { to: '/vms/import', icon: 'menu-new-import', label: 'newImport' }
]}
]
/* eslint-enable object-property-newline */
return <div className={classNames(
'xo-menu',
@@ -200,15 +211,24 @@ export default class Menu extends Component {
<span className={styles.hiddenCollapsed}>{' '}{_('signOut')}</span>
</Button>
</li>
<li className='nav-item'>
<Link className='nav-link' style={{display: 'flex'}} to={'/user'}>
<div style={{margin: 'auto'}}>
<Tooltip content={user ? user.email : ''}>
<Icon icon='user' size='lg' />
</Tooltip>
</div>
<li className='nav-item xo-menu-item'>
<Link className='nav-link text-xs-center' to={'/user'}>
<Tooltip content={user ? user.email : ''}>
<Icon icon='user' size='lg' />
</Tooltip>
</Link>
</li>
<li>&nbsp;</li>
<li>&nbsp;</li>
{status === 'connecting'
? <li className='nav-item text-xs-center'>{_('statusConnecting')}</li>
: status === 'disconnected' &&
<li className='nav-item text-xs-center xo-menu-item'>
<Button className='nav-link' onClick={connect}>
<Icon icon='alarm' size='lg' fixedWidth /> {_('statusDisconnected')}
</Button>
</li>
}
</ul>
</div>
}

View File

@@ -54,14 +54,13 @@
.configDrive {
display: flex;
flex-direction: column;
background-color: #eee;
padding: 1em;
margin-bottom: 0.5em;
}
.configDriveToggle {
margin-left: auto;
margin-right: auto;
margin: auto;
}
.refreshNames {
@@ -71,3 +70,7 @@
.customConfig {
resize: both;
}
.fixedWidth {
width: 20em;
}

View File

@@ -13,27 +13,33 @@ import Icon from 'icon'
import includes from 'lodash/includes'
import isArray from 'lodash/isArray'
import isEmpty from 'lodash/isEmpty'
import isIp from 'is-ip'
import isObject from 'lodash/isObject'
import join from 'lodash/join'
import map from 'lodash/map'
import Page from '../page'
import React from 'react'
import size from 'lodash/size'
import slice from 'lodash/slice'
import store from 'store'
import Tooltip from 'tooltip'
import Wizard, { Section } from 'wizard'
import { Button } from 'react-bootstrap-4/lib'
import { Container, Row, Col } from 'grid'
import { injectIntl } from 'react-intl'
import {
addSshKey,
createVm,
createVms,
getCloudInitConfig,
subscribeCurrentUser,
subscribePermissions,
subscribeResourceSets,
XEN_DEFAULT_CPU_CAP,
XEN_DEFAULT_CPU_WEIGHT
} from 'xo'
import {
SelectIp,
SelectNetwork,
SelectPool,
SelectResourceSet,
@@ -42,6 +48,7 @@ import {
SelectResourceSetsVdi,
SelectResourceSetsVmTemplate,
SelectSr,
SelectSshKey,
SelectVdi,
SelectVmTemplate
} from 'select-objects'
@@ -50,6 +57,7 @@ import {
Toggle
} from 'form'
import {
addSubscriptions,
buildTemplate,
connectStore,
formatSize,
@@ -72,7 +80,9 @@ const NB_VMS_MAX = 100
/* eslint-disable camelcase */
// Sub-components --------------------------------------------------------------
const getObject = createGetObject((_, id) => id)
// Sub-components
const SectionContent = ({ summary, column, children }) => (
<div className={classNames(
@@ -83,11 +93,13 @@ const SectionContent = ({ summary, column, children }) => (
{children}
</div>
)
const LineItem = ({ children }) => (
<div className={styles.lineItem}>
{children}
</div>
)
const Item = ({ label, children, className }) => (
<span className={styles.item}>
{label && <span>{_(label)}&nbsp;</span>}
@@ -95,21 +107,32 @@ const Item = ({ label, children, className }) => (
</span>
)
// -----------------------------------------------------------------------------
const getObject = createGetObject((_, id) => id)
@addSubscriptions({
user: subscribeCurrentUser
})
@connectStore(() => ({
isAdmin: createSelector(
getUser,
user => user && user.permission === 'admin'
),
networks: createGetObjectsOfType('network').sort(),
pool: createGetObject((_, props) => props.location.query.pool),
pools: createGetObjectsOfType('pool'),
templates: createGetObjectsOfType('VM-template').sort()
templates: createGetObjectsOfType('VM-template').sort(),
userSshKeys: createSelector(
(_, props) => {
const user = props.user
return user && user.preferences && user.preferences.sshKeys
},
keys => keys
)
}))
@injectIntl
export default class NewVm extends BaseComponent {
static contextTypes = {
router: React.PropTypes.object
}
constructor () {
super()
@@ -159,10 +182,16 @@ export default class NewVm extends BaseComponent {
}
_replaceState = (state, callback) =>
this.setState({ state }, callback)
_linkState = (path, targetPath) =>
this.linkState(`state.${path}`, targetPath)
// Actions ---------------------------------------------------------------------
_reset = ({ pool, resourceSet } = { pool: this.state.pool, resourceSet: this.state.resourceSet }) => {
if (!pool) {
pool = this.props.pool
}
this.setState({ pool, resourceSet })
this._replaceState({
bootAfterCreate: true,
@@ -215,7 +244,11 @@ export default class NewVm extends BaseComponent {
if (state.configDrive) {
const hostname = state.name_label.replace(/^\s+|\s+$/g, '').replace(/\s+/g, '-')
if (state.installMethod === 'SSH') {
cloudConfig = '#cloud-config\nhostname: ' + hostname + '\nssh_authorized_keys:\n - ' + state.sshKey + '\n'
cloudConfig = `#cloud-config\nhostname: ${hostname}\nssh_authorized_keys:\n${
join(map(state.sshKeys, keyId => {
return this.props.userSshKeys[keyId] ? ` - ${this.props.userSshKeys[keyId].key}\n` : ''
}), '')
}`
} else {
cloudConfig = state.customConfig
}
@@ -223,6 +256,26 @@ export default class NewVm extends BaseComponent {
cloudConfig = state.cloudConfig
}
// Split allowed IPs into IPv4 and IPv6
const { VIFs } = state
const _VIFs = map(VIFs, vif => {
const _vif = { ...vif }
delete _vif.addresses
_vif.allowedIpv4Addresses = []
_vif.allowedIpv6Addresses = []
forEach(vif.addresses, ip => {
if (!isIp(ip)) {
return
}
if (isIp.v4(ip)) {
_vif.allowedIpv4Addresses.push(ip)
} else {
_vif.allowedIpv6Addresses.push(ip)
}
})
return _vif
})
const data = {
clone: !this.isDiskTemplate && state.fastClone,
existingDisks: state.existingDisks,
@@ -230,9 +283,8 @@ export default class NewVm extends BaseComponent {
name_label: state.name_label,
template: state.template.id,
VDIs: state.VDIs,
VIFs: state.VIFs,
VIFs: _VIFs,
resourceSet: resourceSet && resourceSet.id,
// TODO: To be added in xo-server
// vm.set parameters
CPUs: state.CPUs,
cpuWeight: state.cpuWeight === '' ? null : state.cpuWeight,
@@ -310,7 +362,8 @@ export default class NewVm extends BaseComponent {
cpuCap: '',
cpuWeight: '',
// installation
installMethod: template.install_methods && template.install_methods[0] || state.installMethod,
installMethod: template.install_methods && template.install_methods[0] || 'SSH',
sshKeys: this.props.userSshKeys && this.props.userSshKeys.length && [ 0 ],
customConfig: '#cloud-config\n#hostname: myhostname\n#ssh_authorized_keys:\n# - ssh-rsa <myKey>\n#packages:\n# - htop\n',
// interfaces
VIFs,
@@ -375,8 +428,10 @@ export default class NewVm extends BaseComponent {
(isInPool, isInResourceSet) => disk =>
(isInResourceSet(disk) || isInPool(disk)) && disk.content_type !== 'iso' && disk.size > 0
)
_getIsoPredicate = () => disk =>
disk.content_type === 'iso'
_getIsoPredicate = createSelector(
() => this.state.pool && this.state.pool.id,
poolId => sr => (poolId == null || poolId === sr.$pool) && sr.SR_type === 'iso'
)
_getNetworkPredicate = createSelector(
this._getIsInPool,
this._getIsInResourceSet,
@@ -467,11 +522,13 @@ export default class NewVm extends BaseComponent {
this._setState({ [prop]: value })
}
}
_onChangeSshKeys = keys => this._setState({ sshKeys: map(keys, key => key.id) })
_updateNbVms = () => {
const { nbVms, nameLabels, seqStart } = this.state.state
const nbVmsClamped = clamp(nbVms, NB_VMS_MIN, NB_VMS_MAX)
const newNameLabels = [ ...nameLabels ]
if (nbVmsClamped < nameLabels.length) {
this._setState({ nameLabels: slice(newNameLabels, 0, nbVmsClamped) })
} else {
@@ -487,6 +544,7 @@ export default class NewVm extends BaseComponent {
const nbVms = nameLabels.length
const newNameLabels = []
const replacer = this._buildTemplate()
for (let i = +seqStart; i <= +seqStart + nbVms - 1; i++) {
newNameLabels.push(replacer(this.state.state, i))
}
@@ -494,11 +552,19 @@ export default class NewVm extends BaseComponent {
}
_selectResourceSet = resourceSet =>
this._reset({ pool: undefined, resourceSet })
_selectPool = pool =>
_selectPool = pool => {
const { pathname, query } = this.props.location
this.context.router.push({
pathname,
query: { ...query, pool: pool.id }
})
this._reset({ pool, resourceSet: undefined })
}
_addVdi = () => {
const { pool, state } = this.state
const device = String(this.getUniqueId())
this._setState({ VDIs: [ ...state.VDIs, {
device,
name_description: 'Created by XO',
@@ -509,10 +575,12 @@ export default class NewVm extends BaseComponent {
}
_removeVdi = index => {
const { VDIs } = this.state.state
this._setState({ VDIs: [ ...VDIs.slice(0, index), ...VDIs.slice(index + 1) ] })
}
_addInterface = () => {
const networkId = this._getDefaultNetworkId()
this._setState({ VIFs: [ ...this.state.state.VIFs, {
id: this.getUniqueId(),
network: networkId
@@ -520,9 +588,29 @@ export default class NewVm extends BaseComponent {
}
_removeInterface = index => {
const { VIFs } = this.state.state
this._setState({ VIFs: [ ...VIFs.slice(0, index), ...VIFs.slice(index + 1) ] })
}
_addNewSshKey = () => {
const { newSshKey, sshKeys } = this.state.state
const { userSshKeys } = this.props
const splitKey = newSshKey.split(' ')
const title = splitKey.length === 3 ? splitKey[2].split('\n')[0] : newSshKey.substring(newSshKey.length - 10, newSshKey.length)
// save key
addSshKey({
title,
key: newSshKey
}).then(() => {
// select key
this._setState({
sshKeys: [ ...(sshKeys || []), userSshKeys ? userSshKeys.length : 0 ],
newSshKey: ''
})
})
}
_getRedirectionUrl = id =>
this.state.state.multipleVms ? '/home' : `/vms/${id}`
@@ -700,11 +788,11 @@ export default class NewVm extends BaseComponent {
value={nbVms}
/>
<span className='input-group-btn'>
<Button bsStyle='secondary' disabled={!multipleVms} onClick={this._updateNbVms}><Icon icon='arrow-right' /></Button>
<Tooltip content={_('newVmNumberRecalculate')}><Button bsStyle='secondary' disabled={!multipleVms} onClick={this._updateNbVms}><Icon icon='arrow-right' /></Button></Tooltip>
</span>
</Item>
<Item>
<a className={styles.refreshNames} onClick={this._updateNameLabels}><Icon icon='refresh' /></a>
<Tooltip content={_('newVmNameRefresh')}><a className={styles.refreshNames} onClick={this._updateNameLabels}><Icon icon='refresh' /></a></Tooltip>
</Item>
{multipleVms && <LineItem>
{map(nameLabels, (nameLabel, index) =>
@@ -784,44 +872,64 @@ export default class NewVm extends BaseComponent {
installIso,
installMethod,
installNetwork,
newSshKey,
pv_args,
sshKey
sshKeys
} = this.state.state
return <Section icon='new-vm-install-settings' title='newVmInstallSettingsPanel' done={this._isInstallSettingsDone()}>
{this._isDiskTemplate ? <SectionContent key='diskTemplate'>
<div className={styles.configDrive}>
<span className={styles.configDriveToggle}>
{_('newVmConfigDrive')}
</span>
<span className={styles.configDriveToggle}>
<Toggle
value={configDrive}
onChange={this._getOnChange('configDrive')}
{this._isDiskTemplate ? <SectionContent key='diskTemplate' column>
<LineItem>
<div className={styles.configDrive}>
<span className={styles.configDriveToggle}>
{_('newVmConfigDrive')}
</span>
&nbsp;
<span className={styles.configDriveToggle}>
<Toggle
value={configDrive}
onChange={this._getOnChange('configDrive')}
/>
</span>
</div>
</LineItem>
<LineItem>
<span>
<input
checked={installMethod === 'SSH'}
disabled={!configDrive}
name='installMethod'
onChange={this._getOnChange('installMethod')}
type='radio'
value='SSH'
/>
{' '}
<span>{_('newVmSshKey')}</span>
</span>
</div>
<Item>
<input
checked={installMethod === 'SSH'}
disabled={!configDrive}
name='installMethod'
onChange={this._getOnChange('installMethod')}
type='radio'
value='SSH'
/>
{' '}
<span>{_('newVmSshKey')}</span>
{' '}
<DebounceInput
className='form-control'
debounceTimeout={DEBOUNCE_TIMEOUT}
disabled={!configDrive || installMethod !== 'SSH'}
onChange={this._getOnChange('sshKey')}
type='text'
value={sshKey}
/>
</Item>
<Item>
&nbsp;
<span className={classNames('input-group', styles.fixedWidth)}>
<DebounceInput
className='form-control'
disabled={!configDrive || installMethod !== 'SSH'}
debounceTimeout={DEBOUNCE_TIMEOUT}
onChange={this._getOnChange('newSshKey')}
value={newSshKey}
/>
<span className='input-group-btn'>
<Button className='btn btn-secondary' onClick={this._addNewSshKey} disabled={!newSshKey}>
<Icon icon='add' />
</Button>
</span>
</span>
{this.props.userSshKeys && this.props.userSshKeys.length > 0 && <span className={styles.fixedWidth}>
<SelectSshKey
disabled={!configDrive || installMethod !== 'SSH'}
onChange={this._onChangeSshKeys}
multi
value={sshKeys || []}
/>
</span>}
</LineItem>
<LineItem>
<input
checked={installMethod === 'customConfig'}
disabled={!configDrive}
@@ -830,9 +938,9 @@ export default class NewVm extends BaseComponent {
type='radio'
value='customConfig'
/>
{' '}
&nbsp;
<span>{_('newVmCustomConfig')}</span>
{' '}
&nbsp;
<DebounceInput
className={classNames('form-control', styles.customConfig)}
debounceTimeout={DEBOUNCE_TIMEOUT}
@@ -841,7 +949,7 @@ export default class NewVm extends BaseComponent {
onChange={this._getOnChange('customConfig')}
value={customConfig}
/>
</Item>
</LineItem>
</SectionContent>
: <SectionContent>
<Item>
@@ -938,7 +1046,7 @@ export default class NewVm extends BaseComponent {
installIso,
installMethod,
installNetwork,
sshKey,
sshKeys,
template
} = this.state.state
switch (installMethod) {
@@ -946,7 +1054,7 @@ export default class NewVm extends BaseComponent {
case 'ISO': return installIso
case 'network': return /^(http|ftp|nfs)/i.exec(installNetwork)
case 'PXE': return true
case 'SSH': return sshKey || !configDrive
case 'SSH': return !isEmpty(sshKeys) || !configDrive
default: return template && this._isDiskTemplate && !configDrive
}
}
@@ -954,11 +1062,12 @@ export default class NewVm extends BaseComponent {
// INTERFACES ------------------------------------------------------------------
_renderInterfaces = () => {
const { formatMessage } = this.props.intl
const {
state: { VIFs },
pool
} = this.state
const { formatMessage } = this.props.intl
return <Section icon='new-vm-interfaces' title='newVmInterfacesPanel' done={this._isInterfacesDone()}>
<SectionContent column>
{map(VIFs, (vif, index) => <div key={index}>
@@ -967,7 +1076,7 @@ export default class NewVm extends BaseComponent {
<DebounceInput
className='form-control'
debounceTimeout={DEBOUNCE_TIMEOUT}
onChange={this._getOnChange('VIFs', index, 'mac')}
onChange={this._linkState(`VIFs.${index}.mac`)}
placeholder={formatMessage(messages.newVmMacPlaceholder)}
rows={7}
value={vif.mac}
@@ -976,17 +1085,27 @@ export default class NewVm extends BaseComponent {
<Item label='newVmNetworkLabel'>
<span className={styles.inlineSelect}>
{pool ? <SelectNetwork
onChange={this._getOnChange('VIFs', index, 'network', 'id')}
onChange={this._linkState(`VIFs.${index}.network`, 'id')}
predicate={this._getNetworkPredicate()}
value={vif.network}
/>
: <SelectResourceSetsNetwork
onChange={this._getOnChange('VIFs', index, 'network', 'id')}
onChange={this._linkState(`VIFs.${index}.network`, 'id')}
resourceSet={this.state.resourceSet}
value={vif.network}
/>}
</span>
</Item>
<LineItem>
<span className={styles.inlineSelect}>
<SelectIp
containerPredicate={pool => find(pool.networks, poolNetwork => poolNetwork === vif.network)}
multi
onChange={this._linkState(`VIFs.${index}.addresses`, '*.id')}
value={vif.addresses}
/>
</span>
</LineItem>
<Item>
<Button onClick={() => this._removeInterface(index)} bsStyle='secondary'>
<Icon icon='new-vm-remove' />
@@ -994,8 +1113,7 @@ export default class NewVm extends BaseComponent {
</Item>
</LineItem>
{index < VIFs.length - 1 && <hr />}
</div>
)}
</div>)}
<Item>
<Button onClick={this._addInterface} bsStyle='secondary'>
<Icon icon='new-vm-add' />
@@ -1207,4 +1325,4 @@ export default class NewVm extends BaseComponent {
</Section>
}
}
/* eslint-enable camelcase*/
/* eslint-enable camelcase */

View File

@@ -7,6 +7,7 @@ import styles from './index.css'
const Page = ({
children,
collapsedHeader,
formatTitle,
header,
intl,
@@ -16,9 +17,9 @@ const Page = ({
return (
<DocumentTitle title={formatTitle ? formatMessage(messages[title]) : title}>
<div className={styles.container}>
<nav className={'page-header ' + styles.header}>
{!collapsedHeader && <nav className={'page-header ' + styles.header}>
{header}
</nav>
</nav>}
<div className={styles.content}>
{children}
</div>
@@ -29,6 +30,7 @@ const Page = ({
Page.propTypes = {
children: React.PropTypes.node,
collapsedHeader: React.PropTypes.bool,
formatTitle: React.PropTypes.bool,
header: React.PropTypes.node,
title: React.PropTypes.string

View File

@@ -1,5 +1,8 @@
import ActionBar from 'action-bar'
import React from 'react'
import {
addHostToPool
} from 'xo'
const NOT_IMPLEMENTED = () => {
throw new Error('not implemented')
@@ -11,17 +14,17 @@ const PoolActionBar = ({ pool }) => (
{
icon: 'add-sr',
label: 'addSrLabel',
handler: NOT_IMPLEMENTED // TODO add sr
redirectOnSuccess: `new/sr?host=${pool.master}`
},
{
icon: 'add-vm',
label: 'addVmLabel',
handler: NOT_IMPLEMENTED // TODO add VM
redirectOnSuccess: `vms/new?pool=${pool.id}`
},
{
icon: 'add-host',
label: 'addHostLabel',
handler: NOT_IMPLEMENTED // TODO add host
handler: addHostToPool
},
{
icon: 'disconnect',

View File

@@ -95,6 +95,9 @@ import TabStorage from './tab-storage'
})
export default class Pool extends Component {
_setNameDescription = nameDescription => editPool(this.props.pool, { name_description: nameDescription })
_setNameLabel = nameLabel => editPool(this.props.pool, { name_label: nameLabel })
header () {
const { pool } = this.props
if (!pool) {
@@ -108,13 +111,13 @@ export default class Pool extends Component {
{' '}
<Text
value={pool.name_label}
onChange={nameLabel => editPool(pool, { nameLabel })}
onChange={this._setNameLabel}
/>
</h2>
<span>
<Text
value={pool.name_description}
onChange={nameDescription => editPool(pool, { nameDescription })}
onChange={this._setNameDescription}
/>
</span>
</Col>

View File

@@ -1,8 +1,15 @@
import _ from 'intl'
import find from 'lodash/find'
import Icon from 'icon'
import map from 'lodash/map'
import React from 'react'
import sumBy from 'lodash/sumBy'
import Tags from 'tags'
import { addTag, removeTag } from 'xo'
import Link, { BlockLink } from 'link'
import { Container, Row, Col } from 'grid'
import Usage, { UsageElement } from 'usage'
import { formatSize } from 'utils'
export default ({
hosts,
@@ -10,20 +17,49 @@ export default ({
pool,
srs
}) => <Container>
<br />
<Row className='text-xs-center'>
<Col mediumSize={4}>
<h2>{hosts.length}x <Icon icon='host' size='lg' /></h2>
<BlockLink to={`/pools/${pool.id}/hosts`}><h2>{hosts.length}x <Icon icon='host' size='lg' /></h2></BlockLink>
</Col>
<Col mediumSize={4}>
<h2>{srs.length}x <Icon icon='sr' size='lg' /></h2>
<BlockLink to={`/pools/${pool.id}/storage`}><h2>{srs.length}x <Icon icon='sr' size='lg' /></h2></BlockLink>
</Col>
<Col mediumSize={4}>
<h2>{nVms}x <Icon icon='vm' size='lg' /></h2>
<BlockLink to={`/home?s=$pool:${pool.id}`}><h2>{nVms}x <Icon icon='vm' size='lg' /></h2></BlockLink>
</Col>
</Row>
<br />
<Row>
<Col className='text-xs-center'>
<h5>Pool RAM usage:</h5>
</Col>
</Row>
<Row>
<Col smallOffset={1} mediumSize={10}>
<Usage total={sumBy(hosts, 'memory.size')}>
{map(hosts, host => <UsageElement
tooltip={host.name_label}
key={host.id}
value={host.memory.usage}
href={`#/hosts/${host.id}`}
/>)}
</Usage>
</Col>
</Row>
<Row>
<Col className='text-xs-center'>
<h5>{_('poolRamUsage', {used: formatSize(sumBy(hosts, 'memory.usage')), total: formatSize(sumBy(hosts, 'memory.size'))})}</h5>
</Col>
</Row>
<Row className='text-xs-center'>
<Col>
<h2 className='text-xs-center'>
{_('poolMaster')} <Link to={`/hosts/${pool.master}`}>{find(hosts, host => host.id === pool.master).name_label}</Link>
</Col>
</Row>
<Row className='text-xs-center'>
<Col>
<h2>
<Tags labels={pool.tags} onDelete={tag => removeTag(pool.id, tag)} onAdd={tag => addTag(pool.id, tag)} />
</h2>
</Col>

View File

@@ -3,17 +3,24 @@ import isEmpty from 'lodash/isEmpty'
import Link from 'link'
import React from 'react'
import SortedTable from 'sorted-table'
import store from 'store'
import Tooltip from 'tooltip'
import { Container, Row, Col } from 'grid'
import { editHost } from 'xo'
import { Text } from 'editable'
import { formatSize } from 'utils'
import { getObject } from 'selectors'
const HOST_COLUMNS = [
{
name: _('hostNameLabel'),
itemRenderer: host => (
<Link to={`/hosts/${host.id}`}>
<Text value={host.name_label} onChange={value => editHost(host, { name_label: value })} useLongClick />
</Link>
<span>
<Link to={`/hosts/${host.id}`}>
<Text value={host.name_label} onChange={value => editHost(host, { name_label: value })} useLongClick />
</Link>
{host.id === getObject(store.getState(), host.$pool).master && <span className='tag tag-pill tag-info'>{_('pillMaster')}</span>}
</span>
),
sortCriteria: 'name_label'
},
@@ -24,7 +31,10 @@ const HOST_COLUMNS = [
},
{
name: _('hostMemory'),
itemRenderer: ({ memory }) => <meter value={memory.usage} min='0' max={memory.size}></meter>,
itemRenderer: ({ memory }) =>
<Tooltip content={_('memoryLeftTooltip', {used: Math.round((memory.usage / memory.size) * 100), free: formatSize(memory.size - memory.usage)})}>
<meter value={memory.usage} min='0' max={memory.size} />
</Tooltip>,
sortCriteria: ({ memory }) => memory.usage / memory.size,
sortOrder: 'desc'
}

View File

@@ -6,11 +6,13 @@ import React, { Component } from 'react'
import some from 'lodash/some'
import store from 'store'
import TabButton from 'tab-button'
import Tooltip from 'tooltip'
import { ButtonGroup } from 'react-bootstrap-4/lib'
import { Text } from 'editable'
import { Container, Row, Col } from 'grid'
import { connectStore } from 'utils'
import { createGetObject, createSelector } from 'selectors'
import { createGetObject, createGetObjectsOfType, createSelector } from 'selectors'
import { Toggle } from 'form'
import {
connectPif,
createNetwork,
@@ -24,6 +26,12 @@ const getObject = createGetObject((_, id) => id)
const disableUnplug = pif =>
pif.attached && (pif.management || pif.disallowUnplug)
const _toggleDefaultLockingMode = (component, tooltip) => tooltip
? <Tooltip content={tooltip}>
{component}
</Tooltip>
: component
@connectStore(() => {
const pif = createGetObject()
const host = createGetObject(
@@ -69,6 +77,9 @@ class PifItem extends Component {
}
}
@connectStore(() => ({
vifsByNetwork: createGetObjectsOfType('VIF').groupBy('$network')
}))
export default class TabNetworks extends Component {
_disableDelete = network => {
const state = store.getState()
@@ -76,7 +87,7 @@ export default class TabNetworks extends Component {
}
render () {
const { networks } = this.props
const { networks, vifsByNetwork } = this.props
return <Container>
<Row>
@@ -99,20 +110,33 @@ export default class TabNetworks extends Component {
<th>{_('poolNetworkNameLabel')}</th>
<th>{_('poolNetworkDescription')}</th>
<th>{_('poolNetworkMTU')}</th>
<th>{_('defaultLockingMode')}</th>
<th>{_('poolNetworkPif')}</th>
<th />
</tr>
</thead>
<tbody>
{map(networks, network => {
const networkInUse = some(vifsByNetwork[network.id], vif => vif.attached)
return <tr key={network.id}>
<td>
<Text value={network.name_label} onChange={value => editNetwork(network, { name_label: value })} />
</td>
<td>
<Text value={network.name_description} onChange={value => editNetwork(network, { name_label: value })} />
<Text value={network.name_description} onChange={value => editNetwork(network, { name_description: value })} />
</td>
<td>{network.MTU}</td>
<td className='text-xs-center'>
{_toggleDefaultLockingMode(
<Toggle
disabled={networkInUse}
onChange={() => editNetwork(network, { defaultIsLocked: !network.defaultIsLocked })}
value={network.defaultIsLocked}
/>,
networkInUse && _('networkInUse')
)}
</td>
<td>
{!isEmpty(network.PIFs) && <table className='table'>
<thead className='thead-default'>

View File

@@ -3,6 +3,7 @@ import isEmpty from 'lodash/isEmpty'
import Link from 'link'
import React from 'react'
import SortedTable from 'sorted-table'
import Tooltip from 'tooltip'
import { Container, Row, Col } from 'grid'
import { editSr, isSrShared } from 'xo'
import { formatSize } from 'utils'
@@ -30,7 +31,10 @@ const SR_COLUMNS = [
},
{
name: _('srUsage'),
itemRenderer: sr => sr.size > 1 && <meter value={(sr.physical_usage / sr.size) * 100} min='0' max='100' optimum='40' low='80' high='90'></meter>,
itemRenderer: sr => sr.size > 1 &&
<Tooltip content={_('spaceLeftTooltip', {used: Math.round((sr.physical_usage / sr.size) * 100), free: formatSize(sr.size - sr.physical_usage)})}>
<meter value={(sr.physical_usage / sr.size) * 100} min='0' max='100' optimum='40' low='80' high='90' />
</Tooltip>,
sortCriteria: sr => sr.physical_usage / sr.size,
sortOrder: 'desc'
},

View File

@@ -8,6 +8,8 @@ import { NavLink, NavTabs } from 'nav'
import Acls from './acls'
import Groups from './groups'
import Ips from './ips'
import Logs from './logs'
import Plugins from './plugins'
import Remotes from './remotes'
import Servers from './servers'
@@ -26,6 +28,8 @@ const HEADER = <Container>
<NavLink to={'/settings/acls'}><Icon icon='menu-settings-acls' /> {_('settingsAclsPage')}</NavLink>
<NavLink to={'/settings/remotes'}><Icon icon='menu-backup-remotes' /> {_('backupRemotesPage')}</NavLink>
<NavLink to={'/settings/plugins'}><Icon icon='menu-settings-plugins' /> {_('settingsPluginsPage')}</NavLink>
<NavLink to={'/settings/logs'}><Icon icon='menu-settings-logs' /> {_('settingsLogsPage')}</NavLink>
<NavLink to={'/settings/ips'}><Icon icon='ip' /> {_('settingsIpsPage')}</NavLink>
</NavTabs>
</Col>
</Row>
@@ -34,6 +38,8 @@ const HEADER = <Container>
const Settings = routes('servers', {
acls: Acls,
groups: Groups,
ips: Ips,
logs: Logs,
plugins: Plugins,
remotes: Remotes,
servers: Servers,

View File

@@ -0,0 +1,324 @@
import _, { messages } from 'intl'
import ActionButton from 'action-button'
import ActionRowButton from 'action-row-button'
import BaseComponent from 'base-component'
import DebounceInput from 'react-debounce-input'
import findIndex from 'lodash/findIndex'
import forEach from 'lodash/forEach'
import Icon from 'icon'
import includes from 'lodash/includes'
import isEmpty from 'lodash/isEmpty'
import isObject from 'lodash/isObject'
import keys from 'lodash/keys'
import map from 'lodash/map'
import React from 'react'
import SingleLineRow from 'single-line-row'
import SortedTable from 'sorted-table'
import Upgrade from 'xoa-upgrade'
import { addSubscriptions, connectStore } from 'utils'
import { Container, Row, Col } from 'grid'
import { formatIps, getNextIpV4, parseIpPattern } from 'ip'
import { createGetObjectsOfType, createSelector } from 'selectors'
import { injectIntl } from 'react-intl'
import { SelectNetwork } from 'select-objects'
import { Text } from 'editable'
import {
createIpPool,
deleteIpPool,
setIpPool,
subscribeIpPools
} from 'xo'
const FULL_WIDTH = { width: '100%' }
const NETWORK_FORM_STYLE = { maxWidth: '40em' }
@connectStore(() => ({
networks: createGetObjectsOfType('network').groupBy('id'),
vifs: createGetObjectsOfType('VIF').groupBy('id')
}))
class IpsCell extends BaseComponent {
_addIps = () => {
const addresses = {}
forEach(parseIpPattern(this.state.newIps), ip => {
addresses[ip] = {}
})
setIpPool(this.props.ipPool.id, { addresses })
this.setState({ newIps: '' })
}
_deleteIp = ip => {
const toBeRemoved = {}
if (isObject(ip)) {
let currentIp = ip.first
while (currentIp !== ip.last) {
toBeRemoved[currentIp] = null
currentIp = getNextIpV4(currentIp)
}
toBeRemoved[currentIp] = null
} else {
toBeRemoved[ip] = null
}
setIpPool(this.props.ipPool.id, { addresses: toBeRemoved })
}
render () {
const {
ipPool,
networks,
vifs
} = this.props
const {
newIps,
showNewIpForm
} = this.state
return <Container>
<Row>
<Col mediumSize={6} offset={5}><strong>{_('ipsVifs')}</strong></Col>
</Row>
{ipPool.addresses && map(formatIps(keys(ipPool.addresses)), ip => {
if (isObject(ip)) { // Range of IPs
return <Row>
<Col mediumSize={5}>
<strong>{ip.first} <Icon icon='arrow-right' /> {ip.last}</strong>
</Col>
<Col mediumSize={1} offset={6}>
<ActionRowButton
handler={this._deleteIp}
handlerParam={ip}
icon='delete'
/>
</Col>
</Row>
}
const addressVifs = ipPool.addresses[ip].vifs
return <Row>
<Col mediumSize={5}>
<strong>{ip}</strong>
</Col>
<Col mediumSize={6}>{!isEmpty(addressVifs)
? map(addressVifs, vifId => {
const vif = vifs[vifId][0]
const network = networks[vif.$network][0]
return `${network.name_label} #${vif.device}`
}).join(', ')
: <em>{_('ipsNotUsed')}</em>
}</Col>
<Col mediumSize={1}>
<ActionRowButton
handler={this._deleteIp}
handlerParam={ip}
icon='delete'
/>
</Col>
</Row>
})}
<Row>
<Col>
{showNewIpForm
? <form id='newIpForm' className='form-inline'>
<ActionButton btnStyle='danger' handler={this.toggleState('showNewIpForm')} icon='remove' />
{' '}
<DebounceInput
autoFocus
onChange={this.linkState('newIps')}
type='text'
className='form-control'
required
value={newIps || ''}
/>
{' '}
<ActionButton form={`newIpForm`} icon='save' btnStyle='primary' handler={this._addIps} />
</form>
: <ActionButton btnStyle='success' size='small' handler={this.toggleState('showNewIpForm')} icon='add' />}
</Col>
</Row>
</Container>
}
}
@connectStore(() => {
const getNetworks = createGetObjectsOfType('network')
return (state, props) => ({
networks: getNetworks(state, props)
})
})
class NetworksCell extends BaseComponent {
state = { newNetworks: [] }
_addNetworks = () => {
if (isEmpty(this.state.newNetworks)) {
return this._toggleNewNetworks()
}
const { ipPool } = this.props
setIpPool(ipPool.id, {
networks: [ ...ipPool.networks, ...this.state.newNetworks ]
})
this._toggleNewNetworks()
this.setState({ newNetworks: [] })
}
_deleteNetwork = networkId => {
const _networks = [ ...this.props.ipPool.networks ]
const index = findIndex(_networks, network => network === networkId)
if (index !== -1) {
_networks.splice(index, 1)
setIpPool(this.props.ipPool.id, { networks: _networks })
}
}
_toggleNewNetworks = () =>
this.setState({ showNewNetworkForm: !this.state.showNewNetworkForm })
_getNetworkPredicate = createSelector(
() => this.props.ipPool && this.props.ipPool.networks,
networks => network =>
!includes(networks, network.id)
)
render () {
const { ipPool, networks } = this.props
const { newNetworks, showNewNetworkForm } = this.state
return <Container>
{map(ipPool.networks, networkId => <Row>
<Col mediumSize={11}>
{networks[networkId].name_label}
</Col>
<Col mediumSize={1}>
<ActionButton btnStyle='default' size='small' handler={this._deleteNetwork} handlerParam={networkId} icon='delete' />
</Col>
</Row>)}
<Row>
{showNewNetworkForm
? <form id='newNetworkForm' style={NETWORK_FORM_STYLE}>
<Col mediumSize={10}>
<SelectNetwork
autoFocus
multi
onChange={this.linkState('newNetworks', '*.id')}
predicate={this._getNetworkPredicate()}
value={newNetworks}
/>
</Col>
<Col mediumSize={2}>
<ActionButton form='newNetworkForm' icon='save' btnStyle='primary' handler={this._addNetworks} />
</Col>
</form>
: <Col><ActionButton btnStyle='success' size='small' handler={this._toggleNewNetworks} icon='add' /></Col>
}
</Row>
</Container>
}
}
@addSubscriptions({
ipPools: subscribeIpPools
})
@injectIntl
export default class Ips extends BaseComponent {
_create = () => {
const { name, ips: { value: pattern }, networks } = this.refs
this.setState({ creatingIpPool: true })
return createIpPool({
ips: parseIpPattern(pattern),
name: name.value,
networks: map(networks.value, network => network.id)
}).then(() => {
name.value = this.refs.ips.value = networks.value = ''
this.setState({ creatingIpPool: false })
})
}
_ipColumns = [
{
name: _('ipPoolName'),
itemRenderer: ipPool => <Text onChange={name => setIpPool(ipPool, { name })} value={ipPool.name} />,
sortCriteria: ipPool => ipPool.name
},
{
name: _('ipPoolIps'),
itemRenderer: ipPool => <IpsCell ipPool={ipPool} />
},
{
name: _('ipPoolNetworks'),
itemRenderer: ipPool => <NetworksCell ipPool={ipPool} />
},
{
name: '',
itemRenderer: ipPool => <span className='pull-right'>
<ActionButton btnStyle='default' handler={deleteIpPool} handlerParam={ipPool.id} icon='delete' />
</span>
}
]
render () {
if (process.env.XOA_PLAN < 4) {
return <Container><Upgrade place='health' available={4} /></Container>
}
const { ipPools, intl } = this.props
const { creatingIpPool } = this.state
return <div>
<Row>
<Col size={6}>
<form id='newIpPoolForm' className='form-inline'>
<SingleLineRow>
<Col mediumSize={6}>
<input
className='form-control'
disabled={creatingIpPool}
placeholder={intl.formatMessage(messages.ipPoolName)}
ref='name'
required
style={FULL_WIDTH}
type='text'
/>
</Col>
<Col mediumSize={6}>
<input
className='form-control'
disabled={creatingIpPool}
placeholder={intl.formatMessage(messages.ipPoolIps)}
ref='ips'
required
style={FULL_WIDTH}
type='text'
/>
</Col>
</SingleLineRow>
<br />
<SingleLineRow>
<Col mediumSize={12}>
<SelectNetwork
disabled={creatingIpPool}
multi
ref='networks'
/>
</Col>
</SingleLineRow>
<br />
<SingleLineRow>
<Col mediumSize={6}>
<ActionButton
btnStyle='success'
form='newIpPoolForm' icon='add'
handler={this._create}
>
{_('ipsCreate')}
</ActionButton>
</Col>
</SingleLineRow>
</form>
</Col>
</Row>
<hr />
{isEmpty(ipPools)
? <p><em>{_('ipsNoIpPool')}</em></p>
: <SortedTable collection={ipPools} columns={this._ipColumns} defaultColumn={0} />
}
</div>
}
}

View File

@@ -0,0 +1,5 @@
.widthLimit {
overflow-wrap: break-word;
width: 100%;
max-width: 30em;
}

View File

@@ -0,0 +1,89 @@
import _ from 'intl'
import ActionRowButton from 'action-row-button'
import BaseComponent from 'base-component'
import Copiable from 'copiable'
import find from 'lodash/find'
import forEach from 'lodash/forEach'
import map from 'lodash/map'
import React from 'react'
import size from 'lodash/size'
import SortedTable from 'sorted-table'
import TabButton from 'tab-button'
import Tooltip from 'tooltip'
import { addSubscriptions } from 'utils'
import { subscribeApiLogs, subscribeUsers, deleteApiLog } from 'xo'
import { FormattedDate } from 'react-intl'
import { alert, confirm } from 'modal'
import styles from './index.css'
@addSubscriptions({
logs: subscribeApiLogs,
users: subscribeUsers
})
export default class Logs extends BaseComponent {
_showStack = log => alert(_('logStack'), <Copiable tagName='pre'>{`${log.data.method}
${JSON.stringify(log.data.params, null, 2)}
${log.data.error.stack}`}</Copiable>)
_showParams = log => alert(_('logParams'), <Copiable tagName='pre'>{JSON.stringify(log.data.params, null, 2)}</Copiable>)
_deleteAllLogs = () =>
confirm({
title: _('logDeleteAllTitle'),
body: _('logDeleteAllMessage')
}).then(() =>
forEach(this.props.logs, (log, id) => deleteApiLog(id))
)
render () {
const columns = [
{
name: '',
itemRenderer: log => <Tooltip content={_('logDisplayDetails')}><ActionRowButton icon='preview' handler={this._showStack} handlerParam={log} /></Tooltip>
},
{
name: _('logUser'),
itemRenderer: log => {
if (log.data.userId == null) {
return _('unknownUser')
}
return this.props.users ? find(this.props.users, user => user.id === log.data.userId).email : '...'
},
sortCriteria: log => log.data.userId
},
{
name: _('logMessage'),
itemRenderer: log => <pre className={styles.widthLimit}>{log.data.error && log.data.error.message}</pre>,
sortCriteria: log => log.data.error && log.data.error.message
},
{
name: _('logTime'),
itemRenderer: log => <span>
{log.time && <FormattedDate value={new Date(log.time)} month='long' day='numeric' year='numeric' hour='2-digit' minute='2-digit' second='2-digit' />}
</span>,
sortCriteria: log => log.time,
sortOrder: 'desc'
},
{
itemRenderer: log => <ActionRowButton btnStyle='default' handler={deleteApiLog} handlerParam={log.id} icon='delete' />
}
]
const { logs } = this.props
if (!logs) {
return <h3>{_('loadingLogs')}</h3>
}
return <div>
{size(logs)
? <div>
<span className='pull-xs-right'>
<TabButton
btnStyle='danger'
handler={this._deleteAllLogs}
icon='delete'
labelId='logDeleteAll'
/>
</span>
{' '}
<SortedTable collection={map(logs, (log, id) => ({ ...log, id }))} columns={columns} defaultColumn={4} />
</div>
: <p>{_('noLogs')}</p>}
</div>
}
}

View File

@@ -73,7 +73,7 @@ class AbstractRemote extends Component {
} = this.props
return <tr>
<td></td>
<td />
<td><Text value={remote.name} onChange={this._changeName} placeholder='remote name*' /></td>
<td>{this._renderRemoteInfo(remote)}</td>
<td>{this._renderAuthInfo(remote)}</td>
@@ -257,10 +257,10 @@ export default class Remotes extends Component {
<th className='text-info'>Local</th>
<th>Name</th>
<th>Path</th>
<th></th>
<th />
<th>State</th>
<th>Error</th>
<th></th>
<th />
</tr>
{map(remotes.file, (remote, key) => <LocalRemote remote={remote} key={key} />)}
</tbody>
@@ -271,10 +271,10 @@ export default class Remotes extends Component {
<th className='text-info'>NFS</th>
<th>Name</th>
<th>Device</th>
<th></th>
<th />
<th>State</th>
<th>Error</th>
<th></th>
<th />
</tr>
{map(remotes.nfs, (remote, key) => <NfsRemote remote={remote} key={key} />)}
</tbody>
@@ -288,7 +288,7 @@ export default class Remotes extends Component {
<th>Auth</th>
<th>State</th>
<th>Error</th>
<th></th>
<th />
</tr>
{map(remotes.smb, (remote, key) => <SmbRemote remote={remote} key={key} />)}
</tbody>

View File

@@ -2,6 +2,7 @@ import _ from 'intl'
import ActionButton from 'action-button'
import ActionRowButton from 'action-row-button'
import map from 'lodash/map'
import Tooltip from 'tooltip'
import React, { Component } from 'react'
import { addSubscriptions } from 'utils'
import { Container } from 'grid'
@@ -68,24 +69,28 @@ export default class Servers extends Component {
</td>
<td>
{server.status === 'disconnected'
? <ActionRowButton
btnStyle='secondary'
handler={connectServer}
handlerParam={server}
icon='connect'
style={{
marginRight: '0.5em'
}}
/>
: <ActionRowButton
btnStyle='warning'
handler={disconnectServer}
handlerParam={server}
icon='disconnect'
style={{
marginRight: '0.5em'
}}
/>
? <Tooltip content={_('serverConnect')}>
<ActionRowButton
btnStyle='secondary'
handler={connectServer}
handlerParam={server}
icon='connect'
style={{
marginRight: '0.5em'
}}
/>
</Tooltip>
: <Tooltip content={_('serverDisconnect')}>
<ActionRowButton
btnStyle='warning'
handler={disconnectServer}
handlerParam={server}
icon='disconnect'
style={{
marginRight: '0.5em'
}}
/>
</Tooltip>
}
<ActionRowButton
btnStyle='danger'

View File

@@ -120,9 +120,11 @@ export default class Sr extends Component {
value={sr.name_description}
onChange={nameDescription => editSr(sr, { nameDescription })}
/>
<span className='text-muted'>
{' - '}{container.name_label}
</span>
{container &&
<span className='text-muted'>
{' - '}{container.name_label}
</span>
}
</span>
</Col>
<Col mediumSize={6}>

View File

@@ -46,7 +46,7 @@ export const TaskItem = connectStore(() => ({
{' ' + Math.round(task.progress * 100)}%
</Col>
<Col mediumSize={4}>
<progress style={TASK_ITEM_STYLE} className='progress' value={task.progress * 100} max='100'></progress>
<progress style={TASK_ITEM_STYLE} className='progress' value={task.progress * 100} max='100' />
</Col>
<Col mediumSize={2}>
<ButtonGroup>

View File

@@ -20,12 +20,9 @@
margin: auto;
}
.cleanButtonContainer {
align-items: flex-end;
display: flex;
justify-content: flex-end;
}
.filesRow {
display: flex;
.vmContainer {
margin-bottom: 1em;
border-radius: 0.25rem;
border: Solid 0.05em dropzoneColor;
padding: 1em;
}

View File

@@ -1,25 +1,48 @@
import * as FormGrid from 'form-grid'
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import Dropzone from 'react-dropzone'
import * as FormGrid from 'form-grid'
import Icon from 'icon'
import React from 'react'
import _ from 'intl'
import filter from 'lodash/filter'
import isEmpty from 'lodash/isEmpty'
import map from 'lodash/map'
import orderBy from 'lodash/orderBy'
import propTypes from 'prop-types'
import React from 'react'
import Upgrade from 'xoa-upgrade'
import { Container, Col, Row } from 'grid'
import { importVms, isSrWritable } from 'xo'
import { SizeInput } from 'form'
import {
createFinder,
createGetObject,
createGetObjectsOfType,
createSelector
} from 'selectors'
import {
connectStore,
formatSize,
mapPlus,
noop
} from 'utils'
import {
SelectNetwork,
SelectPool,
SelectSr
} from 'select-objects'
import { formatSize } from 'utils'
import Page from '../page'
import parseOvaFile from './ova'
import styles from './index.css'
// ===================================================================
const FORMAT_TO_HANDLER = {
ova: parseOvaFile,
xva: noop
}
const HEADER = (
<Container>
<Row>
@@ -30,109 +53,327 @@ const HEADER = (
</Container>
)
// ===================================================================
@propTypes({
descriptionLabel: propTypes.string,
disks: propTypes.objectOf(
propTypes.shape({
capacity: propTypes.number.isRequired,
descriptionLabel: propTypes.string.isRequired,
nameLabel: propTypes.string.isRequired,
path: propTypes.string.isRequired
})
),
memory: propTypes.number,
nameLabel: propTypes.string,
nCpus: propTypes.number,
networks: propTypes.array,
pool: propTypes.object.isRequired
})
@connectStore(() => {
const getHostMaster = createGetObject(
(_, props) => props.pool.master
)
const getPifs = createGetObjectsOfType('PIF').pick(
(state, props) => getHostMaster(state, props).$PIFs
)
const getDefaultNetworkId = createSelector(
createFinder(
getPifs,
[ pif => pif.management ]
),
pif => pif.$network
)
return {
defaultNetwork: getDefaultNetworkId
}
}, { withRef: true })
class VmData extends Component {
get value () {
const { props, refs } = this
return {
descriptionLabel: refs.descriptionLabel.value,
disks: map(props.disks, ({ capacity, path, position }, diskId) => ({
capacity,
descriptionLabel: refs[`disk-description-${diskId}`].value,
nameLabel: refs[`disk-name-${diskId}`].value,
path,
position
})),
memory: +refs.memory.value,
nameLabel: refs.nameLabel.value,
networks: map(props.networks, (_, networkId) => refs[`network-${networkId}`].value.id),
nCpus: +refs.nCpus.value
}
}
_getNetworkPredicate = createSelector(
() => this.props.pool.id,
id => network => network.$pool === id
)
render () {
const {
descriptionLabel,
defaultNetwork,
disks,
memory,
nameLabel,
nCpus,
networks
} = this.props
return (
<div>
<Row>
<Col mediumSize={6}>
<div className='form-group'>
<label>{_('vmNameLabel')}</label>
<input className='form-control' ref='nameLabel' defaultValue={nameLabel} type='text' required />
</div>
<div className='form-group'>
<label>{_('vmNameDescription')}</label>
<input className='form-control' ref='descriptionLabel' defaultValue={descriptionLabel} type='text' required />
</div>
</Col>
<Col mediumSize={6}>
<div className='form-group'>
<label>{_('nCpus')}</label>
<input className='form-control' ref='nCpus' defaultValue={nCpus} type='number' required />
</div>
<div className='form-group'>
<label>{_('vmMemory')}</label>
<SizeInput defaultValue={memory} ref='memory' required />
</div>
</Col>
</Row>
<Row>
<Col mediumSize={6}>
{!isEmpty(disks)
? map(disks, (disk, diskId) => (
<Row key={diskId}>
<Col mediumSize={6}>
<div className='form-group'>
<label>
{_('diskInfo', {
position: `${disk.position}`,
capacity: formatSize(disk.capacity)
})}
</label>
<input className='form-control' ref={`disk-name-${diskId}`} defaultValue={disk.nameLabel} type='text' required />
</div>
</Col>
<Col mediumSize={6}>
<div className='form-group'>
<label>{_('diskDescription')}</label>
<input className='form-control' ref={`disk-description-${diskId}`} defaultValue={disk.descriptionLabel} type='text' required />
</div>
</Col>
</Row>
)) : _('noDisks')
}
</Col>
<Col mediumSize={6}>
{networks.length > 0
? map(networks, (name, networkId) => (
<div className='form-group' key={networkId}>
<label>{_('networkInfo', { name })}</label>
<SelectNetwork defaultValue={defaultNetwork} ref={`network-${networkId}`} predicate={this._getNetworkPredicate()} />
</div>
)) : _('noNetworks')
}
</Col>
</Row>
</div>
)
}
}
// ===================================================================
const parseFile = async (file, type, func) => {
try {
return {
data: await func(file),
file,
type
}
} catch (error) {
return { error, file, type }
}
}
export default class Import extends Component {
constructor (props) {
super(props)
this.state.files = []
this.state.vms = []
}
_import = () => importVms(this.state.files, this.refs.selectSr.value.id)
_import = () => {
const { state } = this
return importVms(
mapPlus(state.vms, (vm, push, vmIndex) => {
if (!vm.error) {
const ref = this.refs[`vm-data-${vmIndex}`]
push({
...vm,
data: ref && ref.value
})
}
}),
state.sr
)
}
_handleDrop = async files => {
const vms = await Promise.all(mapPlus(files, (file, push) => {
const { name } = file
const extIndex = name.lastIndexOf('.')
let func
let type
if (
extIndex >= 0 &&
(type = name.substring(extIndex + 1)) &&
(func = FORMAT_TO_HANDLER[type])
) {
push(parseFile(file, type, func))
}
}))
_onDrop = files => {
this.setState({
files: filter(files, file => file.name.endsWith('.xva'))
vms: orderBy(vms, vm => [ vm.error != null, vm.type, vm.file.name ])
})
}
_onCleanSelectedVms = () => {
_handleCleanSelectedVms = () => {
this.setState({
files: []
vms: []
})
}
_handleSelectedPool = pool => {
const srPredicate = pool !== ''
? sr => sr.$pool === pool.id && isSrWritable(sr)
: undefined
if (pool === '') {
this.setState({
pool: undefined,
sr: undefined,
srPredicate: undefined
})
} else {
this.setState({
pool,
sr: pool.default_SR,
srPredicate: sr => sr.$pool === this.state.pool.id && isSrWritable(sr)
})
}
}
_handleSelectedSr = sr => {
this.setState({
srPredicate
}, () => { this.refs.selectSr.value = pool.default_SR })
sr: sr === '' ? undefined : sr
})
}
render () {
const { files, srPredicate } = this.state
const {
pool,
sr,
srPredicate,
vms
} = this.state
return <Page header={HEADER} title='newImport' formatTitle>
{process.env.XOA_PLAN > 1
? <Container>
<form id='import-form'>
<FormGrid.Row>
<FormGrid.LabelCol>{_('vmImportToPool')}</FormGrid.LabelCol>
<FormGrid.InputCol>
<SelectPool onChange={this._handleSelectedPool} required />
</FormGrid.InputCol>
</FormGrid.Row>
<FormGrid.Row>
<FormGrid.LabelCol>{_('vmImportToSr')}</FormGrid.LabelCol>
<FormGrid.InputCol>
<SelectSr
disabled={!srPredicate}
predicate={srPredicate}
ref='selectSr'
required
/>
</FormGrid.InputCol>
</FormGrid.Row>
<Dropzone onDrop={this._onDrop} className={styles.dropzone} activeClassName={styles.activeDropzone}>
<div className={styles.dropzoneText}>{_('importVmsList')}</div>
</Dropzone>
<hr />
<h5>{_('vmsToImport')}</h5>
{files.length ? (
<Row className={styles.filesRow}>
<Col mediumSize={10}>
<ul className='list-group'>
{map(files, (file, key) => (
<li key={key} className='list-group-item'>
{file.name}
<span className='pull-xs-right'>{`(${formatSize(file.size)})`}</span>
</li>
))}
</ul>
</Col>
<Col mediumSize={2} className={styles.cleanButtonContainer}>
<button
className='btn btn-secondary'
onClick={this._onCleanSelectedVms}
type='button'
>
{_('importVmsCleanList')}
</button>
</Col>
</Row>
) : <p>{_('noSelectedVms')}</p>}
<hr />
<div className='form-group pull-xs-right'>
<div className='btn-toolbar'>
<div className='btn-group'>
<ActionButton
btnStyle='primary'
disabled={!files.length}
form='import-form'
handler={this._import}
icon='import'
redirectOnSuccess='/'
type='submit'
>
{_('newImport')}
</ActionButton>
? (
<Container>
<form id='import-form'>
<FormGrid.Row>
<FormGrid.LabelCol>{_('vmImportToPool')}</FormGrid.LabelCol>
<FormGrid.InputCol>
<SelectPool value={pool} onChange={this._handleSelectedPool} required />
</FormGrid.InputCol>
</FormGrid.Row>
<FormGrid.Row>
<FormGrid.LabelCol>{_('vmImportToSr')}</FormGrid.LabelCol>
<FormGrid.InputCol>
<SelectSr
disabled={!sr}
onChange={this._handleSelectedSr}
predicate={srPredicate}
required
value={sr}
/>
</FormGrid.InputCol>
</FormGrid.Row>
{sr && (
<div>
<Dropzone onDrop={this._handleDrop} className={styles.dropzone} activeClassName={styles.activeDropzone}>
<div className={styles.dropzoneText}>{_('importVmsList')}</div>
</Dropzone>
<hr />
<h5>{_('vmsToImport')}</h5>
{vms.length > 0
? (
<div>
{map(vms, ({ data, error, file, type }, vmIndex) => (
<div key={file.preview} className={styles.vmContainer}>
<strong>{file.name}</strong>
<span className='pull-xs-right'>
<strong>{`(${formatSize(file.size)})`}</strong>
</span>
{!error
? (data &&
<div>
<hr />
<div className='alert alert-info' role='alert'>
<strong>{_('vmImportFileType', { type })}</strong> {_('vmImportConfigAlert')}
</div>
<VmData {...data} ref={`vm-data-${vmIndex}`} pool={pool} />
</div>
) : (
<div>
<hr />
<div className='alert alert-danger' role='alert'>
<strong>{_('vmImportError')}</strong> {(error && error.message) || _('noVmImportErrorDescription')}
</div>
</div>
)
}
</div>
))}
</div>
) : <p>{_('noSelectedVms')}</p>
}
<hr />
<div className='form-group pull-xs-right'>
<ActionButton
btnStyle='primary'
disabled={!vms.length}
className='m-r-1'
form='import-form'
handler={this._import}
icon='import'
redirectOnSuccess='/'
type='submit'
>
{_('newImport')}
</ActionButton>
<button
className='btn btn-secondary'
onClick={this._handleCleanSelectedVms}
type='button'
>
{_('importVmsCleanList')}
</button>
</div>
</div>
</div>
</div>
</form>
</Container>
: <Container><Upgrade place='vmImport' available={2} /></Container>
)}
</form>
</Container>
) : <Container><Upgrade place='vmImport' available={2} /></Container>
}
</Page>
}

View File

@@ -0,0 +1,174 @@
import find from 'lodash/find'
import forEach from 'lodash/forEach'
import tar from 'tar-stream'
import xml2js from 'xml2js'
import {
ensureArray,
htmlFileToStream,
streamToString
} from 'utils'
// ===================================================================
// See: http://opennodecloud.com/howto/2013/12/25/howto-ON-ovf-reference.html
// See: http://www.dmtf.org/sites/default/files/standards/documents/DSP0243_1.0.0.pdf
// See: http://www.dmtf.org/sites/default/files/standards/documents/DSP0243_2.1.0.pdf
// ===================================================================
const MEMORY_UNIT_TO_FACTOR = {
k: 1024,
m: 1048576,
g: 1073741824,
t: 1099511627776
}
const RESOURCE_TYPE_TO_HANDLER = {
// CPU.
'3': (data, {
'rasd:VirtualQuantity': nCpus
}) => {
data.nCpus = +nCpus
},
// RAM.
'4': (data, {
'rasd:AllocationUnits': unit,
'rasd:VirtualQuantity': quantity
}) => {
data.memory = quantity * allocationUnitsToFactor(unit)
},
// Network.
'10': ({ networks }, {
'rasd:AutomaticAllocation': enabled,
'rasd:Connection': name
}) => {
if (enabled) {
networks.push(name)
}
},
// Disk.
'17': ({ disks }, {
'rasd:AddressOnParent': position,
'rasd:Description': description = 'No description',
'rasd:ElementName': name,
'rasd:HostResource': resource
}) => {
const diskId = resource.match(/^(?:ovf:)?\/disk\/(.+)$/)
const disk = diskId && disks[diskId[1]]
if (disk) {
disk.descriptionLabel = description
disk.nameLabel = name
disk.position = +position
} else {
// TODO: Log error in U.I.
console.error(`No disk found: '${diskId}'.`)
}
}
}
const allocationUnitsToFactor = unit => {
const intValue = unit.match(/\^([0-9]+)$/)
return intValue != null
? Math.pow(2, intValue[1])
: MEMORY_UNIT_TO_FACTOR[unit.charAt(0).toLowerCase()]
}
const filterDisks = disks => {
for (const diskId in disks) {
if (disks[diskId].position == null) {
// TODO: Log error in U.I.
console.error(`No position specified for '${diskId}'.`)
delete disks[diskId]
}
}
}
// ===================================================================
const parseOvaFile = file => (
new Promise((resolve, reject) => {
const stream = htmlFileToStream(file)
const extract = tar.extract()
stream.on('error', reject)
// tar module can work with bad tar files...
// So it's necessary to reject at end of stream.
extract.on('finish', () => { reject(new Error('No ovf file found.')) })
extract.on('error', reject)
extract.on('entry', ({ name }, stream, cb) => {
// Not a XML file.
const extIndex = name.lastIndexOf('.')
if (extIndex === -1 || name.substring(extIndex + 1) !== 'ovf') {
stream.on('end', cb)
stream.resume()
return
}
// XML file.
streamToString(stream).then(xmlString => {
xml2js.parseString(xmlString, {
mergeAttrs: true,
explicitArray: false
}, (err, res) => {
if (err) {
reject(err)
return
}
const {
Envelope: {
DiskSection: { Disk: disks },
References: { File: files },
VirtualSystem: system
}
} = res
const data = {
disks: {},
networks: []
}
const hardware = system.VirtualHardwareSection
// Get VM name/description.
data.nameLabel = hardware.System['vssd:VirtualSystemIdentifier']
data.descriptionLabel =
(system.AnnotationSection && system.AnnotationSection.Annotation) ||
(system.OperatingSystemSection && system.OperatingSystemSection.Description)
// Get disks.
forEach(ensureArray(disks), disk => {
const file = find(ensureArray(files), file => file['ovf:id'] === disk['ovf:fileRef'])
const unit = disk['ovf:capacityAllocationUnits']
data.disks[disk['ovf:diskId']] = {
capacity: disk['ovf:capacity'] * ((unit && allocationUnitsToFactor(unit)) || 1),
path: file && file['ovf:href']
}
})
// Get hardware info: CPU, RAM, disks, networks...
forEach(ensureArray(hardware.Item), item => {
const handler = RESOURCE_TYPE_TO_HANDLER[item['rasd:ResourceType']]
if (!handler) {
return
}
handler(data, item)
})
// Remove disks which not have a position.
// (i.e. no info in hardware.Item section.)
filterDisks(data.disks)
// Done!
resolve(data)
cb()
})
})
})
stream.pipe(extract)
})
)
export { parseOvaFile as default }

View File

@@ -1,5 +1,6 @@
import _ from 'intl'
import assign from 'lodash/assign'
import BaseComponent from 'base-component'
import forEach from 'lodash/forEach'
import Icon from 'icon'
import isEmpty from 'lodash/isEmpty'
@@ -8,12 +9,13 @@ import map from 'lodash/map'
import { NavLink, NavTabs } from 'nav'
import Page from '../page'
import pick from 'lodash/pick'
import React, { cloneElement, Component } from 'react'
import React, { cloneElement } from 'react'
import VmActionBar from './action-bar'
import { Select, Text } from 'editable'
import {
editVm,
fetchVmStats,
isVmRunning,
migrateVm
} from 'xo'
import { Container, Row, Col } from 'grid'
@@ -37,8 +39,6 @@ import TabSnapshots from './tab-snapshots'
import TabLogs from './tab-logs'
import TabAdvanced from './tab-advanced'
const isRunning = vm => vm && vm.power_state === 'Running'
// ===================================================================
@routes('general', {
@@ -113,7 +113,7 @@ const isRunning = vm => vm && vm.power_state === 'Running'
}
}
})
export default class Vm extends Component {
export default class Vm extends BaseComponent {
static contextTypes = {
router: React.PropTypes.object
}
@@ -123,7 +123,7 @@ export default class Vm extends Component {
this.cancel()
}
if (!isRunning(vm)) {
if (!isVmRunning(vm)) {
return
}
@@ -162,9 +162,9 @@ export default class Vm extends Component {
this.context.router.push('/')
}
if (!isRunning(vmCur) && isRunning(vmNext)) {
if (!isVmRunning(vmCur) && isVmRunning(vmNext)) {
this.loop(vmNext)
} else if (isRunning(vmCur) && !isRunning(vmNext)) {
} else if (isVmRunning(vmCur) && !isVmRunning(vmNext)) {
this.setState({
statsOverview: undefined
})
@@ -243,6 +243,8 @@ export default class Vm extends Component {
</Container>
}
_toggleHeader = () => this.setState({ collapsedHeader: !this.state.collapsedHeader })
render () {
const { container, vm } = this.props
@@ -262,8 +264,8 @@ export default class Vm extends Component {
]), pick(this.state, [
'statsOverview'
]))
return <Page header={this.header()} title={`${vm.name_label}${container ? ` (${container.name_label})` : ''}`}>
{cloneElement(this.props.children, childProps)}
return <Page header={this.header()} collapsedHeader={this.state.collapsedHeader} title={`${vm.name_label}${container ? ` (${container.name_label})` : ''}`}>
{cloneElement(this.props.children, { ...childProps, toggleHeader: this._toggleHeader })}
</Page>
}
}

View File

@@ -7,7 +7,9 @@ import invoke from 'invoke'
import IsoDevice from 'iso-device'
import NoVnc from 'react-novnc'
import React from 'react'
import { resolveUrl } from 'xo'
import Tooltip from 'tooltip'
import { Button } from 'react-bootstrap-4/lib'
import { resolveUrl, isVmRunning } from 'xo'
import { Container, Row, Col } from 'grid'
import {
CpuSparkLines,
@@ -17,6 +19,11 @@ import {
} from 'xo-sparklines'
export default class TabConsole extends Component {
componentWillReceiveProps (props) {
if (isVmRunning(this.props.vm) && !isVmRunning(props.vm)) {
this.props.minimalLayout && this._toggleMinimalLayout()
}
}
_sendCtrlAltDel = () => {
this.refs.noVnc.sendCtrlAltDel()
}
@@ -36,21 +43,32 @@ export default class TabConsole extends Component {
_getClipboardContent = () =>
this.refs.clipboard && this.refs.clipboard.value
_toggleMinimalLayout = () => {
this.props.toggleHeader()
this.setState({ minimalLayout: !this.state.minimalLayout })
}
render () {
const {
statsOverview,
vm
} = this.props
const {
minimalLayout,
scale
} = this.state
if (vm.power_state !== 'Running') {
if (!isVmRunning(vm)) {
return (
<p>Console is only available for running VMs.</p>
<Container>
<p>Console is only available for running VMs.</p>
</Container>
)
}
return (
<Container>
{statsOverview && <Row className='text-xs-center'>
{!minimalLayout && statsOverview && <Row className='text-xs-center'>
<Col mediumSize={3}>
<p>
<Icon icon='cpu' size={2} />
@@ -81,10 +99,10 @@ export default class TabConsole extends Component {
</Col>
</Row>}
<Row>
<Col mediumSize={5}>
<Col mediumSize={3}>
<IsoDevice vm={vm} />
</Col>
<Col mediumSize={5}>
<Col mediumSize={3}>
<div className='input-group'>
<input type='text' className='form-control' ref='clipboard' onChange={this._setRemoteClipboard} />
<span className='input-group-btn'>
@@ -104,11 +122,34 @@ export default class TabConsole extends Component {
<Icon icon='vm-keyboard' /> {_('ctrlAltDelButtonLabel')}
</button>
</Col>
<Col mediumSize={3}>
<input
className='form-control'
max={3}
min={0.1}
onChange={this.linkState('scale')}
step={0.1}
type='range'
value={scale}
/>
</Col>
<Col mediumSize={1}>
<Tooltip content={minimalLayout ? _('showHeaderTooltip') : _('hideHeaderTooltip')}>
<Button bsStyle='secondary' onClick={this._toggleMinimalLayout}>
<Icon icon={minimalLayout ? 'caret' : 'caret-up'} />
</Button>
</Tooltip>
</Col>
</Row>
<Row className='console'>
<Col>
<NoVnc ref='noVnc' url={resolveUrl(`consoles/${vm.id}`)} onClipboardChange={this._getRemoteClipboard} />
<p><em><Icon icon='info' /> {_('tipLabel')} {_('tipConsoleLabel')}</em></p>
<NoVnc
onClipboardChange={this._getRemoteClipboard}
ref='noVnc'
scale={scale}
url={resolveUrl(`consoles/${vm.id}`)}
/>
{!minimalLayout && <p><em><Icon icon='info' /> {_('tipLabel')} {_('tipConsoleLabel')}</em></p>}
</Col>
</Row>
</Container>

View File

@@ -24,11 +24,13 @@ import { XoSelect, Size, Text } from 'editable'
import {
attachDiskToVm,
createDisk,
connectVbd,
deleteVbd,
deleteVdi,
disconnectVbd,
editVdi,
isSrWritable,
isVmRunning,
migrateVdi,
setBootableVbd,
setVmBootOrder
@@ -145,7 +147,7 @@ class AttachDisk extends Component {
const { vm, vbds, onClose = noop } = this.props
const { vdi } = this.state
const { bootable, readOnly } = this.refs
const _isFreeForWriting = vdi => some(vdi.$VBDs, id => {
const _isFreeForWriting = vdi => vdi.$VBDs.length === 0 || some(vdi.$VBDs, id => {
const vbd = vbds[id]
return !vbd || !vbd.attached || vbd.read_only
})
@@ -444,6 +446,14 @@ export default class TabDisks extends Component {
{_('vbdStatusDisconnected')}
</span>
<ButtonGroup className='pull-xs-right'>
{isVmRunning(vm) &&
<ActionRowButton
btnStyle='default'
icon='connect'
handler={connectVbd}
handlerParam={vbd}
/>
}
<ActionRowButton
btnStyle='default'
icon='vdi-forget'

View File

@@ -5,6 +5,7 @@ import isEmpty from 'lodash/isEmpty'
import map from 'lodash/map'
import React from 'react'
import Tags from 'tags'
import Tooltip from 'tooltip'
import { addTag, editVm, removeTag } from 'xo'
import { BlockLink } from 'link'
import { FormattedRelative } from 'react-intl'
@@ -79,8 +80,7 @@ export default ({
</BlockLink>
</Col>
<Col mediumSize={3}>
{/* TODO: tooltip and better icon usage */}
<BlockLink to={`/vms/${vm.id}/advanced`}><h1><Icon className='text-info' icon={vm.os_version && vm.os_version.distro && osFamily(vm.os_version.distro)} /></h1></BlockLink>
<BlockLink to={`/vms/${vm.id}/advanced`}><Tooltip content={vm.os_version ? vm.os_version.name : _('unknownOsName')}><h1><Icon className='text-info' icon={vm.os_version && vm.os_version.distro && osFamily(vm.os_version.distro)} /></h1></Tooltip></BlockLink>
</Col>
</Row>
{!vm.xenTools && vm.power_state === 'Running' &&

View File

@@ -1,32 +1,68 @@
import _, { messages } from 'intl'
import ActionButton from 'action-button'
import ActionRowButton from 'action-row-button'
import concat from 'lodash/concat'
import every from 'lodash/every'
import find from 'lodash/find'
import Icon from 'icon'
import isEmpty from 'lodash/isEmpty'
import map from 'lodash/map'
import propTypes from 'prop-types'
import React, { Component } from 'react'
import remove from 'lodash/remove'
import TabButton from 'tab-button'
import Tooltip from 'tooltip'
import { isIp, isIpV4 } from 'ip'
import { ButtonGroup } from 'react-bootstrap-4/lib'
import { connectStore, noop } from 'utils'
import { Container, Row, Col } from 'grid'
import {
createFinder,
createGetObject,
createGetObjectsOfType,
createSelector
} from 'selectors'
import { injectIntl } from 'react-intl'
import { SelectNetwork } from 'select-objects'
import { SelectNetwork, SelectIp } from 'select-objects'
import { XoSelect } from 'editable'
import {
connectVif,
createVmInterface,
deleteVif,
disconnectVif
disconnectVif,
isVmRunning,
setVif
} from 'xo'
const IP_COLUMN_STYLE = { maxWidth: '20em' }
const TABLE_STYLE = { minWidth: '0' }
@propTypes({
onClose: propTypes.func,
vm: propTypes.object.isRequired
})
@connectStore(() => {
const getHostMaster = createGetObject(
(_, props) => props.pool && props.pool.master
)
const getPifs = createGetObjectsOfType('PIF').pick(
(state, props) => {
const hostMaster = getHostMaster(state, props)
return hostMaster && hostMaster.$PIFs
}
)
const getDefaultNetworkId = createSelector(
createFinder(
getPifs,
[ pif => pif.management ]
),
pif => pif && pif.$network
)
return {
defaultNetworkId: getDefaultNetworkId
}
})
@injectIntl
class NewVif extends Component {
constructor (props) {
@@ -57,7 +93,7 @@ class NewVif extends Component {
const formatMessage = this.props.intl.formatMessage
return <form id='newVifForm'>
<div className='form-group'>
<SelectNetwork predicate={this._getNetworkPredicate()} onChange={this._selectNetwork} required />
<SelectNetwork defaultValue={this.props.defaultNetworkId} predicate={this._getNetworkPredicate()} onChange={this._selectNetwork} required />
</div>
<fieldset className='form-inline'>
<div className='form-group'>
@@ -102,11 +138,64 @@ export default class TabNetwork extends Component {
_toggleNewVif = () => this.setState({
newVif: !this.state.newVif
})
_toggleNewIp = vifIndex => {
const { showNewIpForm } = this.state
this.setState({
showNewIpForm: { ...showNewIpForm, [vifIndex]: !(showNewIpForm && showNewIpForm[vifIndex]) }
})
}
_saveIp = (vifIndex, ipIndex, newIp) => {
if (!isIp(newIp.id)) {
throw new Error('Not a valid IP')
}
const vif = this.props.vifs[vifIndex]
const { allowedIpv4Addresses, allowedIpv6Addresses } = vif
if (isIpV4(newIp.id)) {
allowedIpv4Addresses[ipIndex] = newIp.id
} else {
allowedIpv6Addresses[ipIndex - allowedIpv4Addresses.length] = newIp.id
}
setVif(vif, { allowedIpv4Addresses, allowedIpv6Addresses })
}
_addIp = (vifIndex, ip) => {
this._toggleNewIp(vifIndex)
if (!isIp(ip.id)) {
return
}
const vif = this.props.vifs[vifIndex]
let { allowedIpv4Addresses, allowedIpv6Addresses } = vif
if (isIpV4(ip.id)) {
allowedIpv4Addresses = [ ...allowedIpv4Addresses, ip.id ]
} else {
allowedIpv6Addresses = [ ...allowedIpv6Addresses, ip.id ]
}
setVif(vif, { allowedIpv4Addresses, allowedIpv6Addresses })
}
_deleteIp = ({ vifIndex, ipIndex }) => {
const vif = this.props.vifs[vifIndex]
const { allowedIpv4Addresses, allowedIpv6Addresses } = vif
if (ipIndex < allowedIpv4Addresses.length) {
remove(allowedIpv4Addresses, (_, i) => i === ipIndex)
} else {
remove(allowedIpv6Addresses, (_, i) => i === ipIndex - allowedIpv4Addresses.length)
}
setVif(vif, { allowedIpv4Addresses, allowedIpv6Addresses })
}
_getIpPredicate = vifIndex => selectedIp =>
every(this._concatIps(this.props.vifs[vifIndex]), vifIp => vifIp !== selectedIp.id)
_getIpPoolPredicate = vifNetwork => ipPool =>
find(ipPool.networks, network => network === vifNetwork)
_noIps = vif => isEmpty(vif.allowedIpv4Addresses) && isEmpty(vif.allowedIpv6Addresses)
_concatIps = vif => concat(vif.allowedIpv4Addresses, vif.allowedIpv6Addresses)
render () {
const { newVif } = this.state
const { newVif, showNewIpForm } = this.state
const {
networks,
pool,
vifs,
vm
} = this.props
@@ -124,30 +213,72 @@ export default class TabNetwork extends Component {
</Row>
<Row>
<Col>
{newVif && <div><NewVif vm={vm} onClose={this._toggleNewVif} /><hr /></div>}
{newVif && <div><NewVif vm={vm} pool={pool} onClose={this._toggleNewVif} /><hr /></div>}
</Col>
</Row>
<Row>
<Col>
{!isEmpty(vifs)
? <span>
<table className='table' style={{ minWidth: '0' }}>
<table className='table' style={TABLE_STYLE}>
<thead>
<tr>
<th>{_('vifDeviceLabel')}</th>
<th>{_('vifMacLabel')}</th>
<th>{_('vifMtuLabel')}</th>
<th>{_('vifNetworkLabel')}</th>
<th>{_('vifAllowedIps')}</th>
<th>{_('vifStatusLabel')}</th>
</tr>
</thead>
<tbody>
{map(vifs, vif =>
<tr key={vif.id}>
{map(vifs, (vif, vifIndex) => {
const lockedNetwork = networks[vif.$network].defaultIsLocked
return <tr key={vif.id}>
<td>VIF #{vif.device}</td>
<td><pre>{vif.MAC}</pre></td>
<td>{vif.MTU}</td>
<td>{networks[vif.$network] && networks[vif.$network].name_label}</td>
<td style={IP_COLUMN_STYLE}>
<Container>
{this._noIps(vif)
? <Row>
<Col><em>{_('vifNoIps')}</em></Col>
</Row>
: map(this._concatIps(vif), (ip, ipIndex) => <Row>
<Col size={10}>
<XoSelect
onChange={newIp => this._saveIp(vifIndex, ipIndex, newIp)}
predicate={this._getIpPredicate(vifIndex)}
value={ip}
xoType='ip'
>
{ip}
</XoSelect>
</Col>
<Col size={1}>
<ActionRowButton handler={this._deleteIp} handlerParam={{ vifIndex, ipIndex }} icon='delete' />
</Col>
</Row>)
}
<Row>
<Col size={10}>
{showNewIpForm && showNewIpForm[vifIndex]
? <span onBlur={() => this._toggleNewIp(vifIndex)}>
<SelectIp
autoFocus
onChange={ip => this._addIp(vifIndex, ip)}
containerPredicate={this._getIpPoolPredicate(vif.$network)}
predicate={this._getIpPredicate(vifIndex)}
required
/>
</span>
: <ActionButton btnStyle='success' size='small' handler={this._toggleNewIp} handlerParam={vifIndex} icon='add' />}
</Col>
</Row>
</Container>
</td>
<td>
{vif.attached
? <span>
@@ -167,11 +298,13 @@ export default class TabNetwork extends Component {
{_('vifStatusDisconnected')}
</span>
<ButtonGroup className='pull-xs-right'>
<ActionRowButton
icon='connect'
handler={connectVif}
handlerParam={vif}
/>
{isVmRunning(vm) &&
<ActionRowButton
icon='connect'
handler={connectVif}
handlerParam={vif}
/>
}
<ActionRowButton
icon='remove'
handler={deleteVif}
@@ -180,9 +313,17 @@ export default class TabNetwork extends Component {
</ButtonGroup>
</span>
}
{' '}
{lockedNetwork && isEmpty(this._concatIps(vif))
? <Tooltip content={_('vifLockedNetworkNoIps')}>
<Icon icon='error' />
</Tooltip>
: <Tooltip content={lockedNetwork && _('vifLockedNetwork')}>
<Icon icon={lockedNetwork ? 'lock' : 'unlock'} />
</Tooltip>}
</td>
</tr>
)}
})}
</tbody>
</table>
{vm.addresses && !isEmpty(vm.addresses)

View File

@@ -15,7 +15,9 @@ import {
createGetObjectsOfType
} from 'selectors'
import {
copyVm,
deleteVm,
exportVm,
editVm,
revertSnapshot,
snapshotVm
@@ -72,6 +74,22 @@ export default class TabSnapshot extends Component {
</td>
<td>
<ButtonGroup>
<Tooltip content={_('copySnapshot')}>
<ActionRowButton
btnStyle='primary'
handler={copyVm}
handlerParam={snapshot}
icon='vm-copy'
/>
</Tooltip>
<Tooltip content={_('exportSnapshot')}>
<ActionRowButton
btnStyle='primary'
handler={exportVm}
handlerParam={snapshot}
icon='vm-export'
/>
</Tooltip>
<Tooltip content={_('revertSnapshot')}>
<ActionRowButton
btnStyle='warning'

View File

@@ -12,7 +12,8 @@ import Tooltip from 'tooltip'
import xoaUpdater, { exposeTrial, isTrialRunning } from 'xoa-updater'
import { confirm } from 'modal'
import { connectStore } from 'utils'
import { Container } from 'grid'
import { Card, CardBlock, CardHeader } from 'card'
import { Container, Row, Col } from 'grid'
import { error } from 'notification'
import { Password } from 'form'
import { serverVersion } from 'xo'
@@ -76,6 +77,7 @@ export default class XoaUpdates extends Component {
const { registration } = this.props
const alreadyRegistered = (registration.state === 'registered')
if (alreadyRegistered) {
try {
await confirm({
@@ -85,6 +87,7 @@ export default class XoaUpdates extends Component {
return
}
}
this.setState({ askRegisterAgain: false })
return xoaUpdater.register(email.value, password.value, alreadyRegistered)
.then(() => { email.value = password.value = '' })
}
@@ -116,6 +119,7 @@ export default class XoaUpdates extends Component {
_trialAvailable = trial => trial.state === 'default' && isTrialRunning(trial.trial)
_trialConsumed = trial => trial.state === 'default' && !isTrialRunning(trial.trial) && !exposeTrial(trial.trial)
_updaterDown = trial => isEmpty(trial) || trial.state === 'ERROR'
_toggleAskRegisterAgain = () => this.setState({ askRegisterAgain: !this.state.askRegisterAgain })
_startTrial = async () => {
try {
@@ -132,6 +136,7 @@ export default class XoaUpdates extends Component {
}
componentWillMount () {
this.setState({ askRegisterAgain: false })
serverVersion.then(serverVersion => {
this.setState({ serverVersion })
})
@@ -154,6 +159,8 @@ export default class XoaUpdates extends Component {
} = this.props
let { configuration } = this.props // Configuration from the store
const alreadyRegistered = (registration.state === 'registered')
configuration = assign({}, configuration)
const {
proxyHost,
@@ -173,141 +180,168 @@ export default class XoaUpdates extends Component {
<p className='text-danger'>{_('noUpdaterWarning')}</p>
</div>
: <div>
<p>{_('currentVersion')} {`xo-server ${this.state.serverVersion}`} / {`xo-web ${pkg.version}`}</p>
<p>
<strong>{states[state]}</strong>
{' '}
<ActionButton
btnStyle='info'
handler={update}
icon='refresh'>
{_('update')}
</ActionButton>
{' '}
<ActionButton
btnStyle='primary'
handler={upgrade}
icon='upgrade'>
{_('upgrade')}
</ActionButton>
</p>
<div>
{map(log, (log, key) => (
<p key={key}>
<span className={textClasses[log.level]} >{log.date}</span>: <span dangerouslySetInnerHTML={{__html: ansiUp.ansi_to_html(log.message)}} />
</p>
))}
</div>
<h2>{_('settings')} {configEdited ? '*' : ''}</h2>
<form className='form-inline'>
<fieldset>
<div className='form-group'>
<input
className='form-control'
placeholder='Host (myproxy.example.org)'
type='text'
value={configuration.proxyHost}
onChange={this._handleProxyHostChange}
/>
</div>
{' '}
<div className='form-group'>
<input
className='form-control'
placeholder='Port (3128 ?...)'
type='text'
value={configuration.proxyPort}
onChange={this._handleProxyPortChange}
/>
</div>
{' '}
<div className='form-group'>
<input
className='form-control'
placeholder='User name'
type='text'
value={configuration.proxyUser}
onChange={this._handleProxyUserChange}
/>
</div>
{' '}
<div className='form-group'>
<Password
placeholder='password'
ref='proxyPassword'
/>
</div>
</fieldset>
<br />
<fieldset>
<ActionButton icon='save' btnStyle='primary' handler={this._configure}>{_('saveResourceSet')}</ActionButton>
{' '}
<button type='button' className='btn btn-default' onClick={this._handleConfigReset} disabled={!configEdited}>{_('resetResourceSet')}</button>
</fieldset>
</form>
<h2>{_('registration')}</h2>
<p>
<strong>{registration.state}</strong>
{registration.email && <span> to {registration.email}</span>}
<span className='text-danger'> {registration.error}</span>
</p>
<form id='registrationForm' className='form-inline'>
<div className='form-group'>
<input
className='form-control'
placeholder='account email'
ref='email'
required
type='text'
/>
</div>
{' '}
<div className='form-group'>
<Password
placeholder='password'
ref='password'
required
/>
</div>
{' '}
<ActionButton form='registrationForm' icon='success' btnStyle='primary' handler={this._register}>{_('register')}</ActionButton>
</form>
{+process.env.XOA_PLAN === 1 &&
<div>
<h2>{_('trial')}</h2>
{this._trialAllowed(trial) &&
<div>
{registration.state !== 'registered' && <p>{_('trialRegistration')}</p>}
{registration.state === 'registered' &&
<ActionButton btnStyle='success' handler={this._startTrial} icon='trial'>{_('trialStartButton')}</ActionButton>
<Row>
<Col mediumSize={12}>
<Card>
<CardHeader>
<UpdateTag /> {states[state]}
</CardHeader>
<CardBlock>
<p>{_('currentVersion')} {`xo-server ${this.state.serverVersion}`} / {`xo-web ${pkg.version}`}</p>
<ActionButton
btnStyle='info'
handler={update}
icon='refresh'>
{_('refresh')}
</ActionButton>
{' '}
<ActionButton
btnStyle='success'
handler={upgrade}
icon='upgrade'>
{_('upgrade')}
</ActionButton>
<hr />
<div>
{map(log, (log, key) => (
<p key={key}>
<span className={textClasses[log.level]} >{log.date}</span>: <span dangerouslySetInnerHTML={{__html: ansiUp.ansi_to_html(log.message)}} />
</p>
))}
</div>
</CardBlock>
</Card>
</Col>
</Row>
<Row>
<Col mediumSize={6}>
<Card>
<CardHeader>
{_('proxySettings')} {configEdited ? '*' : ''}
</CardHeader>
<CardBlock>
<form>
<fieldset>
<div className='form-group'>
<input
className='form-control'
placeholder='Host (myproxy.example.org)'
type='text'
value={configuration.proxyHost}
onChange={this._handleProxyHostChange}
/>
</div>
{' '}
<div className='form-group'>
<input
className='form-control'
placeholder='Port (eg: 3128)'
type='text'
value={configuration.proxyPort}
onChange={this._handleProxyPortChange}
/>
</div>
{' '}
<div className='form-group'>
<input
className='form-control'
placeholder='Username'
type='text'
value={configuration.proxyUser}
onChange={this._handleProxyUserChange}
/>
</div>
{' '}
<div className='form-group'>
<Password
placeholder='Password'
ref='proxyPassword'
/>
</div>
</fieldset>
<br />
<fieldset>
<ActionButton icon='save' btnStyle='primary' handler={this._configure}>{_('saveResourceSet')}</ActionButton>
{' '}
<button type='button' className='btn btn-default' onClick={this._handleConfigReset} disabled={!configEdited}>{_('resetResourceSet')}</button>
</fieldset>
</form>
</CardBlock>
</Card>
</Col>
<Col mediumSize={6}>
<Card>
<CardHeader>
{_('registration')}
</CardHeader>
<CardBlock>
<strong>{registration.state}</strong>
{registration.email && <span> to {registration.email}</span>}
<span className='text-danger'> {registration.error}</span>
{(!alreadyRegistered || this.state.askRegisterAgain)
? <form id='registrationForm'>
<div className='form-group'>
<input
className='form-control'
placeholder='Your email account'
ref='email'
required
type='text'
/>
</div>
{' '}
<div className='form-group'>
<Password
placeholder='Your password'
ref='password'
required
/>
</div>
{' '}
<ActionButton form='registrationForm' icon='success' btnStyle='primary' handler={this._register}>{_('register')}</ActionButton>
</form>
: <ActionButton icon='edit' btnStyle='primary' handler={this._toggleAskRegisterAgain}>{_('editRegistration')}</ActionButton>
}
</div>
}
{this._trialAvailable(trial) &&
<p className='text-success'>{_('trialAvailableUntil', {date: new Date(trial.trial.end)})}</p>
}
{this._trialConsumed(trial) &&
<p>{_('trialConsumed')}</p>
}
</div>
}
{(process.env.XOA_PLAN > 1 && process.env.XOA_PLAN < 5) &&
<div>
{trial.state === 'trustedTrial' &&
<p>{trial.message}</p>
}
{trial.state === 'untrustedTrial' &&
<p className='text-danger'>{trial.message}</p>
}
</div>
}
{process.env.XOA_PLAN < 5 &&
<div>
{this._updaterDown(trial) &&
<p className='text-danger'>{_('trialLocked')}</p>
}
</div>
}
{+process.env.XOA_PLAN === 1 &&
<div>
<h2>{_('trial')}</h2>
{this._trialAllowed(trial) &&
<div>
{registration.state !== 'registered' && <p>{_('trialRegistration')}</p>}
{registration.state === 'registered' &&
<ActionButton btnStyle='success' handler={this._startTrial} icon='trial'>{_('trialStartButton')}</ActionButton>
}
</div>
}
{this._trialAvailable(trial) &&
<p className='text-success'>{_('trialAvailableUntil', {date: new Date(trial.trial.end)})}</p>
}
{this._trialConsumed(trial) &&
<p>{_('trialConsumed')}</p>
}
</div>
}
{(process.env.XOA_PLAN > 1 && process.env.XOA_PLAN < 5) &&
<div>
{trial.state === 'trustedTrial' &&
<p>{trial.message}</p>
}
{trial.state === 'untrustedTrial' &&
<p className='text-danger'>{trial.message}</p>
}
</div>
}
{process.env.XOA_PLAN < 5 &&
<div>
{this._updaterDown(trial) &&
<p className='text-danger'>{_('trialLocked')}</p>
}
</div>
}
</CardBlock>
</Card>
</Col>
</Row>
</div>
}
</Container>