feat(backups): improve smart backup feedback (#2320)

Fixes #2253
This commit is contained in:
badrAZ 2018-02-06 14:39:51 +01:00 committed by Julien Fontanet
parent 682d9e88ec
commit 59985adc5d
6 changed files with 210 additions and 46 deletions

View File

@ -144,6 +144,7 @@
"uglify-es": "^3.3.4",
"uncontrollable-input": "^0.1.1",
"url-parse": "^1.2.0",
"value-matcher": "^0.0.0",
"vinyl": "^2.1.0",
"watchify": "^3.7.0",
"whatwg-fetch": "^2.0.3",

View File

@ -187,6 +187,7 @@ const messages = {
selectAll: 'Select all',
remove: 'Remove',
preview: 'Preview',
action: 'Action',
item: 'Item',
noSelectedValue: 'No selected value',
selectSubjects: 'Choose user(s) and/or group(s)',
@ -299,6 +300,9 @@ const messages = {
jobOwnerPlaceholder: 'Job owner',
jobUserNotFound: "This job's creator no longer exists",
backupUserNotFound: "This backup's creator no longer exists",
redirectToMatchingVms: 'Click here to see the matching VMs',
noMatchingVms: 'There are no matching VMs!',
allMatchingVms: '{icon} See the matching VMs ({nMatchingVms, number})',
backupOwner: 'Backup owner',
// ------ New backup -----
@ -316,6 +320,7 @@ const messages = {
editBackupSmartResidentOn: 'Resident on',
editBackupSmartPools: 'Pools',
editBackupSmartTags: 'Tags',
sampleOfMatchingVms: 'Sample of matching Vms',
editBackupSmartTagsTitle: 'VMs Tags',
editBackupNot: 'Reverse',
editBackupTagTitle: 'Tag',

View File

@ -0,0 +1,53 @@
import * as CM from 'complex-matcher'
import { flatten, identity, map } from 'lodash'
import { EMPTY_OBJECT } from './utils'
export const destructPattern = (pattern, valueTransform = identity) =>
pattern && {
not: !!pattern.__not,
values: valueTransform((pattern.__not || pattern).__or),
}
export const constructPattern = (
{ not, values } = EMPTY_OBJECT,
valueTransform = identity
) => {
if (values == null || !values.length) {
return
}
const pattern = { __or: valueTransform(values) }
return not ? { __not: pattern } : pattern
}
const parsePattern = pattern => {
const patternValues = flatten(
pattern.__not !== undefined ? pattern.__not.__or : pattern.__or
)
const queryString = new CM.Or(
map(patternValues, array => new CM.String(array))
)
return pattern.__not !== undefined ? CM.Not(queryString) : queryString
}
export const constructQueryString = pattern => {
const powerState = pattern.power_state
const pool = pattern.$pool
const tags = pattern.tags
const filter = []
if (powerState !== undefined) {
filter.push(new CM.Property('power_state', new CM.String(powerState)))
}
if (pool !== undefined) {
filter.push(new CM.Property('$pool', parsePattern(pool)))
}
if (tags !== undefined) {
filter.push(new CM.Property('tags', parsePattern(tags)))
}
return filter.length !== 0 ? new CM.And(filter).toString() : ''
}

View File

@ -5,26 +5,38 @@ import Component from 'base-component'
import GenericInput from 'json-schema-input'
import getEventValue from 'get-event-value'
import Icon from 'icon'
import Link from 'link'
import moment from 'moment-timezone'
import PropTypes from 'prop-types'
import React from 'react'
import renderXoItem from 'render-xo-item'
import Scheduler, { SchedulePreview } from 'scheduling'
import Tooltip from 'tooltip'
import uncontrollableInput from 'uncontrollable-input'
import Upgrade from 'xoa-upgrade'
import Wizard, { Section } from 'wizard'
import { confirm } from 'modal'
import { connectStore, EMPTY_OBJECT } from 'utils'
import { Card, CardBlock, CardHeader } from 'card'
import { Container, Row, Col } from 'grid'
import { createPredicate } from 'value-matcher'
import { createSelector } from 'reselect'
import { generateUiSchema } from 'xo-json-schema-input'
import { getUser } from 'selectors'
import { SelectSubject } from 'select-objects'
import { createGetObjectsOfType, getUser } from 'selectors'
import { connectStore, EMPTY_OBJECT } from 'utils'
import {
constructPattern,
destructPattern,
constructQueryString,
} from 'smart-backup-pattern'
import {
filter,
forEach,
identity,
isArray,
map,
mapValues,
noop,
pickBy,
startsWith,
} from 'lodash'
@ -274,6 +286,83 @@ const BACKUP_METHOD_TO_INFO = {
// ===================================================================
const SAMPLE_SIZE_OF_MATCHING_VMS = 3
@connectStore({
vms: createGetObjectsOfType('VM'),
})
class SmartBackupPreview extends Component {
static propTypes = {
pattern: PropTypes.object.isRequired,
}
_getMatchingVms = createSelector(
() => this.props.vms,
createSelector(
() => this.props.pattern,
pattern => createPredicate(pickBy(pattern, val => val != null))
),
(vms, predicate) => filter(vms, predicate)
)
_getSampleOfMatchingVms = createSelector(this._getMatchingVms, vms =>
vms.slice(0, SAMPLE_SIZE_OF_MATCHING_VMS)
)
_getQueryString = createSelector(
() => this.props.pattern,
constructQueryString
)
render () {
const nMatchingVms = this._getMatchingVms().length
const sampleOfMatchingVms = this._getSampleOfMatchingVms()
const queryString = this._getQueryString()
return (
<Card>
<CardHeader>{_('sampleOfMatchingVms')}</CardHeader>
<CardBlock>
{nMatchingVms === 0 ? (
<p className='text-xs-center'>{_('noMatchingVms')}</p>
) : (
<div>
<ul className='list-group'>
{map(sampleOfMatchingVms, vm => (
<li className='list-group-item' key={vm.id}>
{renderXoItem(vm)}
</li>
))}
</ul>
<br />
<Tooltip content={_('redirectToMatchingVms')}>
<Link
className='pull-right'
target='_blank'
to={{
pathname: '/home',
query: {
t: 'VM',
s: queryString,
},
}}
>
{_('allMatchingVms', {
icon: <Icon icon='preview' />,
nMatchingVms,
})}
</Link>
</Tooltip>
</div>
)}
</CardBlock>
</Card>
)
}
}
// ===================================================================
@uncontrollableInput()
class TimeoutInput extends Component {
_onChange = event => {
@ -313,24 +402,6 @@ const extractId = value => {
return value
}
const destructPattern = (pattern, valueTransform = identity) =>
pattern && {
not: !!pattern.__not,
values: valueTransform((pattern.__not || pattern).__or),
}
const constructPattern = (
{ not, values } = EMPTY_OBJECT,
valueTransform = identity
) => {
if (values == null || !values.length) {
return
}
const pattern = { __or: valueTransform(values) }
return not ? { __not: pattern } : pattern
}
const normalizeMainParams = params => {
if (!('retention' in params)) {
const { depth, ...rest } = params
@ -399,6 +470,13 @@ export default class New extends Component {
}
)
_constructPattern = vms => ({
$pool: constructPattern(vms.$pool),
power_state: vms.power_state === 'All' ? undefined : vms.power_state,
tags: constructPattern(vms.tags, tags => map(tags, tag => [tag])),
type: 'VM',
})
_getMainParams = () => this.state.mainParams || this._getParams().main
_getVmsParam = () => this.state.vmsParam || this._getParams().vms
@ -456,15 +534,7 @@ export default class New extends Component {
type: 'map',
collection: {
type: 'fetchObjects',
pattern: {
$pool: constructPattern(vms.$pool),
power_state:
vms.power_state === 'All' ? undefined : vms.power_state,
tags: constructPattern(vms.tags, tags =>
map(tags, tag => [tag])
),
type: 'VM',
},
pattern: this._constructPattern(vms),
},
iteratee: {
type: 'extractProperties',
@ -667,20 +737,25 @@ export default class New extends Component {
</select>
</fieldset>
{smartBackupMode ? (
<Upgrade place='newBackup' required={3}>
<GenericInput
label={
<span>
<Icon icon='vm' /> {_('vmsToBackup')}
</span>
}
onChange={this.linkState('vmsParam')}
required
schema={SMART_SCHEMA}
uiSchema={SMART_UI_SCHEMA}
value={vms}
<div>
<Upgrade place='newBackup' required={3}>
<GenericInput
label={
<span>
<Icon icon='vm' /> {_('vmsToBackup')}
</span>
}
onChange={this.linkState('vmsParam')}
required
schema={SMART_SCHEMA}
uiSchema={SMART_UI_SCHEMA}
value={vms}
/>
</Upgrade>
<SmartBackupPreview
pattern={this._constructPattern(vms)}
/>
</Upgrade>
</div>
) : (
<GenericInput
label={
@ -706,12 +781,12 @@ export default class New extends Component {
onChange={this.linkState('scheduling')}
value={scheduling}
/>
<SchedulePreview cronPattern={scheduling.cronPattern} />
</Section>
<Section icon='preview' title='preview' summary>
<Section title='action' summary>
<Container>
<Row>
<Col>
<SchedulePreview cronPattern={scheduling.cronPattern} />
{process.env.XOA_PLAN < 4 &&
backupInfo &&
process.env.XOA_PLAN <

View File

@ -11,6 +11,7 @@ import SortedTable from 'sorted-table'
import StateButton from 'state-button'
import Tooltip from 'tooltip'
import { addSubscriptions } from 'utils'
import { constructQueryString } from 'smart-backup-pattern'
import { createSelector } from 'selectors'
import { Card, CardHeader, CardBlock } from 'card'
import { filter, find, forEach, get, map, orderBy } from 'lodash'
@ -80,7 +81,7 @@ const JOB_COLUMNS = [
},
{
name: _('jobAction'),
itemRenderer: ({ schedule }, isScheduleUserMissing) => (
itemRenderer: ({ redirect, schedule }, isScheduleUserMissing) => (
<fieldset>
{!isScheduleUserMissing[schedule.id] && (
<Tooltip content={_('backupUserNotFound')}>
@ -94,6 +95,14 @@ const JOB_COLUMNS = [
<Icon icon='edit' />
</Link>
<ButtonGroup>
{redirect && (
<ActionRowButton
btnStyle='primary'
handler={redirect}
icon='preview'
tooltip={_('redirectToMatchingVms')}
/>
)}
<ActionRowButton
icon='delete'
btnStyle='danger'
@ -120,6 +129,10 @@ const JOB_COLUMNS = [
users: subscribeUsers,
})
export default class Overview extends Component {
static contextTypes = {
router: React.PropTypes.object,
}
constructor (props) {
super(props)
this.state = {
@ -166,6 +179,13 @@ export default class Overview extends Component {
}
}
_redirectToMatchingVms = pattern => {
this.context.router.push({
pathname: '/home',
query: { t: 'VM', s: constructQueryString(pattern) },
})
}
_getScheduleCollection = createSelector(
() => this.state.schedules,
() => this.state.scheduleTable,
@ -178,10 +198,14 @@ export default class Overview extends Component {
return map(schedules, schedule => {
const job = jobs[schedule.job]
const { items } = job.paramsVector
const pattern = get(items, '[1].collection.pattern')
return {
jobId: job.id,
jobLabel: jobKeyToLabel[job.key] || _('unknownSchedule'),
redirect:
pattern !== undefined &&
(() => this._redirectToMatchingVms(pattern)),
// Old versions of XenOrchestra use items[0]
scheduleTag:
get(items, '[0].values[0].tag') ||

View File

@ -8695,6 +8695,12 @@ validate-npm-package-license@^3.0.1:
spdx-correct "~1.0.0"
spdx-expression-parse "~1.0.0"
value-matcher@^0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/value-matcher/-/value-matcher-0.0.0.tgz#c0caf87dc3998a68ea56b31fd1916adefe39f7be"
dependencies:
"@babel/polyfill" "^7.0.0-beta.36"
value-or-function@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/value-or-function/-/value-or-function-3.0.0.tgz#1c243a50b595c1be54a754bfece8563b9ff8d813"