Compare commits

...

47 Commits

Author SHA1 Message Date
Julien Fontanet
5ad49de642 5.5.2 2016-12-22 12:27:03 +01:00
Fabrice Marsaud
b45bb5c144 fix(xoa-updater): use the new source info (#1846) 2016-12-22 12:26:02 +01:00
Julien Fontanet
9402596f69 5.5.1 2016-12-22 11:18:32 +01:00
Fabrice Marsaud
096687ae2c fix(xoa-updates): also refresh on plan change (#1843) 2016-12-22 11:16:45 +01:00
Julien Fontanet
210b5de992 fix(backup/file-restore-modal): fetched timestamps are in seconds
Follow up to for #1840.
2016-12-20 17:10:55 +01:00
Julien Fontanet
f742fdbf1b fix(backup/file-restore): fetched timestamps are in seconds
Fixes #1840
2016-12-20 16:49:37 +01:00
Pierre Donias
e7026c522d fix(editable/XoSelect): update value before saving (#1835) 2016-12-20 13:54:22 +01:00
Julien Fontanet
c21fc4beda 5.5.0 2016-12-20 13:39:05 +01:00
Julien Fontanet
edf6fe782e chore: update yarn.lock 2016-12-20 13:36:39 +01:00
Pierre Donias
3cbb6c4a98 feat(backup): implement file restore (#1838)
Fixes #1590
2016-12-20 13:34:59 +01:00
Olivier Lambert
568a50acc5 feat(changelog): update changelog 2016-12-19 17:59:14 +01:00
Pierre Donias
fbcb756cef fix(backup/new): remove "Only metadata" option (#1837)
Fixes #1818
2016-12-19 17:53:04 +01:00
Pierre Donias
81eb4ba4f9 fix(form/Select): make text wrap when too long (#1836)
Fixes #1832
2016-12-19 17:15:03 +01:00
Olivier Lambert
0cc14d2ab8 fix(intl): fix the place holder for NFS path, removing the initial slash 2016-12-19 13:48:49 +01:00
greenkeeper[bot]
6aedadc982 chore(package): update jest to version 18.0.0 (#1831)
https://greenkeeper.io/
2016-12-15 12:34:05 +01:00
Olivier Lambert
a8d10dab3c feat(changelog): changelog for 5.5 release 2016-12-15 12:19:35 +01:00
Pierre Donias
1ff6ff1d7a fix(getDefaultNetworkForVif): allow PIFs with no IP (#1830)
Fixes #1788
2016-12-15 10:31:03 +01:00
Pierre Donias
8afe4a85dc fix(form/select-plain-object): make it controlled (#1829) 2016-12-14 17:18:31 +01:00
greenkeeper[bot]
c57fbdce63 chore(package): update index-modules to version 0.2.1 (#1822) 2016-12-12 16:49:29 +01:00
greenkeeper[bot]
bdc0278fd1 chore(package): update gulp-sass to version 3.0.0 (#1820)
https://greenkeeper.io/
2016-12-10 15:07:20 +01:00
Fabrice Marsaud
c3ac8d0587 fix(xoa-updater): propose refresh when xo-web is not up to date (#1815)
Fixes #1801.
2016-12-08 16:24:47 +01:00
Julien Fontanet
f3a5e1e97c feat: yarn integration (#1813) 2016-12-07 14:36:51 +01:00
Julien Fontanet
919aa5fc43 feat(tests): basic tests for grid components 2016-12-07 14:06:20 +01:00
Julien Fontanet
416c98ffd2 chore(tests): use jest instead of ava 2016-12-07 14:06:20 +01:00
Pierre Donias
8094447183 fix(self-service): make it controlled and multiple fixes (#1812)
Fixes:
- VIF IP edition was not possible if the current IP's IP-pool did not exist anymore
- VM creation: removing the pool/resource set in the selector was broken
- Prevent selecting a negative number as an IP pool quantity
- Prevent deleting a resource set that hasn't been created yet
- Remove console.log
2016-12-06 16:43:07 +01:00
Julien Fontanet
575375d3e0 5.4.1 2016-12-05 14:08:56 +01:00
Julien Fontanet
4296ae02dc fix(vm/network): fix IP addresses concatenation 2016-12-05 14:04:43 +01:00
Julien Fontanet
0e40af0515 feat(patch-react): name the patched render function 2016-12-05 14:03:39 +01:00
Julien Fontanet
5d3a0e7a41 fix(patch-react): assign arguments object directly 2016-12-05 10:43:09 +01:00
Julien Fontanet
8ae2aae37a fix(package): update xo-acl-resolver to v0.2.3
It ships a workaround for an issue when a VDI snapshot $snapshot_of is itself.
2016-12-02 16:18:31 +01:00
Pierre Donias
83b3cf406a fix(lang): auto-refresh XO app when changing language (#1809)
Fixes #1800
2016-12-02 13:09:35 +01:00
Julien Fontanet
1643ced4e0 feat: do not break if a component throws 2016-12-01 16:49:36 +01:00
Pierre Donias
b2a1840da7 fix(vm-import): SR selector disable condition (#1808)
Fixes #1804
2016-12-01 15:54:58 +01:00
Pierre Donias
b9f20d1e80 fix(select-objects): allow integer IDs (#1807)
Fixes #1805
2016-12-01 15:39:47 +01:00
Pierre Donias
0c77781be8 feat(backup/smart): tags/pools select all and negation (#1802)
Fixes #1503
2016-12-01 14:56:18 +01:00
Julien Fontanet
83245af1e2 feat: improve debugging in production (#1806)
* feat(build): enable sourcemaps in production

* feat(index): let browsers handle unhandled rejections

They do a much better job to display the error and its trace with sourcemaps.
2016-12-01 14:22:07 +01:00
Julien Fontanet
7db806a461 fix(xo/subcriptions): handle unsubscription during notification 2016-12-01 12:15:12 +01:00
Olivier Lambert
92b15fb1e2 feat(consoles): update tip about console issue to point to XS ticket 2016-11-29 11:00:30 +01:00
Pierre Donias
7b5182111c fix(dashboard/health): message parsing and link to object view (#1796)
Related to #1776
2016-11-29 09:53:19 +01:00
Nicolas Raynaud
82b1b81999 fix(dashboard/overview): graphs on Safari (#1771)
There seems to be a bug in Safari with flex layout, hard coding the height of the graph seems to do the trick.

Fixes #1755
2016-11-29 09:21:04 +01:00
Pierre Donias
f0a430f350 feat(acls): filter object selector (#1791)
Fixes #1515
2016-11-28 12:05:56 +01:00
Olivier Lambert
90f95b7270 feat(i18n): added selector for simplified Chinese 2016-11-28 10:53:41 +01:00
Olivier Lambert
15e6a93fac feat(i18n): added simplified Chinese translation 2016-11-28 10:45:33 +01:00
Julien Fontanet
01541a2577 feat(@autoControlledInput): make controlled inputs able to handle uncontrolled mode
See #1628.
2016-11-24 11:34:21 +01:00
Olivier Lambert
8c70bc0a17 feat(i18n): fix wrong translate key 2016-11-24 10:16:44 +01:00
Nicolas Raynaud
9d96074604 chore(package): update react-shortcuts to v1.3.1 (#1792)
Fix #1691
2016-11-24 09:58:56 +01:00
Julien Fontanet
114a4028f4 fix: coding style fixes 2016-11-24 09:57:02 +01:00
65 changed files with 10304 additions and 3012 deletions

View File

@@ -4,9 +4,7 @@ node_js:
#- '4' # npm 3's flat tree is needed because some packages do not
# declare their deps correctly (e.g. chartist-plugin-tooltip)
cache:
directories:
- node_modules
cache: yarn
# Use containers.
# http://docs.travis-ci.com/user/workers/container-based-infrastructure/

View File

@@ -1,5 +1,28 @@
# ChangeLog
## **5.5.0** (2016-12-20)
File level restore.
### Enhancements
- Better auto select network when migrate VM [\#1788](https://github.com/vatesfr/xo-web/issues/1788)
- Plugin for passive backup job reporting in Nagios [\#1664](https://github.com/vatesfr/xo-web/issues/1664)
- File level restore for delta backup [\#1590](https://github.com/vatesfr/xo-web/issues/1590)
- Better select filters for ACLs [\#1515](https://github.com/vatesfr/xo-web/issues/1515)
- All pools and "negative" filters [\#1503](https://github.com/vatesfr/xo-web/issues/1503)
- VM copy with disk selection [\#826](https://github.com/vatesfr/xo-web/issues/826)
- Disable metadata exports [\#1818](https://github.com/vatesfr/xo-web/issues/1818)
### Bug fixes
- Tool small selector [\#1832](https://github.com/vatesfr/xo-web/issues/1832)
- Replication does not work from a VM created by a CR or delta backup [\#1811](https://github.com/vatesfr/xo-web/issues/1811)
- Can't add a SSH key in VM creation [\#1805](https://github.com/vatesfr/xo-web/issues/1805)
- Issue when no default SR in a pool [\#1804](https://github.com/vatesfr/xo-web/issues/1804)
- XOA doesn't refresh after an update anymore [\#1801](https://github.com/vatesfr/xo-web/issues/1801)
- Shortcuts not inhibited on inputs on Safari [\#1691](https://github.com/vatesfr/xo-web/issues/1691)
## **5.4.0** (2016-11-23)
### Enhancements

View File

@@ -162,7 +162,7 @@ function browserify (path, opts) {
var bundler = require('browserify')(path, {
basedir: SRC_DIR,
debug: DEVELOPMENT, // TODO: enable also in production but need to make it work with gulp-uglify.
debug: true,
extensions: opts.extensions,
fullPaths: false,
paths: SRC_DIR + '/common',
@@ -257,6 +257,7 @@ gulp.task(function buildScripts () {
}]
]
}),
require('gulp-sourcemaps').init({ loadMaps: true }),
PRODUCTION && require('gulp-uglify')(),
dest()
)

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "xo-web",
"version": "5.4.0",
"version": "5.5.2",
"license": "AGPL-3.0",
"description": "Web interface client for Xen-Orchestra",
"keywords": [
@@ -33,7 +33,6 @@
"devDependencies": {
"ansi_up": "^1.3.0",
"asap": "^2.0.4",
"ava": "^0.16.0",
"babel-eslint": "^7.0.0",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-react-constant-elements": "^6.5.0",
@@ -58,6 +57,8 @@
"cookies-js": "^1.2.2",
"d3": "^4.2.8",
"dependency-check": "^2.5.1",
"enzyme": "^2.6.0",
"enzyme-to-json": "^1.4.4",
"event-to-promise": "^0.7.0",
"font-awesome": "^4.5.0",
"font-mfizz": "github:fizzed/font-mfizz",
@@ -71,13 +72,16 @@
"gulp-plumber": "^1.1.0",
"gulp-pug": "^3.1.0",
"gulp-refresh": "^1.1.0",
"gulp-sass": "^2.2.0",
"gulp-sass": "^3.0.0",
"gulp-sourcemaps": "^1.9.1",
"gulp-uglify": "^2.0.0",
"gulp-watch": "^4.3.5",
"human-format": "^0.7.0",
"index-modules": "0.1.0",
"index-modules": "^0.2.1",
"is-ip": "^1.0.0",
"jest": "^18.0.0",
"jsonrpc-websocket-client": "^0.1.1",
"kindof": "^2.0.0",
"later": "^1.2.0",
"lodash": "^4.6.1",
"loose-envify": "^1.1.0",
@@ -92,6 +96,7 @@
"random-password": "^0.1.2",
"react": "^15.0.0",
"react-addons-shallow-compare": "^15.1.0",
"react-addons-test-utils": "^15.4.1",
"react-bootstrap-4": "^0.29.1",
"react-chartist": "^0.11.0",
"react-copy-to-clipboard": "^4.0.2",
@@ -108,7 +113,7 @@
"react-redux": "^4.4.0",
"react-router": "^3.0.0",
"react-select": "^1.0.0-beta13",
"react-shortcuts": "^1.0.7",
"react-shortcuts": "^1.3.1",
"react-sparklines": "^1.5.0",
"react-virtualized": "^8.0.8",
"readable-stream": "^2.0.6",
@@ -124,7 +129,7 @@
"vinyl": "^2.0.0",
"watchify": "^3.7.0",
"xml2js": "^0.4.17",
"xo-acl-resolver": "^0.2.2",
"xo-acl-resolver": "^0.2.3",
"xo-common": "0.1.0",
"xo-lib": "^0.8.0",
"xo-remote-parser": "^0.3"
@@ -134,11 +139,11 @@
"build": "npm run build-indexes && NODE_ENV=production gulp build",
"build-indexes": "index-modules --auto src",
"dev": "npm run build-indexes && NODE_ENV=development gulp build",
"dev-test": "ava --watch",
"dev-test": "jest --watch",
"lint": "standard",
"posttest": "npm run lint",
"prepublish": "npm run build",
"test": "ava"
"test": "jest"
},
"browserify": {
"transform": [
@@ -146,15 +151,6 @@
"loose-envify"
]
},
"ava": {
"babel": "inherit",
"files": [
"src/**/*.spec.js"
],
"require": [
"babel-register"
]
},
"babel": {
"env": {
"development": {
@@ -185,6 +181,11 @@
"commit-msg": "npm test"
}
},
"jest": {
"snapshotSerializers": [
"<rootDir>/node_modules/enzyme-to-json/serializer"
]
},
"standard": {
"ignore": [
"dist"

View File

@@ -27,6 +27,13 @@ $ct-series-colors: (
flex-direction: column-reverse;
}
// safari has a bug in flex computing that prevent charts from showing see #1755
// by fixing the height with a value found in Chrome it seems like it fixes the issue without breaking the layout
// elsewhere
.dashboardItem .ct-chart {
height: 150px;
}
// Line in charts with only 2px in width
.ct-line {
stroke-width: 2px;

View File

@@ -0,0 +1,14 @@
exports[`test Col 1`] = `
<div
className="col-xs-12" />
`;
exports[`test Container 1`] = `
<div
className="container-fluid" />
`;
exports[`test Row 1`] = `
<div
className=" row" />
`;

View File

@@ -0,0 +1,87 @@
import React from 'react'
import { isFunction, omit } from 'lodash'
import Component from './base-component'
import getEventValue from './get-event-value'
const __DEV__ = process.env.NODE_ENV !== 'production'
// This decorator can be used on a controlled input component to make
// it able to automatically handled the uncontrolled mode.
export default options => ControlledInput => {
class AutoControlledInput extends Component {
constructor (props) {
super()
const opts = isFunction(options) ? options(props) : options
const controlled = this._controlled = 'value' in props
if (!controlled) {
this.state.value = props.defaultValue || opts && opts.defaultValue
this._onChange = event => {
let defaultPrevented = false
const { onChange } = this.props
if (onChange) {
onChange(event)
defaultPrevented = event && event.defaultPrevented
}
if (!defaultPrevented) {
this.setState({ value: getEventValue(event) })
}
}
} else if (__DEV__ && 'defaultValue' in props) {
throw new Error(`${this.constructor.name}: controlled component should not have a default value`)
}
}
get value () {
return this._controlled
? this.props.value
: this.state.value
}
set value (value) {
if (__DEV__ && this._controlled) {
throw new Error(`${this.constructor.name}: should not set value on controlled component`)
}
this.setState({ value })
}
render () {
if (this._controlled) {
return <ControlledInput {...this.props} />
}
return <ControlledInput
{...omit(this.props, 'defaultValue')}
onChange={this._onChange}
value={this.state.value}
/>
}
}
if (__DEV__) {
AutoControlledInput.prototype.componentWillReceiveProps = function (newProps) {
const { name } = this.constructor
const controlled = this._controlled
const newControlled = 'value' in newProps
if (!controlled) {
if (newControlled) {
throw new Error(`${name}: uncontrolled component should not become controlled`)
}
} else if (!newControlled) {
throw new Error(`${name}: controlled component should not become uncontrolled`)
}
if (newProps.defaultValue !== this.props.defaultValue) {
throw new Error(`${name}: default value should not change`)
}
}
}
return AutoControlledInput
}

View File

@@ -1,4 +1,4 @@
import test from 'ava'
/* eslint-env jest */
import {
getPropertyClausesStrings,
@@ -11,43 +11,36 @@ import {
pattern
} from './index.fixtures'
test('getPropertyClausesStrings', t => {
let tmp = parse('foo bar:baz baz:|(foo bar)')::getPropertyClausesStrings()
t.deepEqual(
tmp,
{
bar: [ 'baz' ],
baz: [ 'foo', 'bar' ]
}
)
it('getPropertyClausesStrings', () => {
const tmp = parse('foo bar:baz baz:|(foo bar)')::getPropertyClausesStrings()
expect(tmp).toEqual({
bar: [ 'baz' ],
baz: [ 'foo', 'bar' ]
})
})
test('parse', t => {
t.deepEqual(parse(pattern), ast)
it('parse', () => {
expect(parse(pattern)).toEqual(ast)
})
test('setPropertyClause', t => {
t.is(
null::setPropertyClause('foo', 'bar')::toString(),
'foo:bar'
)
it('setPropertyClause', () => {
expect(
null::setPropertyClause('foo', 'bar')::toString()
).toBe('foo:bar')
t.is(
parse('baz')::setPropertyClause('foo', 'bar')::toString(),
'baz foo:bar'
)
expect(
parse('baz')::setPropertyClause('foo', 'bar')::toString()
).toBe('baz foo:bar')
t.is(
parse('plip foo:baz plop')::setPropertyClause('foo', 'bar')::toString(),
'plip plop foo:bar'
)
expect(
parse('plip foo:baz plop')::setPropertyClause('foo', 'bar')::toString()
).toBe('plip plop foo:bar')
t.is(
parse('foo:|(baz plop)')::setPropertyClause('foo', 'bar')::toString(),
'foo:bar'
)
expect(
parse('foo:|(baz plop)')::setPropertyClause('foo', 'bar')::toString()
).toBe('foo:bar')
})
test('toString', t => {
t.is(pattern, ast::toString())
it('toString', () => {
expect(pattern).toBe(ast::toString())
})

View File

@@ -398,7 +398,7 @@ const MAP_TYPE_SELECT = {
})
export class XoSelect extends Editable {
get value () {
return this.refs.select.value
return this.state.value
}
_renderDisplay () {
@@ -406,9 +406,8 @@ export class XoSelect extends Editable {
<span>{this.props.value[this.props.labelProp]}</span>
}
_onChange = object => {
object ? this._save() : this._closeEdition()
}
_onChange = object =>
this.setState({ value: object }, object && this._save)
_renderEdition () {
const {
@@ -432,7 +431,6 @@ export class XoSelect extends Editable {
autoFocus
disabled={saving}
onChange={this._onChange}
ref='select'
/>
</a>
}

View File

@@ -1,4 +1,4 @@
import test from 'ava'
/* eslint-env jest */
import filterReduce from './filter-reduce'
@@ -6,23 +6,20 @@ const add = (a, b) => a + b
const data = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
const isEven = x => !(x & 1)
test('filterReduce', t => {
it('filterReduce', () => {
// Returns all elements not matching the predicate and the result of
// a reduction over those who do.
t.deepEqual(
filterReduce(data, isEven, add),
expect(filterReduce(data, isEven, add)).toEqual(
[ 1, 3, 5, 7, 9, 20 ]
)
// The default reducer is the identity.
t.deepEqual(
filterReduce(data, isEven),
expect(filterReduce(data, isEven)).toEqual(
[ 1, 3, 5, 7, 9, 0 ]
)
// If an initial value is passed it is used.
t.deepEqual(
filterReduce(data, isEven, add, 22),
expect(filterReduce(data, isEven, add, 22)).toEqual(
[ 1, 3, 5, 7, 9, 42 ]
)
})

View File

@@ -1,6 +1,8 @@
import autoControlledInput from 'auto-controlled-input'
import Component from 'base-component'
import find from 'lodash/find'
import map from 'lodash/map'
import React, { Component } from 'react'
import React from 'react'
import propTypes from '../prop-types'
@@ -8,7 +10,6 @@ import Select from './select'
@propTypes({
autoFocus: propTypes.bool,
defaultValue: propTypes.any,
disabled: propTypes.bool,
optionRenderer: propTypes.func,
multi: propTypes.bool,
@@ -16,13 +17,26 @@ import Select from './select'
options: propTypes.array,
placeholder: propTypes.string,
predicate: propTypes.func,
required: propTypes.bool
required: propTypes.bool,
value: propTypes.any
})
@autoControlledInput()
export default class SelectPlainObject extends Component {
constructor (props) {
super(props)
this.state = {
value: this._computeValue(props.defaultValue, props)
componentDidMount () {
const { options, value } = this.props
this.setState({
options: this._computeOptions(options),
value: this._computeValue(value, this.props)
})
}
componentWillReceiveProps (newProps) {
if (newProps !== this.props) {
this.setState({
options: this._computeOptions(newProps.options),
value: this._computeValue(newProps.value, newProps)
})
}
}
@@ -36,25 +50,10 @@ export default class SelectPlainObject extends Component {
}
return map(value, reduceValue)
}
return reduceValue(value)
}
componentWillMount () {
const { options } = this.props
this.setState({
options: this._computeOptions(options)
})
}
componentWillReceiveProps (newProps) {
const { options } = newProps
this.setState({
options: this._computeOptions(options)
})
}
_computeOptions (options) {
const { optionKey = 'id' } = this.props
const { optionRenderer = o => o.label || o[optionKey] || o } = this.props
@@ -64,10 +63,13 @@ export default class SelectPlainObject extends Component {
}))
}
get value () {
const { optionKey = 'id' } = this.props
const { value } = this.state
const { options } = this.props
_getObject (value) {
if (value == null) {
return undefined
}
const { optionKey = 'id', options } = this.props
const pickValue = value => {
value = value.value || value
return find(options, option => option[optionKey] === value || option === value)
@@ -80,18 +82,12 @@ export default class SelectPlainObject extends Component {
return pickValue(value)
}
set value (value) {
this.setState({
value: this._computeValue(value)
})
}
_handleChange = value => {
const { onChange } = this.props
this.setState({
value: this._computeValue(value)
}, onChange && (() => { onChange(this.value) }))
if (onChange) {
onChange(this._getObject(value))
}
}
_renderOption = option => option.label
@@ -111,7 +107,8 @@ export default class SelectPlainObject extends Component {
placeholder={props.placeholder}
required={props.required}
value={state.value}
valueRenderer={this._renderOption} />
valueRenderer={this._renderOption}
/>
)
}
}

View File

@@ -18,6 +18,10 @@ const SELECT_STYLE = {
minWidth: '10em'
}
const LIST_STYLE = {
whiteSpace: 'normal'
}
const MAX_OPTIONS = 5
// See: https://github.com/bvaughn/react-virtualized-select/blob/master/source/VirtualizedSelect/VirtualizedSelect.js
@@ -75,6 +79,7 @@ export default class Select extends Component {
rowHeight={getRowHeight}
rowRenderer={wrappedRowRenderer}
scrollToIndex={focusedOptionIndex}
style={LIST_STYLE}
width={width}
/>
}}

13
src/common/grid.spec.js Normal file
View File

@@ -0,0 +1,13 @@
/* eslint-env jest */
import React from 'react'
import { forEach } from 'lodash'
import { shallow } from 'enzyme'
import * as grid from './grid'
forEach(grid, (Component, name) => {
it(name, () => {
expect(shallow(<Component />)).toMatchSnapshot()
})
})

View File

@@ -62,7 +62,13 @@ export class IntlProvider extends Component {
render () {
const { lang, children } = this.props
// Adding a key prop is a work-around suggested by react-intl documentation
// to make sure changes to the locale trigger a re-render of the child components
// https://github.com/yahoo/react-intl/wiki/Components#dynamic-language-selection
//
// FIXME: remove the key prop when React context propagation is fixed (https://github.com/facebook/react/issues/2517)
return <IntlProvider_
key={lang}
locale={lang}
messages={locales[lang]}
>

View File

@@ -723,7 +723,7 @@ export default {
remoteNfsPlaceHolderHost: 'hôte*',
// Original text: "/path/to/backup"
remoteNfsPlaceHolderPath: '/chemin/de/la/sauvegarde',
remoteNfsPlaceHolderPath: 'chemin/de/la/sauvegarde',
// Original text: "subfolder [path\\to\\backup]"
remoteSmbPlaceHolderRemotePath: 'sous-répertoire [chemin\\vers\\la\\sauvegarde]',

File diff suppressed because it is too large Load Diff

View File

@@ -66,6 +66,7 @@ var messages = {
backupNewPage: 'New',
backupRemotesPage: 'Remotes',
backupRestorePage: 'Restore',
backupFileRestorePage: 'File restore',
schedule: 'Schedule',
newVmBackup: 'New VM backup',
editVmBackup: 'Edit VM backup',
@@ -146,6 +147,7 @@ var messages = {
// ----- Forms -----
add: 'Add',
selectAll: 'Select all',
remove: 'Remove',
preview: 'Preview',
item: 'Item',
@@ -245,7 +247,10 @@ var messages = {
editBackupVmsTitle: 'VMs',
editBackupSmartStatusTitle: 'VMs statuses',
editBackupSmartResidentOn: 'Resident on',
editBackupSmartPools: 'Pools',
editBackupSmartTags: 'Tags',
editBackupSmartTagsTitle: 'VMs Tags',
editBackupNot: 'Reverse',
editBackupTagTitle: 'Tag',
editBackupReportTitle: 'Report',
editBackupReportEnable: 'Enable immediately after creation',
@@ -284,7 +289,7 @@ var messages = {
remoteMyNamePlaceHolder: 'Name *',
remoteLocalPlaceHolderPath: '/path/to/backup',
remoteNfsPlaceHolderHost: 'host *',
remoteNfsPlaceHolderPath: '/path/to/backup',
remoteNfsPlaceHolderPath: 'path/to/backup',
remoteSmbPlaceHolderRemotePath: 'subfolder [path\\to\\backup]',
remoteSmbPlaceHolderUsername: 'Username',
remoteSmbPlaceHolderPassword: 'Password',
@@ -472,6 +477,7 @@ var messages = {
// ----- host stat tab -----
statLoad: 'Load average',
// ----- host advanced tab -----
memoryHostState: 'RAM Usage: {memoryUsed}',
hardwareHostSettingsLabel: 'Hardware',
hostAddress: 'Address',
hostStatus: 'Status',
@@ -596,7 +602,7 @@ var messages = {
copyToClipboardLabel: 'Copy',
ctrlAltDelButtonLabel: 'Ctrl+Alt+Del',
tipLabel: 'Tip:',
tipConsoleLabel: 'non-US keyboard could have issues with console: switch your own layout to US.',
tipConsoleLabel: 'Due to a XenServer issue, non-US keyboard layouts aren\'t well supported. Switch your own layout to US to workaround it.',
hideHeaderTooltip: 'Hide infos',
showHeaderTooltip: 'Show infos',
@@ -740,7 +746,7 @@ var messages = {
poolPanel: 'Pool{pools, plural, one {} other {s}}',
hostPanel: 'Host{hosts, plural, one {} other {s}}',
vmPanel: 'VM{vms, plural, one {} other {s}}',
memoryStatePanel: 'RAM Usage: {memoryUsed}',
memoryStatePanel: 'RAM Usage:',
cpuStatePanel: 'CPUs Usage',
vmStatePanel: 'VMs Power state',
taskStatePanel: 'Pending tasks',
@@ -931,6 +937,18 @@ var messages = {
importBackupMessage: 'Starting your backup import',
vmsToBackup: 'VMs to backup',
// ----- Restore files view -----
listRemoteBackups: 'List remote backups',
restoreFiles: 'Restore backup files',
restoreFilesError: 'Invalid options',
restoreFilesFromBackup: 'Restore file from {name}',
restoreFilesSelectBackup: 'Select a backup…',
restoreFilesSelectDisk: 'Select a disk…',
restoreFilesSelectPartition: 'Select a partition…',
restoreFilesSelectFolderPath: 'Folder path',
restoreFilesSelectFiles: 'Select a file…',
restoreFileContentNotFound: 'Content not found',
// ----- Modals -----
emergencyShutdownHostsModalTitle: 'Emergency shutdown Host{nHosts, plural, one {} other {s}}',
emergencyShutdownHostsModalMessage: 'Are you sure you want to shutdown {nHosts} Host{nHosts, plural, one {} other {s}}?',

View File

@@ -67,7 +67,6 @@ export default class IsoDevice extends Component {
<SelectVdi
srPredicate={this._getPredicate()}
onChange={this._handleInsert}
ref='selectIso'
value={mountedIso}
/>
<span className='input-group-btn'>

View File

@@ -20,7 +20,11 @@ class ArrayItem extends Component {
}
set value (value) {
this.refs.input.value = value
this.setState({
use: true
}, () => {
this.refs.input.value = value
})
}
render () {

View File

@@ -73,8 +73,12 @@ export default class ObjectInput extends Component {
}
set value (value = {}) {
forEach(this.refs, (instance, id) => {
instance.value = value[id]
this.setState({
use: true
}, () => {
forEach(this.refs, (instance, id) => {
instance.value = value[id]
})
})
}

View File

@@ -138,14 +138,18 @@ export default class Modal extends Component {
constructor () {
super()
this.state = { showModal: false }
}
componentDidMount () {
if (instance) {
throw new Error('Modal is a singleton!')
}
instance = this
}
componentWillMount () {
this.setState({ showModal: false })
componentWillUnmount () {
instance = undefined
}
close () {

View File

@@ -8,15 +8,17 @@ export let info
export let success
export class Notification extends Component {
constructor () {
super()
componentDidMount () {
if (instance) {
throw new Error('Notification is a singleton!')
}
instance = this
}
componentWillUnmount () {
instance = undefined
}
// This special component never have to rerender!
shouldComponentUpdate () {
return false

View File

@@ -1,27 +1,38 @@
import React from 'react'
import assign from 'lodash/assign'
import classNames from 'classnames'
import filter from 'lodash/filter'
import flatten from 'lodash/flatten'
import forEach from 'lodash/forEach'
import groupBy from 'lodash/groupBy'
import includes from 'lodash/includes'
import isEmpty from 'lodash/isEmpty'
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 Icon from 'icon'
import store from 'store'
import Tooltip from 'tooltip'
import { Button } from 'react-bootstrap-4/lib'
import { parse as parseRemote } from 'xo-remote-parser'
import {
assign,
filter,
flatten,
forEach,
groupBy,
includes,
isArray,
isEmpty,
isInteger,
isString,
keyBy,
keys,
map,
mapValues,
pick,
sortBy,
toArray
} from 'lodash'
import _ from './intl'
import autoControlledInput from './auto-controlled-input'
import Component from './base-component'
import propTypes from './prop-types'
import renderXoItem from './render-xo-item'
import { Select } from './form'
import {
createCollectionWrapper,
createFilter,
createGetObjectsOfType,
createGetTags,
@@ -47,6 +58,26 @@ import {
// ===================================================================
// react-select's line-height is 1.4
// https://github.com/JedWatson/react-select/blob/916ab0e62fc7394be8e24f22251c399a68de8b1c/less/multi.less#L33
// while bootstrap button's line-height is 1.25
// https://github.com/twbs/bootstrap/blob/959c4e527c6ef69623928db638267ba1c370479d/scss/_variables.scss#L342
const ADDON_BUTTON_STYLE = { lineHeight: '1.4' }
const getIds = value => value == null || isString(value) || isInteger(value)
? value
: isArray(value)
? map(value, getIds)
: value.id
const getOption = (object, container) => ({
label: container
? `${getLabel(object)} ${getLabel(container)}`
: getLabel(object),
value: object.id,
xoItem: object
})
const getLabel = object =>
object.name_label ||
object.name ||
@@ -55,6 +86,10 @@ const getLabel = object =>
object.value ||
object.label
const options = props => ({
defaultValue: props.multi ? [] : undefined
})
// ===================================================================
/*
@@ -86,12 +121,11 @@ const getLabel = object =>
@propTypes({
autoFocus: propTypes.bool,
clearable: propTypes.bool,
defaultValue: propTypes.any,
disabled: propTypes.bool,
hasSelectAll: propTypes.bool,
multi: propTypes.bool,
onChange: propTypes.func,
placeholder: propTypes.any.isRequired,
predicate: propTypes.func,
required: propTypes.bool,
value: propTypes.any,
xoContainers: propTypes.array,
@@ -101,145 +135,111 @@ const getLabel = object =>
]).isRequired
})
export class GenericSelect extends Component {
constructor (props) {
super(props)
this.state = {
value: this._setValue(props.value || props.defaultValue, props)
componentDidUpdate (prevProps) {
const { onChange, xoObjects } = this.props
if (!onChange || prevProps.xoObjects === xoObjects) {
return
}
}
_getValue (xoObjectsById = this.state.xoObjectsById, props = this.props) {
const { value } = this.state
const ids = this._getSelectValue()
const objectsById = this._getObjectsById()
if (props.multi) {
// Returns the values of the selected objects
// if they are contained in xoObjectsById.
return mapPlus(value, (value, push) => {
const o = xoObjectsById[value.value !== undefined ? value.value : value]
if (!isArray(ids)) {
ids && !objectsById[ids] && onChange(undefined)
} else {
let shouldTriggerOnChange
if (o) {
push(o)
const newValue = isArray(ids) && mapPlus(ids, (id, push) => {
const object = objectsById[id]
if (object) {
push(object)
} else {
shouldTriggerOnChange = true
}
})
}
return xoObjectsById[value.value || value] || ''
}
// Supports id strings and objects.
_setValue (value, props = this.props) {
if (props.multi) {
return map(value, object => object.id !== undefined ? object.id : object)
}
return (value != null)
? value.id !== undefined ? value.id : value
: ''
}
componentWillMount () {
const { props } = this
this.setState({
...this._computeOptions(props)
})
}
componentWillReceiveProps (newProps) {
const { props } = this
const { value, xoContainers, xoObjects } = newProps
if (
xoContainers !== props.xoContainers ||
xoObjects !== props.xoObjects
) {
const {
options,
xoObjectsById
} = this._computeOptions(newProps)
const value = this._getValue(xoObjectsById, newProps)
this.setState({
options,
value: this._setValue(value, newProps),
xoObjectsById
})
}
if (value !== props.value) {
this.setState({
value: this._setValue(value, newProps)
})
if (shouldTriggerOnChange) {
this.props.onChange(newValue)
}
}
}
_computeOptions ({ xoContainers, xoObjects }) {
if (!xoContainers) {
if (process.env.NODE_ENV !== 'production') {
if (!Array.isArray(xoObjects)) {
throw new Error('without xoContainers, xoObjects must be an array')
_getObjectsById = createSelector(
() => this.props.xoObjects,
objects => keyBy(
isArray(objects)
? objects
: flatten(toArray(objects)),
'id'
)
)
_getOptions = createSelector(
() => this.props.xoContainers,
() => this.props.xoObjects,
(containers, objects) => { // createCollectionWrapper with a depth?
const __DEV__ = process.env.NODE_ENV !== 'production'
const { name } = this.constructor
if (!containers) {
if (__DEV__ && !isArray(objects)) {
throw new Error(`${name}: without xoContainers, xoObjects must be an array`)
}
return map(objects, getOption)
}
return {
xoObjectsById: keyBy(xoObjects, 'id'),
options: map(xoObjects, object => ({
label: getLabel(object),
value: object.id,
xoItem: object
}))
if (__DEV__ && isArray(objects)) {
throw new Error(`${name}: with xoContainers, xoObjects must be an object`)
}
}
if (process.env.NODE_ENV !== 'production') {
if (Array.isArray(xoObjects)) {
throw new Error('with xoContainers, xoObjects must be an object')
}
}
const options = []
forEach(containers, container => {
options.push({
disabled: true,
xoItem: container
})
const options = []
const xoObjectsById = {}
forEach(xoContainers, container => {
const containerObjects = keyBy(xoObjects[container.id], 'id')
assign(xoObjectsById, containerObjects)
options.push({
disabled: true,
xoItem: container
forEach(objects[container.id], object => {
options.push(getOption(object, container))
})
})
return options
}
)
options.push.apply(options, map(containerObjects, object => ({
label: `${getLabel(object)} ${getLabel(container)}`,
value: object.id,
xoItem: object
})))
})
_getSelectValue = createSelector(
() => this.props.value,
createCollectionWrapper(getIds)
)
return { xoObjectsById, options }
}
_getNewSelectedObjects = createSelector(
this._getObjectsById,
value => value,
(objectsById, value) => value == null
? value
: isArray(value)
? map(value, value => objectsById[value.value])
: objectsById[value.value]
)
get value () {
return this._getValue()
}
set value (value) {
this.setState({
value: this._setValue(value)
})
}
_handleChange = value => {
_onChange = value => {
const { onChange } = this.props
if (onChange) {
onChange(this._getNewSelectedObjects(value))
}
}
this.setState({
value: this._setValue(value)
}, onChange && (() => onChange(this.value)))
_selectAll = () => {
this._onChange(
filter(this._getOptions(), ({ disabled }) => !disabled)
)
}
// GroupBy: Display option with margin if not disabled and containers exists.
_renderOption = option => (
_renderOption = option =>
<span
className={classNames(
!option.disabled && this.props.xoContainers && 'ml-1'
@@ -247,56 +247,68 @@ export class GenericSelect extends Component {
>
{renderXoItem(option.xoItem)}
</span>
)
render () {
const { props, state } = this
const {
autoFocus,
disabled,
hasSelectAll,
multi,
placeholder,
required,
return (
<Select
autofocus={props.autoFocus}
clearable={props.clearable == null ? props.multi || !props.required : props.clearable}
disabled={props.disabled}
multi={props.multi}
onChange={this._handleChange}
openOnFocus
optionRenderer={this._renderOption}
options={state.options}
placeholder={props.placeholder}
required={props.required}
value={state.value}
valueRenderer={this._renderOption}
/>
)
clearable = Boolean(multi || !required)
} = this.props
const select = <Select
{...{
autofocus: autoFocus,
clearable,
disabled,
multi,
placeholder,
required
}}
onChange={this._onChange}
openOnFocus
optionRenderer={this._renderOption}
options={this._getOptions()}
value={this._getSelectValue()}
valueRenderer={this._renderOption}
/>
if (!multi || !hasSelectAll) {
return select
}
// `hasSelectAll` should be provided by react-select after this pull request has been merged:
// https://github.com/JedWatson/react-select/pull/748
// TODO: remove once it has been merged upstream.
return <div className='input-group'>
{select}
<span className='input-group-btn'>
<Tooltip content={_('selectAll')}>
<Button type='button' bsStyle='secondary' onClick={this._selectAll} style={ADDON_BUTTON_STYLE}>
<Icon icon='add' />
</Button>
</Tooltip>
</span>
</div>
}
}
const makeStoreSelect = (createSelectors, props) => connectStore(
createSelectors,
{ withRef: true }
)(
class extends Component {
get value () {
return this.refs.select.value
}
set value (value) {
this.refs.select.value = value
}
render () {
return (
<GenericSelect
ref='select'
{...props}
{...this.props}
/>
)
}
}
const makeStoreSelect = (createSelectors, defaultProps) => autoControlledInput(options)(
connectStore(createSelectors)(
props =>
<GenericSelect
{...defaultProps}
{...props}
/>
)
)
const makeSubscriptionSelect = (subscribe, props) => (
const makeSubscriptionSelect = (subscribe, props) => autoControlledInput(options)(
class extends Component {
constructor (props) {
super(props)
@@ -325,14 +337,6 @@ const makeSubscriptionSelect = (subscribe, props) => (
)
}
get value () {
return this.refs.select.value
}
set value (value) {
this.refs.select.value = value
}
componentWillMount () {
this.componentWillUnmount = subscribe(::this.setState)
}
@@ -340,7 +344,6 @@ const makeSubscriptionSelect = (subscribe, props) => (
render () {
return (
<GenericSelect
ref='select'
{...props}
{...this.props}
xoObjects={this._getFilteredXoObjects()}
@@ -521,11 +524,11 @@ export const SelectTag = makeStoreSelect((_, props) => ({
}), { placeholder: _('selectTags') })
export const SelectHighLevelObject = makeStoreSelect(() => {
const getHosts = createGetObjectsOfType('host')
const getNetworks = createGetObjectsOfType('network')
const getPools = createGetObjectsOfType('pool')
const getSrs = createGetObjectsOfType('SR')
const getVms = createGetObjectsOfType('VM')
const getHosts = createGetObjectsOfType('host').filter(getPredicate)
const getNetworks = createGetObjectsOfType('network').filter(getPredicate)
const getPools = createGetObjectsOfType('pool').filter(getPredicate)
const getSrs = createGetObjectsOfType('SR').filter(getPredicate)
const getVms = createGetObjectsOfType('VM').filter(getPredicate)
const getHighLevelObjects = createSelector(
getHosts,

View File

@@ -37,12 +37,16 @@ export {
// Use case: in connect, to avoid rerendering a component where the
// objects are still the same.
const _createCollectionWrapper = selector => {
let cache
let cache, previous
return (...args) => {
const value = selector(...args)
if (!shallowEqual(value, cache)) {
cache = value
if (value !== previous) {
previous = value
if (!shallowEqual(value, cache)) {
cache = value
}
}
return cache
}
@@ -142,10 +146,10 @@ export const createPicker = (object, props) =>
// - predicate == null → no filtering
// - predicate === false → everything is filtered out
export const createFilter = (collection, predicate) =>
_createCollectionWrapper(
_create2(
collection,
predicate,
_create2(
collection,
predicate,
_createCollectionWrapper(
(collection, predicate) => predicate === false
? (isArrayLike(collection) ? EMPTY_ARRAY : EMPTY_OBJECT)
: predicate
@@ -168,17 +172,18 @@ export const createGroupBy = (collection, getter) =>
groupBy
)
export const createPager = (array, page, n = 25) => _createCollectionWrapper(
export const createPager = (array, page, n = 25) =>
_create2(
array,
page,
n,
(array, page, n) => {
const start = (page - 1) * n
return slice(array, start, start + n)
}
_createCollectionWrapper(
(array, page, n) => {
const start = (page - 1) * n
return slice(array, start, start + n)
}
)
)
)
export const createSort = (
collection,
@@ -187,11 +192,11 @@ export const createSort = (
) => _create2(collection, getter, order, orderBy)
export const createTop = (collection, iteratee, n) =>
_createCollectionWrapper(
_create2(
collection,
iteratee,
n,
_create2(
collection,
iteratee,
n,
_createCollectionWrapper(
(objects, iteratee, n) => {
let results = orderBy(objects, iteratee, 'desc')
if (n < results.length) {
@@ -453,23 +458,24 @@ export const createDoesHostNeedRestart = hostSelector => {
return (state, props) => restartPoolPatch(state, props) !== undefined
}
export const createGetHostMetrics = hostSelector => _createCollectionWrapper(
export const createGetHostMetrics = hostSelector =>
create(
hostSelector,
hosts => {
const metrics = {
count: 0,
cpus: 0,
memoryTotal: 0,
memoryUsage: 0
_createCollectionWrapper(
hosts => {
const metrics = {
count: 0,
cpus: 0,
memoryTotal: 0,
memoryUsage: 0
}
forEach(hosts, host => {
metrics.count++
metrics.cpus += host.cpus.cores
metrics.memoryTotal += host.memory.size
metrics.memoryUsage += host.memory.usage
})
return metrics
}
forEach(hosts, host => {
metrics.count++
metrics.cpus += host.cpus.cores
metrics.memoryTotal += host.memory.size
metrics.memoryUsage += host.memory.usage
})
return metrics
}
)
)
)

View File

@@ -1,3 +1,5 @@
import kindOf from 'kindof'
// Tests that two collections (arrays or objects) have strictly equals
// values (items or properties)
const shallowEqual = (c1, c2) => {
@@ -5,8 +7,8 @@ const shallowEqual = (c1, c2) => {
return true
}
const type = typeof c1
if (type !== typeof c2) {
const type = kindOf(c1)
if (type !== kindOf(c2)) {
return false
}
@@ -25,6 +27,10 @@ const shallowEqual = (c1, c2) => {
return true
}
if (type !== 'object') {
return false
}
let n = 0
for (const _ in c2) { // eslint-disable-line no-unused-vars
++n

View File

@@ -1,7 +1,7 @@
.tooltipEnabled {
background-color: #222;
border-radius: 3px;
border: 1px solid $fff;
border: 1px solid #fff;
color: #fff;
display: inline-block;
font-size: 13px;

View File

@@ -17,11 +17,18 @@ export class TooltipViewer extends Component {
constructor () {
super()
this.state.place = 'top'
}
componentDidMount () {
if (instance) {
throw new Error('Tooltip viewer is a singleton!')
}
instance = this
this.state.place = 'top'
}
componentWillUnmount () {
instance = undefined
}
render () {

View File

@@ -462,3 +462,20 @@ export const htmlFileToStream = file => {
return stream
}
// ===================================================================
export const resolveId = value =>
(value != null && typeof value === 'object' && 'id' in value)
? value.id
: value
export const resolveIds = params => {
for (const key in params) {
const param = params[key]
if (param != null && typeof param === 'object' && 'id' in param) {
params[key] = param.id
}
}
return params
}

View File

@@ -14,6 +14,7 @@ export default class HighLevelObjectInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectHighLevelObject
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
ref='input'

View File

@@ -14,6 +14,7 @@ export default class HostInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectHost
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
ref='input'

View File

@@ -14,6 +14,7 @@ export default class PoolInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectPool
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
ref='input'

View File

@@ -14,6 +14,7 @@ export default class RemoteInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectRemote
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
ref='input'

View File

@@ -14,6 +14,7 @@ export default class RoleInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectRole
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
ref='input'

View File

@@ -14,6 +14,7 @@ export default class SrInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectSr
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
ref='input'

View File

@@ -14,6 +14,7 @@ export default class SubjectInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectSubject
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
ref='input'

View File

@@ -14,6 +14,7 @@ export default class TagInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectTag
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
ref='input'

View File

@@ -14,6 +14,7 @@ export default class VmInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectVm
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
ref='input'

View File

@@ -24,7 +24,7 @@ import invoke from '../invoke'
import logError from '../log-error'
import { alert, confirm } from '../modal'
import { error, info, success } from '../notification'
import { noop, rethrow, tap } from '../utils'
import { noop, rethrow, tap, resolveId, resolveIds } from '../utils'
import {
connected,
disconnected,
@@ -163,14 +163,13 @@ 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)
// A subscriber might have disappeared during iteration.
//
// E.g.: if a subscriber triggers the subscription of another.
if (subscriber) {
subscriber(result)
}
})
}
}, error => {
@@ -269,23 +268,6 @@ export const serverVersion = _call('system.getServerVersion')
export const getXoServerTimezone = _call('system.getServerTimezone')
// ===================================================================
const resolveId = value =>
(value != null && typeof value === 'object' && 'id' in value)
? value.id
: value
const resolveIds = params => {
for (const key in params) {
const param = params[key]
if (param != null && typeof param === 'object' && 'id' in param) {
params[key] = param.id
}
}
return params
}
// XO --------------------------------------------------------------------------
export const importConfig = config => (
@@ -1403,12 +1385,34 @@ export const listRemote = remote => (
)
)
export const listRemoteBackups = remote => (
_call('backup.list', resolveIds({ remote }))::rethrow(
err => error(_('listRemote'), err.message || String(err))
)
)
export const testRemote = remote => (
_call('remote.test', resolveIds({id: remote}))::rethrow(
err => error(_('testRemote'), err.message || String(err))
)
)
// File restore ----------------------------------------------------
export const scanDisk = (remote, disk) => (
_call('backup.scanDisk', resolveIds({ remote, disk }))
)
export const scanFiles = (remote, disk, path, partition) => (
_call('backup.scanFiles', resolveIds({ remote, disk, path, partition }))
)
export const fetchFiles = (remote, disk, partition, paths) => (
_call('backup.fetchFiles', resolveIds({ remote, disk, partition, paths })).then(
({ $getFrom: url }) => { window.location = `.${url}` }
)
)
// -------------------------------------------------------------------
export const probeSrNfs = (host, server) => (

View File

@@ -5,7 +5,7 @@ export const getDefaultNetworkForVif = (vif, host, pifs, networks) => {
let defaultNetwork
forEach(host.$PIFs, pifId => {
const pif = pifs[pifId]
if (pif.ip && networks[pif.$network].name_label === nameLabel) {
if (networks[pif.$network].name_label === nameLabel) {
defaultNetwork = pif.$network
return false
}

View File

@@ -52,7 +52,7 @@ function getCurrentUrl () {
}
function adaptUrl (url, port = null) {
const matches = /^http(s?):\/\/([^\/:]*(?::[^\/]*)?)(?:[^:]*)?$/.exec(url)
const matches = /^http(s?):\/\/([^/:]*(?::[^/]*)?)(?:[^:]*)?$/.exec(url)
if (!matches || !matches[2]) {
throw new Error('current URL not recognized')
}
@@ -77,7 +77,7 @@ class XoaUpdater extends EventEmitter {
state (state) {
this._state = state
this.emit(state)
this.emit(state, this._lowState && this._lowState.source)
}
async update () {
@@ -99,7 +99,7 @@ class XoaUpdater extends EventEmitter {
}
_upgradeSuccessful () {
this.emit('upgradeSuccessful')
this.emit('upgradeSuccessful', this._lowState && this._lowState.source)
}
async _open () {

View File

@@ -674,6 +674,10 @@
@extend .fa;
@extend .fa-upload;
}
&-file-restore {
@extend .fa;
@extend .fa-file-o;
}
}
&-menu-jobs {
@extend .fa;

View File

@@ -1,3 +1,5 @@
import './patch-react'
import DevTools from 'store/dev-tools'
import hashHistory from 'react-router/lib/hashHistory'
import React from 'react'
@@ -8,15 +10,6 @@ import { render } from 'react-dom'
import XoApp from './xo-app'
if (
typeof window !== 'undefined' &&
typeof window.addEventListener === 'function'
) {
window.addEventListener('unhandledRejection', reason => {
console.error(reason)
})
}
render(
<Provider store={store}>
<div>

42
src/patch-react.js Normal file
View File

@@ -0,0 +1,42 @@
import logError from 'log-error'
import React from 'react'
import { assign, isFunction } from 'lodash'
// Avoid global breakage if a component fails to render.
//
// Inspired by https://gist.github.com/Aldredcz/4d63b0a9049b00f54439f8780be7f0d8
React.createElement = (createElement => {
const errorComponent = <p className='text-danger' style={{
fontWeight: 'bold'
}}>an error has occured</p>
const wrapRender = render => function patchedRender () {
try {
return render.apply(this, arguments)
} catch (error) {
logError(error)
return errorComponent
}
}
return function (Component) {
if (isFunction(Component)) {
const patched = Component._patched
if (patched) {
arguments[0] = patched
} else {
const { prototype } = Component
let render
if (prototype && isFunction(render = prototype.render)) {
prototype.render = wrapRender(render)
Component._patched = Component // itself
} else {
arguments[0] = Component._patched = assign(wrapRender(Component), Component)
}
}
}
return createElement.apply(this, arguments)
}
})(React.createElement)

View File

@@ -0,0 +1,130 @@
import _ from 'intl'
import Component from 'base-component'
import Icon from 'icon'
import React from 'react'
import SortedTable from 'sorted-table'
import Upgrade from 'xoa-upgrade'
import { confirm } from 'modal'
import { addSubscriptions, noop } from 'utils'
import { Container, Row, Col } from 'grid'
import { error } from 'notification'
import { FormattedDate } from 'react-intl'
import {
find,
filter,
forEach,
groupBy,
isEmpty,
map,
mapValues,
reduce,
uniq
} from 'lodash'
import {
fetchFiles,
listRemoteBackups,
subscribeRemotes
} from 'xo'
import RestoreFileModalBody from './restore-file-modal'
const VM_COLUMNS = [
{
name: _('backupVmNameColumn'),
itemRenderer: ({ last }) => last.name,
sortCriteria: ({ last }) => last.name
},
{
name: _('backupTags'),
itemRenderer: ({ tagsByRemote }) => <Container>
{map(tagsByRemote, ({ tags, remoteName }) => <Row>
<Col mediumSize={3}><strong>{remoteName}</strong></Col>
<Col mediumSize={9}>{tags.join(', ')}</Col>
</Row>)}
</Container>
},
{
name: _('lastBackupColumn'),
itemRenderer: ({ last }) => <FormattedDate value={last.datetime * 1e3} month='long' day='numeric' year='numeric' hour='2-digit' minute='2-digit' second='2-digit' />,
sortCriteria: ({ last }) => last.datetime,
sortOrder: 'desc'
},
{
name: _('availableBackupsColumn'),
itemRenderer: ({ count }) => <span>{count}</span>,
sortCriteria: ({ count }) => count
}
]
const openImportModal = ({ backups }) => confirm({
title: _('restoreFilesFromBackup', {name: backups[0].name}),
body: <RestoreFileModalBody vmName={backups[0].name} backups={backups} />
}).then(
({ remote, disk, partition, paths }) => {
if (!remote || !disk || !paths) {
return error(_('restoreFiles'), _('restoreFilesError'))
}
return fetchFiles(remote, disk, partition, paths)
},
noop
)
const _listAllBackups = async remotes => {
const remotesBackups = await Promise.all(map(remotes, remote => listRemoteBackups(remote)))
const backupsByVm = {}
forEach(remotesBackups, (backups, index) => {
forEach(backups, backup => {
if (backup.disks) {
const remote = remotes[index]
backupsByVm[backup.name] || (backupsByVm[backup.name] = [])
backupsByVm[backup.name].push({
...backup,
remoteId: remote.id,
remoteName: remote.name
})
}
})
})
const backupInfoByVm = mapValues(backupsByVm, backups => ({
backups,
count: backups.length,
last: reduce(backups, (last, b) => b.datetime > last.datetime ? b : last),
tagsByRemote: mapValues(groupBy(backups, 'remoteId'), (backups, remoteId) => ({
remoteName: find(remotes, remote => remote.id === remoteId).name,
tags: uniq(map(backups, 'tag'))
}))
}))
return backupInfoByVm
}
@addSubscriptions({
backupInfoByVm: cb => subscribeRemotes(remotes =>
_listAllBackups(filter(remotes, 'enabled')).then(cb)
)
})
export default class FileRestore extends Component {
render () {
const { backupInfoByVm } = this.props
if (!backupInfoByVm) {
return <h2>{_('statusLoading')}</h2>
}
return process.env.XOA_PLAN > 3
? <Container>
<h2>{_('restoreFiles')}</h2>
{isEmpty(backupInfoByVm)
? _('noBackup')
: <div>
<em><Icon icon='info' /> {_('restoreBackupsInfo')}</em>
<SortedTable collection={backupInfoByVm} columns={VM_COLUMNS} rowAction={openImportModal} defaultColumn={2} />
</div>
}
</Container>
: <Container><Upgrade place='restoreFiles' available={4} /></Container>
}
}

View File

@@ -0,0 +1,207 @@
import _ from 'intl'
import Component from 'base-component'
import endsWith from 'lodash/endsWith'
import React from 'react'
import replace from 'lodash/replace'
import { formatSize, noop } from 'utils'
import { FormattedDate } from 'react-intl'
import { SelectPlainObject } from 'form'
import {
isEmpty,
map
} from 'lodash'
import {
scanDisk,
scanFiles
} from 'xo'
const backupOptionRenderer = backup => <span>
{backup.tag} - {backup.remoteName}
{' '}
(<FormattedDate value={backup.datetime * 1e3} month='long' day='numeric' year='numeric' hour='2-digit' minute='2-digit' second='2-digit' />)
</span>
const partitionOptionRenderer = partition => <span>
{partition.name} {partition.type} {partition.size && `(${formatSize(+partition.size)})`}
</span>
const diskOptionRenderer = disk => <span>
{disk.name}
</span>
const fileOptionRenderer = file => <span>
{file.name}
</span>
const formatFilesOptions = (rawFiles, root) => {
const files = !root
? [{
name: '..',
id: '..',
content: {}
}]
: []
return files.concat(map(rawFiles, (file, name) => ({
name,
id: name,
content: file
})))
}
const getParentPath = path => replace(path, /^(\/+.+)*(\/+.+)/, '$1/')
// -----------------------------------------------------------------------------
export default class RestoreFileModalBody extends Component {
get value () {
const { state } = this
return {
disk: state.disk,
partition: state.partition,
paths: state.file && [ state.path + state.file.id ],
remote: state.backup.remoteId
}
}
_scanFiles = (path = this.state.path) => {
const { backup, disk, partition } = this.state
return scanFiles(backup.remoteId, disk, path, partition).then(
rawFiles => {
this.setState({
files: formatFilesOptions(rawFiles, path === '/'),
path
})
},
noop
)
}
_onBackupChange = backup => {
this.setState({
backup,
disk: undefined,
partition: undefined,
file: undefined
})
}
_onDiskChange = disk => {
this.setState({
disk,
partition: undefined,
file: undefined
})
if (!disk) {
return
}
scanDisk(this.state.backup.remoteId, disk).then(
({ partitions }) => {
if (isEmpty(partitions)) {
return this._scanFiles('/')
}
this.setState({
partitions
})
},
noop
)
}
_onPartitionChange = partition => {
this.setState({
partition,
path: '/',
file: undefined
}, partition && this._scanFiles)
}
_onFileChange = file => {
const { path } = this.state
const isFile = file && file.id !== '..' && !endsWith(file.id, '/')
this.setState({
file: isFile ? file : undefined
})
if (isFile) {
return
}
// Ugly workaround to keep the ReactSelect open after selecting a folder
// FIXME: Remove and use isOpen/alwaysOpen prop once one of these issues is fixed:
// https://github.com/JedWatson/react-select/issues/662 -> /pull/817
// https://github.com/JedWatson/react-select/issues/962 -> /pull/1015
const select = document.activeElement
select.blur()
select.focus()
this._scanFiles(file.id === '..' ? getParentPath(path) : `${path}${file.id}`)
}
// ---------------------------------------------------------------------------
render () {
const { backups } = this.props
const {
backup,
disk,
file,
files,
partition,
partitions,
path
} = this.state
const noPartitions = isEmpty(partitions)
return <div>
<SelectPlainObject
onChange={this._onBackupChange}
optionKey='id'
optionRenderer={backupOptionRenderer}
options={backups}
placeholder={_('restoreFilesSelectBackup')}
value={backup}
/>
{backup && [
<br />,
<SelectPlainObject
onChange={this._onDiskChange}
optionKey='id'
optionRenderer={diskOptionRenderer}
options={backup.disks}
placeholder={_('restoreFilesSelectDisk')}
value={disk}
/>
]}
{disk && !noPartitions && [
<br />,
<SelectPlainObject
onChange={this._onPartitionChange}
optionKey='id'
optionRenderer={partitionOptionRenderer}
options={partitions}
placeholder={_('restoreFilesSelectPartition')}
value={partition}
/>
]}
{(partition || disk && noPartitions) && [
<br />,
<pre>{path}{file && file.id}&nbsp;</pre>,
<SelectPlainObject
onChange={this._onFileChange}
optionKey='id'
optionRenderer={fileOptionRenderer}
options={files}
placeholder={_('restoreFilesSelectFiles')}
value={file}
/>
]}
</div>
}
}

View File

@@ -10,6 +10,7 @@ import Edit from './edit'
import New from './new'
import Overview from './overview'
import Restore from './restore'
import FileRestore from './file-restore'
const HEADER = <Container>
<Row>
@@ -21,6 +22,7 @@ const HEADER = <Container>
<NavLink to={'/backup/overview'}><Icon icon='menu-backup-overview' /> {_('backupOverviewPage')}</NavLink>
<NavLink to={'/backup/new'}><Icon icon='menu-backup-new' /> {_('backupNewPage')}</NavLink>
<NavLink to={'/backup/restore'}><Icon icon='menu-backup-restore' /> {_('backupRestorePage')}</NavLink>
<NavLink to={'/backup/file-restore'}><Icon icon='menu-backup-file-restore' /> {_('backupFileRestorePage')}</NavLink>
</NavTabs>
</Col>
</Row>
@@ -30,7 +32,8 @@ const Backup = routes('overview', {
':id/edit': Edit,
new: New,
overview: Overview,
restore: Restore
restore: Restore,
'file-restore': FileRestore
})(
({ children }) => <Page header={HEADER} title='backupPage' formatTitle>{children}</Page>
)

View File

@@ -57,25 +57,47 @@ const SMART_SCHEMA = {
title: _('editBackupSmartStatusTitle'),
description: 'The statuses of VMs to backup.' // FIXME: can't translate
},
pools: {
type: 'array',
items: {
type: 'string',
'xo:type': 'pool'
},
title: _('editBackupSmartResidentOn')
poolsOptions: {
type: 'object',
title: _('editBackupSmartPools'),
properties: {
not: {
type: 'boolean',
title: _('editBackupNot'),
description: 'Toggle on to backup VMs that are NOT resident on these pools'
},
pools: {
type: 'array',
items: {
type: 'string',
'xo:type': 'pool'
},
title: _('editBackupSmartResidentOn')
}
}
},
tags: {
type: 'array',
items: {
type: 'string',
'xo:type': 'tag'
},
title: _('editBackupSmartTagsTitle'),
description: 'VMs which contains at least one of these tags. Not used if empty.' // FIXME: can't translate
tagsOptions: {
type: 'object',
title: _('editBackupSmartTags'),
properties: {
not: {
type: 'boolean',
title: _('editBackupNot'),
description: 'Toggle on to backup VMs that do NOT contain these tags'
},
tags: {
type: 'array',
items: {
type: 'string',
'xo:type': 'tag'
},
title: _('editBackupSmartTagsTitle'),
description: 'VMs which contain at least one of these tags. Not used if empty.' // FIXME: can't translate
}
}
}
},
required: [ 'status', 'pools' ]
required: [ 'status', 'poolsOptions' ]
}
const SMART_UI_SCHEMA = generateUiSchema(SMART_SCHEMA)
@@ -120,11 +142,6 @@ const BACKUP_SCHEMA = {
...COMMON_SCHEMA.properties,
depth: DEPTH_PROPERTY,
remoteId: REMOTE_PROPERTY,
onlyMetadata: {
type: 'boolean',
title: 'Only MetaData',
description: 'No disks export.'
},
compress: {
type: 'boolean',
title: 'Enable compression',
@@ -303,8 +320,8 @@ export default class New extends Component {
if (values[1].type === 'map') {
// Smart backup.
const {
$pool: { __or: pools },
tags: { __or: tags } = {},
$pool: poolsOptions,
tags: tagsOptions = {},
power_state: status = 'All'
} = values[1].collection.pattern
@@ -314,9 +331,15 @@ export default class New extends Component {
smartBackupMode: true
}, () => {
vmsInput.value = {
pools,
poolsOptions: {
pools: poolsOptions.__not ? poolsOptions.__not.__or : poolsOptions.__or,
not: !!poolsOptions.__not
},
status,
tags: map(tags, tag => tag[0])
tagsOptions: {
tags: map(tagsOptions.__not ? tagsOptions.__not.__or : tagsOptions.__or, tag => tag[0]),
not: !!tagsOptions.__not
}
}
})
} else {
@@ -341,6 +364,10 @@ export default class New extends Component {
owner
} = this.state
const { pools, not: notPools } = vmsInputValue.poolsOptions || {}
const { tags, not: notTags } = vmsInputValue.tagsOptions || {}
const formattedTags = map(tags, tag => [ tag ])
const paramsVector = !smartBackupMode
? {
type: 'crossProduct',
@@ -361,9 +388,13 @@ export default class New extends Component {
collection: {
type: 'fetchObjects',
pattern: {
$pool: !vmsInputValue.pools.length ? undefined : { __or: vmsInputValue.pools },
$pool: notPools ? { __not: { __or: pools } } : { __or: pools },
power_state: vmsInputValue.status === 'All' ? undefined : vmsInputValue.status,
tags: !vmsInputValue.tags.length ? undefined : { __or: map(vmsInputValue.tags, tag => [ tag ]) },
tags: tags && tags.length
? (notTags
? { __not: { __or: formattedTags } }
: { __or: formattedTags })
: undefined,
type: 'VM'
}
},
@@ -486,7 +517,7 @@ export default class New extends Component {
onChange={this.linkState('owner', 'id')}
predicate={this._subjectPredicate}
required
value={owner}
value={owner || null}
/>
</fieldset>
<fieldset className='form-group'>

View File

@@ -15,9 +15,8 @@ import SortedTable from 'sorted-table'
import uniq from 'lodash/uniq'
import Upgrade from 'xoa-upgrade'
import { confirm } from 'modal'
import { connectStore, addSubscriptions, noop } from 'utils'
import { addSubscriptions, noop } from 'utils'
import { Container, Row, Col } from 'grid'
import { createGetObjectsOfType } from 'selectors'
import { FormattedDate, injectIntl } from 'react-intl'
import { info, error } from 'notification'
import { SelectPlainObject, Toggle } from 'form'
@@ -36,9 +35,9 @@ const parseDate = date => +moment(date, 'YYYYMMDDTHHmmssZ').format('x')
const backupOptionRenderer = backup => <span>
{backup.type === 'delta' && <span><span className='tag tag-info'>{_('delta')}</span>{' '}</span>}
{backup.tag}
{backup.tag} - {backup.remoteName}
{' '}
<FormattedDate value={new Date(backup.date)} month='long' day='numeric' year='numeric' hour='2-digit' minute='2-digit' second='2-digit' />
(<FormattedDate value={new Date(backup.date)} month='long' day='numeric' year='numeric' hour='2-digit' minute='2-digit' second='2-digit' />)
</span>
const VM_COLUMNS = [
@@ -99,11 +98,6 @@ const doImport = ({ backup, sr, start }) => {
}
}
@connectStore(() => ({
writableSrs: createGetObjectsOfType('SR').filter(
[ isSrWritable ]
).sort()
}), { withRef: true })
class _ModalBody extends Component {
get value () {
return this.state
@@ -130,11 +124,6 @@ class _ModalBody extends Component {
const ImportModalBody = injectIntl(_ModalBody, {withRef: true})
@connectStore(() => ({
writableSrs: createGetObjectsOfType('SR').filter(
[ isSrWritable ]
).sort()
}))
@addSubscriptions({
rawRemotes: subscribeRemotes
})
@@ -154,7 +143,7 @@ export default class Restore extends Component {
forEach(remoteFiles, file => {
let backup
const deltaInfo = /^vm_delta_(.*)_([^\/]+)\/([^_]+)_(.*)$/.exec(file)
const deltaInfo = /^vm_delta_(.*)_([^/]+)\/([^_]+)_(.*)$/.exec(file)
if (deltaInfo) {
const [ , tag, id, date, name ] = deltaInfo
backup = {
@@ -164,7 +153,8 @@ export default class Restore extends Component {
name,
path: file,
tag,
remoteId: remote.id
remoteId: remote.id,
remoteName: remote.name
}
} else {
const backupInfo = /^([^_]+)_([^_]+)_(.*)\.xva$/.exec(file)
@@ -176,7 +166,8 @@ export default class Restore extends Component {
name,
path: file,
tag,
remoteId: remote.id
remoteId: remote.id,
remoteName: remote.name
}
}
}

View File

@@ -48,7 +48,17 @@ const AlarmColObject = connectStore(() => ({
if (!object) {
return null
}
return object.type === 'VM-controller' ? <Link to={`hosts/${object.$container}`}>{object.name_label}</Link> : <Link to={`vms/${object.id}`}>{object.name_label}</Link>
switch (object.type) {
case 'VM':
return <Link to={`vms/${object.id}`}>{object.name_label}</Link>
case 'VM-controller':
return <Link to={`hosts/${object.$container}`}>{object.name_label}</Link>
case 'host':
return <Link to={`hosts/${object.id}`}>{object.name_label}</Link>
default:
return null
}
})
const AlarmColPool = connectStore(() => ({
@@ -240,44 +250,52 @@ const ALARM_COLUMNS = [
export default class Health extends Component {
componentWillReceiveProps (props) {
if (props.alertMessages !== this.props.alertMessages) {
Promise.all(
map(props.alertMessages, ({ body }) => {
const matches = /^value: ([0-9\.]+) config: (.*)$/.exec(body)
if (!matches) {
return
}
const [ , value, xml ] = matches
return fromCallback(cb =>
xml2js.parseString(xml, cb)
).then(
result => {
const object = mapValues(result && result.variable, value => get(value, '[0].$.value'))
if (!object || !object.name) {
return
}
const { name, ...alarmAttributes } = object
return { name, value, alarmAttributes }
},
noop
)
})
).then(
formattedMessages => {
this.setState({
messages: map(formattedMessages, (formattedMessage, index) => ({
formatted: formattedMessage,
...props.alertMessages[index]
}))
})
},
noop
)
this._updateAlarms(props)
}
}
componentDidMount () {
this._updateAlarms(this.props)
}
_updateAlarms = props => {
Promise.all(
map(props.alertMessages, ({ body }, id) => {
const matches = /^value:\s*([0-9.]+)\s+config:\s*([^]*)$/.exec(body)
if (!matches) {
return
}
const [ , value, xml ] = matches
return fromCallback(cb =>
xml2js.parseString(xml, cb)
).then(
result => {
const object = mapValues(result && result.variable, value => get(value, '[0].$.value'))
if (!object || !object.name) {
return
}
const { name, ...alarmAttributes } = object
return { name, value, alarmAttributes, id }
},
noop
)
})
).then(
formattedMessages => {
this.setState({
messages: map(formattedMessages, ({ ...formattedMessage, id }) => ({
formatted: formattedMessage,
...props.alertMessages[id]
}))
})
},
noop
)
}
_deleteOrphanedVdis = () =>
deleteOrphanedVdis(this.props.vdiOrphaned)

View File

@@ -203,7 +203,7 @@ export default class Overview extends Component {
<CardHeader>
<Icon icon='memory' /> {_('memoryStatePanel')}
</CardHeader>
<CardBlock>
<CardBlock className='dashboardItem'>
<ChartistGraph
data={{
labels: ['Used Memory', 'Total Memory'],
@@ -227,7 +227,7 @@ export default class Overview extends Component {
<Icon icon='cpu' /> {_('cpuStatePanel')}
</CardHeader>
<CardBlock>
<div className='ct-chart'>
<div className='ct-chart dashboardItem'>
<ChartistGraph
data={{
labels: ['vCPUs', 'CPUs'],
@@ -252,7 +252,7 @@ export default class Overview extends Component {
<Icon icon='disk' /> {_('srUsageStatePanel')}
</CardHeader>
<CardBlock>
<div className='ct-chart'>
<div className='ct-chart dashboardItem'>
<BlockLink to='/dashboard/health'>
<ChartistGraph
data={{
@@ -318,7 +318,7 @@ export default class Overview extends Component {
<CardHeader>
<Icon icon='vm-force-shutdown' /> {_('vmStatePanel')}
</CardHeader>
<CardBlock>
<CardBlock className='dashboardItem'>
<BlockLink to='/home?t=VM'>
<ChartistGraph
data={{
@@ -340,7 +340,7 @@ export default class Overview extends Component {
<CardHeader>
<Icon icon='disk' /> {_('srTopUsageStatePanel')}
</CardHeader>
<CardBlock>
<CardBlock className='dashboardItem'>
<BlockLink to='/dashboard/health'>
<ChartistGraph
style={{strokeWidth: '30px'}}

View File

@@ -77,7 +77,7 @@ export default ({
<br />
<Row>
<Col className='text-xs-center'>
<h5>{_('memoryStatePanel', { memoryUsed: formatSize(memoryUsed) })}</h5>
<h5>{_('memoryHostState', { memoryUsed: formatSize(memoryUsed) })}</h5>
</Col>
</Row>
<Row>

View File

@@ -286,11 +286,12 @@ export default class Jobs extends Component {
}
const {name, method} = this.refs
const action = find(actions, action => action.method === job.method)
name.value = job.name
method.value = job.method
method.value = action
this.setState({
job,
action: find(actions, action => action.method === job.method)
action
}, () => delay(this._populateForm, 250, job)) // Work around.
// Without the delay, some selects are not always ready to load a value
// Values are displayed, but html5 compliant browsers say the value is required and empty on submit

View File

@@ -98,7 +98,7 @@ export default class Schedules extends Component {
}
_edit = id => {
const { schedules } = this.state
const { schedules, jobs } = this.state
const schedule = find(schedules, schedule => schedule.id === id)
if (!schedule) {
error('Schedule edition', 'This schedule was not found, or may not longer exists.')
@@ -107,7 +107,7 @@ export default class Schedules extends Component {
const { name, job } = this.refs
name.value = schedule.name
job.value = schedule.job
job.value = jobs[schedule.job]
this.setState({
cronPattern: schedule.cron,
schedule,

View File

@@ -25,7 +25,6 @@ import {
createFilter,
createGetObjectsOfType,
createSelector,
getLang,
getStatus,
getUser,
isAdmin
@@ -35,11 +34,6 @@ import styles from './index.css'
@connectStore(() => ({
isAdmin,
// FIXME: remove when fixed in React.
//
// There are currently issues between context updates (used by
// react-intl) and pure components.
lang: getLang,
nTasks: createGetObjectsOfType('task').count(
[ task => task.status === 'pending' ]
),
@@ -135,7 +129,8 @@ export default class Menu extends Component {
isAdmin && { to: '/backup/overview', icon: 'menu-backup', label: 'backupPage', subMenu: [
{ to: '/backup/overview', icon: 'menu-backup-overview', label: 'backupOverviewPage' },
{ to: '/backup/new', icon: 'menu-backup-new', label: 'backupNewPage' },
{ to: '/backup/restore', icon: 'menu-backup-restore', label: 'backupRestorePage' }
{ to: '/backup/restore', icon: 'menu-backup-restore', label: 'backupRestorePage' },
{ to: '/backup/file-restore', icon: 'menu-backup-file-restore', label: 'backupFileRestorePage' }
]},
isAdmin && { to: '/xoa-update', icon: 'menu-update', label: 'updatePage', extra: <UpdateTag /> },
isAdmin && { to: '/settings/servers', icon: 'menu-settings', label: 'settingsPage', subMenu: [

View File

@@ -647,7 +647,7 @@ export default class NewVm extends BaseComponent {
this.context.router.push({
pathname,
query: { resourceSet: resourceSet.id }
query: resourceSet && { resourceSet: resourceSet.id }
})
this._reset()
}
@@ -656,7 +656,7 @@ export default class NewVm extends BaseComponent {
this.context.router.push({
pathname,
query: { pool: pool.id }
query: pool && { pool: pool.id }
})
this._reset()
}

View File

@@ -1,9 +1,15 @@
import _ from 'intl'
import filter from 'lodash/filter'
import forEach from 'lodash/forEach'
import includes from 'lodash/includes'
import intersection from 'lodash/intersection'
import keyBy from 'lodash/keyBy'
import map from 'lodash/map'
import propTypes from 'prop-types'
import React, { Component } from 'react'
import reduce from 'lodash/reduce'
import renderXoItem from 'render-xo-item'
import { resolveIds } from 'utils'
import {
subscribeGroups,
@@ -66,3 +72,25 @@ export class Subjects extends Component {
)
}
}
export const computeAvailableHosts = (pools, srs, hostsByPool) => {
const validHosts = reduce(
hostsByPool,
(result, hosts, poolId) =>
includes(resolveIds(pools), poolId)
? result.concat(hosts)
: result,
[]
)
const availableHosts = filter(validHosts, host => {
let kept = false
forEach(srs, sr =>
!(kept = intersection(sr.$PBDs, host.$PBDs).length > 0)
)
return kept
})
return availableHosts
}

View File

@@ -11,12 +11,11 @@ import Icon from 'icon'
import includes from 'lodash/includes'
import intersection from 'lodash/intersection'
import isEmpty from 'lodash/isEmpty'
import keyBy from 'lodash/keyBy'
import keys from 'lodash/keys'
import map from 'lodash/map'
import mapKeys from 'lodash/mapKeys'
import propTypes from 'prop-types'
import React from 'react'
import reduce from 'lodash/reduce'
import remove from 'lodash/remove'
import renderXoItem from 'render-xo-item'
import Upgrade from 'xoa-upgrade'
@@ -39,6 +38,7 @@ import {
connectStore,
firstDefined,
formatSize,
resolveIds,
resolveResourceSets
} from 'utils'
@@ -58,6 +58,7 @@ import {
} from 'select-objects'
import {
computeAvailableHosts,
Subjects
} from './helpers'
@@ -126,6 +127,8 @@ const Hosts = propTypes({
</div>
))
// ===================================================================
@propTypes({
onSave: propTypes.func,
resourceSet: propTypes.object
@@ -143,22 +146,20 @@ const Hosts = propTypes({
export class Edit extends Component {
constructor (props) {
super(props)
const { resourceSet } = props
const ipPools = []
if (resourceSet) {
forEach(resourceSet.ipPools, ipPool => {
ipPools.push({
id: ipPool,
quantity: get(resourceSet, `limits[ipPool:${ipPool}].total`)
})
})
}
this.state = {
cpus: '',
disk: null,
eligibleHosts: [],
excludedHosts: props.hosts,
ipPools
ipPools: [],
memory: null,
name: '',
networks: [],
pools: [],
srs: [],
subjects: [],
templates: []
}
}
@@ -166,156 +167,135 @@ export class Edit extends Component {
const { resourceSet } = this.props
if (resourceSet) {
let selectedPools = {}
forEach(resourceSet.objectsByType, (objects, type) => {
// Objects
const { objectsByType } = resourceSet
let pools = {}
forEach(objectsByType, objects => {
forEach(objects, object => {
selectedPools[object.$pool] = true
pools[object.$pool] = true
})
})
selectedPools = keyBy(Object.keys(selectedPools))
const { refs } = this
const { objectsByType } = resourceSet
this._updateSelectedPools(keys(pools), objectsByType.SR, objectsByType.network)
const selectedSrs = objectsByType.SR
const selectedNetworks = objectsByType.network
// Limits and others
const { ipPools: rawIpPools, limits } = resourceSet
this._updateSelectedPools(selectedPools, selectedSrs, selectedNetworks, () => {
refs.selectPool.value = selectedPools
refs.inputName.value = resourceSet.name
refs.selectSubject.value = resourceSet.subjects
refs.selectVmTemplate.value = objectsByType['VM-template']
refs.selectSr.value = selectedSrs
refs.selectNetwork.value = selectedNetworks
const ipPools = []
forEach(rawIpPools, ipPool => {
ipPools.push({
id: ipPool,
quantity: get(limits, `[ipPool:${ipPool}].total`)
})
})
const { limits } = resourceSet
if (!limits) {
refs.inputMaxCpus.value = refs.inputMaxDiskSpace.value = refs.inputMaxRam.value = ''
} else {
refs.inputMaxCpus.value = (limits.cpus && limits.cpus.total) || ''
refs.inputMaxDiskSpace.value = (limits.disk && limits.disk.total) || null
refs.inputMaxRam.value = (limits.memory && limits.memory.total) || null
}
this.setState({
cpus: get(limits, 'cpus.total', ''),
disk: get(limits, 'disk.total', null),
ipPools,
memory: get(limits, 'memory.total', null),
name: resourceSet.name,
subjects: resourceSet.subjects,
templates: objectsByType['VM-template'] || []
})
}
}
_save = async () => {
const { refs } = this
const {
cpus,
disk,
ipPools,
memory,
name,
networks,
srs,
subjects,
templates
} = this.state
const cpus = refs.inputMaxCpus.value
const memory = refs.inputMaxRam.value
const disk = refs.inputMaxDiskSpace.value
const set = this.props.resourceSet || await createResourceSet(refs.inputName.value)
const objects = [
...refs.selectVmTemplate.value,
...refs.selectSr.value,
...refs.selectNetwork.value
]
const set = this.props.resourceSet || await createResourceSet(name)
const objects = [ ...templates, ...srs, ...networks ]
const ipPoolsLimits = {}
const ipPools = []
forEach(this.state.ipPools, ipPool => {
forEach(ipPools, ipPool => {
if (ipPool.quantity) {
ipPoolsLimits[`ipPool:${ipPool.id}`] = +ipPool.quantity
}
ipPools.push(ipPool.id)
})
await editResourceSet(set.id, {
name: refs.inputName.value,
name,
limits: {
cpus: cpus === '' ? undefined : +cpus,
memory: memory === null ? undefined : memory,
disk: disk === null ? undefined : disk,
...ipPoolsLimits
},
objects: map(objects, object => object.id),
subjects: map(refs.selectSubject.value, object => object.id),
ipPools
objects: resolveIds(objects),
subjects: resolveIds(subjects),
ipPools: resolveIds(ipPools)
})
this.props.onSave()
}
_reset = () => {
const { refs } = this
this._updateSelectedPools([], [], [], () => {
refs.selectPool.value = undefined
refs.inputName.value = ''
refs.selectSubject.value = undefined
refs.selectVmTemplate.value = undefined
refs.selectSr.value = undefined
refs.selectNetwork.value = undefined
refs.inputMaxCpus.value = ''
refs.inputMaxDiskSpace.value = null
refs.inputMaxRam.value = null
})
this.setState({ ipPools: [], newIpPool: undefined, newQuantity: '' })
}
_updateSelectedPools = (pools, srs, networks, onChange) => {
const selectedPools = Array.isArray(pools) ? keyBy(pools, 'id') : pools
const predicate = object => selectedPools[object.$pool]
this._updateSelectedPools([], [], [])
this.setState({
nPools: Object.keys(selectedPools).length,
selectedPools,
srPredicate: predicate,
vmTemplatePredicate: predicate
}, () => { this._updateSelectedSrs(srs || this.refs.selectSr.value, networks, onChange) })
}
// Helper for handler selected srs.
_computeAvailableHosts (pools, srs) {
const validHosts = reduce(
this.props.hostsByPool,
(result, value, key) => pools[key] ? result.concat(value) : result,
[]
)
return filter(validHosts, host => {
let kept = false
forEach(srs, sr =>
!(kept = intersection(sr.$PBDs, host.$PBDs).length > 0)
)
return kept
cpus: '',
disk: null,
ipPools: [],
memory: null,
newIpPool: undefined,
newIpPoolQuantity: '',
subjects: []
})
}
_updateSelectedSrs = (srs, networks, onChange) => {
const selectableHosts = this._computeAvailableHosts(this.state.selectedPools, srs)
// -----------------------------------------------------------------------------
_updateSelectedPools = (newPools, newSrs, newNetworks) => {
const predicate = object => includes(resolveIds(newPools), object.$pool)
this.setState({
nPools: newPools.length,
pools: newPools,
srPredicate: predicate,
vmTemplatePredicate: predicate
}, () => this._updateSelectedSrs(newSrs || this.state.srs, newNetworks))
}
_updateSelectedSrs = (newSrs, newNetworks) => {
const availableHosts = computeAvailableHosts(this.state.pools, newSrs, this.props.hostsByPool)
const networkPredicate = network => {
let kept = false
forEach(selectableHosts, host => !(kept = intersection(network.PIFs, host.PIFs).length > 0))
forEach(availableHosts, host => !(kept = intersection(network.PIFs, host.PIFs).length > 0))
return kept
}
this.setState({
nSrs: srs.length,
availableHosts,
networkPredicate,
selectableHosts
}, () => { this._updateSelectedNetworks(networks || this.refs.selectNetwork.value, onChange) })
nSrs: newSrs.length,
srs: newSrs
}, () => this._updateSelectedNetworks(newNetworks || this.state.networks))
}
_updateSelectedNetworks = (networks, onChange) => {
const { state } = this
const eligibleHosts = filter(state.selectableHosts, host => {
_updateSelectedNetworks = newNetworks => {
const { availableHosts, srs } = this.state
const eligibleHosts = filter(availableHosts, host => {
let keptBySr = false
let keptByNetwork = false
forEach(this.refs.selectSr.value, sr =>
forEach(srs, sr =>
!(keptBySr = (intersection(sr.$PBDs, host.$PBDs).length > 0))
)
if (keptBySr) {
forEach(networks, network =>
forEach(newNetworks, network =>
!(keptByNetwork = intersection(network.PIFs, host.PIFs).length > 0)
)
}
@@ -326,16 +306,19 @@ export class Edit extends Component {
this.setState({
eligibleHosts,
excludedHosts: differenceBy(this.props.hosts, eligibleHosts, host => host.id),
selectedNetworks: networks
}, onChange)
networks: newNetworks
})
}
// -----------------------------------------------------------------------------
_addIpPool = () => {
const { ipPools, newIpPool, newQuantity } = this.state
const { ipPools, newIpPool, newIpPoolQuantity } = this.state
this.setState({
ipPools: [ ...ipPools, { id: newIpPool.id, quantity: newQuantity } ],
ipPools: [ ...ipPools, { id: newIpPool.id, quantity: newIpPoolQuantity } ],
newIpPool: undefined,
newQuantity: ''
newIpPoolQuantity: ''
})
}
_removeIpPool = index => {
@@ -350,6 +333,8 @@ export class Edit extends Component {
!includes(ipPoolsIds, ipPool.id)
)
// -----------------------------------------------------------------------------
render () {
const { state } = this
const { formatMessage } = this.props.intl
@@ -363,25 +348,27 @@ export class Edit extends Component {
<Col mediumSize={4}>
<input
className='form-control'
onChange={this.linkState('name')}
placeholder={formatMessage(messages.resourceSetName)}
ref='inputName'
required
type='text'
value={state.name}
/>
</Col>
<Col mediumSize={4}>
<SelectSubject
multi
ref='selectSubject'
onChange={this.linkState('subjects')}
required
value={state.subjects}
/>
</Col>
<Col mediumSize={4}>
<SelectPool
multi
onChange={this._updateSelectedPools}
ref='selectPool'
required
value={state.pools}
/>
</Col>
</Row>
@@ -392,9 +379,10 @@ export class Edit extends Component {
<SelectVmTemplate
disabled={!state.nPools}
multi
onChange={this.linkState('templates')}
predicate={state.vmTemplatePredicate}
ref='selectVmTemplate'
required
value={state.templates}
/>
</Col>
<Col mediumSize={4}>
@@ -403,8 +391,8 @@ export class Edit extends Component {
multi
onChange={this._updateSelectedSrs}
predicate={state.srPredicate}
ref='selectSr'
required
value={state.srs}
/>
</Col>
<Col mediumSize={4}>
@@ -413,8 +401,8 @@ export class Edit extends Component {
multi
onChange={this._updateSelectedNetworks}
predicate={state.networkPredicate}
ref='selectNetwork'
required
value={state.networks}
/>
</Col>
</Row>
@@ -425,21 +413,24 @@ export class Edit extends Component {
<input
className='form-control'
min={0}
onChange={this.linkState('cpus')}
placeholder={formatMessage(messages.maxCpus)}
ref='inputMaxCpus'
type='number'
value={state.cpus}
/>
</Col>
<Col mediumSize={4}>
<SizeInput
onChange={this.linkState('memory')}
placeholder={formatMessage(messages.maxRam)}
ref='inputMaxRam'
value={state.memory}
/>
</Col>
<Col mediumSize={4}>
<SizeInput
onChange={this.linkState('disk')}
placeholder={formatMessage(messages.maxDiskSpace)}
ref='inputMaxDiskSpace'
value={state.disk}
/>
</Col>
</Row>
@@ -460,7 +451,7 @@ export class Edit extends Component {
<SelectIpPool onChange={this.linkState(`ipPools.${index}.id`, 'id')} value={ipPool.id} />
</Col>
<Col mediumSize={3}>
<input className='form-control' type='number' onChange={this.linkState(`ipPools.${index}.quantity`)} value={firstDefined(ipPool.quantity, '')} placeholder='∞' />
<input className='form-control' type='number' min={0} onChange={this.linkState(`ipPools.${index}.quantity`)} value={firstDefined(ipPool.quantity, '')} placeholder='∞' />
</Col>
<Col mediumSize={2}>
<ActionButton btnStyle='secondary' icon='delete' handler={this._removeIpPool} handlerParam={index} />
@@ -471,7 +462,7 @@ export class Edit extends Component {
<SelectIpPool onChange={this.linkState('newIpPool')} value={state.newIpPool} predicate={this._getIpPoolPredicate()} />
</Col>
<Col mediumSize={3}>
<input className='form-control' type='number' onChange={this.linkState('newQuantity')} value={state.newQuantity || ''} placeholder='∞' />
<input className='form-control' type='number' min={0} onChange={this.linkState('newIpPoolQuantity')} value={state.newIpPoolQuantity || ''} placeholder='∞' />
</Col>
<Col mediumSize={2}>
<ActionButton btnStyle='secondary' icon='add' handler={this._addIpPool} />
@@ -488,7 +479,7 @@ export class Edit extends Component {
<div className='btn-toolbar'>
<ActionButton btnStyle='primary' icon='save' handler={this._save} type='submit'>{_('saveResourceSet')}</ActionButton>
<ActionButton btnStyle='secondary' icon='reset' handler={this._reset}>{_('resetResourceSet')}</ActionButton>
<ActionButton btnStyle='danger' icon='delete' handler={deleteResourceSet} handlerParam={resourceSet}>{_('deleteResourceSet')}</ActionButton>
{resourceSet && <ActionButton btnStyle='danger' icon='delete' handler={deleteResourceSet} handlerParam={resourceSet}>{_('deleteResourceSet')}</ActionButton>}
</div>
</li>
</div>

View File

@@ -10,6 +10,7 @@ import map from 'lodash/map'
import pickBy from 'lodash/pickBy'
import React from 'react'
import renderXoItem, { renderXoItemFromId } from 'render-xo-item'
import some from 'lodash/some'
import SortedTable from 'sorted-table'
import toArray from 'lodash/toArray'
import Upgrade from 'xoa-upgrade'
@@ -140,34 +141,63 @@ export default class Acls extends Component {
constructor (props) {
super(props)
this.state = {
isAllSelected: {},
subjects: [],
action: '',
objects: [],
role: undefined
subjects: [],
typeFilters: {}
}
}
_handleSelectObjects = objects => this.setState({objects})
_handleSelectRole = action => this.setState({action})
_handleSelectSubject = subjects => this.setState({subjects})
_toggleTypeFilter = type => {
const {
someTypeFilters,
typeFilters,
objects
} = this.state
_toggleAll = type => {
const { isAllSelected, objects } = this.state
let newObjects
if (!isAllSelected[type]) {
newObjects = [ ...objects, ...toArray(createGetObjectsOfType(type)(store.getState())) ]
} else {
newObjects = filter(objects, object => object.type !== type)
const newTypeFilters = { ...typeFilters, [type]: !typeFilters[type] }
const newSomeTypeFilters = some(newTypeFilters)
// If some objects need to be removed from the selected objects
if (!newTypeFilters[type] || !someTypeFilters && newSomeTypeFilters) {
this.setState({
objects: filter(objects, ({ type }) => !newSomeTypeFilters || newTypeFilters[type])
})
}
this.refs.selectObject.value = newObjects
this.setState({
objects: newObjects,
isAllSelected: {
...isAllSelected,
[type]: !isAllSelected[type] }
typeFilters: { ...typeFilters, [type]: !typeFilters[type] },
someTypeFilters: some(newTypeFilters)
}, () => {
// If some objects need to be removed from the selected objects
if (!this.state.typeFilters[type] || !someTypeFilters && this.state.someTypeFilters) {
this.setState({
objects: filter(objects, this._getObjectPredicate())
})
}
})
}
_getObjectPredicate = createSelector(
() => this.state.typeFilters,
() => this.state.someTypeFilters,
(typeFilters, someTypeFilters) => ({ type }) =>
!someTypeFilters || typeFilters[type]
)
_selectAll = () => {
const { someTypeFilters, typeFilters } = this.state
const objects = []
forEach(TYPES, type => {
if (!someTypeFilters || typeFilters[type]) {
const typeObjects = createGetObjectsOfType(type)(store.getState())
objects.push(...toArray(typeObjects))
}
})
this.setState({ objects })
}
_addAcl = async () => {
const {
subjects,
@@ -182,10 +212,12 @@ export default class Acls extends Component {
})
})
await Promise.all(promises)
const { selectSubject, selectObject, selectAction } = this.refs
selectSubject.value = []
selectObject.value = []
selectAction.value = ''
this.setState({
subjects: [],
objects: [],
action: ''
})
} catch (err) {
error('Add ACL(s)', err.message || String(err))
}
@@ -193,7 +225,7 @@ export default class Acls extends Component {
render () {
const {
isAllSelected,
typeFilters,
objects,
action,
subjects
@@ -203,20 +235,29 @@ export default class Acls extends Component {
? <Container>
<form>
<div className='form-group'>
<SelectSubject ref='selectSubject' multi onChange={this._handleSelectSubject} />
<SelectSubject multi onChange={this.linkState('subjects')} value={subjects} />
</div>
<div className='form-group'>
<SelectHighLevelObject ref='selectObject' multi onChange={this._handleSelectObjects} />
<SelectHighLevelObject multi onChange={this.linkState('objects')} value={objects} predicate={this._getObjectPredicate()} />
</div>
<div className='form-group'>
<ButtonGroup className='pb-1'>
<div className='form-group mb-1'>
<ButtonGroup className='mr-1'>
{map(TYPES, type =>
<ActionButton tooltip={_('settingsAclsButtonTooltip' + type)} key={type} btnStyle={isAllSelected[type] ? 'success' : 'secondary'} size='small' icon={type.toLowerCase()} handler={this._toggleAll} handlerParam={type} />
<ActionButton
btnStyle={typeFilters[type] ? 'success' : 'secondary'}
handler={this._toggleTypeFilter}
handlerParam={type}
icon={type.toLowerCase()}
key={type}
size='small'
tooltip={_('settingsAclsButtonTooltip' + type)}
/>
)}
</ButtonGroup>
<ActionButton tooltip='Select all' btnStyle='secondary' size='small' icon='add' handler={this._selectAll} />
</div>
<div className='form-group'>
<SelectRole ref='selectAction' onChange={this._handleSelectRole} />
<SelectRole onChange={this.linkState('action')} value={action} />
</div>
<ActionButton icon='add' btnStyle='success' handler={this._addAcl} disabled={isEmpty(subjects) || isEmpty(objects) || !action}>{_('aclCreate')}</ActionButton>
</form>

View File

@@ -362,6 +362,6 @@ export default class Remotes extends Component {
</div>
</form>
</div>
)
)
}
}

View File

@@ -374,6 +374,7 @@ export default class User extends Component {
<option value='he'>עברי</option>
<option value='pt'>Português</option>
<option value='es'>Español</option>
<option value='zh'>简体中文</option>
</select>
</Col>
</Row>

View File

@@ -300,12 +300,12 @@ export default class Import extends Component {
<FormGrid.LabelCol>{_('vmImportToSr')}</FormGrid.LabelCol>
<FormGrid.InputCol>
<SelectSr
disabled={!sr}
disabled={!pool}
onChange={this._handleSelectedSr}
predicate={srPredicate}
required
value={sr}
/>
/>
</FormGrid.InputCol>
</FormGrid.Row>
{sr && (

View File

@@ -151,7 +151,7 @@ export default class TabConsole extends Component {
scale={scale}
url={resolveUrl(`consoles/${vm.id}`)}
/>
{!minimalLayout && <p><em><Icon icon='info' /> {_('tipLabel')} {_('tipConsoleLabel')}</em></p>}
{!minimalLayout && <p><em><Icon icon='info' /> <a href='https://bugs.xenserver.org/browse/XSO-650' target='_blank'>{_('tipLabel')} {_('tipConsoleLabel')}</a></em></p>}
</Col>
</Row>
</Container>

View File

@@ -15,19 +15,24 @@ import React from 'react'
import remove from 'lodash/remove'
import TabButton from 'tab-button'
import Tooltip from 'tooltip'
import { addSubscriptions, connectStore, noop } from 'utils'
import { isIp, isIpV4 } from 'ip'
import { ButtonGroup } from 'react-bootstrap-4/lib'
import { Container, Row, Col } from 'grid'
import { injectIntl } from 'react-intl'
import { SelectNetwork, SelectIp, SelectResourceSetIp } from 'select-objects'
import { XoSelect, Text } from 'editable'
import {
addSubscriptions,
connectStore,
EMPTY_ARRAY,
noop
} from 'utils'
import {
createFinder,
createGetObject,
createGetObjectsOfType,
createSelector
} from 'selectors'
import { injectIntl } from 'react-intl'
import { SelectNetwork, SelectIp, SelectResourceSetIp } from 'select-objects'
import { XoSelect, Text } from 'editable'
import {
connectVif,
@@ -106,8 +111,8 @@ class VifItem extends BaseComponent {
}
_getIps = createSelector(
() => this.props.vif.allowedIpv4Addresses,
() => this.props.vif.allowedIpv6Addresses,
() => this.props.vif.allowedIpv4Addresses || EMPTY_ARRAY,
() => this.props.vif.allowedIpv6Addresses || EMPTY_ARRAY,
concat
)
_getIpPredicate = createSelector(

View File

@@ -21,14 +21,21 @@ import { serverVersion } from 'xo'
import pkg from '../../../package'
const promptForReload = () => confirm({
title: _('promptUpgradeReloadTitle'),
body: <p>{_('promptUpgradeReloadMessage')}</p>
}).then(() => window.location.reload())
let updateSource
const promptForReload = (source, force) => {
if (force || (updateSource && source !== updateSource)) {
confirm({
title: _('promptUpgradeReloadTitle'),
body: <p>{_('promptUpgradeReloadMessage')}</p>
}).then(() => window.location.reload())
}
updateSource = source
}
if (+process.env.XOA_PLAN < 5) {
xoaUpdater.start()
xoaUpdater.on('upgradeSuccessful', promptForReload)
xoaUpdater.on('upgradeSuccessful', source => promptForReload(source, !source))
xoaUpdater.on('upToDate', promptForReload)
}
const HEADER = <Container>

7436
yarn.lock Normal file

File diff suppressed because it is too large Load Diff