feat(xo-web/home): ability to list VMs which are (not) backed up (#4974)

Fixes #4777
This commit is contained in:
Rajaa.BARHTAOUI
2020-05-28 10:10:34 +02:00
committed by GitHub
parent f44e5b3b7a
commit f736381933
8 changed files with 434 additions and 80 deletions

View File

@@ -17,6 +17,7 @@
- [Audit] Record failed connection attempts [#4844](https://github.com/vatesfr/xen-orchestra/issues/4844) (PR [#4900](https://github.com/vatesfr/xen-orchestra/pull/4900))
- [XO config export] Ability to encrypt the exported file (PR [#4997](https://github.com/vatesfr/xen-orchestra/pull/4997))
- [SDN Controller] Ability to choose host as preferred center at private network creation [#4991](https://github.com/vatesfr/xen-orchestra/issues/4991) (PR [#5000](https://github.com/vatesfr/xen-orchestra/pull/5000))
- [Home/VM] Ability to list VMs which are (not) backed up [#4777](https://github.com/vatesfr/xen-orchestra/issues/4777) (PR [#4974](https://github.com/vatesfr/xen-orchestra/pull/4974))
### Bug fixes

View File

@@ -194,6 +194,9 @@ const messages = {
editUserProfile: 'Edit my settings {username}',
// ----- Home view ------
allVms: 'All VMs',
backedUpVms: 'Backed up VMs',
notBackedUpVms: 'Not backed up VMs',
homeFetchingData: 'Fetching data…',
homeWelcome: 'Welcome to Xen Orchestra!',
homeWelcomeText: 'Add your XenServer hosts or pools',
@@ -257,6 +260,7 @@ const messages = {
warningHostTimeTooltip:
'Host time and XOA time are not consistent with each other',
selectExistingTags: 'Select from existing tags',
description: 'Description',
// ----- Home snapshots -----
snapshotVmsName: 'Name',

View File

@@ -248,6 +248,8 @@ class SortedTable extends Component {
static propTypes = {
defaultColumn: PropTypes.number,
defaultFilter: PropTypes.string,
// To not duplicate filter on the home page.
displayFilter: PropTypes.bool,
collection: PropTypes.oneOfType([PropTypes.array, PropTypes.object])
.isRequired,
columns: PropTypes.arrayOf(
@@ -288,6 +290,10 @@ class SortedTable extends Component {
individualLabel: PropTypes.node,
label: PropTypes.node.isRequired,
level: PropTypes.oneOf(['primary', 'warning', 'danger']),
redirectOnSuccess: PropTypes.oneOfType([
PropTypes.func,
PropTypes.string,
]),
})
),
groupedActions: actionsShape,
@@ -308,6 +314,7 @@ class SortedTable extends Component {
}
static defaultProps = {
displayFilter: true,
itemsPerPage: 10,
}
@@ -718,6 +725,7 @@ class SortedTable extends Component {
icon: a.icon,
label: a.individualLabel !== undefined ? a.individualLabel : a.label,
level: a.level,
redirectOnSuccess: a.redirectOnSuccess,
}))
return sortBy(
@@ -808,6 +816,7 @@ class SortedTable extends Component {
const { props, state } = this
const {
actions,
displayFilter,
filterContainer,
individualActions,
itemsPerPage,
@@ -840,7 +849,7 @@ class SortedTable extends Component {
/>
)
const filterInstance = (
const filterInstance = displayFilter && (
<TableFilter
filters={props.filters}
onChange={this._setFilter}
@@ -981,13 +990,14 @@ class SortedTable extends Component {
))}
</Col>
<Col mediumSize={4}>
{filterContainer ? (
<Portal container={() => filterContainer()}>
{filterInstance}
</Portal>
) : (
filterInstance
)}
{displayFilter &&
(filterContainer ? (
<Portal container={() => filterContainer()}>
{filterInstance}
</Portal>
) : (
filterInstance
))}
</Col>
</SingleLineRow>
</Container>

View File

@@ -613,6 +613,11 @@ export const createCompare = criterias => (...items) => {
// ===================================================================
export const createCompareContainers = poolId =>
createCompare([c => c.$pool === poolId, c => c.type === 'pool'])
// ===================================================================
export const hasLicenseRestrictions = host => {
const licenseType = host.license_params.sku_type
return (

View File

@@ -4,14 +4,12 @@ import PropTypes from 'prop-types'
import React from 'react'
import SingleLineRow from 'single-line-row'
import { Container, Col } from 'grid'
import { createCompare } from 'utils'
import { createCompare, createCompareContainers } from 'utils'
import { createSelector } from 'selectors'
import { SelectSr } from 'select-objects'
import { isSrShared } from '../'
const createCompareContainers = poolId =>
createCompare([_ => _.$pool === poolId, _ => _.type === 'pool'])
const compareSrs = createCompare([isSrShared])
export default class MigrateVdiModalBody extends Component {

View File

@@ -0,0 +1,236 @@
import _ from 'intl'
import decorate from 'apply-decorators'
import Icon from 'icon'
import PropTypes from 'prop-types'
import React from 'react'
import SortedTable from 'sorted-table'
import Tooltip from 'tooltip'
import { addSubscriptions, connectStore, createCompareContainers } from 'utils'
import {
copyVms,
deleteVms,
editVm,
migrateVm,
migrateVms,
pauseVms,
restartVms,
snapshotVms,
startVms,
stopVms,
subscribeBackupNgJobs,
suspendVms,
} from 'xo'
import { createGetObjectsOfType } from 'selectors'
import { createPredicate } from 'value-matcher'
import {
difference,
filter,
flatMap,
isEmpty,
map,
omit,
some,
uniq,
} from 'lodash'
import { Host, Pool } from 'render-xo-item'
import { injectState, provideState } from 'reaclette'
import { Text, XoSelect } from 'editable'
const getVmUrl = ({ id }) => `vms/${id}/general`
const COLUMNS = [
{
name: _('name'),
itemRenderer: vm => {
const operations = vm.current_operations
const state = isEmpty(operations) ? vm.power_state : 'Busy'
return (
<span style={{ whiteSpace: 'nowrap' }}>
<Tooltip
content={
<span>
{_(`powerState${state}`)}
{state === 'Busy' && (
<span> ({Object.values(operations)[0]})</span>
)}
</span>
}
>
<Icon icon={state.toLowerCase()} />
</Tooltip>
<Text
value={vm.name_label}
onChange={value => editVm(vm, { name_label: value })}
placeholder={_('vmHomeNamePlaceholder')}
useLongClick
/>
</span>
)
},
sortCriteria: 'name_label',
},
{
name: _('description'),
itemRenderer: vm => (
<Text
value={vm.name_description}
onChange={value => editVm(vm, { name_description: value })}
placeholder={_('vmHomeDescriptionPlaceholder')}
useLongClick
/>
),
sortCriteria: 'name_description',
},
{
name: _('containersTabName'),
itemRenderer: (vm, { pools, hosts }) => {
let container
return vm.power_state === 'Running' &&
(container = hosts[vm.$container]) !== undefined ? (
<XoSelect
compareContainers={createCompareContainers(vm.$pool)}
labelProp='name_label'
onChange={host => migrateVm(vm, host)}
placeholder={_('homeMigrateTo')}
useLongClick
value={container}
xoType='host'
>
<Host id={container.id} link />
</XoSelect>
) : (
(container = pools[vm.$container]) !== undefined && (
<Pool id={container.id} link />
)
)
},
},
]
const ACTIONS = [
{
disabled: vms => some(vms, { power_state: 'Running' }),
handler: startVms,
icon: 'vm-start',
label: _('startVmLabel'),
},
{
disabled: vms => some(vms, { power_state: 'Halted' }),
handler: vms => stopVms(vms),
icon: 'vm-stop',
label: _('stopVmLabel'),
},
{
handler: migrateVms,
icon: 'vm-migrate',
label: _('migrateVmLabel'),
},
{
handler: snapshotVms,
icon: 'vm-snapshot',
label: _('snapshotVmLabel'),
},
{
handler: copyVms,
icon: 'vm-copy',
label: _('copyVmLabel'),
},
{
handler: (vms, { setHomeVmIdsSelection }) => {
setHomeVmIdsSelection(map(vms, 'id'))
},
icon: 'backup',
label: _('backupLabel'),
redirectOnSuccess: '/backup/new/vms',
},
{
handler: deleteVms,
icon: 'vm-delete',
label: _('vmRemoveButton'),
level: 'danger',
},
]
const GROUPED_ACTIONS = [
{
disabled: vms => some(vms, _ => _.power_state !== 'Running'),
handler: vms => restartVms(vms),
icon: 'vm-reboot',
label: _('rebootVmLabel'),
},
{
disabled: vms => some(vms, _ => _.power_state !== 'Running'),
handler: pauseVms,
icon: 'vm-pause',
label: _('pauseVmLabel'),
},
{
disabled: vms => some(vms, _ => _.power_state !== 'Running'),
handler: suspendVms,
icon: 'vm-suspend',
label: _('suspendVmLabel'),
},
]
const BackedUpVms = decorate([
addSubscriptions({
jobs: subscribeBackupNgJobs,
}),
connectStore(() => ({
hosts: createGetObjectsOfType('host'),
pools: createGetObjectsOfType('pool'),
})),
provideState({
computed: {
backedUpVms: (_, { showBackedUpVms, jobs, vms }) =>
uniq(
flatMap(jobs, job =>
filter(vms, createPredicate(omit(job.vms, 'power_state')))
)
),
collection: (state, { showBackedUpVms }) =>
showBackedUpVms ? state.backedUpVms : state.notBackedUpVms,
notBackedUpVms: ({ backedUpVms }, { vms }) =>
difference(vms, backedUpVms),
title: (state, { showBackedUpVms }) =>
showBackedUpVms ? _('backedUpVms') : _('notBackedUpVms'),
},
}),
injectState,
({
hosts,
itemsPerPage,
pools,
setHomeVmIdsSelection,
state: { collection, title },
}) => (
<div>
<h5>{title}</h5>
<SortedTable
actions={ACTIONS}
collection={collection}
columns={COLUMNS}
data-hosts={hosts}
data-pools={pools}
data-setHomeVmIdsSelection={setHomeVmIdsSelection}
displayFilter={false}
groupedActions={GROUPED_ACTIONS}
itemsPerPage={itemsPerPage}
rowLink={getVmUrl}
shortcutsTarget='body'
stateUrlParam='s_backup'
/>
</div>
),
])
BackedUpVms.propTypes = {
showBackedUpVms: PropTypes.bool,
vms: PropTypes.arrayOf(PropTypes.object),
}
BackedUpVms.defaultProps = {
showBackedUpVms: true,
}
export default BackedUpVms

View File

@@ -86,8 +86,10 @@ import {
OverlayTrigger,
Popover,
} from 'react-bootstrap-4/lib'
import { Select } from 'form'
import styles from './index.css'
import BackedUpVms from './backed-up-vms'
import HostItem from './host-item'
import PoolItem from './pool-item'
import VmItem from './vm-item'
@@ -308,6 +310,12 @@ const TYPES = {
const DEFAULT_TYPE = 'VM'
const BACKUP_FILTERS = [
{ value: 'all', label: _('allVms') },
{ value: 'backedUpVms', label: _('backedUpVms') },
{ value: 'notBackedUpVms', label: _('notBackedUpVms') },
]
@connectStore(() => {
const noServersConnected = invoke(
createGetObjectsOfType('host'),
@@ -544,7 +552,14 @@ export default class Home extends Component {
const { pathname, query } = this.props.location
this.context.router.push({
pathname,
query: { ...query, t: type, s: undefined, p: 1 },
query: {
...query,
backup: undefined,
p: 1,
s: undefined,
s_backup: undefined,
t: type,
},
})
}
@@ -843,7 +858,7 @@ export default class Home extends Component {
: items.length - 1,
})
break
case 'SELECT':
case 'SELECT': {
const itemId = items[this.state.highlighted].id
this.setState({
selectedItems: {
@@ -852,24 +867,57 @@ export default class Home extends Component {
},
})
break
case 'JUMP_INTO':
}
case 'JUMP_INTO': {
const item = items[this.state.highlighted]
if (includes(['VM', 'host', 'pool', 'SR'], item && item.type)) {
this.context.router.push({
pathname: `${item.type.toLowerCase()}s/${item.id}`,
})
}
}
}
}
)
// Header --------------------------------------------------------------------
_getBackupFilter = createSelector(
() => this.props.location.query.backup,
backup =>
backup === undefined
? 'all'
: backup === 'true'
? 'backedUpVms'
: 'notBackedUpVms'
)
_setBackupFilter = backupFilter => {
const { pathname, query } = this.props.location
const isAll = backupFilter === 'all'
this.context.router.push({
pathname,
query: {
...query,
backup: isAll ? undefined : backupFilter === 'backedUpVms',
p: isAll ? 1 : undefined,
s_backup: undefined,
},
})
}
_renderHeader() {
const customFilters = this._getCustomFilters()
const filteredItems = this._getFilteredItems()
const nItems = this._getNumberOfItems()
const { isAdmin, isPoolAdmin, items, noResourceSets, type } = this.props
const {
isAdmin,
isPoolAdmin,
items,
location,
noResourceSets,
type,
} = this.props
const {
homeItemsPerPage,
@@ -890,6 +938,10 @@ export default class Home extends Component {
showResourceSetsSelector,
} = options
// Disable all the features that are already handled by the SortedTable
// or irrelevant with the SortedTable.
const disableHomeFeatures = location.query.backup !== undefined
return (
<Container>
<Row className={styles.itemRowHeader}>
@@ -971,25 +1023,29 @@ export default class Home extends Component {
</Row>
<Row className={classNames(styles.itemRowHeader, 'mt-1')}>
<Col smallSize={6} mediumSize={2}>
<input
checked={this._getIsAllSelected()}
onChange={this._toggleMaster}
ref='masterCheckbox'
type='checkbox'
/>{' '}
<span className='text-muted'>
{this._getNumberOfSelectedItems()
? _('homeSelectedItems', {
icon: <Icon icon={type.toLowerCase()} />,
selected: this._getNumberOfSelectedItems(),
total: nItems,
})
: _('homeDisplayedItems', {
displayed: filteredItems.length,
icon: <Icon icon={type.toLowerCase()} />,
total: nItems,
})}
</span>
{!disableHomeFeatures && (
<span>
<input
checked={this._getIsAllSelected()}
onChange={this._toggleMaster}
ref='masterCheckbox'
type='checkbox'
/>{' '}
<span className='text-muted'>
{this._getNumberOfSelectedItems()
? _('homeSelectedItems', {
icon: <Icon icon={type.toLowerCase()} />,
selected: this._getNumberOfSelectedItems(),
total: nItems,
})
: _('homeDisplayedItems', {
displayed: filteredItems.length,
icon: <Icon icon={type.toLowerCase()} />,
total: nItems,
})}
</span>
</span>
)}
</Col>
<Col mediumSize={8} className='text-xs-right hidden-sm-down'>
{this._getNumberOfSelectedItems() ? (
@@ -1033,6 +1089,33 @@ export default class Home extends Component {
</div>
) : (
<div>
{type === 'VM' && (
<OverlayTrigger
trigger='click'
rootClose
placement='bottom'
overlay={
<Popover
className={styles.selectObject}
id='backupPopover'
>
<Select
autoFocus
onChange={this._setBackupFilter}
openOnFocus
options={BACKUP_FILTERS}
required
simpleValue
value={this._getBackupFilter()}
/>
</Popover>
}
>
<Button btnStyle='link'>
<Icon icon='backup' /> {_('backup')}
</Button>
</OverlayTrigger>
)}
{showPoolsSelector && (
<OverlayTrigger
trigger='click'
@@ -1122,6 +1205,7 @@ export default class Home extends Component {
)}
<DropdownButton
bsStyle='link'
disabled={disableHomeFeatures}
id='sort'
title={_('homeSortBy')}
>
@@ -1148,9 +1232,11 @@ export default class Home extends Component {
)}
</Col>
<Col smallSize={6} mediumSize={2} className='text-xs-right'>
<Button onClick={this._expandAll}>
<Icon icon='nav' />
</Button>{' '}
{!disableHomeFeatures && (
<Button onClick={this._expandAll}>
<Icon icon='nav' />
</Button>
)}{' '}
<DropdownButton bsStyle='info' title={homeItemsPerPage}>
{ITEMS_PER_PAGE_OPTIONS.map(nItems => (
<MenuItem
@@ -1174,7 +1260,11 @@ export default class Home extends Component {
areObjectsFetched,
isAdmin,
isPoolAdmin,
location: {
query: { backup },
},
noResourceSets,
type,
} = this.props
if (!areObjectsFetched) {
@@ -1201,8 +1291,13 @@ export default class Home extends Component {
const filteredItems = this._getFilteredItems()
const visibleItems = this._getVisibleItems()
const { Item } = OPTIONS[this.props.type]
const { expandAll, highlighted, selectedItems } = this.state
const { Item } = OPTIONS[type]
const {
expandAll,
highlighted,
homeItemsPerPage,
selectedItems,
} = this.state
// Necessary because indeterminate cannot be used as an attribute
if (this.refs.masterCheckbox) {
@@ -1218,49 +1313,55 @@ export default class Home extends Component {
name='Home'
targetNodeSelector='body'
/>
<div>
<div className={styles.itemContainer}>
{isEmpty(filteredItems) ? (
<p className='text-xs-center mt-1'>
<a className='btn btn-link' onClick={this._clearFilter}>
<Icon icon='info' /> {_('homeNoMatches')}
</a>
</p>
) : (
map(visibleItems, (item, index) => (
<div
key={item.id}
className={
highlighted === index ? styles.highlight : undefined
}
>
<Item
expandAll={expandAll}
item={item}
{backup === undefined ? (
<div>
<div className={styles.itemContainer}>
{isEmpty(filteredItems) ? (
<p className='text-xs-center mt-1'>
<a className='btn btn-link' onClick={this._clearFilter}>
<Icon icon='info' /> {_('homeNoMatches')}
</a>
</p>
) : (
map(visibleItems, (item, index) => (
<div
key={item.id}
onSelect={this.toggleState(`selectedItems.${item.id}`)}
selected={Boolean(selectedItems[item.id])}
/>
className={
highlighted === index ? styles.highlight : undefined
}
>
<Item
expandAll={expandAll}
item={item}
key={item.id}
onSelect={this.toggleState(`selectedItems.${item.id}`)}
selected={Boolean(selectedItems[item.id])}
/>
</div>
))
)}
</div>
{filteredItems.length > homeItemsPerPage && (
<Row>
<div style={{ display: 'flex', width: '100%' }}>
<div style={{ margin: 'auto' }}>
<Pagination
onChange={this._onPageSelection}
pages={ceil(filteredItems.length / homeItemsPerPage)}
value={this._getPage()}
/>
</div>
</div>
))
</Row>
)}
</div>
{filteredItems.length > this.state.homeItemsPerPage && (
<Row>
<div style={{ display: 'flex', width: '100%' }}>
<div style={{ margin: 'auto' }}>
<Pagination
onChange={this._onPageSelection}
pages={ceil(
filteredItems.length / this.state.homeItemsPerPage
)}
value={this._getPage()}
/>
</div>
</div>
</Row>
)}
</div>
) : (
<BackedUpVms
itemsPerPage={homeItemsPerPage}
showBackedUpVms={backup === 'true'}
vms={filteredItems}
/>
)}
</Page>
)
}

View File

@@ -26,6 +26,7 @@ import {
addSubscriptions,
connectStore,
createCompare,
createCompareContainers,
formatSize,
noop,
resolveResourceSet,
@@ -66,8 +67,6 @@ import {
subscribeResourceSets,
} from 'xo'
const createCompareContainers = poolId =>
createCompare([c => c.$pool === poolId, c => c.type === 'pool'])
const compareSrs = createCompare([isSrShared])
class VdiSr extends Component {