Compare commits
47 Commits
xo-web/v5.
...
v5.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ad49de642 | ||
|
|
b45bb5c144 | ||
|
|
9402596f69 | ||
|
|
096687ae2c | ||
|
|
210b5de992 | ||
|
|
f742fdbf1b | ||
|
|
e7026c522d | ||
|
|
c21fc4beda | ||
|
|
edf6fe782e | ||
|
|
3cbb6c4a98 | ||
|
|
568a50acc5 | ||
|
|
fbcb756cef | ||
|
|
81eb4ba4f9 | ||
|
|
0cc14d2ab8 | ||
|
|
6aedadc982 | ||
|
|
a8d10dab3c | ||
|
|
1ff6ff1d7a | ||
|
|
8afe4a85dc | ||
|
|
c57fbdce63 | ||
|
|
bdc0278fd1 | ||
|
|
c3ac8d0587 | ||
|
|
f3a5e1e97c | ||
|
|
919aa5fc43 | ||
|
|
416c98ffd2 | ||
|
|
8094447183 | ||
|
|
575375d3e0 | ||
|
|
4296ae02dc | ||
|
|
0e40af0515 | ||
|
|
5d3a0e7a41 | ||
|
|
8ae2aae37a | ||
|
|
83b3cf406a | ||
|
|
1643ced4e0 | ||
|
|
b2a1840da7 | ||
|
|
b9f20d1e80 | ||
|
|
0c77781be8 | ||
|
|
83245af1e2 | ||
|
|
7db806a461 | ||
|
|
92b15fb1e2 | ||
|
|
7b5182111c | ||
|
|
82b1b81999 | ||
|
|
f0a430f350 | ||
|
|
90f95b7270 | ||
|
|
15e6a93fac | ||
|
|
01541a2577 | ||
|
|
8c70bc0a17 | ||
|
|
9d96074604 | ||
|
|
114a4028f4 |
@@ -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/
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
35
package.json
35
package.json
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
14
src/common/__snapshots__/grid.spec.js.snap
Normal file
14
src/common/__snapshots__/grid.spec.js.snap
Normal 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" />
|
||||
`;
|
||||
87
src/common/auto-controlled-input.js
Normal file
87
src/common/auto-controlled-input.js
Normal 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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 ]
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
13
src/common/grid.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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]}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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}}?',
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -674,6 +674,10 @@
|
||||
@extend .fa;
|
||||
@extend .fa-upload;
|
||||
}
|
||||
&-file-restore {
|
||||
@extend .fa;
|
||||
@extend .fa-file-o;
|
||||
}
|
||||
}
|
||||
&-menu-jobs {
|
||||
@extend .fa;
|
||||
|
||||
11
src/index.js
11
src/index.js
@@ -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
42
src/patch-react.js
Normal 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)
|
||||
130
src/xo-app/backup/file-restore/index.js
Normal file
130
src/xo-app/backup/file-restore/index.js
Normal 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>
|
||||
}
|
||||
}
|
||||
207
src/xo-app/backup/file-restore/restore-file-modal.js
Normal file
207
src/xo-app/backup/file-restore/restore-file-modal.js
Normal 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} </pre>,
|
||||
<SelectPlainObject
|
||||
onChange={this._onFileChange}
|
||||
optionKey='id'
|
||||
optionRenderer={fileOptionRenderer}
|
||||
options={files}
|
||||
placeholder={_('restoreFilesSelectFiles')}
|
||||
value={file}
|
||||
/>
|
||||
]}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -362,6 +362,6 @@ export default class Remotes extends Component {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user