Compare commits

..

73 Commits

Author SHA1 Message Date
BCedric
1083aba33c add message for current operation 2017-06-23 16:51:46 +02:00
BCedric
d90156ff15 tabManagement, remove displaying vm list if the group is empty 2017-06-23 16:17:25 +02:00
BCedric
45b6e010df correct getMemoryTotal function 2017-06-23 16:14:43 +02:00
BCedric
1fbdb7799e display current operations 2017-06-23 16:12:45 +02:00
BCedric
3050fd7ce1 display an halted icon if the groupe is empty 2017-06-23 16:09:04 +02:00
BCedric
1988934f8a use proptypes decorator 2017-06-23 16:04:31 +02:00
BCedric
5331ced4fe add dragNDropOrder generic component 2017-06-22 15:57:56 +02:00
BCedric
74066f7d44 move NewVmGroup in new folder 2017-06-22 15:57:56 +02:00
BCedric
98e3aa89bb use intl in vm list 2017-06-22 15:57:56 +02:00
BCedric
070bb65740 use connectStore in VmGroup TabGeneral 2017-06-22 15:57:56 +02:00
BCedric
4e51959e3b add function removeAppliance 2017-06-22 15:57:56 +02:00
BCedric
b5ff695c74 add functions to calculate stats 2017-06-22 15:57:56 +02:00
BCedric
f879e2ace0 add VmGroup handlers in Home mainActions 2017-06-22 15:55:58 +02:00
BCedric
9aeabce4d8 add createVmGroup function 2017-06-22 15:55:58 +02:00
BCedric
2f17cd4ba9 allow to delete a vmGroup 2017-06-22 15:55:58 +02:00
BCedric
60e70a08c1 allow vms management 2017-06-22 15:55:58 +02:00
BCedric
6e0ba2bae3 add editVmGroup function 2017-06-22 15:55:58 +02:00
BCedric
feb996890e management of icon color 2017-06-22 15:55:58 +02:00
BCedric
a06bc85142 allow edition of a vmGroup 2017-06-22 15:55:58 +02:00
BCedric
f530aef92b remove vmGroup tag 2017-06-22 15:55:58 +02:00
BCedric
2eb7330335 add functions on action-bar 2017-06-22 15:55:58 +02:00
BCedric
89157e7b7e rename vm-groupe to vmGroup 2017-06-22 15:55:58 +02:00
BCedric
3834e2ef91 expose vmGroup, remove subscription 2017-06-22 15:55:58 +02:00
BCedric
a711231955 add creation of a vmGroup 2017-06-22 15:55:58 +02:00
BCedric
0a5e301b3e add stats tab 2017-06-22 15:55:58 +02:00
BCedric
c82b9893c5 add advanced tab 2017-06-22 15:55:58 +02:00
BCedric
f4dfabc34c add management tab 2017-06-22 15:55:58 +02:00
BCedric
059521aeda add general tab 2017-06-22 15:55:58 +02:00
BCedric
debca09e2c remove _getItems, rename VMGroupItem => VmGroupItem 2017-06-22 15:55:58 +02:00
BCedric
0699cfc449 display VM-Groups list 2017-06-22 15:55:58 +02:00
Julien Fontanet
f0d85f4c4e fix(vm): remove unused imports 2017-06-20 19:19:29 +02:00
Julien Fontanet
1801f9cb06 feat(Home): display total disk size for each VM 2017-06-20 19:07:57 +02:00
Julien Fontanet
4be018ad15 feat(selectors/createSumBy): sum collection of items 2017-06-20 17:51:47 +02:00
Julien Fontanet
5dcf060975 chore(selectors/createPicker): avoid running collection wrapper when possible 2017-06-20 17:51:00 +02:00
Julien Fontanet
59f6b1f0c8 fix(StateButton): missing semicolons in CSS 2017-06-20 15:19:51 +02:00
Julien Fontanet
ae38a85b19 chore(package): update dependencies 2017-06-20 15:08:41 +02:00
badrAZ
324dbbcfc8 fix(modal/alert): resolve on close (#2224)
Fixes #2222
2017-06-20 15:08:33 +02:00
Julien Fontanet
9770b77df4 feat(sr/disks): improve filters 2017-06-20 10:41:50 +02:00
Julien Fontanet
0f91de389a fix(Health): check VBDs for orphaned VDI snapshots
VDI snapshots attached to a VM are not considered orphaned.
2017-06-19 16:41:55 +02:00
Julien Fontanet
7f5a623b37 feat(sr/general): display disks size 2017-06-08 11:59:44 +02:00
Julien Fontanet
c7cf73ff05 fix(sr/disks): difference between no VM and unknown VM 2017-06-08 11:59:08 +02:00
Julien Fontanet
4aab425cef 5.9.1 2017-06-08 10:23:34 +02:00
Julien Fontanet
0d9666639f fix(sr): unused imports 2017-06-06 17:08:12 +02:00
Julien Fontanet
6c26c09685 fix(sr/general): show VDI snaphots 2017-06-06 16:57:56 +02:00
Julien Fontanet
819f650b48 chore(sr/general): better retrieve VM associated to VDI 2017-06-06 16:57:56 +02:00
Julien Fontanet
353eba6365 fix(sr/disks): show the correct attached VM for snapshots 2017-06-06 16:57:55 +02:00
Julien Fontanet
063302b91d feat(renderXoUnknownItem): expose it 2017-06-06 16:57:55 +02:00
Julien Fontanet
562b51bc2f feat(SortedTable): can accept component instead of itemRenderer 2017-06-06 16:57:55 +02:00
Julien Fontanet
e33a6f9a05 chore(xo): use tap() from promise-toolbox 2017-06-05 15:50:16 +02:00
Danp2
b9db4e7704 fix(xo/deleteGroup): properly handle confirm rejection (#2197)
Resolve issue with canceling / exiting dialog
2017-06-05 15:48:31 +02:00
Julien Fontanet
3270d9c3a7 chore(plugins): coding style 2017-06-02 16:57:32 +02:00
Julien Fontanet
6d7399f96c fix(plugins): remove collpase button if not configurable 2017-06-02 16:57:32 +02:00
Julien Fontanet
886ef87bc5 fix(plugins): primary style on save button 2017-06-02 16:57:32 +02:00
Julien Fontanet
1e5dc9efe7 fix(json-schema-input/string): consider empty value as undefined (#2192) 2017-06-02 16:21:30 +02:00
Julien Fontanet
28ec66bf3b fix(backups): handle object values without id prop 2017-06-02 12:11:55 +02:00
Julien Fontanet
9199784a23 5.9.0 2017-05-31 18:14:51 +02:00
Pierre Donias
c7e447db6f feat(host): update patches when joining pool (#2187)
Fixes #878
2017-05-31 17:59:11 +02:00
Pierre Donias
f81615f8b6 feat(dashboard/health): VDIs attached to control domain (#2183)
Fixes #2126
2017-05-31 16:33:37 +02:00
badrAZ
12caceb02b feat: start a VM even when forbidden (#2161)
Fixes #2119
2017-05-31 16:05:57 +02:00
Julien Fontanet
30f71ab444 feat(selectors/createDoesHostNeedRestart): use host.rebootRequired (#2179) 2017-05-31 15:33:05 +02:00
Pierre Donias
fe04481ca3 feat(xo): subscribe to missing patches instead of explicitly checking (#2182) 2017-05-31 15:02:05 +02:00
Pierre Donias
7766e8edcd Better createDoesHostNeedRestart selector 2017-05-31 14:54:07 +02:00
Olivier Lambert
31d417c9d3 feat(changelog): added info for 5.9 release 2017-05-31 13:46:29 +02:00
Pierre Donias
5ed29197cf Use host.rebootRequired boolean 2017-05-30 16:26:32 +02:00
Pierre Donias
ff5f3e12d3 feat(selectors/createDoesHostNeedRestart): use host.patchesRequiringReboot
Fixes #2124
2017-05-30 16:26:32 +02:00
badrAZ
240180405c fix(job/logs): correctly extract vm id from returned value (#2167) 2017-05-30 15:51:37 +02:00
badrAZ
edca6495fc feat(self-service): add button "Select all" to the selects (#2181) 2017-05-30 12:39:20 +02:00
BCedric
8a9b753b01 feat(host/patches): advise to patch from pool (#2130)
Fixes #2057
2017-05-29 14:52:54 +02:00
Julien Fontanet
445fc696c9 fix(backup/new): clarify enabled setting (#2177) 2017-05-29 10:39:15 +02:00
Julien Fontanet
492e2362be chore(utils/firstDefined): fix comment, null is considered defined 2017-05-26 15:50:28 +02:00
badrAZ
1acee209be feat(backup/new): DR previous backups can be removed first (#2173)
Fixes #2157
2017-05-26 13:34:16 +02:00
Olivier Lambert
6785c48709 feat(tasks): display task description if it exists (#2172)
Fixes #2125
2017-05-25 12:45:33 +02:00
Olivier Lambert
808e674503 feat(menu): hide About entry if non-admin (on XOA) (#2170) 2017-05-24 17:36:13 +02:00
50 changed files with 4259 additions and 2676 deletions

View File

@@ -1,5 +1,41 @@
# ChangeLog
## **5.9.0** (2017-05-31)
### Enhancements
- Allow DR to remove previous backup first [\#2157](https://github.com/vatesfr/xo-web/issues/2157)
- Feature request - add amount of RAM to memory bars [\#2149](https://github.com/vatesfr/xo-web/issues/2149)
- Make the acceptability of invalid certificates configurable [\#2138](https://github.com/vatesfr/xo-web/issues/2138)
- label of VM names in tasks link [\#2135](https://github.com/vatesfr/xo-web/issues/2135)
- Backup report timezone [\#2133](https://github.com/vatesfr/xo-web/issues/2133)
- xo-server-recover-account [\#2129](https://github.com/vatesfr/xo-web/issues/2129)
- Detect disks attached to control domain [\#2126](https://github.com/vatesfr/xo-web/issues/2126)
- Add task description in Tasks view [\#2125](https://github.com/vatesfr/xo-web/issues/2125)
- Host reboot warning after patching for 7.1 [\#2124](https://github.com/vatesfr/xo-web/issues/2124)
- Continuous Replication - possibility run VM without a clone [\#2119](https://github.com/vatesfr/xo-web/issues/2119)
- Unreachable host should be detected [\#2099](https://github.com/vatesfr/xo-web/issues/2099)
- Orange icon when host is is disabled [\#2098](https://github.com/vatesfr/xo-web/issues/2098)
- Enhanced backup report logs [\#2096](https://github.com/vatesfr/xo-web/issues/2096)
- Only show failures when configured to report on failures [\#2095](https://github.com/vatesfr/xo-web/issues/2095)
- "Add all" button in self service [\#2081](https://github.com/vatesfr/xo-web/issues/2081)
- Patch and pack mechanism changed on Ely [\#2058](https://github.com/vatesfr/xo-web/issues/2058)
- Tip or ask people to patch from pool view [\#2057](https://github.com/vatesfr/xo-web/issues/2057)
- File restore - Remind compatible backup [\#1930](https://github.com/vatesfr/xo-web/issues/1930)
- Reporting for halted vm time [\#1613](https://github.com/vatesfr/xo-web/issues/1613)
- Add standalone XS server to a pool and patch it to the pool level [\#878](https://github.com/vatesfr/xo-web/issues/878)
- Add Cores-per-sockets [\#130](https://github.com/vatesfr/xo-web/issues/130)
### Bug fixes
- VM creation is broken for non-admins [\#2168](https://github.com/vatesfr/xo-web/issues/2168)
- Can't create cloud config drive [\#2162](https://github.com/vatesfr/xo-web/issues/2162)
- Select is "moving" [\#2142](https://github.com/vatesfr/xo-web/issues/2142)
- Select issue for affinity host [\#2141](https://github.com/vatesfr/xo-web/issues/2141)
- Dashboard Storage Usage incorrect [\#2123](https://github.com/vatesfr/xo-web/issues/2123)
- Detect unmerged *base copy* and prevent too long chains [\#2047](https://github.com/vatesfr/xo-web/issues/2047)
## **5.8.0** (2017-04-28)
### Enhancements

View File

@@ -258,7 +258,7 @@ gulp.task(function buildScripts () {
]
}),
require('gulp-sourcemaps').init({ loadMaps: true }),
PRODUCTION && require('gulp-uglify')(),
PRODUCTION && require('gulp-uglify/composer')(require('uglify-es'))(),
dest()
)
})

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "xo-web",
"version": "5.9.0",
"version": "5.9.1",
"license": "AGPL-3.0",
"description": "Web interface client for Xen-Orchestra",
"keywords": [
@@ -67,7 +67,7 @@
"get-stream": "^2.3.0",
"globby": "^6.0.0",
"gulp": "github:gulpjs/gulp#4.0",
"gulp-autoprefixer": "^3.1.0",
"gulp-autoprefixer": "^4.0.0",
"gulp-csso": "^3.0.0",
"gulp-embedlr": "^0.5.2",
"gulp-plumber": "^1.1.0",
@@ -75,13 +75,13 @@
"gulp-refresh": "^1.1.0",
"gulp-sass": "^3.0.0",
"gulp-sourcemaps": "^2.2.3",
"gulp-uglify": "^2.0.0",
"gulp-uglify": "^3.0.0",
"gulp-watch": "^4.3.5",
"human-format": "^0.8.0",
"husky": "^0.13.1",
"index-modules": "^0.3.0",
"is-ip": "^1.0.0",
"jest": "^19.0.2",
"jest": "^20.0.4",
"jsonrpc-websocket-client": "^0.1.1",
"kindof": "^2.0.0",
"later": "^1.2.0",
@@ -89,20 +89,20 @@
"loose-envify": "^1.1.0",
"make-error": "^1.2.1",
"marked": "^0.3.5",
"modular-css": "^4.1.1",
"modular-css": "^5.1.6",
"moment": "^2.13.0",
"moment-timezone": "^0.5.4",
"notifyjs": "^3.0.0",
"novnc-node": "^0.5.3",
"promise-toolbox": "^0.8.0",
"promise-toolbox": "^0.9.4",
"random-password": "^0.1.2",
"react": "^15.4.1",
"react-addons-shallow-compare": "^15.1.0",
"react-addons-test-utils": "^15.4.1",
"react-bootstrap-4": "^0.29.1",
"react-chartist": "^0.12.0",
"react-copy-to-clipboard": "^4.0.2",
"react-debounce-input": "^2.4.0",
"react-copy-to-clipboard": "^5.0.0",
"react-debounce-input": "^3.0.0",
"react-dnd": "^2.1.4",
"react-dnd-html5-backend": "^2.1.2",
"react-document-title": "^2.0.2",
@@ -127,9 +127,10 @@
"reselect": "^2.5.4",
"semver": "^5.3.0",
"standard": "^10.0.0",
"styled-components": "^1.4.4",
"styled-components": "^2.1.0",
"superagent": "^3.5.0",
"tar-stream": "^1.5.2",
"uglify-es": "^3.0.18",
"uncontrollable-input": "^0.0.1",
"vinyl": "^2.0.0",
"watchify": "^3.7.0",

View File

@@ -0,0 +1,128 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import HTML5Backend from 'react-dnd-html5-backend'
import Icon from 'icon'
import map from 'lodash/map'
import propTypes from 'prop-types-decorator'
import React from 'react'
import { DragDropContext, DragSource, DropTarget } from 'react-dnd'
import { Toggle } from 'form'
const orderItemSource = {
beginDrag: props => ({
id: props.id,
index: props.index
})
}
const orderItemTarget = {
hover: (props, monitor, component) => {
const dragIndex = monitor.getItem().index
const hoverIndex = props.index
if (dragIndex === hoverIndex) {
return
}
props.move(dragIndex, hoverIndex)
monitor.getItem().index = hoverIndex
}
}
@DropTarget('orderItem', orderItemTarget, connect => ({
connectDropTarget: connect.dropTarget()
}))
@DragSource('orderItem', orderItemSource, (connect, monitor) => ({
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging()
}))
@propTypes({
connectDragSource: propTypes.func.isRequired,
connectDropTarget: propTypes.func.isRequired,
index: propTypes.number.isRequired,
isDragging: propTypes.bool.isRequired,
id: propTypes.any.isRequired,
item: propTypes.object.isRequired,
move: propTypes.func.isRequired
})
class OrderItem extends Component {
_toggle = checked => {
const { item } = this.props
item.active = checked
this.forceUpdate()
}
render () {
const { item, connectDragSource, connectDropTarget, toggle } = this.props
return connectDragSource(connectDropTarget(
<li className='list-group-item'>
<Icon icon='grab' />
{' '}
<Icon icon='grab' />
{' '}
{item.text}
{toggle && <span className='pull-right'>
<Toggle value={item.active} onChange={this._toggle} />
</span>}
</li>
))
}
}
@propTypes({
onClose: propTypes.func
})
@DragDropContext(HTML5Backend)
export default class DragNDropOrder extends Component {
constructor (props) {
super(props)
const { parseOrderParam, parseOrder } = props
this.state = parseOrder(parseOrderParam)
}
_moveOrderItem = (dragIndex, hoverIndex) => {
const order = this.state.order.slice()
const dragItem = order.splice(dragIndex, 1)
if (dragItem.length) {
order.splice(hoverIndex, 0, dragItem.pop())
this.setState({order})
}
}
_reset = () => {
const { parseOrderParam, parseOrder } = this.props
this.state = this.setState(parseOrder(parseOrderParam))
}
_save = () => {
const { order, toggleActive } = this.state
this.props.setOrder(this.props.parseOrderParam, order, toggleActive)
}
_toggleOnChange = event => this.setState({toggleActive: event})
render () {
const { order, toggleActive } = this.state
const { toggleItems } = this.props
return <form>
{!toggleItems && <Toggle value={toggleActive} onChange={this._toggleOnChange} />}
<ul>
{map(order, (item, index) => <OrderItem
toggle={toggleItems}
key={index}
index={index}
id={item.id}
// FIXME missing translation
item={item}
move={this._moveOrderItem}
/>)}
</ul>
<fieldset className='form-inline'>
<span className='pull-right'>
<ActionButton icon='save' btnStyle='primary' handler={this._save}>{_('saveBootOption')}</ActionButton>
{' '}
<ActionButton icon='reset' handler={this._reset}>{_('resetBootOption')}</ActionButton>
</span>
</fieldset>
</form>
}
}

View File

@@ -31,3 +31,8 @@ export const SR = {
...common,
homeFilterTags: 'tags:'
}
export const vmGroup = {
...common,
homeFilterTags: 'tags:'
}

View File

@@ -19,9 +19,9 @@ import {
createSelector
} from './selectors'
import {
getHostMissingPatches,
installAllHostPatches,
installAllPatchesOnPool
installAllPatchesOnPool,
subscribeHostMissingPatches
} from './xo'
// ===================================================================
@@ -89,11 +89,25 @@ class HostsPatchesTable extends Component {
)
)
_refreshMissingPatches = () => (
Promise.all(
map(this.props.hosts, this._refreshHostMissingPatches)
_subscribeMissingPatches = (hosts = this.props.hosts) => {
const unsubs = map(hosts, host =>
subscribeHostMissingPatches(
host,
patches => this.setState({
missingPatches: {
...this.state.missingPatches,
[host.id]: patches.length
}
})
)
)
)
if (this.unsubscribeMissingPatches !== undefined) {
this.unsubscribeMissingPatches()
}
this.unsubscribeMissingPatches = () => forEach(unsubs, unsub => unsub())
}
_installAllMissingPatches = () => {
const pools = {}
@@ -104,100 +118,69 @@ class HostsPatchesTable extends Component {
return Promise.all(map(
keys(pools),
installAllPatchesOnPool
)).then(this._refreshMissingPatches)
}
_refreshHostMissingPatches = host => (
getHostMissingPatches(host).then(patches => {
this.setState({
missingPatches: {
...this.state.missingPatches,
[host.id]: patches.length
}
})
})
)
_installAllHostPatches = host => (
installAllHostPatches(host).then(() =>
this._refreshHostMissingPatches(host)
)
)
componentWillMount () {
this._refreshMissingPatches()
))
}
componentDidMount () {
// Force one Portal refresh.
// Because Portal cannot see the container reference at first rendering.
this.forceUpdate()
this._subscribeMissingPatches()
}
componentWillReceiveProps (nextProps) {
forEach(nextProps.hosts, host => {
const { id } = host
if (nextProps.hosts !== this.props.hosts) {
this._subscribeMissingPatches(nextProps.hosts)
}
}
if (this.state.missingPatches[id] !== undefined) {
return
}
this.setState({
missingPatches: {
...this.state.missingPatches,
[id]: 0
}
})
this._refreshHostMissingPatches(host)
})
componentWillUnmount () {
this.unsubscribeMissingPatches()
}
render () {
const {
buttonsGroupContainer,
container,
displayPools,
pools,
useTabButton
} = this.props
const hosts = this._getHosts()
const noPatches = isEmpty(hosts)
const { props } = this
const Container = props.container || 'div'
const Container = container || 'div'
const Button = this.props.useTabButton
const Button = useTabButton
? TabButton
: ActionButton_
const Buttons = (
<Container>
<Button
handler={this._refreshMissingPatches}
icon='refresh'
labelId='checkForUpdates'
/>
<Button
btnStyle='primary'
disabled={noPatches}
handler={this._installAllMissingPatches}
icon='host-patch-update'
labelId='installPoolPatches'
/>
</Container>
)
return (
<div>
{!noPatches
? (
<SortedTable
collection={hosts}
columns={props.displayPools ? POOLS_MISSING_PATCHES_COLUMNS : MISSING_PATCHES_COLUMNS}
columns={displayPools ? POOLS_MISSING_PATCHES_COLUMNS : MISSING_PATCHES_COLUMNS}
userData={{
installAllHostPatches: this._installAllHostPatches,
installAllHostPatches,
missingPatches: this.state.missingPatches,
pools: props.pools
pools
}}
/>
) : <p>{_('patchNothing')}</p>
}
<Portal container={() => props.buttonsGroupContainer()}>
{Buttons}
<Portal container={() => buttonsGroupContainer()}>
<Container>
<Button
btnStyle='primary'
disabled={noPatches}
handler={this._installAllMissingPatches}
icon='host-patch-update'
labelId='installPoolPatches'
/>
</Container>
</Portal>
</div>
)

View File

@@ -627,7 +627,7 @@ export default {
editBackupReportTitle: undefined,
// Original text: 'Enable immediately after creation'
editBackupReportEnable: undefined,
editBackupScheduleEnabled: undefined,
// Original text: 'Depth'
editBackupDepthTitle: undefined,

View File

@@ -630,7 +630,7 @@ export default {
editBackupReportTitle: 'Rapport',
// Original text: "Enable immediately after creation"
editBackupReportEnable: 'Activer aussitôt après la création',
editBackupScheduleEnabled: 'Executer en fonction de la planification',
// Original text: "Depth"
editBackupDepthTitle: 'Profondeur',

View File

@@ -627,7 +627,7 @@ export default {
editBackupReportTitle: undefined,
// Original text: 'Enable immediately after creation'
editBackupReportEnable: undefined,
editBackupScheduleEnabled: undefined,
// Original text: 'Depth'
editBackupDepthTitle: undefined,

View File

@@ -726,7 +726,7 @@ export default {
editBackupReportTitle: 'Riport',
// Original text: "Enable immediately after creation"
editBackupReportEnable: 'Azonnal a létrehozás után',
editBackupScheduleEnabled: 'Azonnal a létrehozás után',
// Original text: "Depth"
editBackupDepthTitle: 'Mélység',

View File

@@ -630,7 +630,7 @@ export default {
editBackupReportTitle: 'Raport',
// Original text: "Enable immediately after creation"
editBackupReportEnable: 'Uruchom natychamiast po utworzeniu',
editBackupScheduleEnabled: 'Uruchom natychamiast po utworzeniu',
// Original text: "Depth"
editBackupDepthTitle: 'Depth',

View File

@@ -627,7 +627,7 @@ export default {
editBackupReportTitle: undefined,
// Original text: 'Enable immediately after creation'
editBackupReportEnable: undefined,
editBackupScheduleEnabled: undefined,
// Original text: 'Depth'
editBackupDepthTitle: undefined,

View File

@@ -18,15 +18,16 @@ var messages = {
// ----- Modals -----
alertOk: 'OK',
confirmOk: 'OK',
confirmCancel: 'Cancel',
genericCancel: 'Cancel',
// ----- Filters -----
onError: 'On error',
successful: 'Successful',
filterNoSnapshots: 'Full disks only',
filterOnlyBaseCopy: 'Base copy only',
filterOnlyRegularDisks: 'Regular disks only',
filterOnlySnapshots: 'Snapshots only',
filterOnlyManaged: 'Managed disks',
filterOnlyOrphaned: 'Orphaned disks',
filterOnlyRegular: 'Normal disks',
filterOnlySnapshots: 'Snapshot disks',
filterOnlyUnmanaged: 'Unmanaged disks',
// ----- Copiable component -----
copyToClipboard: 'Copy to clipboard',
@@ -37,6 +38,7 @@ var messages = {
// ----- Titles -----
homePage: 'Home',
homeVmPage: 'VMs',
homeVmGroupPage: 'VM-Groups',
homeHostPage: 'Hosts',
homePoolPage: 'Pools',
homeTemplatePage: 'Templates',
@@ -65,6 +67,7 @@ var messages = {
taskMenu: 'Tasks',
taskPage: 'Tasks',
newVmPage: 'VM',
newVmGroupPage: 'VM-Group',
newSrPage: 'Storage',
newServerPage: 'Server',
newImport: 'Import',
@@ -120,6 +123,7 @@ var messages = {
homeTypePool: 'Pool',
homeTypeHost: 'Host',
homeTypeVm: 'VM',
homeTypeVmGroup: 'VM group',
homeTypeSr: 'SR',
homeTypeVmTemplate: 'Template',
homeSort: 'Sort',
@@ -275,9 +279,10 @@ var messages = {
editBackupNot: 'Reverse',
editBackupTagTitle: 'Tag',
editBackupReportTitle: 'Report',
editBackupReportEnable: 'Enable immediately after creation',
editBackupScheduleEnabled: 'Automatically run as scheduled',
editBackupDepthTitle: 'Depth',
editBackupRemoteTitle: 'Remote',
deleteOldBackupsFirst: 'Delete the old backups first',
// ------ New Remote -----
remoteList: 'Remote stores for backup',
@@ -491,6 +496,10 @@ var messages = {
addSrLabel: 'Add SR',
addVmLabel: 'Add VM',
addHostLabel: 'Add Host',
hostNeedsPatchUpdate: 'This host needs to install {patches, number} patch{patches, plural, one {} other {es}} before it can be added to the pool. This operation may be long.',
hostNeedsPatchUpdateNoInstall: 'This host cannot be added to the pool because it\'s missing some patches.',
addHostErrorTitle: 'Adding host failed',
addHostNotHomogeneousErrorMessage: 'Host patches could not be homogenized.',
disconnectServer: 'Disconnect',
// ----- Host actions ------
@@ -597,10 +606,13 @@ var messages = {
hostAppliedPatches: 'Applied patches',
hostMissingPatches: 'Missing patches',
hostUpToDate: 'Host up-to-date!',
installPatchWarningTitle: 'Non-recommended patch install',
installPatchWarningContent: 'This will install a patch only on this host. This is NOT the recommended way: please go into the Pool patch view and follow instructions there. If you are sure about this, you can continue anyway',
installPatchWarningReject: 'Go to pool',
installPatchWarningResolve: 'Install',
// ----- Pool patch tabs -----
refreshPatches: 'Refresh patches',
installPoolPatches: 'Install pool patches',
checkForUpdates: 'Check for updates',
// ----- Pool storage tabs -----
defaultSr: 'Default SR',
setAsDefaultSr: 'Set as default SR',
@@ -615,6 +627,7 @@ var messages = {
advancedTabName: 'Advanced',
networkTabName: 'Network',
disksTabName: 'Disk{disks, plural, one {} other {s}}',
managementTabName: 'Management',
powerStateHalted: 'halted',
powerStateRunning: 'running',
@@ -676,6 +689,8 @@ var messages = {
vdiBootOrder: 'Boot order',
vdiNameLabel: 'Name',
vdiNameDescription: 'Description',
vdiPool: 'Pool',
vdiDisconnect: 'Disconnect',
vdiTags: 'Tags',
vdiSize: 'Size',
vdiSr: 'SR',
@@ -687,6 +702,7 @@ var messages = {
vdiMigrateNoSrMessage: 'A target SR is required to migrate a VDI',
vdiForget: 'Forget',
vdiRemove: 'Remove VDI',
noControlDomainVdis: 'No VDIs attached to Control Domain',
vbdBootableStatus: 'Boot flag',
vbdStatus: 'Status',
vbdStatusConnected: 'Connected',
@@ -853,6 +869,7 @@ var messages = {
orphanedVms: 'Orphaned VMs snapshot',
noOrphanedObject: 'No orphans',
removeAllOrphanedObject: 'Remove all orphaned snapshot VDIs',
vdisOnControlDomain: 'VDIs attached to Control Domain',
vmNameLabel: 'Name',
vmNameDescription: 'Description',
vmContainer: 'Resident on',
@@ -921,6 +938,32 @@ var messages = {
newVmHideAdvanced: 'Hide advanced settings',
newVmShare: 'Share this VM',
// ----- VM-Group-----
newVmGroupTitle: 'Create a new Vm-Group on ',
newVmGroupNameLabel: 'Label',
newVmGroupDescriptionLabel: 'Description',
newVmGroupReset: 'Reset',
newVmGroupCreate: 'Create',
newVmGroupInfoPanel: 'Infos',
// ----- VM-Group item -----
powerStateVmGroupRunning: 'All vms are running',
powerStateVmGroupHalted: 'All vms are halted',
powerStateVmGroupBusy: 'Contains vms halted and vms running',
// ----- VM-Group management Tab -----
'attachVmButton': 'new VM',
'vmsBootOrder': 'Boot order',
'vmGroupLabel': 'Label',
'vmGroupDescription': 'Description',
'vmGroupActions': 'Actions',
// ----- VM-Group general Tab -----
'vmGroupCurrentStatus': 'Current status',
// ----- VM-Group stats Tab -----
'vmGroupAllVm': 'All VMs',
// ----- Self -----
resourceSets: 'Resource sets',
noResourceSets: 'No resource sets.',
@@ -1034,7 +1077,14 @@ var messages = {
restartHostsModalTitle: 'Restart Host{nHosts, plural, one {} other {s}}',
restartHostsModalMessage: 'Are you sure you want to restart {nHosts, number} Host{nHosts, plural, one {} other {s}}?',
startVmsModalTitle: 'Start VM{vms, plural, one {} other {s}}',
cloneAndStartVM: 'Start a copy',
forceStartVm: 'Force start',
forceStartVmModalTitle: 'Forbidden operation',
blockedStartVmModalMessage: 'Start operation for this vm is blocked.',
blockedStartVmsModalMessage: 'Forbidden operation start for {nVms, number} vm{nVms, plural, one {} other {s}}.',
startVmsModalMessage: 'Are you sure you want to start {vms, number} VM{vms, plural, one {} other {s}}?',
failedVmsErrorMessage: '{nVms, number} vm{nVms, plural, one {} other {s}} are failed. Please see your logs to get more information',
failedVmsErrorTitle: 'Start failed',
stopHostsModalTitle: 'Stop Host{nHosts, plural, one {} other {s}}',
stopHostsModalMessage: 'Are you sure you want to stop {nHosts, number} Host{nHosts, plural, one {} other {s}}?',
stopVmsModalTitle: 'Stop VM{vms, plural, one {} other {s}}',
@@ -1066,6 +1116,8 @@ var messages = {
migrateVmNetwork: 'Network',
migrateVmNoTargetHost: 'No target host',
migrateVmNoTargetHostMessage: 'A target host is required to migrate a VM',
deleteVmGroupModalTitle: 'Delete VM-Group',
deleteVmGroupModalMessage: 'Are you sure you want to delete this VMGroup ?',
deleteVdiModalTitle: 'Delete VDI',
deleteVdiModalMessage: 'Are you sure you want to delete this disk? ALL DATA ON THIS DISK WILL BE LOST',
revertVmModalTitle: 'Revert your VM',

View File

@@ -1,8 +1,9 @@
import React from 'react'
import uncontrollableInput from 'uncontrollable-input'
import Combobox from '../combobox'
import Component from '../base-component'
import getEventValue from '../get-event-value'
import propTypes from '../prop-types-decorator'
import { PrimitiveInputWrapper } from './helpers'
@@ -14,23 +15,30 @@ import { PrimitiveInputWrapper } from './helpers'
})
@uncontrollableInput()
export default class StringInput extends Component {
// the value of this input is undefined not '' when empty to make
// it homogenous with when the user has never touched this input
_onChange = event => {
const value = getEventValue(event)
this.props.onChange(value !== '' ? value : undefined)
}
render () {
const { required, schema } = this.props
const {
disabled,
onChange,
password,
placeholder = schema.default,
value,
...props
} = this.props
delete props.onChange
return (
<PrimitiveInputWrapper {...props}>
<Combobox
value={value || ''}
value={value !== undefined ? value : ''}
disabled={disabled}
onChange={onChange}
onChange={this._onChange}
options={schema.defaults}
placeholder={placeholder || schema.default}
required={required}

View File

@@ -1,5 +1,6 @@
import isArray from 'lodash/isArray'
import isString from 'lodash/isString'
import map from 'lodash/map'
import React, { Component, cloneElement } from 'react'
import { Modal as ReactModal } from 'react-bootstrap-4/lib'
@@ -7,6 +8,7 @@ import _ from './intl'
import Button from './button'
import Icon from './icon'
import propTypes from './prop-types-decorator'
import Tooltip from './tooltip'
import {
disable as disableShortcuts,
enable as enableShortcuts
@@ -23,54 +25,33 @@ const modal = (content, onClose) => {
instance.setState({ content, onClose, showModal: true }, disableShortcuts)
}
export const alert = (title, body) => {
return new Promise(resolve => {
const { Body, Footer, Header, Title } = ReactModal
modal(
<div>
<Header closeButton>
<Title>{title}</Title>
</Header>
<Body>{body}</Body>
<Footer>
<Button bsStyle='primary' onClick={() => {
resolve()
instance.close()
}}>
{_('alertOk')}
</Button>
</Footer>
</div>,
resolve
)
})
}
const _addRef = (component, ref) => {
if (isString(component) || isArray(component)) {
return component
@propTypes({
buttons: propTypes.arrayOf(propTypes.shape({
btnStyle: propTypes.string,
icon: propTypes.string,
label: propTypes.string.isRequired,
tooltip: propTypes.node,
value: propTypes.any
})).isRequired,
children: propTypes.node.isRequired,
icon: propTypes.string,
title: propTypes.node.isRequired
})
class GenericModal extends Component {
_getBodyValue = () => {
const { body } = this.refs
if (body !== undefined) {
return body.getWrappedInstance === undefined
? body.value
: body.getWrappedInstance().value
}
}
try {
return cloneElement(component, { ref })
} catch (_) {} // Stateless component.
return component
}
@propTypes({
children: propTypes.node.isRequired,
title: propTypes.node.isRequired,
icon: propTypes.string
})
class Confirm extends Component {
_resolve = () => {
const { body } = this.refs
this.props.resolve(body && (body.getWrappedInstance
? body.getWrappedInstance().value
: body.value
))
_resolve = (value = this._getBodyValue()) => {
this.props.resolve(value)
instance.close()
}
_reject = () => {
this.props.reject()
instance.close()
@@ -78,7 +59,12 @@ class Confirm extends Component {
render () {
const { Body, Footer, Header, Title } = ReactModal
const { title, icon } = this.props
const {
buttons,
icon,
title
} = this.props
const body = _addRef(this.props.children, 'body')
@@ -95,39 +81,99 @@ class Confirm extends Component {
{body}
</Body>
<Footer>
<Button
btnStyle='primary'
onClick={this._resolve}
style={this._style}
>
{_('confirmOk')}
</Button>
{' '}
<Button
onClick={this._reject}
>
{_('confirmCancel')}
</Button>
{map(buttons, ({
label,
tooltip,
value,
icon,
...props
}) => {
const button = <Button
onClick={() => this._resolve(value)}
key={value}
{...props}
>
{icon !== undefined && <Icon icon={icon} fixedWidth />}
{label}
</Button>
return <span>
{tooltip !== undefined
? <Tooltip content={tooltip}>{button}</Tooltip>
: button
}
{' '}
</span>
})}
{this.props.reject !== undefined &&
<Button onClick={this._reject} >
{_('genericCancel')}
</Button>
}
</Footer>
</div>
}
}
const ALERT_BUTTONS = [ { label: _('alertOk'), value: 'ok' } ]
export const alert = (title, body) => (
new Promise(resolve => {
modal(
<GenericModal
buttons={ALERT_BUTTONS}
resolve={resolve}
title={title}
>
{body}
</GenericModal>,
resolve
)
})
)
const _addRef = (component, ref) => {
if (isString(component) || isArray(component)) {
return component
}
try {
return cloneElement(component, { ref })
} catch (_) {} // Stateless component.
return component
}
const CONFIRM_BUTTONS = [ { btnStyle: 'primary', label: _('confirmOk') } ]
export const confirm = ({
body,
title,
icon = 'alarm'
icon = 'alarm',
title
}) => (
chooseAction({
body,
buttons: CONFIRM_BUTTONS,
icon,
title
})
)
export const chooseAction = ({
body,
buttons,
icon,
title
}) => {
return new Promise((resolve, reject) => {
modal(
<Confirm
title={title}
resolve={resolve}
reject={reject}
<GenericModal
buttons={buttons}
icon={icon}
reject={reject}
resolve={resolve}
title={title}
>
{body}
</Confirm>,
</GenericModal>,
reject
)
})

View File

@@ -222,7 +222,9 @@ const GenericXoItem = connectStore(() => {
})
})(({ xoItem, ...props }) => xoItem
? renderXoItem(xoItem, props)
: <span className='text-muted'>{_('errorNoSuchItem')}</span>
: renderXoUnknownItem()
)
export const renderXoItemFromId = (id, props) => <GenericXoItem {...props} id={id} />
export const renderXoUnknownItem = () => <span className='text-muted'>{_('errorNoSuchItem')}</span>

View File

@@ -1,18 +1,21 @@
import add from 'lodash/add'
import checkPermissions from 'xo-acl-resolver'
import filter from 'lodash/filter'
import find from 'lodash/find'
import forEach from 'lodash/forEach'
import groupBy from 'lodash/groupBy'
import isArray from 'lodash/isArray'
import isArrayLike from 'lodash/isArrayLike'
import isFunction from 'lodash/isFunction'
import keys from 'lodash/keys'
import map from 'lodash/map'
import orderBy from 'lodash/orderBy'
import pickBy from 'lodash/pickBy'
import size from 'lodash/size'
import slice from 'lodash/slice'
import { createSelector as create } from 'reselect'
import {
filter,
find,
forEach,
groupBy,
isArray,
isArrayLike,
isFunction,
keys,
map,
orderBy,
pickBy,
size,
slice
} from 'lodash'
import invoke from './invoke'
import shallowEqual from './shallow-equal'
@@ -126,9 +129,9 @@ export const createCounter = (collection, predicate) =>
//
// Should only be used with a reasonable number of properties.
export const createPicker = (object, props) =>
_createCollectionWrapper(
_create2(
object, props,
_create2(
object, props,
_createCollectionWrapper(
(object, props) => {
const values = {}
forEach(props, prop => {
@@ -191,6 +194,13 @@ export const createSort = (
order = 'asc'
) => _create2(collection, getter, order, orderBy)
export const createSumBy = (itemsSelector, iterateeSelector) =>
_create2(
itemsSelector,
iterateeSelector,
(items, iteratee) => map(items, iteratee).reduce(add, 0)
)
export const createTop = (collection, iteratee, n) =>
_create2(
collection,
@@ -481,9 +491,10 @@ export const createGetObjectMessages = objectSelector =>
export const getObject = createGetObject((_, id) => id)
export const createDoesHostNeedRestart = hostSelector => {
// Returns the first patch of the host which requires it to be
// restarted.
const restartPoolPatch = createGetObjectsOfType('pool_patch').pick(
// XS < 7.1
const patchRequiresReboot = createGetObjectsOfType('pool_patch').pick(
// Returns the first patch of the host which requires it to be
// restarted.
create(
createGetObjectsOfType('host_patch').pick(
(state, props) => {
@@ -503,7 +514,11 @@ export const createDoesHostNeedRestart = hostSelector => {
action === 'restartHost' || action === 'restartXapi'
) ])
return (state, props) => restartPoolPatch(state, props) !== undefined
return create(
hostSelector,
(...args) => args,
(host, args) => host.rebootRequired || !!patchRequiresReboot(...args)
)
}
export const createGetHostMetrics = hostSelector =>
@@ -527,3 +542,17 @@ export const createGetHostMetrics = hostSelector =>
}
)
)
export const createGetVmDisks = vmSelector =>
createGetObjectsOfType('VDI').pick(
create(
createGetObjectsOfType('VBD').pick(
(state, props) => vmSelector(state, props).$VBDs
),
_createCollectionWrapper(vbds => map(vbds, vbd =>
vbd.is_cd_drive
? undefined
: vbd.VDI
))
)
)

View File

@@ -33,6 +33,7 @@ import styles from './index.css'
// ===================================================================
@propTypes({
defaultFilter: propTypes.string,
filters: propTypes.object,
nFilteredItems: propTypes.number.isRequired,
nItems: propTypes.number.isRequired,
@@ -75,10 +76,10 @@ class TableFilter extends Component {
</Dropdown>
</div>}
<input
type='text'
ref='filter'
onChange={this._onChange}
className='form-control'
defaultValue={props.defaultFilter}
onChange={this._onChange}
ref='filter'
/>
<div className='input-group-btn'>
<Button onClick={this._cleanFilter}>
@@ -137,14 +138,16 @@ const DEFAULT_ITEMS_PER_PAGE = 10
@propTypes({
defaultColumn: propTypes.number,
defaultFilter: propTypes.string,
collection: propTypes.oneOfType([
propTypes.array,
propTypes.object
]).isRequired,
columns: propTypes.arrayOf(propTypes.shape({
component: propTypes.func,
default: propTypes.bool,
name: propTypes.node,
itemRenderer: propTypes.func.isRequired,
itemRenderer: propTypes.func,
sortCriteria: propTypes.oneOfType([
propTypes.func,
propTypes.string
@@ -176,7 +179,10 @@ export default class SortedTable extends Component {
}
}
const { defaultFilter } = props
this.state = {
filter: defaultFilter !== undefined ? props.filters[defaultFilter] : undefined,
selectedColumn,
itemsPerPage: props.itemsPerPage || DEFAULT_ITEMS_PER_PAGE
}
@@ -192,7 +198,7 @@ export default class SortedTable extends Component {
createFilter(
() => this.props.collection,
createSelector(
() => this.state.filter || '',
() => this.state.filter,
createMatcher
)
),
@@ -291,6 +297,7 @@ export default class SortedTable extends Component {
const filterInstance = (
<TableFilter
defaultFilter={state.filter}
filters={filters}
nFilteredItems={nFilteredItems}
nItems={this._getTotalNumberOfItems()}
@@ -318,11 +325,21 @@ export default class SortedTable extends Component {
</thead>
<tbody>
{map(this._getVisibleItems(), (item, i) => {
const columns = map(props.columns, (column, key) => (
<td key={key} className={column.textAlign && `text-xs-${column.textAlign}`}>
{column.itemRenderer(item, userData)}
const columns = map(props.columns, ({
component: Component,
itemRenderer,
textAlign
}, key) =>
<td
className={textAlign && `text-xs-${textAlign}`}
key={key}
>
{Component !== undefined
? <Component item={item} userData={userData} />
: itemRenderer(item, userData)
}
</td>
))
)
const { id = i } = item

View File

@@ -5,25 +5,28 @@ import ActionButton from './action-button'
import propTypes from './prop-types-decorator'
const Button = styled(ActionButton)`
background-color: ${p => p.theme[`${p.state ? 'enabled' : 'disabled'}StateBg`]}
border: 2px solid ${p => p.theme[`${p.state ? 'enabled' : 'disabled'}StateColor`]}
color: ${p => p.theme[`${p.state ? 'enabled' : 'disabled'}StateColor`]}
background-color: ${p => p.theme[`${p.state ? 'enabled' : 'disabled'}StateBg`]};
border: 2px solid ${p => p.theme[`${p.state ? 'enabled' : 'disabled'}StateColor`]};
color: ${p => p.theme[`${p.state ? 'enabled' : 'disabled'}StateColor`]};
`
const StateButton = ({
disabledHandler,
disabledHandlerParam,
disabledLabel,
disabledTooltip,
enabledLabel,
enabledTooltip,
enabledHandler,
enabledHandlerParam,
state,
...props
}) =>
<Button
handler={state ? enabledHandler : disabledHandler}
handlerParam={state ? enabledHandlerParam : disabledHandlerParam}
tooltip={state ? enabledTooltip : disabledTooltip}
{...props}
icon={state ? 'running' : 'halted'}

View File

@@ -190,7 +190,7 @@ export { default as Debug } from './debug'
// -------------------------------------------------------------------
// Returns the first defined (non-null, non-undefined) value.
// Returns the first defined (non-undefined) value.
export const firstDefined = function () {
const n = arguments.length
for (let i = 0; i < n; ++i) {
@@ -359,20 +359,6 @@ export const throwFn = error => () => {
)
}
// -------------------------------------------------------------------
export function tap (cb) {
return this.then(value =>
Promise.resolve(cb(value)).then(() => value)
)
}
export function rethrow (cb) {
return this.catch(error =>
Promise.resolve(cb(error)).then(() => { throw error })
)
}
// ===================================================================
export const resolveResourceSet = resourceSet => {

View File

@@ -8,6 +8,7 @@ import {
find,
flatten,
floor,
forEach,
map,
max,
size,
@@ -16,7 +17,7 @@ import {
} from 'lodash'
import propTypes from '../prop-types-decorator'
import { computeArraysSum } from '../xo-stats'
import { computeArraysSum, computeArraysAvg } from '../xo-stats'
import { formatSize } from '../utils'
import styles from './index.css'
@@ -214,6 +215,54 @@ export const PoolCpuLineChart = injectIntl(propTypes({
)
}))
export const VmGroupCpuLineChart = injectIntl(propTypes({
addSumSeries: propTypes.bool,
data: propTypes.object.isRequired,
options: propTypes.object
})(({ addSumSeries, data, options = {}, intl }) => {
const firstVmData = data[0]
const length = getStatsLength(firstVmData.stats.cpus)
if (!length) {
return templateError
}
const series = map(data, ({ vm, stats }) => ({
name: vm,
data: computeArraysSum(stats.cpus)
}))
if (addSumSeries) {
series.push({
name: intl.formatMessage(messages.vmGroupAllVm),
data: computeArraysSum(map(series, 'data')),
className: styles.dashedLine
})
}
const nbCpusByVm = map(data, ({ stats }) => stats.cpus.length)
return (
<ChartistGraph
type='Line'
data={{
series
}}
options={{
...makeOptions({
intl,
nValues: length,
endTimestamp: firstVmData.endTimestamp,
interval: firstVmData.interval,
valueTransform: value => `${floor(value)}%`
}),
high: 100 * (addSumSeries ? sum(nbCpusByVm) : max(nbCpusByVm)),
...options
}}
/>
)
}))
export const MemoryLineChart = injectIntl(propTypes({
data: propTypes.object.isRequired,
options: propTypes.object
@@ -302,6 +351,57 @@ export const PoolMemoryLineChart = injectIntl(propTypes({
)
}))
export const VmGroupMemoryLineChart = injectIntl(propTypes({
addSumSeries: propTypes.bool,
data: propTypes.object.isRequired,
options: propTypes.object
})(({ addSumSeries, data, options = {}, intl }) => {
const firstVmData = data[0]
const {
memory,
memoryUsed
} = firstVmData.stats
if (!memory || !memoryUsed) {
return templateError
}
const series = map(data, ({ vm, stats }) => ({
name: vm,
data: stats.memoryUsed
}))
if (addSumSeries) {
series.push({
name: intl.formatMessage(messages.vmGroupAllVm),
data: computeArraysSum(map(data, 'stats.memoryUsed')),
className: styles.dashedLine
})
}
const currentMemoryByHost = map(data, ({ stats }) => stats.memory[stats.memory.length - 1])
return (
<ChartistGraph
type='Line'
data={{
series
}}
options={{
...makeOptions({
intl,
nValues: firstVmData.stats.memoryUsed.length,
endTimestamp: firstVmData.endTimestamp,
interval: firstVmData.interval,
valueTransform: formatSize
}),
high: addSumSeries ? sum(currentMemoryByHost) : max(currentMemoryByHost),
...options
}}
/>
)
}))
export const XvdLineChart = injectIntl(propTypes({
addSumSeries: propTypes.bool,
data: propTypes.object.isRequired,
@@ -334,6 +434,60 @@ export const XvdLineChart = injectIntl(propTypes({
)
}))
export const VmGroupXvdLineChart = injectIntl(propTypes({
addSumSeries: propTypes.bool,
data: propTypes.object.isRequired,
options: propTypes.object
})(({ addSumSeries, data, options = {}, intl }) => {
const firstVmData = data[0]
const {
memory,
memoryUsed
} = firstVmData.stats
if (!memory || !memoryUsed) {
return templateError
}
const series = flatten(map(data, ({ stats, vm }) =>
map(stats.xvds, (xvd, key) => {
return {
name: `${vm} (${key})`,
data: computeArraysAvg(stats.xvds[key])
}
})
))
const datas = []
forEach(series, ({ data }) => datas.push(data))
if (addSumSeries) {
series.push({
name: intl.formatMessage(messages.vmGroupAllVm),
data: computeArraysSum(datas),
className: styles.dashedLine
})
}
return (
<ChartistGraph
type='Line'
data={{
series
}}
options={{
...makeOptions({
intl,
nValues: firstVmData.stats.xvds.r.length,
endTimestamp: data.endTimestamp,
interval: data.interval,
valueTransform: formatSize
}),
...options
}}
/>
)
}))
export const VifLineChart = injectIntl(propTypes({
addSumSeries: propTypes.bool,
data: propTypes.object.isRequired,
@@ -443,6 +597,48 @@ export const PoolPifLineChart = injectIntl(propTypes({
)
}))
export const VmGroupVifLineChart = injectIntl(propTypes({
addSumSeries: propTypes.bool,
data: propTypes.object.isRequired,
options: propTypes.object
})(({ addSumSeries, data, options = {}, intl }) => {
const firstVmData = data[0]
const length = firstVmData.stats && getStatsLength(firstVmData.stats.vifs.rx)
if (!length) {
return templateError
}
const series = addSumSeries
? map(ios, io => ({
name: `${intl.formatMessage(messages.vmGroupAllVm)} (${io})`,
data: computeArraysSum(map(data, ({ stats }) => computeArraysSum(stats.vifs[io])))
}))
: flatten(map(data, ({ stats, vm }) =>
map(ios, io => ({
name: `${vm} (${io})`,
data: computeArraysSum(stats.vifs[io])
}))
))
return (
<ChartistGraph
type='Line'
data={{
series
}}
options={{
...makeOptions({
intl,
nValues: length,
endTimestamp: firstVmData.endTimestamp,
interval: firstVmData.interval,
valueTransform: formatSize
}),
...options
}}
/>
)
}))
export const LoadLineChart = injectIntl(propTypes({
data: propTypes.object.isRequired,
options: propTypes.object

View File

@@ -1,12 +1,16 @@
import _ from 'intl'
import BaseComponent from 'base-component'
import Icon from 'icon'
import React from 'react'
import SingleLineRow from 'single-line-row'
import { Col } from 'grid'
import { connectStore } from 'utils'
import { createCollectionWrapper, createGetObjectsOfType, createSelector } from 'selectors'
import { forEach } from 'lodash'
import { createCollectionWrapper, createGetObjectsOfType, createSelector, createGetObject } from 'selectors'
import { SelectHost } from 'select-objects'
import {
differenceBy,
forEach
} from 'lodash'
@connectStore(() => ({
singleHosts: createSelector(
@@ -30,10 +34,20 @@ import { SelectHost } from 'select-objects'
})
return singleHosts
})
),
poolMasterPatches: createSelector(
createGetObject(
(_, props) => props.pool.master
),
({ patches }) => patches
)
}), { withRef: true })
export default class AddHostModal extends BaseComponent {
get value () {
if (process.env.XOA_PLAN < 2 && this.state.nMissingPatches) {
return {}
}
return this.state
}
@@ -42,18 +56,40 @@ export default class AddHostModal extends BaseComponent {
singleHosts => host => singleHosts[host.id]
)
_onChangeHost = host => {
this.setState({
host,
nMissingPatches: host
? differenceBy(this.props.poolMasterPatches, host.patches, 'name').length
: undefined
})
}
render () {
const { nMissingPatches } = this.state
return <div>
<SingleLineRow>
<Col size={6}>{_('addHostSelectHost')}</Col>
<Col size={6}>
<SelectHost
onChange={this.linkState('host')}
onChange={this._onChangeHost}
predicate={this._getHostPredicate()}
value={this.state.host}
/>
</Col>
</SingleLineRow>
<br />
{nMissingPatches > 0 && <SingleLineRow>
<Col>
<span className='text-danger'>
<Icon icon='error' /> {process.env.XOA_PLAN > 1
? _('hostNeedsPatchUpdate', { patches: nMissingPatches })
: _('hostNeedsPatchUpdateNoInstall')
}
</span>
</Col>
</SingleLineRow>}
</div>
}
}

View File

@@ -15,16 +15,18 @@ import sortBy from 'lodash/sortBy'
import throttle from 'lodash/throttle'
import Xo from 'xo-lib'
import { createBackoff } from 'jsonrpc-websocket-client'
import { lastly, reflect } from 'promise-toolbox'
import { noHostsAvailable } from 'xo-common/api-errors'
import { lastly, reflect, tap } from 'promise-toolbox'
import { forbiddenOperation, noHostsAvailable } from 'xo-common/api-errors'
import { resolve } from 'url'
import _ from '../intl'
import invoke from '../invoke'
import logError from '../log-error'
import { alert, confirm } from '../modal'
import store from 'store'
import { getObject } from 'selectors'
import { alert, chooseAction, confirm } from '../modal'
import { error, info, success } from '../notification'
import { noop, rethrow, tap, resolveId, resolveIds } from '../utils'
import { noop, resolveId, resolveIds } from '../utils'
import {
connected,
disconnected,
@@ -90,7 +92,7 @@ const _call = (method, params) => {
let promise = _signIn.then(() => xo.call(method, params))
if (process.env.NODE_ENV !== 'production') {
promise = promise::rethrow(error => {
promise = promise::tap(null, error => {
console.error('XO error', {
method,
params,
@@ -273,6 +275,8 @@ export const subscribeIpPools = createSubscription(() => _call('ipPool.getAll'))
export const subscribeResourceCatalog = createSubscription(() => _call('cloud.getResourceCatalog'))
export const subscribeVmGroups = createSubscription(() => _call('vmGroup.get'))
const xosanSubscriptions = {}
export const subscribeIsInstallingXosan = (pool, cb) => {
const poolId = resolveId(pool)
@@ -284,6 +288,28 @@ export const subscribeIsInstallingXosan = (pool, cb) => {
return xosanSubscriptions[poolId](cb)
}
const missingPatchesByHost = {}
export const subscribeHostMissingPatches = (host, cb) => {
const hostId = resolveId(host)
if (missingPatchesByHost[hostId] == null) {
missingPatchesByHost[hostId] = createSubscription(() => _call('host.listMissingPatches', { host: hostId }))
}
return missingPatchesByHost[hostId](cb)
}
subscribeHostMissingPatches.forceRefresh = host => {
if (host === undefined) {
forEach(missingPatchesByHost, subscription => subscription.forceRefresh())
return
}
const subscription = missingPatchesByHost[resolveId(host)]
if (subscription !== undefined) {
subscription.forceRefresh()
}
}
// System ============================================================
export const apiMethods = _call('system.getMethodsInfo')
@@ -312,8 +338,9 @@ export const exportConfig = () => (
export const addServer = (host, username, password, label) => (
_call('server.add', { host, label, password, username })::tap(
subscribeServers.forceRefresh
)::rethrow(() => error(_('serverError'), _('serverAddFailed')))
subscribeServers.forceRefresh,
() => error(_('serverError'), _('serverAddFailed'))
)
)
export const editServer = (server, props) => (
@@ -350,6 +377,7 @@ import AddHostModalBody from './add-host-modal' // eslint-disable-line import/fi
export const addHostToPool = (pool, host) => {
if (host) {
return confirm({
icon: 'add',
title: _('addHostModalTitle'),
body: _('addHostModalMessage', { pool: pool.name_label, host: host.name_label })
}).then(() =>
@@ -358,6 +386,7 @@ export const addHostToPool = (pool, host) => {
}
return confirm({
icon: 'add',
title: _('addHostModalTitle'),
body: <AddHostModalBody pool={pool} />
}).then(
@@ -366,7 +395,13 @@ export const addHostToPool = (pool, host) => {
error(_('addHostNoHost'), _('addHostNoHostMessage'))
return
}
_call('pool.mergeInto', { source: params.host.$pool, target: pool.id, force: true })
return _call('pool.mergeInto', { source: params.host.$pool, target: pool.id, force: true }).catch(error => {
if (error.code !== 'HOSTS_NOT_HOMOGENEOUS') {
throw error
}
error(_('addHostErrorTitle'), _('addHostNotHomogeneousErrorMessage'))
})
},
noop
)
@@ -498,15 +533,21 @@ export const emergencyShutdownHosts = hosts => {
}
export const installHostPatch = (host, { uuid }) => (
_call('host.installPatch', { host: resolveId(host), patch: uuid })
_call('host.installPatch', { host: resolveId(host), patch: uuid })::tap(
() => subscribeHostMissingPatches.forceRefresh(host)
)
)
export const installAllHostPatches = host => (
_call('host.installAllPatches', { host: resolveId(host) })
_call('host.installAllPatches', { host: resolveId(host) })::tap(
() => subscribeHostMissingPatches.forceRefresh(host)
)
)
export const installAllPatchesOnPool = pool => (
_call('pool.installAllPatches', { pool: resolveId(pool) })
_call('pool.installAllPatches', { pool: resolveId(pool) })::tap(
() => subscribeHostMissingPatches.forceRefresh()
)
)
export const installSupplementalPack = (host, file) => {
@@ -571,8 +612,38 @@ export const unpauseContainer = (vm, container) => (
// VM ----------------------------------------------------------------
const chooseActionToUnblockForbiddenStartVm = props => (
chooseAction({
icon: 'alarm',
buttons: [
{ label: _('cloneAndStartVM'), value: 'clone', btnStyle: 'success' },
{ label: _('forceStartVm'), value: 'force', btnStyle: 'danger' }
],
...props
})
)
const cloneAndStartVM = async vm => (
_call('vm.start', { id: await cloneVm(vm) })
)
export const startVm = vm => (
_call('vm.start', { id: resolveId(vm) })
_call('vm.start', { id: resolveId(vm) }).catch(async reason => {
if (!forbiddenOperation.is(reason)) {
throw reason
}
const choice = await chooseActionToUnblockForbiddenStartVm({
body: _('blockedStartVmModalMessage'),
title: _('forceStartVmModalTitle')
})
if (choice === 'clone') {
return cloneAndStartVM(vm)
}
return _call('vm.start', { id: resolveId(vm), force: true })
})
)
export const startVms = vms => (
@@ -580,7 +651,52 @@ export const startVms = vms => (
title: _('startVmsModalTitle', { vms: vms.length }),
body: _('startVmsModalMessage', { vms: vms.length })
}).then(
() => map(vms, vmId => startVm({ id: vmId })),
async () => {
const forbiddenStart = []
let nErrors = 0
await Promise.all(map(
vms,
id => _call('vm.start', { id }).catch(reason => {
if (forbiddenOperation.is(reason)) {
forbiddenStart.push(id)
} else {
nErrors++
}
})
))
if (forbiddenStart.length === 0) {
if (nErrors === 0) {
return
}
return error(_('failedVmsErrorTitle'), _('failedVmsErrorMessage', {nVms: nErrors}))
}
const choice = await chooseActionToUnblockForbiddenStartVm({
body: _('blockedStartVmsModalMessage', {nVms: forbiddenStart.length}),
title: _('forceStartVmModalTitle')
}).catch(noop)
if (nErrors !== 0) {
error(_('failedVmsErrorTitle'), _('failedVmsErrorMessage', {nVms: nErrors}))
}
if (choice === 'clone') {
return Promise.all(map(
forbiddenStart,
async id => cloneAndStartVM(getObject(store.getState(), id))
))
}
if (choice === 'force') {
return Promise.all(map(
forbiddenStart,
id => _call('vm.start', { id, force: true })
))
}
},
noop
)
)
@@ -921,6 +1037,10 @@ export const attachDiskToVm = (vdi, vm, { bootable, mode, position }) => (
})
)
export const removeAppliance = vm => {
_call('vm.removeAppliance', { id: resolveId(vm) })
}
// DISK ---------------------------------------------------------------
export const createDisk = (name, size, sr) => (
@@ -1323,16 +1443,14 @@ export const getSchedule = id => (
export const loadPlugin = async id => (
_call('plugin.load', { id })::tap(
subscribePlugins.forceRefresh
)::rethrow(
subscribePlugins.forceRefresh,
err => error(_('pluginError'), (err && err.message) || _('unknownPluginError'))
)
)
export const unloadPlugin = id => (
_call('plugin.unload', { id })::tap(
subscribePlugins.forceRefresh
)::rethrow(
subscribePlugins.forceRefresh,
err => error(_('pluginError'), (err && err.message) || _('unknownPluginError'))
)
)
@@ -1354,8 +1472,7 @@ export const configurePlugin = (id, configuration) =>
() => {
info(_('pluginConfigurationSuccess'), _('pluginConfigurationChanges'))
subscribePlugins.forceRefresh()
}
)::rethrow(
},
err => error(_('pluginError'), JSON.stringify(err.data) || _('unknownPluginError'))
)
@@ -1403,7 +1520,8 @@ export const recomputeResourceSetsLimits = () => (
// Remote ------------------------------------------------------------
export const getRemote = remote => (
_call('remote.get', resolveIds({ id: remote }))::rethrow(
_call('remote.get', resolveIds({ id: remote }))::tap(
null,
err => error(_('getRemote'), err.message || String(err))
)
)
@@ -1440,20 +1558,21 @@ export const editRemote = (remote, { name, url }) => (
export const listRemote = remote => (
_call('remote.list', resolveIds({ id: remote }))::tap(
subscribeRemotes.forceRefresh
)::rethrow(
subscribeRemotes.forceRefresh,
err => error(_('listRemote'), err.message || String(err))
)
)
export const listRemoteBackups = remote => (
_call('backup.list', resolveIds({ remote }))::rethrow(
_call('backup.list', resolveIds({ remote }))::tap(
null,
err => error(_('listRemote'), err.message || String(err))
)
)
export const testRemote = remote => (
_call('remote.test', resolveIds({ id: remote }))::rethrow(
_call('remote.test', resolveIds({ id: remote }))::tap(
null,
err => error(_('testRemote'), err.message || String(err))
)
)
@@ -1560,16 +1679,14 @@ export const deleteApiLog = id => (
export const addAcl = ({ subject, object, action }) => (
_call('acl.add', resolveIds({ subject, object, action }))::tap(
subscribeAcls.forceRefresh
)::rethrow(
subscribeAcls.forceRefresh,
err => error('Add ACL', err.message || String(err))
)
)
export const removeAcl = ({ subject, object, action }) => (
_call('acl.remove', resolveIds({ subject, object, action }))::tap(
subscribeAcls.forceRefresh
)::rethrow(
subscribeAcls.forceRefresh,
err => error('Remove ACL', err.message || String(err))
)
)
@@ -1584,14 +1701,15 @@ export const editAcl = (
) => (
_call('acl.remove', resolveIds({ subject, object, action }))
.then(() => _call('acl.add', resolveIds({ subject: newSubject, object: newObject, action: newAction })))
::tap(subscribeAcls.forceRefresh)
::rethrow(err => error('Edit ACL', err.message || String(err)))
::tap(
subscribeAcls.forceRefresh,
err => error('Edit ACL', err.message || String(err))
)
)
export const createGroup = name => (
_call('group.create', { name })::tap(
subscribeGroups.forceRefresh
):: rethrow(
subscribeGroups.forceRefresh,
err => error(_('createGroup'), err.message || String(err))
)
)
@@ -1602,35 +1720,35 @@ export const setGroupName = (group, name) => (
)
)
export const deleteGroup = group => (
export const deleteGroup = group =>
confirm({
title: _('deleteGroup'),
body: <p>{_('deleteGroupConfirm')}</p>
}).then(() => _call('group.delete', resolveIds({ id: group })))
::tap(subscribeGroups.forceRefresh)
::rethrow(err => error(_('deleteGroup'), err.message || String(err)))
)
}).then(() =>
_call('group.delete', resolveIds({ id: group }))::tap(
subscribeGroups.forceRefresh,
err => error(_('deleteGroup'), err.message || String(err))
),
noop
)
export const removeUserFromGroup = (user, group) => (
_call('group.removeUser', resolveIds({ id: group, userId: user }))::tap(
subscribeGroups.forceRefresh
)::rethrow(
subscribeGroups.forceRefresh,
err => error(_('removeUserFromGroup'), err.message || String(err))
)
)
export const addUserToGroup = (user, group) => (
_call('group.addUser', resolveIds({ id: group, userId: user }))::tap(
subscribeGroups.forceRefresh
)::rethrow(
subscribeGroups.forceRefresh,
err => error('Add User', err.message || String(err))
)
)
export const createUser = (email, password, permission) => (
_call('user.create', { email, password, permission })::tap(
subscribeUsers.forceRefresh
)::rethrow(
subscribeUsers.forceRefresh,
err => error('Create user', err.message || String(err))
)
)
@@ -1640,9 +1758,10 @@ export const deleteUser = user => (
title: _('deleteUser'),
body: <p>{_('deleteUserConfirm')}</p>
}).then(() =>
_call('user.delete', { id: resolveId(user) })
::tap(subscribeUsers.forceRefresh)
::rethrow(err => error(_('deleteUser'), err.message || String(err)))
_call('user.delete', { id: resolveId(user) })::tap(
subscribeUsers.forceRefresh,
err => error(_('deleteUser'), err.message || String(err))
)
)
)
@@ -1838,3 +1957,33 @@ export const downloadAndInstallXosanPack = pool =>
)
export const registerXosan = namespace => _call('cloud.registerResource', { namespace: 'xosan' })
// VM-Group ----------------------------------------------------------------------
export const startVmGroup = vmGroup => {
_call('vmGroup.start', { id: resolveId(vmGroup) })
}
export const shutdownVmGroup = vmGroup => _call('vmGroup.shutdown', { id: resolveId(vmGroup) })
export const rebootVmGroup = async vmGroup => {
await shutdownVmGroup(vmGroup)
await startVmGroup(vmGroup)
}
export const editVmGroup = (vmGroup, props) => _call('vmGroup.set', { id: resolveId(vmGroup), ...props })
export const deleteVmGroup = (vmGroup, vms) =>
confirm({
title: _('deleteVmGroupModalTitle'),
body: _('deleteVmGroupModalMessage')
}).then(() => {
forEach(vms, vm => removeAppliance(vm))
_call('vmGroup.destroy', { id: resolveId(vmGroup) })
}, Promise.reject())
export const createVmGroup = ({ pool, name_label, name_description }) => _call('vmGroup.create', { id: resolveId(pool), name_label, name_description })
export const startVmGroups = (vmGroupIds) => {
forEach(vmGroupIds, id => startVmGroup({id: id}))
}
export const shutdownVmGroups = (vmGroupIds) => {
forEach(vmGroupIds, id => shutdownVmGroup({id: id}))
}
export const rebootVmGroups = (vmGroupIds) => {
forEach(vmGroupIds, id => rebootVmGroup({id: id}))
}

View File

@@ -132,7 +132,7 @@ const COMMON_SCHEMA = {
},
enabled: {
type: 'boolean',
title: _('editBackupReportEnable')
title: _('editBackupScheduleEnabled')
}
},
required: [ 'tag', 'vms', '_reportWhen' ]
@@ -190,6 +190,15 @@ const DISASTER_RECOVERY_SCHEMA = {
properties: {
...COMMON_SCHEMA.properties,
depth: DEPTH_PROPERTY,
deleteOldBackupsFirst: {
type: 'boolean',
title: _('deleteOldBackupsFirst'),
description: [
'Delete the old backups before copy the vms.',
'',
'If the backup fails, you will lose your old backups.'
].join('\n')
},
sr: {
type: 'string',
'xo:type': 'sr',

View File

@@ -1,12 +1,8 @@
import _ from 'intl'
import ActionRowButton from 'action-row-button'
import Component from 'base-component'
import get from 'lodash/get'
import Icon from 'icon'
import isEmpty from 'lodash/isEmpty'
import Link from 'link'
import map from 'lodash/map'
import mapValues from 'lodash/mapValues'
import SortedTable from 'sorted-table'
import TabButton from 'tab-button'
import Tooltip from 'tooltip'
@@ -15,11 +11,26 @@ import React from 'react'
import xml2js from 'xml2js'
import { Card, CardHeader, CardBlock } from 'card'
import { confirm } from 'modal'
import { deleteMessage, deleteVdi, deleteOrphanedVdis, deleteVm, isSrWritable } from 'xo'
import { FormattedRelative, FormattedTime } from 'react-intl'
import { fromCallback } from 'promise-toolbox'
import { Container, Row, Col } from 'grid'
import {
deleteMessage,
deleteOrphanedVdis,
deleteVbd,
deleteVdi,
deleteVm,
isSrWritable
} from 'xo'
import {
flatten,
get,
isEmpty,
map,
mapValues
} from 'lodash'
import {
createCollectionWrapper,
createGetObject,
createGetObjectsOfType,
createSelector
@@ -27,6 +38,7 @@ import {
import {
connectStore,
formatSize,
mapPlus,
noop
} from 'utils'
@@ -102,10 +114,21 @@ const SR_COLUMNS = [
}
]
const VDI_COLUMNS = [
const ORPHANED_VDI_COLUMNS = [
{
name: _('snapshotDate'),
itemRenderer: vdi => <span><FormattedTime value={vdi.snapshot_time * 1000} minute='numeric' hour='numeric' day='numeric' month='long' year='numeric' /> (<FormattedRelative value={vdi.snapshot_time * 1000} />)</span>,
itemRenderer: vdi => <span>
<FormattedTime
day='numeric'
hour='numeric'
minute='numeric'
month='long'
value={vdi.snapshot_time * 1000}
year='numeric'
/>
{' '}
(<FormattedRelative value={vdi.snapshot_time * 1000} />)
</span>,
sortCriteria: vdi => vdi.snapshot_time,
sortOrder: 'desc'
},
@@ -141,10 +164,58 @@ const VDI_COLUMNS = [
}
]
const CONTROL_DOMAIN_VDI_COLUMNS = [
{
name: _('vdiNameLabel'),
itemRenderer: vdi => vdi && vdi.name_label,
sortCriteria: vdi => vdi && vdi.name_label
},
{
name: _('vdiNameDescription'),
itemRenderer: vdi => vdi && vdi.name_description,
sortCriteria: vdi => vdi && vdi.name_description
},
{
name: _('vdiPool'),
itemRenderer: vdi => vdi && vdi.pool && <Link to={`pools/${vdi.pool.id}`}>{vdi.pool.name_label}</Link>,
sortCriteria: vdi => vdi && vdi.pool && vdi.pool.name_label
},
{
name: _('vdiSize'),
itemRenderer: vdi => vdi && formatSize(vdi.size),
sortCriteria: vdi => vdi && vdi.size
},
{
name: _('vdiSr'),
itemRenderer: vdi => vdi && vdi.sr && <Link to={`srs/${vdi.sr.id}`}>{vdi.sr.name_label}</Link>,
sortCriteria: vdi => vdi && vdi.sr && vdi.sr.name_label
},
{
name: _('vdiAction'),
itemRenderer: vdi => vdi && vdi.vbd && <ActionRowButton
btnStyle='danger'
handler={deleteVbd}
handlerParam={vdi.vbd}
icon='delete'
/>
}
]
const VM_COLUMNS = [
{
name: _('snapshotDate'),
itemRenderer: vm => <span><FormattedTime value={vm.snapshot_time * 1000} minute='numeric' hour='numeric' day='numeric' month='long' year='numeric' /> (<FormattedRelative value={vm.snapshot_time * 1000} />)</span>,
itemRenderer: vm => <span>
<FormattedTime
day='numeric'
hour='numeric'
minute='numeric'
month='long'
value={vm.snapshot_time * 1000}
year='numeric'
/>
{' '}
(<FormattedRelative value={vm.snapshot_time * 1000} />)
</span>,
sortCriteria: vm => vm.snapshot_time,
sortOrder: 'desc'
},
@@ -178,9 +249,18 @@ const VM_COLUMNS = [
const ALARM_COLUMNS = [
{
name: _('alarmDate'),
itemRenderer: message => (
<span><FormattedTime value={message.time * 1000} minute='numeric' hour='numeric' day='numeric' month='long' year='numeric' /> (<FormattedRelative value={message.time * 1000} />)</span>
),
itemRenderer: message => <span>
<FormattedTime
day='numeric'
hour='numeric'
minute='numeric'
month='long'
value={message.time * 1000}
year='numeric'
/>
{' '}
(<FormattedRelative value={message.time * 1000} />)
</span>,
sortCriteria: message => message.time,
sortOrder: 'desc'
},
@@ -224,8 +304,40 @@ const ALARM_COLUMNS = [
@connectStore(() => {
const getOrphanVdiSnapshots = createGetObjectsOfType('VDI-snapshot')
.filter([ snapshot => !snapshot.$snapshot_of ])
.filter([ _ => !_.$snapshot_of && _.$VBDs.length === 0 ])
.sort()
const getControlDomainVbds = createGetObjectsOfType('VBD')
.pick(
createSelector(
createGetObjectsOfType('VM-controller'),
createCollectionWrapper(
vmControllers => flatten(map(vmControllers, '$VBDs'))
)
)
)
.sort()
const getControlDomainVdis = createSelector(
getControlDomainVbds,
createGetObjectsOfType('VDI'),
createGetObjectsOfType('pool'),
createGetObjectsOfType('SR'),
(vbds, vdis, pools, srs) =>
mapPlus(vbds, (vbd, push) => {
const vdi = vdis[vbd.VDI]
if (vdi == null) {
return
}
push({
...vdi,
pool: pools[vbd.$pool],
sr: srs[vdi.$SR],
vbd
})
}
)
)
const getOrphanVmSnapshots = createGetObjectsOfType('VM-snapshot')
.filter([ snapshot => !snapshot.$snapshot_of ])
.sort()
@@ -241,6 +353,7 @@ const ALARM_COLUMNS = [
return {
alertMessages: getAlertMessages,
controlDomainVdis: getControlDomainVdis,
userSrs: getUserSrs,
vdiOrphaned: getOrphanVdiSnapshots,
vdiSr: getVdiSrs,
@@ -362,7 +475,7 @@ export default class Health extends Component {
</Row>
<Row>
<Col>
<SortedTable collection={this.props.vdiOrphaned} columns={VDI_COLUMNS} />
<SortedTable collection={this.props.vdiOrphaned} columns={ORPHANED_VDI_COLUMNS} />
</Col>
</Row>
</div>
@@ -371,6 +484,21 @@ export default class Health extends Component {
</Card>
</Col>
</Row>
<Row>
<Col>
<Card>
<CardHeader>
<Icon icon='disk' /> {_('vdisOnControlDomain')}
</CardHeader>
<CardBlock>
{isEmpty(this.props.controlDomainVdis)
? <p className='text-xs-center'>{_('noControlDomainVdis')}</p>
: <SortedTable collection={this.props.controlDomainVdis} columns={CONTROL_DOMAIN_VDI_COLUMNS} />
}
</CardBlock>
</Card>
</Col>
</Row>
<Row>
<Col>
<Card>

View File

@@ -42,12 +42,15 @@ import {
forgetSrs,
isSrShared,
migrateVms,
rebootVmGroups,
reconnectAllHostsSrs,
rescanSrs,
restartHosts,
restartHostsAgents,
restartVms,
shutdownVmGroups,
snapshotVms,
startVmGroups,
startVms,
stopHosts,
stopVms,
@@ -86,6 +89,7 @@ import {
import styles from './index.css'
import HostItem from './host-item'
import PoolItem from './pool-item'
import VmGroupItem from './vm-group-item'
import VmItem from './vm-item'
import TemplateItem from './template-item'
import SrItem from './sr-item'
@@ -150,6 +154,20 @@ const OPTIONS = {
{ labelId: 'homeSortByCpus', sortBy: 'CPUs.number', sortOrder: 'desc' }
]
},
'VmGroup': {
defaultFilter: '',
filters: homeFilters.vmGroup,
mainActions: [
{ handler: shutdownVmGroups, icon: 'vm-stop', tooltip: _('stopVmLabel') },
{ handler: startVmGroups, icon: 'vm-start', tooltip: _('startVmLabel') },
{ handler: rebootVmGroups, icon: 'vm-reboot', tooltip: _('rebootVmLabel') }
],
Item: VmGroupItem,
sortOptions: [
{ labelId: 'homeSortByName', sortBy: 'name_label', sortOrder: 'asc' },
{ labelId: 'homeSortByPowerstate', sortBy: 'power_state', sortOrder: 'desc' }
]
},
pool: {
defaultFilter: '',
filters: homeFilters.pool,
@@ -196,6 +214,7 @@ const OPTIONS = {
const TYPES = {
VM: _('homeTypeVm'),
VmGroup: _('homeTypeVmGroup'),
'VM-template': _('homeTypeVmTemplate'),
host: _('homeTypeHost'),
pool: _('homeTypePool'),
@@ -212,14 +231,18 @@ const DEFAULT_TYPE = 'VM'
createGetObjectsOfType('host'),
hosts => state => isEmpty(hosts(state))
)
const type = (_, props) => props.location.query.t || DEFAULT_TYPE
const getType = (_, props) => props.location.query.t || DEFAULT_TYPE
const getObjectsByType = createGetObjectsOfType(getType)
return {
areObjectsFetched,
items: createGetObjectsOfType(type),
noServersConnected,
type,
user: getUser
return (state, props) => {
const type = getType(state, props)
return {
areObjectsFetched: areObjectsFetched(state, props),
items: getObjectsByType(state, props),
noServersConnected: noServersConnected(state, props),
type,
user: getUser(state, props)
}
}
})
export default class Home extends Component {
@@ -806,7 +829,7 @@ export default class Home extends Component {
</OverlayTrigger>
)}
{' '}
<OverlayTrigger
{type !== 'VmGroup' && <OverlayTrigger
autoFocus
trigger='click'
rootClose
@@ -824,7 +847,7 @@ export default class Home extends Component {
}
>
<Button btnStyle='link'><Icon icon='tags' /> {_('homeAllTags')}</Button>
</OverlayTrigger>
</OverlayTrigger> }
{' '}
<DropdownButton bsStyle='link' id='sort' title={_('homeSortBy')}>
{map(options.sortOptions, ({ labelId, sortBy: _sortBy, sortOrder }, key) => (

View File

@@ -0,0 +1,77 @@
import _ from 'intl'
import Component from 'base-component'
import Ellipsis, { EllipsisContainer } from 'ellipsis'
import forEach from 'lodash/forEach'
import Icon from 'icon'
import isEmpty from 'lodash/isEmpty'
import map from 'lodash/map'
import React from 'react'
import SingleLineRow from 'single-line-row'
import Tooltip from 'tooltip'
import { BlockLink } from 'link'
import { Col } from 'grid'
import { connectStore } from 'utils'
import { createGetObject } from 'selectors'
import { editVmGroup } from 'xo'
import { Text } from 'editable'
import styles from './index.css'
@connectStore(() => {
return (state, props) => {
const vms = {}
forEach(props.item.$VMs, vmId => {
const getVM = createGetObject(() => vmId)
vms[vmId] = getVM(state, props)
})
return {
vms
}
}
})
export default class VmGroupItem extends Component {
toggleState = stateField => () => this.setState({ [stateField]: !this.state[stateField] })
_onSelect = () => this.props.onSelect(this.props.item.id)
_setNameDescription = description => editVmGroup(this.props.item, {name_description: description})
_setNameLabel = label => editVmGroup(this.props.item, {name_label: label})
_getVmGroupState = (vmGroup) => {
const states = map(this.props.vms, vm => vm.power_state)
return isEmpty(states)
? 'Busy'
: states.indexOf('Halted') === -1
? states[0]
: states.indexOf('Running') === -1
? states[0]
: 'Busy'
}
render () {
const { item: vmGroup, selected, vms } = this.props
return <div className={styles.item}>
<BlockLink to={`/vm-group/${vmGroup.id}`}>
<SingleLineRow>
<Col smallSize={10} mediumSize={9} largeSize={3}>
<EllipsisContainer>
<input type='checkbox' checked={selected} onChange={this._onSelect} value={vmGroup.id} />
&nbsp;&nbsp;
<Ellipsis>
<Tooltip content={_(`powerStateVmGroup${this._getVmGroupState(vmGroup)}`)}>
<Icon icon={isEmpty(vms) ? 'halted' : `${this._getVmGroupState(vmGroup).toLowerCase()}`} />
</Tooltip>
<Text value={vmGroup.name_label} onChange={this._setNameLabel} useLongClick />
</Ellipsis>
&nbsp;&nbsp;
</EllipsisContainer>
</Col>
<Col mediumSize={4} className='hidden-md-down'>
<EllipsisContainer>
<Ellipsis>
<Text value={vmGroup.name_description} onChange={this._setNameDescription} useLongClick />
</Ellipsis>
</EllipsisContainer>
</Col>
</SingleLineRow>
</BlockLink>
</div>
}
}

View File

@@ -31,7 +31,9 @@ import {
import {
createFinder,
createGetObject,
createSelector
createGetVmDisks,
createSelector,
createSumBy
} from 'selectors'
import styles from './index.css'
@@ -39,9 +41,13 @@ import styles from './index.css'
@addSubscriptions({
resourceSets: subscribeResourceSets
})
@connectStore({
container: createGetObject((_, props) => props.item.$container)
})
@connectStore(() => ({
container: createGetObject((_, props) => props.item.$container),
totalDiskSize: createSumBy(
createGetVmDisks((_, props) => props.item),
'size'
)
}))
export default class VmItem extends Component {
get _isRunning () {
const vm = this.props.item
@@ -164,6 +170,8 @@ export default class VmItem extends Component {
{' '}&nbsp;{' '}
{formatSize(vm.memory.size)} <Icon icon='memory' />
{' '}&nbsp;{' '}
{formatSize(this.props.totalDiskSize)} <Icon icon='disk' />
{' '}&nbsp;{' '}
{isEmpty(vm.snapshots)
? null
: <span>{vm.snapshots.length}x <Icon icon='vm-snapshot' /></span>

View File

@@ -7,8 +7,14 @@ import Page from '../page'
import React, { cloneElement, Component } from 'react'
import Tooltip from 'tooltip'
import { Text } from 'editable'
import { editHost, fetchHostStats, getHostMissingPatches, installAllHostPatches, installHostPatch } from 'xo'
import { Container, Row, Col } from 'grid'
import {
editHost,
fetchHostStats,
installAllHostPatches,
installHostPatch,
subscribeHostMissingPatches
} from 'xo'
import {
connectStore,
routes
@@ -139,6 +145,10 @@ export default class Host extends Component {
}
loop (host = this.props.host) {
if (host == null) {
return
}
if (this.cancel) {
this.cancel()
}
@@ -166,22 +176,19 @@ export default class Host extends Component {
}
loop = ::this.loop
_getMissingPatches (host) {
getHostMissingPatches(host).then(missingPatches => {
this.setState({ missingPatches: sortBy(missingPatches, (patch) => -patch.time) })
})
}
componentWillMount () {
if (!this.props.host) {
return
}
componentDidMount () {
this.loop()
this._getMissingPatches(this.props.host)
this.unsubscribeHostMissingPatches = subscribeHostMissingPatches(
this.props.routeParams.id,
missingPatches => this.setState({
missingPatches: sortBy(missingPatches, patch => -patch.time)
})
)
}
componentWillUnmount () {
clearTimeout(this.timeout)
this.unsubscribeHostMissingPatches()
}
componentWillReceiveProps (props) {
@@ -195,10 +202,6 @@ export default class Host extends Component {
this.context.router.push('/')
}
if (!hostCur) {
this._getMissingPatches(hostNext)
}
if (!isRunning(hostCur) && isRunning(hostNext)) {
this.loop(hostNext)
} else if (isRunning(hostCur) && !isRunning(hostNext)) {
@@ -210,16 +213,12 @@ export default class Host extends Component {
_installAllPatches = () => {
const { host } = this.props
return installAllHostPatches(host).then(() => {
this._getMissingPatches(host)
})
return installAllHostPatches(host)
}
_installPatch = patch => {
const { host } = this.props
return installHostPatch(host, patch).then(() => {
this._getMissingPatches(host)
})
return installHostPatch(host, patch)
}
_setNameDescription = nameDescription => editHost(this.props.host, { name_description: nameDescription })

View File

@@ -4,6 +4,7 @@ import React, { Component } from 'react'
import SortedTable from 'sorted-table'
import TabButton from 'tab-button'
import Upgrade from 'xoa-upgrade'
import { chooseAction } from 'modal'
import { connectStore, formatSize } from 'utils'
import { Container, Row, Col } from 'grid'
import { createDoesHostNeedRestart, createSelector } from 'selectors'
@@ -42,11 +43,10 @@ const MISSING_PATCH_COLUMNS = [
},
{
name: _('patchAction'),
itemRenderer: (patch, installPatch) => (
itemRenderer: (patch, {installPatch, _installPatchWarning}) => (
<ActionRowButton
btnStyle='primary'
handler={installPatch}
handlerParam={patch}
handler={() => _installPatchWarning(patch, installPatch)}
icon='host-patch-update'
/>
)
@@ -111,6 +111,29 @@ const INSTALLED_PATCH_COLUMNS_2 = [
needsRestart: createDoesHostNeedRestart((_, props) => props.host)
}))
export default class HostPatches extends Component {
static contextTypes = {
router: React.PropTypes.object
}
_chooseActionPatch = async doInstall => {
const choice = await chooseAction({
body: <p>{_('installPatchWarningContent')}</p>,
buttons: [
{ label: _('installPatchWarningResolve'), value: 'install', btnStyle: 'primary' },
{ label: _('installPatchWarningReject'), value: 'goToPool' }
],
title: _('installPatchWarningTitle')
})
return choice === 'install'
? doInstall()
: this.context.router.push(`/pools/${this.props.host.$pool}/patches`)
}
_installPatchWarning = (patch, installPatch) => this._chooseActionPatch(() => installPatch(patch))
_installAllPatchesWarning = installAllPatches => this._chooseActionPatch(installAllPatches)
_getPatches = createSelector(
() => this.props.host,
() => this.props.hostPatches,
@@ -136,7 +159,7 @@ export default class HostPatches extends Component {
render () {
const { host, missingPatches, installAllPatches, installPatch } = this.props
const { patches, columns } = this._getPatches()
const hasMissingPatches = !isEmpty(missingPatches)
return process.env.XOA_PLAN > 1
? <Container>
<Row>
@@ -148,26 +171,20 @@ export default class HostPatches extends Component {
icon='host-reboot'
labelId='rebootUpdateHostLabel'
/>}
{isEmpty(missingPatches)
? <TabButton
disabled
handler={installAllPatches}
icon='success'
labelId='hostUpToDate'
/>
: <TabButton
btnStyle='primary'
handler={installAllPatches}
icon='host-patch-update'
labelId='patchUpdateButton'
/>
}
<TabButton
disabled={!hasMissingPatches}
btnStyle={hasMissingPatches ? 'primary' : undefined}
handler={this._installAllPatchesWarning}
handlerParam={installAllPatches}
icon={hasMissingPatches ? 'host-patch-update' : 'success'}
labelId={hasMissingPatches ? 'patchUpdateButton' : 'hostUpToDate'}
/>
</Col>
</Row>
{!isEmpty(missingPatches) && <Row>
{hasMissingPatches && <Row>
<Col>
<h3>{_('hostMissingPatches')}</h3>
<SortedTable collection={missingPatches} userData={installPatch} columns={MISSING_PATCH_COLUMNS} />
<SortedTable collection={missingPatches} userData={{installPatch, _installPatchWarning: this._installPatchWarning}} columns={MISSING_PATCH_COLUMNS} />
</Col>
</Row>}
<Row>

View File

@@ -36,6 +36,7 @@ import Sr from './sr'
import Tasks from './tasks'
import User from './user'
import Vm from './vm'
import VmGroup from './vm-group'
import VmImport from './vm-import'
import XoaUpdates from './xoa-updates'
import Xosan from './xosan'
@@ -86,6 +87,7 @@ const BODY_STYLE = {
'vms/import': VmImport,
'vms/new': NewVm,
'vms/:id': Vm,
'vm-group/:id': VmGroup,
'xoa-update': XoaUpdates,
'xosan': Xosan
})

View File

@@ -71,20 +71,31 @@ class JobReturn extends Component {
}
const Log = props => <ul className='list-group'>
{map(props.log.calls, call => <li key={call.callKey} className='list-group-item'>
<strong className='text-info'>{call.method}: </strong><br />
{map(call.params, (value, key) => [ <JobParam id={value} paramKey={key} key={key} />, <br /> ])}
{call.returnedValue && <span>{' '}<JobReturn id={call.returnedValue} /></span>}
{call.error &&
<span className='text-danger'>
<Icon icon='error' />
{' '}
{call.error.message
? <strong>{call.error.message}</strong>
: JSON.stringify(call.error)
}
</span>}
</li>)}
{map(props.log.calls, call => {
const { returnedValue } = call
let id
if (returnedValue != null) {
id = returnedValue.id
if (id === undefined && typeof returnedValue === 'string') {
id = returnedValue
}
}
return <li key={call.callKey} className='list-group-item'>
<strong className='text-info'>{call.method}: </strong><br />
{map(call.params, (value, key) => [ <JobParam id={value} paramKey={key} key={key} />, <br /> ])}
{id !== undefined && <span>{' '}<JobReturn id={id} /></span>}
{call.error &&
<span className='text-danger'>
<Icon icon='error' />
{' '}
{call.error.message
? <strong>{call.error.message}</strong>
: JSON.stringify(call.error)
}
</span>}
</li>
})}
</ul>
const showCalls = log => alert(_('jobModalTitle', { job: log.jobId }), <Log log={log} />)

View File

@@ -127,6 +127,7 @@ export default class Menu extends Component {
const items = [
{ to: '/home', icon: 'menu-home', label: 'homePage', subMenu: [
{ to: '/home?t=VM', icon: 'vm', label: 'homeVmPage' },
{ to: '/home?t=VmGroup', icon: 'vm', label: 'homeVmGroupPage' },
nHosts !== 0 && { to: '/home?t=host', icon: 'host', label: 'homeHostPage' },
!isEmpty(pools) && { to: '/home?t=pool', icon: 'pool', label: 'homePoolPage' },
isAdmin && { to: '/home?t=VM-template', icon: 'template', label: 'homeTemplatePage' },
@@ -167,6 +168,7 @@ export default class Menu extends Component {
isAdmin && { to: '/xosan', icon: 'menu-xosan', label: 'xosan' },
!(noOperatablePools && noResourceSets) && { to: '/vms/new', icon: 'menu-new', label: 'newMenu', subMenu: [
{ to: '/vms/new', icon: 'menu-new-vm', label: 'newVmPage' },
{ to: '/new/vm-group', icon: 'menu-new-vm', label: 'newVmGroupPage' },
isAdmin && { to: '/new/sr', icon: 'menu-new-sr', label: 'newSrPage' },
isAdmin && { to: '/settings/servers', icon: 'menu-settings-servers', label: 'newServerPage' },
!noOperatablePools && { to: '/vms/import', icon: 'menu-new-import', label: 'newImport' }
@@ -197,7 +199,7 @@ export default class Menu extends Component {
)}
<li>&nbsp;</li>
<li>&nbsp;</li>
<li className='nav-item xo-menu-item'>
{ (isAdmin || +process.env.XOA_PLAN === 5) && <li className='nav-item xo-menu-item'>
<Link className='nav-link' style={{display: 'flex'}} to={'/about'}>
{+process.env.XOA_PLAN === 5
? <span>
@@ -227,7 +229,7 @@ export default class Menu extends Component {
</span>
}
</Link>
</li>
</li>}
<li>&nbsp;</li>
<li>&nbsp;</li>
<li className='nav-item xo-menu-item'>

View File

@@ -1,9 +1,11 @@
import { routes } from 'utils'
import Sr from './sr'
import VmGroup from './vm-group'
const New = routes('vm', {
sr: Sr
sr: Sr,
'vm-group': VmGroup
})(
({ children }) => children
)

View File

@@ -0,0 +1,153 @@
import _ from 'intl'
import ActionButton from 'action-button'
import BaseComponent from 'base-component'
import classNames from 'classnames'
import DebounceInput from 'react-debounce-input'
import Icon from 'icon'
import Page from '../../page'
import React from 'react'
import Wizard, { Section } from 'wizard'
import { Container, Row, Col } from 'grid'
import {
createVmGroup,
subscribeCurrentUser,
subscribePermissions,
subscribeResourceSets
} from 'xo'
import {
createSelector,
createGetObjectsOfType,
getUser
} from 'selectors'
import { SelectPool } from 'select-objects'
import { addSubscriptions, connectStore } from 'utils'
import styles from '../../new-vm/index.css'
const SectionContent = ({ column, children }) => (
<div className={classNames(
'form-inline',
styles.sectionContent,
column && styles.sectionContentColumn
)}>
{children}
</div>
)
const Item = ({ label, children, className }) => (
<span className={styles.item}>
{label && <span>{label}&nbsp;</span>}
<span className={classNames(styles.input, className)}>{children}</span>
</span>
)
@addSubscriptions({
resourceSets: subscribeResourceSets,
permissions: subscribePermissions,
user: subscribeCurrentUser
})
@connectStore(() => ({
isAdmin: createSelector(
getUser,
user => user && user.permission === 'admin'
),
pools: createGetObjectsOfType('pool')
}))
export default class VmGroup extends BaseComponent {
static contextTypes = {
router: React.PropTypes.object
}
constructor () {
super()
this.state = {name_label: '', name_description: ''}
}
_selectPool = pool => {
this.setState({ pool })
this._reset()
}
_getCanOperate = createSelector(
() => this.props.isAdmin,
() => this.props.permissions,
(isAdmin, permissions) => isAdmin
? () => true
: ({ id }) => permissions && permissions[id] && permissions[id].operate
)
_renderHeader = () => {
const { pool } = this.state
return <Container>
<Row>
<Col mediumSize={12}>
<h2><Icon icon='sr' /> {_('newVmGroupTitle')}
<SelectPool
onChange={this._selectPool}
predicate={this._getCanOperate()}
value={pool}
/>
</h2>
</Col>
</Row>
</Container>
}
_reset = () => this.setState({poolId: '', name_label: '', name_description: ''})
_create = () => {
createVmGroup(this.state)
this.context.router.push('home?s=&t=VmGroup')
}
_getOnChange = item => ({target}) => this.setState({[item]: target.value})
render () {
const {pool, name_label: nameLabel, name_description: nameDescription} = this.state
return (
<Page header={this._renderHeader()}>
<form id='vmGroupCreation'>
<Wizard>
<Section icon='new-vm-infos' title='newVmGroupInfoPanel'>
<SectionContent>
<Item label={_('newVmGroupNameLabel')}>
<DebounceInput
className='form-control'
// debounceTimeout={DEBOUNCE_TIMEOUT}
onChange={this._getOnChange('name_label')}
value={nameLabel}
/>
</Item>
<Item label={_('newVmGroupDescriptionLabel')}>
<DebounceInput
className='form-control'
// debounceTimeout={DEBOUNCE_TIMEOUT}
onChange={this._getOnChange('name_description')}
value={nameDescription}
/>
</Item>
</SectionContent>
</Section>
</Wizard>
<div className={styles.submitSection}>
<ActionButton
className={styles.button}
handler={this._reset}
icon='new-vm-reset'
>
{_('newVmGroupReset')}
</ActionButton>
<ActionButton
btnStyle='primary'
className={styles.button}
disabled={nameLabel === '' || pool === undefined}
form='vmGroupCreation'
handler={this._create}
icon='new-vm-create'
redirectOnSuccess={this._getRedirectionUrl}
>
{_('newVmGroupCreate')}
</ActionButton>
</div>
</form>
</Page>
)
}
}

View File

@@ -357,6 +357,7 @@ export class Edit extends Component {
</Col>
<Col mediumSize={4}>
<SelectSubject
hasSelectAll
multi
onChange={this.linkState('subjects')}
required
@@ -365,6 +366,7 @@ export class Edit extends Component {
</Col>
<Col mediumSize={4}>
<SelectPool
hasSelectAll
multi
onChange={this._updateSelectedPools}
required
@@ -378,6 +380,7 @@ export class Edit extends Component {
<Col mediumSize={4}>
<SelectVmTemplate
disabled={!state.nPools}
hasSelectAll
multi
onChange={this.linkState('templates')}
predicate={state.vmTemplatePredicate}
@@ -388,6 +391,7 @@ export class Edit extends Component {
<Col mediumSize={4}>
<SelectSr
disabled={!state.nPools}
hasSelectAll
multi
onChange={this._updateSelectedSrs}
predicate={state.srPredicate}
@@ -398,6 +402,7 @@ export class Edit extends Component {
<Col mediumSize={4}>
<SelectNetwork
disabled={!state.nSrs}
hasSelectAll
multi
onChange={this._updateSelectedNetworks}
predicate={state.networkPredicate}

View File

@@ -122,6 +122,7 @@ class Plugin extends Component {
const { editedConfig, expanded } = state
const {
configurationPresets,
configurationSchema,
loaded
} = props
@@ -148,15 +149,13 @@ class Plugin extends Component {
</div>
</h5>
</Col>
<Col mediumSize={4}>
<div className='form-group pull-right small'>
<Button btnStyle='primary' onClick={this._updateExpanded}>
<Icon icon={expanded ? 'minus' : 'plus'} />
</Button>
</div>
</Col>
{configurationSchema !== undefined && <Col className='text-xs-right' mediumSize={4}>
<Button btnStyle='primary' onClick={this._updateExpanded}>
<Icon icon={expanded ? 'minus' : 'plus'} />
</Button>
</Col>}
</Row>
{expanded && props.configurationSchema &&
{expanded &&
<form id={this.configFormId} onReset={this._stopEditing}>
{size(configurationPresets) > 0 && (
<div>
@@ -188,25 +187,39 @@ class Plugin extends Component {
<GenericInput
label='Configuration'
required
schema={props.configurationSchema}
schema={configurationSchema}
uiSchema={this._getUiSchema()}
onChange={this.linkState('editedConfig')}
value={editedConfig || this.props.configuration}
value={editedConfig || props.configuration}
/>
<div className='form-group pull-right'>
<div className='btn-toolbar'>
<div className='btn-group'>
<ActionButton btnStyle='danger' disabled={!props.configuration} icon='delete' handler={this._deleteConfiguration}>
<ActionButton
btnStyle='danger'
disabled={!props.configuration}
handler={this._deleteConfiguration}
icon='delete'
>
{_('deletePluginConfiguration')}
</ActionButton>
</div>
<div className='btn-group'>
<Button disabled={!editedConfig} type='reset'>
<Button
disabled={!editedConfig}
type='reset'
>
{_('cancelPluginEdition')}
</Button>
</div>
<div className='btn-group'>
<ActionButton disabled={!editedConfig} form={this.configFormId} icon='save' className='btn-primary' handler={this._saveConfiguration}>
<ActionButton
btnStyle='primary'
disabled={!editedConfig}
form={this.configFormId}
handler={this._saveConfiguration}
icon='save'
>
{_('savePluginConfiguration')}
</ActionButton>
</div>

View File

@@ -1,21 +1,19 @@
import _ from 'intl'
import assign from 'lodash/assign'
import Component from 'base-component'
import find from 'lodash/find'
import flatten from 'lodash/flatten'
import forEach from 'lodash/forEach'
import Icon from 'icon'
import Link from 'link'
import map from 'lodash/map'
import mapValues from 'lodash/mapValues'
import Page from '../page'
import pick from 'lodash/pick'
import React, { cloneElement } from 'react'
import SrActionBar from './action-bar'
import { Container, Row, Col } from 'grid'
import { editSr } from 'xo'
import { NavLink, NavTabs } from 'nav'
import { Text } from 'editable'
import {
assign,
map,
pick
} from 'lodash'
import {
connectStore,
routes
@@ -62,73 +60,25 @@ import TabXosan from './tab-xosan'
)
)
// -----------------------------------------------------------------------------
const getVdis = createGetObjectsOfType('VDI').pick(
createSelector(getSr, sr => sr.VDIs)
).sort()
// -----------------------------------------------------------------
const getLogs = createGetObjectMessages(getSr)
const getVbdsByVdi = createGetObjectsOfType('VBD').pick(
createSelector(
getVdis,
vdis => flatten(map(vdis, vdi => vdi.$VBDs))
)
).groupBy('VDI')
// -----------------------------------------------------------------
// -----------------------------------------------------------------------------
const getVdiIds = (state, props) => getSr(state, props).VDIs
const getVdisUnmanaged = createGetObjectsOfType('VDI-unmanaged').pick(
createSelector(getSr, sr => sr.VDIs)
const getVdis = createGetObjectsOfType('VDI').pick(
getVdiIds
).sort()
// -----------------------------------------------------------------------------
const getVdiSnapshots = createGetObjectsOfType('VDI-snapshot').pick(
getVdiIds
).sort()
const getUnmanagedVdis = createGetObjectsOfType('VDI-unmanaged').pick(
createSelector(getSr, sr => sr.VDIs)
).sort()
const getVdiSnapshotToVdi = createSelector(
getVdis,
getVdiSnapshots,
(vdis, vdiSnapshots) => {
const vdiSnapshotToVdi = {}
forEach(vdiSnapshots, vdiSnapshot => {
vdiSnapshotToVdi[vdiSnapshot.id] = vdiSnapshot.$snapshot_of && find(vdis, vdi => vdi.id === vdiSnapshot.$snapshot_of)
})
return vdiSnapshotToVdi
}
)
const getVbdsByVdiSnapshot = createSelector(
getVbdsByVdi,
getVdiSnapshots,
getVdiSnapshotToVdi,
(vbdsByVdi, vdiSnapshots, vdiSnapshotToVdi) => {
const vbdsByVdiSnapshot = {}
forEach(vdiSnapshots, vdiSnapshot => {
const vdi = vdiSnapshotToVdi[vdiSnapshot.id]
vbdsByVdiSnapshot[vdiSnapshot.id] = vdi && vbdsByVdi[vdi.id]
})
return vbdsByVdiSnapshot
}
)
// -----------------------------------------------------------------------------
const getVdisToVmIds = createSelector(
getVbdsByVdi,
getVbdsByVdiSnapshot,
(vbdsByVdi, vbdsByVdiSnapshot) => mapValues({ ...vbdsByVdi, ...vbdsByVdiSnapshot }, vbds => {
const vbd = find(vbds, 'VM')
if (vbd) {
return vbd.VM
}
})
)
// -----------------------------------------------------------------
return (state, props) => {
const sr = getSr(state, props)
@@ -142,9 +92,8 @@ import TabXosan from './tab-xosan'
pbds: getPbds(state, props),
logs: getLogs(state, props),
vdis: getVdis(state, props),
vdisUnmanaged: getVdisUnmanaged(state, props),
unmanagedVdis: getUnmanagedVdis(state, props),
vdiSnapshots: getVdiSnapshots(state, props),
vdisToVmIds: getVdisToVmIds(state, props),
sr
}
}
@@ -223,9 +172,8 @@ export default class Sr extends Component {
'pbds',
'sr',
'vdis',
'vdisUnmanaged',
'vdiSnapshots',
'vdisToVmIds'
'unmanagedVdis',
'vdiSnapshots'
]))
return <Page header={this.header()} title={`${sr.name_label}${container ? ` (${container.name_label})` : ''}`}>
{cloneElement(this.props.children, childProps)}

View File

@@ -1,14 +1,16 @@
import _ from 'intl'
import ActionRow from 'action-row-button'
import Component from 'base-component'
import Icon from 'icon'
import isEmpty from 'lodash/isEmpty'
import Link from 'link'
import React from 'react'
import renderXoItem, { renderXoUnknownItem } from 'render-xo-item'
import SortedTable from 'sorted-table'
import { formatSize } from 'utils'
import { concat, isEmpty } from 'lodash'
import { connectStore, formatSize } from 'utils'
import { Container, Row, Col } from 'grid'
import { createGetObject, createSelector } from 'selectors'
import { deleteVdi, editVdi } from 'xo'
import { renderXoItemFromId } from 'render-xo-item'
import { Text } from 'editable'
// ===================================================================
@@ -37,20 +39,38 @@ const COLUMNS = [
},
{
name: _('vdiVm'),
itemRenderer: (vdi, vdisToVmIds) => {
const id = vdisToVmIds[vdi.id]
const Item = renderXoItemFromId(id)
component: connectStore(() => {
const getObject = createGetObject((_, id) => id)
if (id) {
return (
<Link to={`/vms/${id}${vdi.type === 'VDI-snapshot' ? '/snapshots' : ''}`}>
{Item}
</Link>
)
return {
vm: (state, { item: { $VBDs: [ vbdId ] } }) => {
if (vbdId === undefined) {
return null
}
const vbd = getObject(state, vbdId)
if (vbd != null) {
return getObject(state, vbd.VM)
}
}
}
})(({ vm }) => {
if (vm === null) {
return null // no attached VM
}
return Item
}
if (vm === undefined) {
return renderXoUnknownItem()
}
return <Link to={`/vms/${
vm.type === 'VM-snapshot'
? `${vm.$snapshot_of}/snapshots`
: vm.id
}`}>
{renderXoItem(vm)}
</Link>
})
},
{
name: _('vdiTags'),
@@ -75,23 +95,39 @@ const COLUMNS = [
]
const FILTERS = {
filterNoSnapshots: 'type:!VDI-snapshot',
filterOnlyBaseCopy: 'type:VDI-unmanaged',
filterOnlyRegularDisks: 'type:!VDI-unmanaged type:!VDI-snapshot',
filterOnlySnapshots: 'type:VDI-snapshot'
filterOnlyManaged: 'type:!VDI-unmanaged',
filterOnlyRegular: '!type:|(VDI-snapshot VDI-unmanaged)',
filterOnlySnapshots: 'type:VDI-snapshot',
filterOnlyOrphaned: 'type:!VDI-unmanaged $VBDs:!""',
filterOnlyUnmanaged: 'type:VDI-unmanaged'
}
// ===================================================================
export default ({ vdis, vdisUnmanaged, vdiSnapshots, vdisToVmIds }) => (
<Container>
<Row>
<Col>
{!isEmpty(vdis)
? <SortedTable collection={vdis.concat(vdiSnapshots, vdisUnmanaged)} userData={vdisToVmIds} columns={COLUMNS} filters={FILTERS} />
: <h4 className='text-xs-center'>{_('srNoVdis')}</h4>
}
</Col>
</Row>
</Container>
)
export default class SrDisks extends Component {
_getAllVdis = createSelector(
() => this.props.vdis,
() => this.props.vdiSnapshots,
() => this.props.unmanagedVdis,
concat
)
render () {
const vdis = this._getAllVdis()
return <Container>
<Row>
<Col>
{!isEmpty(vdis)
? <SortedTable
collection={vdis}
columns={COLUMNS}
defaultFilter='filterOnlyManaged'
filters={FILTERS}
/>
: <h4 className='text-xs-center'>{_('srNoVdis')}</h4>
}
</Col>
</Row>
</Container>
}
}

View File

@@ -1,19 +1,30 @@
import _ from 'intl'
import HomeTags from 'home-tags'
import Icon from 'icon'
import map from 'lodash/map'
import React from 'react'
import HomeTags from 'home-tags'
import { addTag, removeTag } from 'xo'
import { Container, Row, Col } from 'grid'
import { formatSize } from 'utils'
import { renderXoItemFromId } from 'render-xo-item'
import Usage, { UsageElement } from 'usage'
import { addTag, removeTag } from 'xo'
import { connectStore, formatSize } from 'utils'
import { Container, Row, Col } from 'grid'
import { createGetObject } from 'selectors'
import { renderXoItemFromId } from 'render-xo-item'
const UsageTooltip = connectStore(() => ({
vbd: createGetObject((_, { vdi }) => vdi.$VBDs[0])
}))(({ vbd, vdi }) =>
<span>
{vdi.name_label} {formatSize(vdi.usage)}
{vbd != null && <br />}
{vbd != null && renderXoItemFromId(vbd.VM)}
</span>
)
export default ({
sr,
vdis,
vdisUnmanaged,
vdisToVmIds
vdiSnapshots,
unmanagedVdis
}) => <Container>
<Row className='text-xs-center'>
<Col mediumSize={4}>
@@ -35,23 +46,21 @@ export default ({
<Row>
<Col smallOffset={1} mediumSize={10}>
<Usage total={sr.size}>
{map(vdisUnmanaged, vdi => <UsageElement
{map(unmanagedVdis, vdi => <UsageElement
highlight
key={vdi.id}
tooltip={<span>
{vdi.name_label}
<br />
{vdisToVmIds[vdi.id] && renderXoItemFromId(vdisToVmIds[vdi.id])}
</span>}
tooltip={<UsageTooltip vdi={vdi} />}
value={vdi.usage}
/>)}
{map(vdis, vdi => <UsageElement
key={vdi.id}
tooltip={<span>
{vdi.name_label}
<br />
{vdisToVmIds[vdi.id] && renderXoItemFromId(vdisToVmIds[vdi.id])}
</span>}
tooltip={<UsageTooltip vdi={vdi} />}
value={vdi.usage}
/>)}
{map(vdiSnapshots, vdi => <UsageElement
highlight
key={vdi.id}
tooltip={<UsageTooltip vdi={vdi} />}
value={vdi.usage}
/>)}
</Usage>

View File

@@ -42,7 +42,7 @@ export const TaskItem = connectStore(() => ({
host: createGetObject((_, props) => props.task.$host)
}))(({ task, host }) => <SingleLineRow className='mb-1'>
<Col mediumSize={6}>
{task.name_label} (on {host
{task.name_label} ({task.name_description && `${task.name_description} `}on {host
? <Link to={`/hosts/${host.id}`}>{host.name_label}</Link>
: `unknown host ${task.$host}`
})

View File

@@ -0,0 +1,53 @@
import ActionBar from 'action-bar'
import React from 'react'
import { connectStore } from 'utils'
import { includes } from 'lodash'
import { isAdmin } from 'selectors'
import {
rebootVmGroup,
startVmGroup,
shutdownVmGroup
} from 'xo'
const vmGroupActionBarByState = ({ isAdmin, vmGroup }) => {
const actions = []
if (vmGroup.allowed_operations.includes('start') || vmGroup.allowed_operations.length === 0) {
actions.push({
icon: 'vm-start',
label: 'startVmLabel',
handler: startVmGroup,
pending: includes(vmGroup.current_operations, 'start')
})
}
if (vmGroup.allowed_operations.includes('shutdown') || vmGroup.allowed_operations.length === 0) {
actions.push({
icon: 'vm-stop',
label: 'stopVmLabel',
handler: shutdownVmGroup,
pending: includes(vmGroup.current_operations, 'shutdown')
})
}
actions.push({
icon: 'vm-reboot',
label: 'rebootVmLabel',
handler: rebootVmGroup,
pending: includes(vmGroup.current_operations, 'shutdown') || includes(vmGroup.current_operations, 'start')
})
return <ActionBar
actions={actions}
display='icon'
param={vmGroup}
/>
}
const VmGroupActionBar = connectStore({
isAdmin
})(({ isAdmin, vmGroup }) => {
const ActionBar = vmGroupActionBarByState
if (!ActionBar) {
return <p>No action bar for state</p>
}
return <ActionBar isAdmin={isAdmin} vmGroup={vmGroup} />
})
export default VmGroupActionBar

View File

@@ -0,0 +1,131 @@
import _ from 'intl'
import BaseComponent from 'base-component'
import Icon from 'icon'
import isEmpty from 'lodash/isEmpty'
import React, { cloneElement } from 'react'
import {
connectStore,
routes
} from 'utils'
import {
assign,
forEach,
map,
pick
} from 'lodash'
import { Container, Row, Col } from 'grid'
import { createGetObject } from 'selectors'
import { editVmGroup } from 'xo'
import { NavLink, NavTabs } from 'nav'
import { Text } from 'editable'
import Page from '../page'
import TabAdvanced from './tab-advanced'
import TabGeneral from './tab-general'
import TabManagement from './tab-management'
import TabStats from './tab-stats'
import VmGroupActionBar from './action-bar'
// ===================================================================
@routes('general', {
advanced: TabAdvanced,
general: TabGeneral,
management: TabManagement,
stats: TabStats
})
@connectStore(() => {
const getVmGroup = createGetObject()
return (state, props) => {
const vmGroup = getVmGroup(state, props)
if (!vmGroup) {
return {}
}
const vms = {}
forEach(vmGroup.$VMs, vmId => {
const getVM = createGetObject(() => vmId)
vms[vmId] = getVM(state, props)
})
return {
vmGroup,
vms
}
}
})
export default class VmGroup extends BaseComponent {
static contextTypes = {
router: React.PropTypes.object
}
_setNameDescription = description => editVmGroup(this.props.vmGroup, {name_description: description})
_setNameLabel = label => editVmGroup(this.props.vmGroup, {name_label: label})
_getVmGroupState = () => {
const states = map(this.props.vms, vm => vm.power_state)
return (isEmpty(states)
? 'busy'
: states.indexOf('Halted') === -1
? states[0]
: states.indexOf('Running') === -1
? states[0]
: 'busy').toLowerCase()
}
header () {
const { vmGroup, vms } = this.props
if (!vmGroup) {
return <Icon icon='loading' />
}
return <Container>
<Row>
<Col mediumSize={6} className='header-title'>
<h2>
<Icon icon={isEmpty(vms) ? `vm-halted` : `vm-${this._getVmGroupState()}`} />
{' '}
<Text value={vmGroup.name_label} onChange={this._setNameLabel} />
</h2>
<span>
<Text
value={vmGroup.name_description}
onChange={this._setNameDescription}
/>
<span className='text-muted'>
{' '}
</span>
</span>
</Col>
<Col mediumSize={6} className='text-xs-center'>
<div>
<VmGroupActionBar vmGroup={vmGroup} />
</div>
</Col>
</Row>
<br />
<Row>
<Col>
<NavTabs>
<NavLink to={`/vm-group/${vmGroup.id}/general`}>{_('generalTabName')}</NavLink>
<NavLink to={`/vm-group/${vmGroup.id}/stats`}>{_('statsTabName')}</NavLink>
<NavLink to={`/vm-group/${vmGroup.id}/management`}>{_('managementTabName')}</NavLink>
<NavLink to={`/vm-group/${vmGroup.id}/advanced`}>{_('advancedTabName')}</NavLink>
</NavTabs>
</Col>
</Row>
</Container>
}
render () {
const { container, vmGroup } = this.props
if (!vmGroup) {
return <h1>{_('statusLoading')}</h1>
}
const childProps = assign(pick(this.props, [
'vmGroup',
'vms'
]))
return <Page header={this.header()} title={`${vmGroup.name_label}${container ? ` (${container.name_label})` : ''}`}>
{cloneElement(this.props.children, { ...childProps })}
</Page>
}
}

View File

@@ -0,0 +1,55 @@
import _ from 'intl'
import Component from 'base-component'
import Copiable from 'copiable'
import React from 'react'
import TabButton from 'tab-button'
import { Container, Row, Col } from 'grid'
import { deleteVmGroup } from 'xo'
import { noop } from 'utils'
export default class TabAdvanced extends Component {
static contextTypes = {
router: React.PropTypes.object
}
_deleteVmGroup = (vmGroup, vms) => {
deleteVmGroup(vmGroup, vms).then(
() => this.context.router.push('home?s=&t=VmGroup'),
noop
)
}
render () {
const { vmGroup, vms } = this.props
return (
<Container>
<Row>
<Col className='text-xs-right'>
<TabButton
btnStyle='danger'
handler={() => this._deleteVmGroup(vmGroup, vms)}
icon='vm-delete'
labelId='vmRemoveButton'
/>
</Col>
<div>
<h3>{_('xenSettingsLabel')}</h3>
{ vmGroup &&
<table className='table'>
<tbody>
<tr>
<th>{_('uuid')}</th>
<Copiable tagName='td'>
{vmGroup.id}
</Copiable>
</tr>
</tbody>
</table>
}
</div>
</Row>
</Container>
)
}
}

View File

@@ -0,0 +1,67 @@
import _ from 'intl'
import forEach from 'lodash/forEach'
import Icon from 'icon'
import isEmpty from 'lodash/isEmpty'
import map from 'lodash/map'
import reduce from 'lodash/reduce'
import React from 'react'
import size from 'lodash/size'
import { connectStore, formatSize } from 'utils'
import { Container, Row, Col } from 'grid'
import { getObject } from 'selectors'
export default connectStore(() => {
const getMemoryTotal = (state, props) => {
const vdiIds = new Set()
forEach(props.vms, vm => forEach(vm.$VBDs, vbdId => vdiIds.add(getObject(state, vbdId).VDI)))
return reduce(Array.from(vdiIds), (sum, vdiId) => {
const vdi = getObject(state, vdiId)
return vdi !== undefined
? sum + vdi.size
: sum
}, 0)
}
const getMemoryDynamicTotal = props => reduce(props.vms, (sum, vm) => vm.memory.dynamic[1] + sum, 0)
const getNbCPU = props => reduce(props.vms, (sum, vm) => vm.CPUs.number + sum, 0)
return (state, props) => ({
memoryTotal: getMemoryTotal(state, props),
memoryDynamical: getMemoryDynamicTotal(props),
nbCPU: getNbCPU(props)
})
})(({ vms, memoryTotal, memoryDynamical, nbCPU, vmGroup }) => {
return (
<Container>
<br />
<div>
<Row className='text-xs-center'>
<Col mediumSize={3}>
<h2>{size(vms)}x <Icon icon='vm' size='lg' /></h2>
</Col>
<Col mediumSize={3}>
<h2>{nbCPU}x <Icon icon='cpu' size='lg' /></h2>
</Col>
<Col mediumSize={3}>
<h2 className='form-inline'>
{formatSize(memoryDynamical)}
&nbsp;<span><Icon icon='memory' size='lg' /></span>
</h2>
</Col>
<Col mediumSize={3}>
<h2>{formatSize(memoryTotal)} <Icon icon='disk' size='lg' /></h2>
</Col>
</Row>
{isEmpty(vmGroup.current_operations)
? null
: <Row className='text-xs-center'>
<Col>
<h4>{_('vmGroupCurrentStatus')}{' '}{map(vmGroup.current_operations)[0]}</h4>
</Col>
</Row>
}
</div>
</Container>
)
})

View File

@@ -0,0 +1,145 @@
import _ from 'intl'
import ActionButton from 'action-button'
import ActionRowButton from 'action-row-button'
import Component from 'base-component'
import DragNDropOrder from 'drag-n-drop-order'
import forEach from 'lodash/forEach'
import Icon from 'icon'
import isEmpty from 'lodash/isEmpty'
import Link from 'link'
import map from 'lodash/map'
import React from 'react'
import sortBy from 'lodash/sortBy'
import SortedTable from 'sorted-table'
import TabButton from 'tab-button'
import Tooltip from 'tooltip'
import { editVm, removeAppliance } from 'xo'
import { Row, Col } from 'grid'
import { SelectVm } from 'select-objects'
const VM_COLUMNS = [
{
name: _('vmGroupLabel'),
itemRenderer: vm => (<span><Tooltip
content={isEmpty(vm.current_operations)
? _(`powerState${vm.power_state}`)
: <div>{_(`powerState${vm.power_state}`)}{' ('}{map(vm.current_operations)[0]}{')'}</div>
}
>
{isEmpty(vm.current_operations)
? <Icon icon={`${vm.power_state.toLowerCase()}`} />
: <Icon icon='busy' />
}
</Tooltip>
&nbsp;
<Link to={`/vms/${vm.id}`}>
{vm.name_label}
</Link>
</span>),
sortCriteria: vm => vm.name_label
},
{
name: _('vmGroupDescription'),
itemRenderer: vm => vm.name_description,
sortCriteria: vm => vm.name_description
},
{
name: _('vmGroupActions'),
itemRenderer: vm => (
<ActionRowButton
btnStyle='danger'
handler={(vm) => removeAppliance(vm)}
handlerParam={vm}
icon='delete'
/>
)
}
]
export default class TabManagement extends Component {
constructor (props) {
super(props)
this.state = {
attachVm: false,
bootOrder: false
}
}
parseBootOrder = vms => {
// FIXME missing translation
var previousOrder = vms[Object.keys(vms)[0]].order
var toggleActive = false
const orderVms = sortBy(vms, vm => {
if (vm.order !== previousOrder) toggleActive = true
return vm.order
})
const order = []
forEach(orderVms, vm => {
order.push({id: vm.id, text: vm.name_label})
})
return {order, toggleActive}
}
setVmBootOrder = (vms, order, toggleActive) => {
var orderValue = 0
forEach(order, (vm, key) => {
editVm(vms[vm.id], { order: toggleActive ? orderValue : 0 })
orderValue += 1
})
}
_addVm = () => {
forEach(this.state.vmsToAdd, vm => editVm(vm, { appliance: this.props.vmGroup.id }))
}
_selectVm = vmsToAdd => this.setState({vmsToAdd})
_toggleBootOrder = () => this.setState({
bootOrder: !this.state.bootOrder,
attachVm: false
})
_toggleNewVm = () => this.setState({
attachVm: !this.state.attachVm,
bootOrder: false
})
_vmPredicate = vm => vm.appliance === null
render () {
const { vms } = this.props
const { attachVm, bootOrder } = this.state
return isEmpty(vms)
? <form id='attachVm'>
<SelectVm multi onChange={this._selectVm} predicate={this._vmPredicate} required />
<span className='pull-right'>
<ActionButton form='attachVm' icon='add' btnStyle='primary' handler={this._addVm}>{_('add')}</ActionButton>
</span>
</form>
: (<div>
<Row>
<Col className='text-xs-right'>
<TabButton
btnStyle={attachVm ? 'info' : 'primary'}
handler={this._toggleNewVm}
icon='add'
labelId='attachVmButton'
/>
<TabButton
btnStyle={bootOrder ? 'info' : 'primary'}
handler={this._toggleBootOrder}
icon='sort'
labelId='vmsBootOrder'
/>
</Col>
</Row>
{bootOrder && <div><DragNDropOrder parseOrderParam={vms} parseOrder={this.parseBootOrder} setOrder={this.setVmBootOrder} toggleItems={false} onClose={this._toggleBootOrder} /><hr /></div>}
{attachVm && <div>
<form id='attachVm'>
<SelectVm multi onChange={this._selectVm} predicate={this._vmPredicate} required />
<span className='pull-right'>
<ActionButton form='attachVm' icon='add' btnStyle='primary' handler={this._addVm}>{_('add')}</ActionButton>
</span>
</form>
</div>}
<SortedTable collection={vms} columns={VM_COLUMNS} />
</div>
)
}
}

View File

@@ -0,0 +1,138 @@
import _ from 'intl'
import Component from 'base-component'
import filter from 'lodash/filter'
import Icon from 'icon'
import map from 'lodash/map'
import React from 'react'
import Tooltip from 'tooltip'
import Upgrade from 'xoa-upgrade'
import { Container, Row, Col } from 'grid'
import { Toggle } from 'form'
import { fetchVmStats } from 'xo'
import {
VmGroupCpuLineChart,
VmGroupMemoryLineChart,
VmGroupVifLineChart,
VmGroupXvdLineChart
} from 'xo-line-chart'
export default class TabStats extends Component {
constructor (props) {
super(props)
this.state.useCombinedValues = false
}
loop (vmGroup = this.props.vmGroup) {
if (this.cancel) {
this.cancel()
}
let cancelled = false
this.cancel = () => { cancelled = true }
Promise.all(map(filter(this.props.vms, vm => vm.power_state === 'Running'), vm =>
fetchVmStats(vm, this.state.granularity).then(
stats => ({
vm: vm.name_label,
...stats
})
)
)).then(stats => {
if (cancelled || !stats[0]) {
return
}
this.cancel = null
clearTimeout(this.timeout)
this.setState({
stats,
selectStatsLoading: false
}, () => {
this.timeout = setTimeout(this.loop, stats[0].interval * 1000)
})
})
}
loop = ::this.loop
handleSelectStats (event) {
const granularity = event.target.value
clearTimeout(this.timeout)
this.setState({
granularity,
selectStatsLoading: true
}, this.loop)
}
handleSelectStats = ::this.handleSelectStats
componentWillMount () {
this.loop()
}
componentWillUnmount () {
clearTimeout(this.timeout)
}
render () {
const {
granularity,
selectStatsLoading,
stats,
useCombinedValues
} = this.state
return !stats
? <p>No stats.</p>
: process.env.XOA_PLAN > 2
? <Container>
<Row>
<Col mediumSize={5}>
<div className='form-group'>
<Tooltip content={_('useStackedValuesOnStats')}>
<Toggle value={useCombinedValues} onChange={this.linkState('useCombinedValues')} />
</Tooltip>
</div>
</Col>
<Col mediumSize={1}>
{selectStatsLoading && (
<div className='text-xs-right'>
<Icon icon='loading' size={2} />
</div>
)}
</Col>
<Col mediumSize={6}>
<div className='btn-tab'>
<select className='form-control' onChange={this.handleSelectStats} defaultValue={granularity} >
{_('statLastTenMinutes', message => <option value='seconds'>{message}</option>)}
{_('statLastTwoHours', message => <option value='minutes'>{message}</option>)}
{_('statLastWeek', message => <option value='hours'>{message}</option>)}
{_('statLastYear', message => <option value='days'>{message}</option>)}
</select>
</div>
</Col>
</Row>
<Row>
<Col mediumSize={6}>
<h5 className='text-xs-center'><Icon icon='cpu' size={1} /> {_('statsCpu')}</h5>
<VmGroupCpuLineChart addSumSeries={useCombinedValues} data={stats} />
</Col>
<Col mediumSize={6}>
<h5 className='text-xs-center'><Icon icon='memory' size={1} /> {_('statsMemory')}</h5>
<VmGroupMemoryLineChart addSumSeries={useCombinedValues} data={stats} />
</Col>
</Row>
<br />
<hr />
<Row>
<Col mediumSize={6}>
<h5 className='text-xs-center'><Icon icon='network' size={1} /> {_('statsNetwork')}</h5>
<VmGroupVifLineChart key={useCombinedValues ? 'stacked' : 'unstacked'} addSumSeries={useCombinedValues} data={stats} />
</Col>
<Col mediumSize={6}>
<h5 className='text-xs-center'><Icon icon='disk' size={1} /> {_('statDisk')}</h5>
<VmGroupXvdLineChart addSumSeries={useCombinedValues} data={stats} />
</Col>
</Row>
</Container>
: <Container><Upgrade place='hostStats' available={3} /></Container>
}
}

View File

@@ -9,7 +9,6 @@ import VmActionBar from './action-bar'
import { Select, Text } from 'editable'
import {
assign,
forEach,
isEmpty,
map,
pick
@@ -23,13 +22,14 @@ import {
import { Container, Row, Col } from 'grid'
import {
connectStore,
mapPlus,
routes
} from 'utils'
import {
createGetObject,
createGetObjectsOfType,
createGetVmDisks,
createSelector,
createSumBy,
getCheckPermissions,
isAdmin
} from 'selectors'
@@ -71,16 +71,7 @@ import TabAdvanced from './tab-advanced'
const getVbds = createGetObjectsOfType('VBD').pick(
(state, props) => getVm(state, props).$VBDs
).sort()
const getVdis = createGetObjectsOfType('VDI').pick(
createSelector(
getVbds,
vbds => mapPlus(vbds, (vbd, push) => {
if (!vbd.is_cd_drive && vbd.VDI) {
push(vbd.VDI)
}
})
)
)
const getVdis = createGetVmDisks(getVm)
const getSrs = createGetObjectsOfType('SR').pick(
createSelector(
getVdis,
@@ -88,15 +79,9 @@ import TabAdvanced from './tab-advanced'
)
)
const getVmTotalDiskSpace = createSelector(
getVdis,
vdis => {
let vmTotalDiskSpace = 0
forEach(vdis, vdi => {
vmTotalDiskSpace += vdi.size
})
return vmTotalDiskSpace
}
const getVmTotalDiskSpace = createSumBy(
createGetVmDisks(getVm),
'size'
)
const getHosts = createGetObjectsOfType('host')

View File

@@ -2,9 +2,8 @@ import _, { messages } from 'intl'
import ActionButton from 'action-button'
import ActionRowButton from 'action-row-button'
import Component from 'base-component'
import DragNDropOrder from 'drag-n-drop-order'
import forEach from 'lodash/forEach'
import HTML5Backend from 'react-dnd-html5-backend'
import Icon from 'icon'
import isEmpty from 'lodash/isEmpty'
import IsoDevice from 'iso-device'
import Link from 'link'
@@ -18,7 +17,6 @@ import TabButton from 'tab-button'
import Tooltip from 'tooltip'
import { Container, Row, Col } from 'grid'
import { createSelector } from 'selectors'
import { DragDropContext, DragSource, DropTarget } from 'react-dnd'
import { injectIntl } from 'react-intl'
import { noop } from 'utils'
import { SelectSr, SelectVdi } from 'select-objects'
@@ -41,26 +39,6 @@ import {
setVmBootOrder
} from 'xo'
const parseBootOrder = bootOrder => {
// FIXME missing translation
const bootOptions = {
c: 'Hard-Drive',
d: 'DVD-Drive',
n: 'Network'
}
const order = []
if (bootOrder) {
for (const id of bootOrder) {
if (id in bootOptions) {
order.push({id, text: bootOptions[id], active: true})
delete bootOptions[id]
}
}
}
forEach(bootOptions, (text, id) => { order.push({id, text, active: false}) })
return order
}
@injectIntl
@propTypes({
onClose: propTypes.func,
@@ -196,129 +174,6 @@ class AttachDisk extends Component {
}
}
const orderItemSource = {
beginDrag: props => ({
id: props.id,
index: props.index
})
}
const orderItemTarget = {
hover: (props, monitor, component) => {
const dragIndex = monitor.getItem().index
const hoverIndex = props.index
if (dragIndex === hoverIndex) {
return
}
props.move(dragIndex, hoverIndex)
monitor.getItem().index = hoverIndex
}
}
@DropTarget('orderItem', orderItemTarget, connect => ({
connectDropTarget: connect.dropTarget()
}))
@DragSource('orderItem', orderItemSource, (connect, monitor) => ({
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging()
}))
@propTypes({
connectDragSource: propTypes.func.isRequired,
connectDropTarget: propTypes.func.isRequired,
index: propTypes.number.isRequired,
isDragging: propTypes.bool.isRequired,
id: propTypes.any.isRequired,
item: propTypes.object.isRequired,
move: propTypes.func.isRequired
})
class OrderItem extends Component {
_toggle = checked => {
const { item } = this.props
item.active = checked
this.forceUpdate()
}
render () {
const { item, connectDragSource, connectDropTarget } = this.props
return connectDragSource(connectDropTarget(
<li className='list-group-item'>
<Icon icon='grab' />
{' '}
<Icon icon='grab' />
{' '}
{item.text}
<span className='pull-right'>
<Toggle value={item.active} onChange={this._toggle} />
</span>
</li>
))
}
}
@propTypes({
onClose: propTypes.func,
vm: propTypes.object.isRequired
})
@DragDropContext(HTML5Backend)
class BootOrder extends Component {
constructor (props) {
super(props)
const { vm } = props
const order = parseBootOrder(vm.boot && vm.boot.order)
this.state = {order}
}
_moveOrderItem = (dragIndex, hoverIndex) => {
const order = this.state.order.slice()
const dragItem = order.splice(dragIndex, 1)
if (dragItem.length) {
order.splice(hoverIndex, 0, dragItem.pop())
this.setState({order})
}
}
_reset = () => {
const { vm } = this.props
const order = parseBootOrder(vm.boot && vm.boot.order)
this.setState({order})
}
_save = () => {
const { vm, onClose = noop } = this.props
const { order: newOrder } = this.state
let order = ''
forEach(newOrder, item => { item.active && (order += item.id) })
return setVmBootOrder(vm, order)
.then(onClose)
}
render () {
const { order } = this.state
return <form>
<ul>
{map(order, (item, index) => <OrderItem
key={index}
index={index}
id={item.id}
// FIXME missing translation
item={item}
move={this._moveOrderItem}
/>)}
</ul>
<fieldset className='form-inline'>
<span className='pull-right'>
<ActionButton icon='save' btnStyle='primary' handler={this._save}>{_('saveBootOption')}</ActionButton>
{' '}
<ActionButton icon='reset' handler={this._reset}>{_('resetBootOption')}</ActionButton>
</span>
</fieldset>
</form>
}
}
class MigrateVdiModalBody extends Component {
get value () {
return this.state
@@ -360,6 +215,34 @@ export default class TabDisks extends Component {
}
}
parseBootOrder = bootOrder => {
// FIXME missing translation
const bootOptions = {
c: 'Hard-Drive',
d: 'DVD-Drive',
n: 'Network'
}
const order = []
if (bootOrder) {
for (const id of bootOrder) {
if (id in bootOptions) {
order.push({id, text: bootOptions[id], active: true})
delete bootOptions[id]
}
}
}
forEach(bootOptions, (text, id) => { order.push({id, text, active: false}) })
return {order}
}
setVmBootOrder = (_, newOrder) => {
const { vm } = this.props
let order = ''
forEach(newOrder, item => { item.active && (order += item.id) })
return setVmBootOrder(vm, order)
.then(noop)
}
_toggleNewDisk = () => this.setState({
newDisk: !this.state.newDisk,
attachDisk: false,
@@ -432,8 +315,8 @@ export default class TabDisks extends Component {
<Row>
<Col>
{newDisk && <div><NewDisk vm={vm} onClose={this._toggleNewDisk} /><hr /></div>}
{attachDisk && <div><AttachDisk vm={vm} vbds={vbds} onClose={this._toggleAttachDisk} /><hr /></div>}
{bootOrder && <div><BootOrder vm={vm} onClose={this._toggleBootOrder} /><hr /></div>}
{attachDisk && <div><AttachDisk vm={vm} vbds={vbds} /><hr /></div>}
{bootOrder && <div><DragNDropOrder parseOrderParam={vm.boot && vm.boot.order} parseOrder={this.parseBootOrder} setOrder={this.setVmBootOrder} toggleItems onClose={this._toggleBootOrder} /><hr /></div>}
</Col>
</Row>
<Row>

4071
yarn.lock

File diff suppressed because it is too large Load Diff