Compare commits

..

8 Commits

Author SHA1 Message Date
Julien Fontanet
8ca98a56fe 5.7.5 2017-04-07 15:52:13 +02:00
Julien Fontanet
705f53e3e5 fix(scheduling): timezone selection 2017-04-07 15:51:49 +02:00
Julien Fontanet
adaf069d20 5.7.4 2017-04-07 15:27:46 +02:00
Julien Fontanet
d7be7d8660 fix(select-objects): do not treat empty string as a value (2) 2017-04-07 15:25:41 +02:00
Julien Fontanet
faddee86b6 fix(select-objects): do not treat empty string as a value 2017-04-07 15:23:27 +02:00
Julien Fontanet
c4fcc65d16 fix(backup/new): coding style 2017-04-07 15:17:51 +02:00
Julien Fontanet
890631d33b fix(select-objects): correctly handle incorrect values with non-multi 2017-04-07 15:17:32 +02:00
Julien Fontanet
8e8145bb48 chore(backup/new): controlled inputs (#2072) 2017-04-07 15:02:53 +02:00
5 changed files with 295 additions and 285 deletions

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "xo-web",
"version": "5.7.3",
"version": "5.7.5",
"license": "AGPL-3.0",
"description": "Web interface client for Xen-Orchestra",
"keywords": [

View File

@@ -17,7 +17,7 @@ const cowSet = (object, path, value, depth) => {
return value
}
object = clone(object)
object = object != null ? clone(object) : {}
const prop = path[depth]
object[prop] = cowSet(object[prop], path, value, depth + 1)
return object

View File

@@ -259,7 +259,11 @@ class TableSelect extends Component {
))}
</tbody>
</table>
<button className='btn btn-secondary pull-right' onClick={this._reset}>
<button
className='btn btn-secondary pull-right'
onClick={this._reset}
type='button'
>
{_(`selectTableAll${labelId}`)} {value && !value.length && <Icon icon='success' />}
</button>
</div>
@@ -447,23 +451,27 @@ class DayPicker extends Component {
// ===================================================================
@propTypes({
cronPattern: propTypes.string.isRequired,
cronPattern: propTypes.string,
onChange: propTypes.func,
timezone: propTypes.string
timezone: propTypes.string,
value: propTypes.shape({
cronPattern: propTypes.string.isRequired,
timezone: propTypes.string
})
})
export default class Scheduler extends Component {
constructor (props) {
super(props)
this._onCronChange = newCrons => {
const cronPattern = this.props.cronPattern.split(' ')
const cronPattern = this._getCronPattern().split(' ')
forEach(newCrons, (cron, unit) => {
cronPattern[PICKTIME_TO_ID[unit]] = cron
})
this.props.onChange({
cronPattern: cronPattern.join(' '),
timezone: this.props.timezone
timezone: this._getTimezone()
})
}
@@ -475,17 +483,24 @@ export default class Scheduler extends Component {
_onTimezoneChange = timezone => {
this.props.onChange({
cronPattern: this.props.cronPattern,
cronPattern: this._getCronPattern(),
timezone
})
}
_getCronPattern = () => {
const { value, cronPattern = value.cronPattern } = this.props
return cronPattern
}
_getTimezone = () => {
const { value, timezone = value && value.timezone } = this.props
return timezone
}
render () {
const {
cronPattern,
timezone
} = this.props
const cronPatternArr = cronPattern.split(' ')
const cronPatternArr = this._getCronPattern().split(' ')
const timezone = this._getTimezone()
return (
<div className='card-block'>

View File

@@ -150,19 +150,17 @@ export class GenericSelect extends Component {
(containers, objects) => { // createCollectionWrapper with a depth?
const { name } = this.constructor
let options = []
if (!containers) {
if (__DEV__ && !isArray(objects)) {
throw new Error(`${name}: without xoContainers, xoObjects must be an array`)
}
return map(objects, getOption)
}
if (__DEV__ && isArray(objects)) {
options = map(objects, getOption)
} else if (__DEV__ && isArray(objects)) {
throw new Error(`${name}: with xoContainers, xoObjects must be an object`)
}
const options = []
forEach(containers, container => {
options.push({
disabled: true,
@@ -173,11 +171,13 @@ export class GenericSelect extends Component {
options.push(getOption(object, container))
})
})
const values = this._getSelectValue()
const objectsById = this._getObjectsById()
forEach(values, val => {
if (!objectsById[val]) {
const addIfMissing = val => {
if (val && !objectsById[val]) {
options.push({
disabled: true,
id: val,
label: val,
value: val,
@@ -187,7 +187,14 @@ export class GenericSelect extends Component {
}
})
}
})
}
if (isArray(values)) {
forEach(values, addIfMissing)
} else {
addIfMissing(values)
}
return options
}
)

View File

@@ -1,24 +1,27 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import delay from 'lodash/delay'
import forEach from 'lodash/forEach'
import GenericInput from 'json-schema-input'
import Icon from 'icon'
import isEmpty from 'lodash/isEmpty'
import map from 'lodash/map'
import moment from 'moment-timezone'
import React from 'react'
import Scheduler, { SchedulePreview } from 'scheduling'
import startsWith from 'lodash/startsWith'
import Upgrade from 'xoa-upgrade'
import Wizard, { Section } from 'wizard'
import { addSubscriptions } from 'utils'
import { addSubscriptions, EMPTY_OBJECT } from 'utils'
import { confirm } from 'modal'
import { error } from 'notification'
import { Container, Row, Col } from 'grid'
import { createSelector } from 'reselect'
import { generateUiSchema } from 'xo-json-schema-input'
import { SelectSubject } from 'select-objects'
import { Container, Row, Col } from 'grid'
import {
forEach,
isArray,
map,
mapValues,
noop,
startsWith
} from 'lodash'
import {
createJob,
@@ -52,13 +55,13 @@ const NO_SMART_UI_SCHEMA = generateUiSchema(NO_SMART_SCHEMA)
const SMART_SCHEMA = {
type: 'object',
properties: {
status: {
power_state: {
default: 'All', // FIXME: can't translate
enum: [ 'All', 'Running', 'Halted' ], // FIXME: can't translate
title: _('editBackupSmartStatusTitle'),
description: 'The statuses of VMs to backup.' // FIXME: can't translate
},
poolsOptions: {
$pool: {
type: 'object',
title: _('editBackupSmartPools'),
properties: {
@@ -67,7 +70,7 @@ const SMART_SCHEMA = {
title: _('editBackupNot'),
description: 'Toggle on to backup VMs that are NOT resident on these pools'
},
pools: {
values: {
type: 'array',
items: {
type: 'string',
@@ -78,7 +81,7 @@ const SMART_SCHEMA = {
}
}
},
tagsOptions: {
tags: {
type: 'object',
title: _('editBackupSmartTags'),
properties: {
@@ -87,7 +90,7 @@ const SMART_SCHEMA = {
title: _('editBackupNot'),
description: 'Toggle on to backup VMs that do NOT contain these tags'
},
tags: {
values: {
type: 'array',
items: {
type: 'string',
@@ -99,7 +102,7 @@ const SMART_SCHEMA = {
}
}
},
required: [ 'status', 'poolsOptions', 'tagsOptions' ]
required: [ 'power_state', '$pool', 'tags' ]
}
const SMART_UI_SCHEMA = generateUiSchema(SMART_SCHEMA)
@@ -262,8 +265,30 @@ const BACKUP_METHOD_TO_INFO = {
// ===================================================================
const DEFAULT_CRON_PATTERN = '0 0 * * *'
const DEFAULT_TIMEZONE = moment.tz.guess()
function negatePattern (pattern, not = true) {
// xo-web v5.7.1 introduced a bug where an extra level
// ({ id: { id: <id> } }) was introduced for the VM param.
//
// This code automatically unbox the ids.
const extractId = value => {
while (typeof value === 'object') {
value = value.id
}
return value
}
const destructPattern = pattern => pattern && ({
not: !!pattern.__not,
values: (pattern.__not || pattern).__or
})
const constructPattern = ({ not, values } = EMPTY_OBJECT) => {
if (values == null || !values.length) {
return
}
const pattern = { __or: values }
return not
? { __not: pattern }
: pattern
@@ -273,187 +298,128 @@ function negatePattern (pattern, not = true) {
currentUser: subscribeCurrentUser
})
export default class New extends Component {
constructor (props) {
super(props)
this.state.cronPattern = DEFAULT_CRON_PATTERN
}
componentWillReceiveProps (props) {
const { currentUser } = props
const { owner } = this.state
if (currentUser && !owner) {
this.setState({ owner: currentUser.id })
}
}
componentWillMount () {
const { job, schedule } = this.props
if (!job || !schedule) {
if (job || schedule) { // Having only one of them is unexpected incomplete information
error(_('backupEditNotFoundTitle'), _('backupEditNotFoundMessage'))
_getParams = createSelector(
() => this.props.job,
job => {
if (!job) {
return EMPTY_OBJECT
}
this.setState({
timezone: moment.tz.guess()
})
return
}
this.setState({
backupInfo: BACKUP_METHOD_TO_INFO[job.method],
cronPattern: schedule.cron,
owner: job.userId,
timeout: job.timeout && job.timeout / 1e3,
timezone: schedule.timezone || null
}, () => 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
}
const { items } = job.paramsVector
_populateForm = job => {
let values = job.paramsVector.items
const {
backupInput,
vmsInput
} = this.refs
// legacy backup jobs
if (items.length === 1) {
const { ...main } = items[0].values[0]
if (values.length === 1) {
// Older versions of XenOrchestra uses only values[0].
const array = values[0].values
const config = array[0]
const reportWhen = config._reportWhen
backupInput.value = {
...config,
_reportWhen:
// Fix old reportWhen values...
(reportWhen === 'fail' && 'failure') ||
(reportWhen === 'alway' && 'always') ||
reportWhen
return {
main,
vms: { vms: map(items[0].values.slice(1), extractId) }
}
}
vmsInput.value = { vms: map(array, ({ id, vm }) => id || vm) }
} else {
if (values[1].type === 'map') {
// Smart backup.
const {
$pool: poolsOptions = {},
tags: tagsOptions = {},
power_state: status = 'All'
} = values[1].collection.pattern
backupInput.value = values[0].values[0]
// smart backup
if (items[1].type === 'map') {
const { pattern } = items[1].collection
const { $pool, tags } = pattern
this.setState({
smartBackupMode: true
}, () => {
vmsInput.value = {
poolsOptions: {
pools: poolsOptions.__not ? poolsOptions.__not.__or : poolsOptions.__or,
not: !!poolsOptions.__not
},
status,
tagsOptions: {
tags: map(tagsOptions.__not ? tagsOptions.__not.__or : tagsOptions.__or, tag => tag[0]),
not: !!tagsOptions.__not
}
return {
main: items[0].values[0],
vms: {
$pool: destructPattern($pool),
power_state: pattern.power_state,
tags: destructPattern(tags)
}
})
} else {
// Normal backup.
backupInput.value = values[1].values[0]
}
}
// xo-web v5.7.1 introduced a bug where an extra level ({ id: { id: <id> } }) was introduced for the VM param.
//
// This code automatically unbox the ids.
const vms = map(values[0].values, id => {
while (typeof id === 'object') {
id = id.id
}
return id
})
vmsInput.value = { vms }
// normal backup
return {
main: items[1].values[0],
vms: { vms: map(items[0].values, extractId) }
}
}
}
)
_getMainParams = () => this.state.mainParams || this._getParams().main
_getVmsParam = () => this.state.vmsParam || this._getParams().vms
_getScheduling = createSelector(
() => this.props.schedule,
() => this.state.scheduling,
(schedule, scheduling) => {
if (scheduling !== undefined) {
return scheduling
}
const {
cron = DEFAULT_CRON_PATTERN,
timezone = DEFAULT_TIMEZONE
} = schedule || EMPTY_OBJECT
return {
cronPattern: cron,
timezone
}
}
)
_handleSubmit = async () => {
const { props, state } = this
const method = this._getValue('job', 'method')
const backupInfo = BACKUP_METHOD_TO_INFO[method]
const {
enabled,
...callArgs
} = this.refs.backupInput.value
const vmsInputValue = this.refs.vmsInput.value
const {
backupInfo,
smartBackupMode,
timeout,
timezone,
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',
items: [{
type: 'set',
values: map(vmsInputValue.vms, vm => ({ id: vm.id || vm }))
}, {
type: 'set',
values: [ callArgs ]
}]
} : {
type: 'crossProduct',
items: [{
type: 'set',
values: [ callArgs ]
}, {
type: 'map',
collection: {
type: 'fetchObjects',
pattern: {
$pool: isEmpty(pools)
? undefined
: negatePattern({ __or: pools }, notPools),
power_state: vmsInputValue.status === 'All' ? undefined : vmsInputValue.status,
tags: isEmpty(tags)
? undefined
: negatePattern({ __or: formattedTags }, notTags),
type: 'VM'
}
},
iteratee: {
type: 'extractProperties',
mapping: { id: 'id' }
}
}]
}
...mainParams
} = this._getMainParams()
const vms = this._getVmsParam()
const job = {
...props.job,
...state.job,
type: 'call',
key: backupInfo.jobKey,
method: backupInfo.method,
paramsVector,
userId: owner,
timeout: timeout ? timeout * 1e3 : undefined
paramsVector: {
type: 'crossProduct',
items: isArray(vms.vms)
? [{
type: 'set',
values: map(vms.vms, vm => ({ id: extractId(vm) }))
}, {
type: 'set',
values: [ mainParams ]
}]
: [{
type: 'set',
values: [ mainParams ]
}, {
type: 'map',
collection: {
type: 'fetchObjects',
pattern: {
$pool: constructPattern(vms.$pool),
power_state: vms.power_state === 'All' ? undefined : vms.power_state,
tags: constructPattern(vms.tags),
type: 'VM'
}
},
iteratee: {
type: 'extractProperties',
mapping: { id: 'id' }
}
}]
}
}
// Update backup schedule.
const { job: oldJob, schedule: oldSchedule } = this.props
if (oldJob && oldSchedule) {
job.id = oldJob.id
return editJob(job).then(() => editSchedule({
...oldSchedule,
cron: this.state.cronPattern,
timezone
}))
const { timeout } = job
if (typeof timeout === 'string') {
job.timeout = timeout ? +timeout : undefined
}
const scheduling = this._getScheduling()
let remoteId
if (job.type === 'call') {
const { paramsVector } = job
@@ -485,58 +451,75 @@ export default class New extends Component {
}
}
// Update backup schedule.
const oldJob = props.job
if (oldJob) {
job.id = oldJob.id
await editJob(job)
return editSchedule({
id: props.schedule.id,
cron: scheduling.cronPattern,
timezone: scheduling.timezone
})
}
// Create backup schedule.
return createSchedule(await createJob(job), { cron: this.state.cronPattern, enabled, timezone })
return createSchedule(await createJob(job), {
cron: scheduling.cronPattern,
enabled,
timezone: scheduling.timezone
})
}
_handleReset = () => {
const { backupInput } = this.refs
if (backupInput) {
backupInput.value = undefined
}
this.setState({
cronPattern: DEFAULT_CRON_PATTERN
})
}
_updateCronPattern = value => {
this.setState(value)
}
_handleBackupSelection = event => {
const method = event.target.value
this.setState({
showVersionWarning: method === 'vm.rollingDeltaBackup' || method === 'vm.deltaCopy',
backupInfo: BACKUP_METHOD_TO_INFO[method]
})
this.setState(mapValues(this.state, noop))
}
_handleSmartBackupMode = event => {
this.setState({
smartBackupMode: event.target.value === 'smart'
})
this.setState(
event.target.value === 'smart'
? { vmsParam: {} }
: { vmsParam: { vms: [] } }
)
}
_subjectPredicate = ({ type, permission }) =>
type === 'user' && permission === 'admin'
render () {
const { state } = this
const {
backupInfo,
cronPattern,
smartBackupMode,
timezone,
owner,
showVersionWarning
} = state
_getValue = (ns, key, defaultValue) => {
let tmp
return process.env.XOA_PLAN > 1
? (
<Wizard>
// look in the state
if (
(tmp = this.state[ns]) != null &&
(tmp = tmp[key]) !== undefined
) {
return tmp
}
// look in the props
if (
(tmp = this.props[ns]) != null &&
(tmp = tmp[key]) !== undefined
) {
return tmp
}
return defaultValue
}
render () {
const method = this._getValue('job', 'method', '')
const scheduling = this._getScheduling()
const vms = this._getVmsParam()
const backupInfo = BACKUP_METHOD_TO_INFO[method]
const smartBackupMode = !isArray(vms.vms)
return (
<Upgrade place='newBackup' required={2}>
<Wizard><form id='form-new-vm-backup'>
<Section icon='backup' title={this.props.job ? 'editVmBackup' : 'newVmBackup'}>
<Container>
<Row>
@@ -544,92 +527,97 @@ export default class New extends Component {
<fieldset className='form-group'>
<label>{_('backupOwner')}</label>
<SelectSubject
onChange={this.linkState('owner', 'id')}
onChange={this.linkState('job.userId', 'id')}
predicate={this._subjectPredicate}
required
value={owner || null}
value={this._getValue('job', 'userId', '')}
/>
</fieldset>
<fieldset className='form-group'>
<label>{_('jobTimeoutPlaceHolder')}</label>
<input type='number' onChange={this.linkState('timeout')} value={state.timeout} className='form-control' />
<input
className='form-control'
onChange={this.linkState('job.timeout')}
type='number'
value={this._getValue('job', 'timeout', '')}
/>
</fieldset>
<fieldset className='form-group'>
<label htmlFor='selectBackup'>{_('newBackupSelection')}</label>
<select
className='form-control'
value={(backupInfo && backupInfo.method) || ''}
id='selectBackup'
onChange={this._handleBackupSelection}
onChange={this.linkState('job.method')}
required
value={method}
>
{_('noSelectedValue', message => <option value=''>{message}</option>)}
{map(BACKUP_METHOD_TO_INFO, (info, key) =>
_(info.label, message => <option key={key} value={key}>{message}</option>)
_(info.label, message => <option key={key} value={key}>{message}</option>)
)}
</select>
</fieldset>
{showVersionWarning && <div className='alert alert-warning' role='alert'>
{(method === 'vm.rollingDeltaBackup' || method === 'vm.deltaCopy') && <div className='alert alert-warning' role='alert'>
<Icon icon='error' /> {_('backupVersionWarning')}
</div>}
<form id='form-new-vm-backup'>
{backupInfo && <div>
<GenericInput
label={<span><Icon icon={backupInfo.icon} /> {_(backupInfo.label)}</span>}
ref='backupInput'
{backupInfo && <div>
<GenericInput
label={<span><Icon icon={backupInfo.icon} /> {_(backupInfo.label)}</span>}
required
schema={backupInfo.schema}
uiSchema={backupInfo.uiSchema}
onChange={this.linkState('mainParams')}
value={this._getMainParams()}
/>
<fieldset className='form-group'>
<label htmlFor='smartMode'>{_('smartBackupModeSelection')}</label>
<select
className='form-control'
id='smartMode'
onChange={this._handleSmartBackupMode}
required
schema={backupInfo.schema}
uiSchema={backupInfo.uiSchema}
/>
<fieldset className='form-group'>
<label htmlFor='smartMode'>{_('smartBackupModeSelection')}</label>
<select
className='form-control'
id='smartMode'
onChange={this._handleSmartBackupMode}
required
value={smartBackupMode ? 'smart' : 'normal'}
>
{_('normalBackup', message => <option value='normal'>{message}</option>)}
{_('smartBackup', message => <option value='smart'>{message}</option>)}
</select>
</fieldset>
{smartBackupMode
? <Upgrade place='newBackup' required={3}>
<GenericInput
label={<span><Icon icon='vm' /> {_('vmsToBackup')}</span>}
ref='vmsInput'
required
schema={SMART_SCHEMA}
uiSchema={SMART_UI_SCHEMA}
/>
</Upgrade>
: <GenericInput
value={smartBackupMode ? 'smart' : 'normal'}
>
{_('normalBackup', message => <option value='normal'>{message}</option>)}
{_('smartBackup', message => <option value='smart'>{message}</option>)}
</select>
</fieldset>
{smartBackupMode
? <Upgrade place='newBackup' required={3}>
<GenericInput
label={<span><Icon icon='vm' /> {_('vmsToBackup')}</span>}
ref='vmsInput'
onChange={this.linkState('vmsParam')}
required
schema={NO_SMART_SCHEMA}
uiSchema={NO_SMART_UI_SCHEMA}
schema={SMART_SCHEMA}
uiSchema={SMART_UI_SCHEMA}
value={vms}
/>
}
</div>}
</form>
</Upgrade>
: <GenericInput
label={<span><Icon icon='vm' /> {_('vmsToBackup')}</span>}
onChange={this.linkState('vmsParam')}
required
schema={NO_SMART_SCHEMA}
uiSchema={NO_SMART_UI_SCHEMA}
value={vms}
/>
}
</div>}
</Col>
</Row>
</Container>
</Section>
<Section icon='schedule' title='schedule'>
<Scheduler
cronPattern={cronPattern}
onChange={this._updateCronPattern}
timezone={timezone}
onChange={this.linkState('scheduling')}
value={scheduling}
/>
</Section>
<Section icon='preview' title='preview' summary>
<Container>
<Row>
<Col>
<SchedulePreview cronPattern={cronPattern} />
<SchedulePreview cronPattern={scheduling.cronPattern} />
{process.env.XOA_PLAN < 4 && backupInfo && process.env.XOA_PLAN < REQUIRED_XOA_PLAN[backupInfo.jobKey]
? <Upgrade place='newBackup' available={REQUIRED_XOA_PLAN[backupInfo.jobKey]} />
: (smartBackupMode && process.env.XOA_PLAN < 3
@@ -655,8 +643,8 @@ export default class New extends Component {
</Row>
</Container>
</Section>
</Wizard>
)
: <Container><Upgrade place='newBackup' available={2} /></Container>
</form></Wizard>
</Upgrade>
)
}
}