Compare commits
52 Commits
fs-v0.6.1
...
xo-web-v5.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54de382f78 | ||
|
|
30aa2b83d0 | ||
|
|
fc42c58079 | ||
|
|
ee9443cf16 | ||
|
|
f91d4a07eb | ||
|
|
c5a5ef6c93 | ||
|
|
7559fbdab7 | ||
|
|
7925ee8fee | ||
|
|
fea5117ed8 | ||
|
|
468a2c5bf3 | ||
|
|
c728eeaffa | ||
|
|
6aa8e0d4ce | ||
|
|
76ae54ff05 | ||
|
|
344e9e06d0 | ||
|
|
d866bccf3b | ||
|
|
3931c4cf4c | ||
|
|
420f1c77a1 | ||
|
|
59106aa29e | ||
|
|
4216a5808a | ||
|
|
12a7000e36 | ||
|
|
685355c6fb | ||
|
|
66f685165e | ||
|
|
8e8b1c009a | ||
|
|
705d069246 | ||
|
|
58e8d75935 | ||
|
|
5eb1454e67 | ||
|
|
04b31db41b | ||
|
|
29b4cf414a | ||
|
|
7a2a88b7ad | ||
|
|
dc34f3478d | ||
|
|
58175a4f5e | ||
|
|
c4587c11bd | ||
|
|
5b1a5f4fe7 | ||
|
|
ee2db918f3 | ||
|
|
0695bafb90 | ||
|
|
8e116063bf | ||
|
|
3f3b372f89 | ||
|
|
24cc1e8e29 | ||
|
|
e988ad4df9 | ||
|
|
5c12d4a546 | ||
|
|
d90b85204d | ||
|
|
6332355031 | ||
|
|
4ce702dfdf | ||
|
|
362a381dfb | ||
|
|
0eec4ee2f7 | ||
|
|
b92390087b | ||
|
|
bce4d5d96f | ||
|
|
27262ff3e8 | ||
|
|
444b6642f1 | ||
|
|
67d11020bb | ||
|
|
7603974370 | ||
|
|
6cb5639243 |
11
.eslintrc.js
11
.eslintrc.js
@@ -1,5 +1,11 @@
|
||||
module.exports = {
|
||||
extends: ['standard', 'standard-jsx', 'prettier'],
|
||||
extends: [
|
||||
'standard',
|
||||
'standard-jsx',
|
||||
'prettier',
|
||||
'prettier/standard',
|
||||
'prettier/react',
|
||||
],
|
||||
globals: {
|
||||
__DEV__: true,
|
||||
$Dict: true,
|
||||
@@ -21,8 +27,5 @@ module.exports = {
|
||||
'node/no-extraneous-import': 'error',
|
||||
'node/no-extraneous-require': 'error',
|
||||
'prefer-const': 'error',
|
||||
|
||||
// See https://github.com/prettier/eslint-config-prettier/issues/65
|
||||
'react/jsx-indent': 'off',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/async-map",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@xen-orchestra/async-map",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/babel-config",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@xen-orchestra/babel-config",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
}
|
||||
|
||||
@@ -82,35 +82,26 @@ ${cliName} v${pkg.version}
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
srcXapi.setFieldEntries(srcSnapshot, 'other_config', metadata),
|
||||
srcXapi.setFieldEntries(srcSnapshot, 'other_config', {
|
||||
'xo:backup:exported': 'true',
|
||||
}),
|
||||
tgtXapi.setField(
|
||||
tgtVm,
|
||||
'name_label',
|
||||
`${srcVm.name_label} (${srcSnapshot.snapshot_time})`
|
||||
),
|
||||
tgtXapi.setFieldEntries(tgtVm, 'other_config', metadata),
|
||||
tgtXapi.setFieldEntries(tgtVm, 'other_config', {
|
||||
srcSnapshot.update_other_config(metadata),
|
||||
srcSnapshot.update_other_config('xo:backup:exported', 'true'),
|
||||
tgtVm.set_name_label(`${srcVm.name_label} (${srcSnapshot.snapshot_time})`),
|
||||
tgtVm.update_other_config(metadata),
|
||||
tgtVm.update_other_config({
|
||||
'xo:backup:sr': tgtSr.uuid,
|
||||
'xo:copy_of': srcSnapshotUuid,
|
||||
}),
|
||||
tgtXapi.setFieldEntries(tgtVm, 'blocked_operations', {
|
||||
start:
|
||||
'Start operation for this vm is blocked, clone it if you want to use it.',
|
||||
}),
|
||||
tgtVm.update_blocked_operations(
|
||||
'start',
|
||||
'Start operation for this vm is blocked, clone it if you want to use it.'
|
||||
),
|
||||
Promise.all(
|
||||
userDevices.map(userDevice => {
|
||||
const srcDisk = srcDisks[userDevice]
|
||||
const tgtDisk = tgtDisks[userDevice]
|
||||
|
||||
return tgtXapi.setFieldEntry(
|
||||
tgtDisk,
|
||||
'other_config',
|
||||
'xo:copy_of',
|
||||
srcDisk.uuid
|
||||
)
|
||||
return tgtDisk.update_other_config({
|
||||
'xo:copy_of': srcDisk.uuid,
|
||||
})
|
||||
})
|
||||
),
|
||||
])
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/cr-seed-cli",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@xen-orchestra/cr-seed-cli",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
@@ -15,6 +16,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"golike-defer": "^0.4.1",
|
||||
"xen-api": "^0.24.2"
|
||||
"xen-api": "^0.24.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/cron",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@xen-orchestra/cron",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/defined",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@xen-orchestra/defined",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/emit-async",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@xen-orchestra/emit-async",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "@xen-orchestra/fs",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "The File System for Xen Orchestra backups.",
|
||||
"keywords": [],
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@xen-orchestra/fs",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
@@ -23,6 +24,7 @@
|
||||
"@marsaud/smb2": "^0.13.0",
|
||||
"@sindresorhus/df": "^2.1.0",
|
||||
"@xen-orchestra/async-map": "^0.0.0",
|
||||
"decorator-synchronized": "^0.3.0",
|
||||
"execa": "^1.0.0",
|
||||
"fs-extra": "^7.0.0",
|
||||
"get-stream": "^4.0.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import execa from 'execa'
|
||||
import fs from 'fs-extra'
|
||||
import { ignoreErrors } from 'promise-toolbox'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
@@ -21,11 +22,12 @@ export default class MountHandler extends LocalHandler {
|
||||
super(remote, opts)
|
||||
|
||||
this._execa = useSudo ? sudoExeca : execa
|
||||
this._keeper = undefined
|
||||
this._params = {
|
||||
...params,
|
||||
options: [params.options, remote.options].filter(
|
||||
_ => _ !== undefined
|
||||
).join(','),
|
||||
options: [params.options, remote.options]
|
||||
.filter(_ => _ !== undefined)
|
||||
.join(','),
|
||||
}
|
||||
this._realPath = join(
|
||||
mountsDir,
|
||||
@@ -37,19 +39,20 @@ export default class MountHandler extends LocalHandler {
|
||||
}
|
||||
|
||||
async _forget() {
|
||||
await this._execa('umount', ['--force', this._getRealPath()], {
|
||||
env: {
|
||||
LANG: 'C',
|
||||
},
|
||||
}).catch(error => {
|
||||
if (
|
||||
error == null ||
|
||||
typeof error.stderr !== 'string' ||
|
||||
!error.stderr.includes('not mounted')
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
const keeper = this._keeper
|
||||
if (keeper === undefined) {
|
||||
return
|
||||
}
|
||||
this._keeper = undefined
|
||||
await fs.close(keeper)
|
||||
|
||||
await ignoreErrors.call(
|
||||
this._execa('umount', [this._getRealPath()], {
|
||||
env: {
|
||||
LANG: 'C',
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
_getRealPath() {
|
||||
@@ -57,26 +60,49 @@ export default class MountHandler extends LocalHandler {
|
||||
}
|
||||
|
||||
async _sync() {
|
||||
await fs.ensureDir(this._getRealPath())
|
||||
const { type, device, options, env } = this._params
|
||||
return this._execa(
|
||||
'mount',
|
||||
['-t', type, device, this._getRealPath(), '-o', options],
|
||||
{
|
||||
env: {
|
||||
LANG: 'C',
|
||||
...env,
|
||||
},
|
||||
// in case of multiple `sync`s, ensure we properly close previous keeper
|
||||
{
|
||||
const keeper = this._keeper
|
||||
if (keeper !== undefined) {
|
||||
this._keeper = undefined
|
||||
ignoreErrors.call(fs.close(keeper))
|
||||
}
|
||||
).catch(error => {
|
||||
let stderr
|
||||
if (
|
||||
error == null ||
|
||||
typeof (stderr = error.stderr) !== 'string' ||
|
||||
!(stderr.includes('already mounted') || stderr.includes('busy'))
|
||||
) {
|
||||
}
|
||||
|
||||
const realPath = this._getRealPath()
|
||||
|
||||
await fs.ensureDir(realPath)
|
||||
|
||||
try {
|
||||
const { type, device, options, env } = this._params
|
||||
await this._execa(
|
||||
'mount',
|
||||
['-t', type, device, realPath, '-o', options],
|
||||
{
|
||||
env: {
|
||||
LANG: 'C',
|
||||
...env,
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
try {
|
||||
// the failure may mean it's already mounted, use `findmnt` to check
|
||||
// that's the case
|
||||
await this._execa('findmnt', ['--target', realPath], {
|
||||
stdio: 'ignore',
|
||||
})
|
||||
} catch (_) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// keep an open file on the mount to prevent it from being unmounted if used
|
||||
// by another handler/process
|
||||
const keeperPath = `${realPath}/.keeper_${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`
|
||||
this._keeper = await fs.open(keeperPath, 'w')
|
||||
ignoreErrors.call(fs.unlink(keeperPath))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import getStream from 'get-stream'
|
||||
|
||||
import asyncMap from '@xen-orchestra/async-map'
|
||||
import path from 'path'
|
||||
import synchronized from 'decorator-synchronized'
|
||||
import { fromCallback, fromEvent, ignoreErrors, timeout } from 'promise-toolbox'
|
||||
import { parse } from 'xo-remote-parser'
|
||||
import { randomBytes } from 'crypto'
|
||||
@@ -34,18 +35,18 @@ const ignoreEnoent = error => {
|
||||
}
|
||||
|
||||
class PrefixWrapper {
|
||||
constructor(remote, prefix) {
|
||||
constructor(handler, prefix) {
|
||||
this._prefix = prefix
|
||||
this._remote = remote
|
||||
this._handler = handler
|
||||
}
|
||||
|
||||
get type() {
|
||||
return this._remote.type
|
||||
return this._handler.type
|
||||
}
|
||||
|
||||
// necessary to remove the prefix from the path with `prependDir` option
|
||||
async list(dir, opts) {
|
||||
const entries = await this._remote.list(this._resolve(dir), opts)
|
||||
const entries = await this._handler.list(this._resolve(dir), opts)
|
||||
if (opts != null && opts.prependDir) {
|
||||
const n = this._prefix.length
|
||||
entries.forEach((entry, i, entries) => {
|
||||
@@ -56,7 +57,7 @@ class PrefixWrapper {
|
||||
}
|
||||
|
||||
rename(oldPath, newPath) {
|
||||
return this._remote.rename(this._resolve(oldPath), this._resolve(newPath))
|
||||
return this._handler.rename(this._resolve(oldPath), this._resolve(newPath))
|
||||
}
|
||||
|
||||
_resolve(path) {
|
||||
@@ -216,6 +217,7 @@ export default class RemoteHandlerAbstract {
|
||||
// FIXME: Some handlers are implemented based on system-wide mecanisms (such
|
||||
// as mount), forgetting them might breaking other processes using the same
|
||||
// remote.
|
||||
@synchronized()
|
||||
async forget(): Promise<void> {
|
||||
await this._forget()
|
||||
}
|
||||
@@ -354,6 +356,7 @@ export default class RemoteHandlerAbstract {
|
||||
// metadata
|
||||
//
|
||||
// This method MUST ALWAYS be called before using the handler.
|
||||
@synchronized()
|
||||
async sync(): Promise<void> {
|
||||
await this._sync()
|
||||
}
|
||||
@@ -565,7 +568,7 @@ function createPrefixWrapperMethods() {
|
||||
if (arguments.length !== 0 && typeof (path = arguments[0]) === 'string') {
|
||||
arguments[0] = this._resolve(path)
|
||||
}
|
||||
return value.apply(this._remote, arguments)
|
||||
return value.apply(this._handler, arguments)
|
||||
}
|
||||
|
||||
defineProperty(pPw, name, descriptor)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/log",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@xen-orchestra/log",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/mixin",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@xen-orchestra/mixin",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -1,5 +1,35 @@
|
||||
# ChangeLog
|
||||
|
||||
## **5.31.2** (2019-02-08)
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [Home] Set description on bulk snapshot [#3925](https://github.com/vatesfr/xen-orchestra/issues/3925) (PR [#3933](https://github.com/vatesfr/xen-orchestra/pull/3933))
|
||||
- Work-around the XenServer issue when `VBD#VDI` is an empty string instead of an opaque reference (PR [#3950](https://github.com/vatesfr/xen-orchestra/pull/3950))
|
||||
- [VDI migration] Retry when XenServer fails with `TOO_MANY_STORAGE_MIGRATES` (PR [#3940](https://github.com/vatesfr/xen-orchestra/pull/3940))
|
||||
- [VM]
|
||||
- [General] The creation date of the VM is now visible [#3932](https://github.com/vatesfr/xen-orchestra/issues/3932) (PR [#3947](https://github.com/vatesfr/xen-orchestra/pull/3947))
|
||||
- [Disks] Display device name [#3902](https://github.com/vatesfr/xen-orchestra/issues/3902) (PR [#3946](https://github.com/vatesfr/xen-orchestra/pull/3946))
|
||||
- [VM Snapshotting]
|
||||
- Detect and destroy broken quiesced snapshot left by XenServer [#3936](https://github.com/vatesfr/xen-orchestra/issues/3936) (PR [#3937](https://github.com/vatesfr/xen-orchestra/pull/3937))
|
||||
- Retry twice after a 1 minute delay if quiesce failed [#3938](https://github.com/vatesfr/xen-orchestra/issues/3938) (PR [#3952](https://github.com/vatesfr/xen-orchestra/pull/3952))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Import] Fix import of big OVA files
|
||||
- [Host] Show the host's memory usage instead of the sum of the VMs' memory usage (PR [#3924](https://github.com/vatesfr/xen-orchestra/pull/3924))
|
||||
- [SAML] Make `AssertionConsumerServiceURL` matches the callback URL
|
||||
- [Backup NG] Correctly delete broken VHD chains [#3875](https://github.com/vatesfr/xen-orchestra/issues/3875) (PR [#3939](https://github.com/vatesfr/xen-orchestra/pull/3939))
|
||||
- [Remotes] Don't ignore `mount` options [#3935](https://github.com/vatesfr/xen-orchestra/issues/3935) (PR [#3931](https://github.com/vatesfr/xen-orchestra/pull/3931))
|
||||
|
||||
### Released packages
|
||||
|
||||
- xen-api v0.24.2
|
||||
- @xen-orchestra/fs v0.6.1
|
||||
- xo-server-auth-saml v0.5.3
|
||||
- xo-server v5.35.0
|
||||
- xo-web v5.35.0
|
||||
|
||||
## **5.31.0** (2019-01-31)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -2,28 +2,27 @@
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [Home] Set description on bulk snapshot [#3925](https://github.com/vatesfr/xen-orchestra/issues/3925) (PR [#3933](https://github.com/vatesfr/xen-orchestra/pull/3933))
|
||||
- Work-around the XenServer issue when `VBD#VDI` is an empty string instead of an opaque reference (PR [#3950](https://github.com/vatesfr/xen-orchestra/pull/3950))
|
||||
- [VDI migration] Retry when XenServer fails with `TOO_MANY_STORAGE_MIGRATES` (PR [#3940](https://github.com/vatesfr/xen-orchestra/pull/3940))
|
||||
- [VM]
|
||||
- [General] The creation date of the VM is now visible [#3932](https://github.com/vatesfr/xen-orchestra/issues/3932) (PR [#3947](https://github.com/vatesfr/xen-orchestra/pull/3947))
|
||||
- [Disks] Display device name [#3902](https://github.com/vatesfr/xen-orchestra/issues/3902) (PR [#3946](https://github.com/vatesfr/xen-orchestra/pull/3946))
|
||||
- [VM Snapshotting]
|
||||
- Detect and destroy broken quiesced snapshot left by XenServer [#3936](https://github.com/vatesfr/xen-orchestra/issues/3936) (PR [#3937](https://github.com/vatesfr/xen-orchestra/pull/3937))
|
||||
- Retry twice after a 1 minute delay if quiesce failed [#3938](https://github.com/vatesfr/xen-orchestra/issues/3938) (PR [#3952](https://github.com/vatesfr/xen-orchestra/pull/3952))
|
||||
- [VM migration] Display same-pool hosts first in the selector [#3262](https://github.com/vatesfr/xen-orchestra/issues/3262) (PR [#3890](https://github.com/vatesfr/xen-orchestra/pull/3890))
|
||||
- [Home/VM] Sort VM by start time [#3955](https://github.com/vatesfr/xen-orchestra/issues/3955) (PR [#3970](https://github.com/vatesfr/xen-orchestra/pull/3970))
|
||||
- [Editable fields] Unfocusing (clicking outside) submits the change instead of canceling (PR [#3980](https://github.com/vatesfr/xen-orchestra/pull/3980))
|
||||
- [Network] Dedicated page for network creation [#3895](https://github.com/vatesfr/xen-orchestra/issues/3895) (PR [#3906](https://github.com/vatesfr/xen-orchestra/pull/3906))
|
||||
- [Logs] Add button to download the log [#3957](https://github.com/vatesfr/xen-orchestra/issues/3957) (PR [#3985](https://github.com/vatesfr/xen-orchestra/pull/3985))
|
||||
- [Continuous Replication] Share full copy between schedules [#3973](https://github.com/vatesfr/xen-orchestra/issues/3973) (PR [#3995](https://github.com/vatesfr/xen-orchestra/pull/3995))
|
||||
- [Backup] Ability to backup XO configuration and pool metadata [#808](https://github.com/vatesfr/xen-orchestra/issues/808) [#3501](https://github.com/vatesfr/xen-orchestra/issues/3501) (PR [#3912](https://github.com/vatesfr/xen-orchestra/pull/3912))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Import] Fix import of big OVA files
|
||||
- [Host] Show the host's memory usage instead of the sum of the VMs' memory usage (PR [#3924](https://github.com/vatesfr/xen-orchestra/pull/3924))
|
||||
- [SAML] Make `AssertionConsumerServiceURL` matches the callback URL
|
||||
- [Backup NG] Correctly delete broken VHD chains [#3875](https://github.com/vatesfr/xen-orchestra/issues/3875) (PR [#3939](https://github.com/vatesfr/xen-orchestra/pull/3939))
|
||||
- [Remotes] Don't ignore `mount` options [#3935](https://github.com/vatesfr/xen-orchestra/issues/3935) (PR [#3931](https://github.com/vatesfr/xen-orchestra/pull/3931))
|
||||
- [Host] Fix multipathing status for XenServer < 7.5 [#3956](https://github.com/vatesfr/xen-orchestra/issues/3956) (PR [#3961](https://github.com/vatesfr/xen-orchestra/pull/3961))
|
||||
- [Home/VM] Show creation date of the VM on if it available [#3953](https://github.com/vatesfr/xen-orchestra/issues/3953) (PR [#3959](https://github.com/vatesfr/xen-orchestra/pull/3959))
|
||||
- [Notifications] Fix invalid notifications when not registered (PR [#3966](https://github.com/vatesfr/xen-orchestra/pull/3966))
|
||||
- [Import] Fix import of some OVA files [#3962](https://github.com/vatesfr/xen-orchestra/issues/3962) (PR [#3974](https://github.com/vatesfr/xen-orchestra/pull/3974))
|
||||
- [Servers] Fix *already connected error* after a server has been removed during connection [#3976](https://github.com/vatesfr/xen-orchestra/issues/3976) (PR [#3977](https://github.com/vatesfr/xen-orchestra/pull/3977))
|
||||
- [Backup] Fix random _mount_ issues with NFS/SMB remotes [#3973](https://github.com/vatesfr/xen-orchestra/issues/3973) (PR [#4003](https://github.com/vatesfr/xen-orchestra/pull/4003))
|
||||
|
||||
### Released packages
|
||||
|
||||
- xen-api v0.24.2
|
||||
- @xen-orchestra/fs v0.6.1
|
||||
- xo-server-auth-saml v0.5.3
|
||||
- xo-server v5.35.0
|
||||
- xo-web v5.35.0
|
||||
- @xen-orchestra/fs v0.7.0
|
||||
- xen-api v0.24.3
|
||||
- xoa-updater v0.15.2
|
||||
- xo-server v5.36.0
|
||||
- xo-web v5.36.0
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
* [Job manager](scheduler.md)
|
||||
* [Alerts](alerts.md)
|
||||
* [Load balancing](load_balancing.md)
|
||||
* [Emergency Shutdown](emergency_shutdown.md)
|
||||
* [Auto scalability](auto_scalability.md)
|
||||
* [Forecaster](forecaster.md)
|
||||
* [Recipes](recipes.md)
|
||||
|
||||
BIN
docs/assets/e-shutdown-1.png
Normal file
BIN
docs/assets/e-shutdown-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
docs/assets/e-shutdown-2.png
Normal file
BIN
docs/assets/e-shutdown-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
docs/assets/e-shutdown-3.png
Normal file
BIN
docs/assets/e-shutdown-3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -41,7 +41,7 @@ Each backups' job execution is identified by a `runId`. You can find this `runId
|
||||
|
||||
All backup types rely on snapshots. But what about data consistency? By default, Xen Orchestra will try to take a **quiesced snapshot** every time a snapshot is done (and fall back to normal snapshots if it's not possible).
|
||||
|
||||
Snapshots of Windows VMs can be quiesced (especially MS SQL or Exchange services) after you have installed Xen Tools in your VMs. However, [there is an extra step to install the VSS provider on windows](quiesce). A quiesced snapshot means the operating system will be notified and the cache will be flushed to disks. This way, your backups will always be consistent.
|
||||
Snapshots of Windows VMs can be quiesced (especially MS SQL or Exchange services) after you have installed Xen Tools in your VMs. However, [there is an extra step to install the VSS provider on windows](https://xen-orchestra.com/blog/xenserver-quiesce-snapshots/). A quiesced snapshot means the operating system will be notified and the cache will be flushed to disks. This way, your backups will always be consistent.
|
||||
|
||||
To see if you have quiesced snapshots for a VM, just go into its snapshot tab, then the "info" icon means it is a quiesced snapshot:
|
||||
|
||||
|
||||
27
docs/emergency_shutdown.md
Normal file
27
docs/emergency_shutdown.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Emergency Shutdown
|
||||
|
||||
If you have a UPS for your hosts, and lose power, you may have a limited amount of time to shut down all of your VM infrastructure before the batteries run out. If you find yourself in this situation, or any other situation requiring the fast shutdown of everything, you can use the **Emergency Shutdown** feature.
|
||||
|
||||
## How to activate
|
||||
On the host view, clicking on this button will trigger the _Emergency Shutdown_ procedure:
|
||||
|
||||

|
||||
|
||||
1. **All running VMs will be suspended** (think of it like "hibernate" on your laptop: the RAM will be stored in the storage repository).
|
||||
2. Only after this is complete, the host will be halted.
|
||||
|
||||
Here, you can see the running VMs are being suspended:
|
||||
|
||||

|
||||
|
||||
And finally, that's it. They are cleanly shut down with the RAM saved to disk to be resumed later:
|
||||
|
||||

|
||||
|
||||
Now the host is halted automatically.
|
||||
|
||||
## Powering back on
|
||||
When the power outage is over, all you need to do is:
|
||||
|
||||
1. Start your host.
|
||||
2. All your VMs can be resumed, your RAM is preserved and therefore your VMs will be in the exact same state as they were before the power outage.
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
> Please take time to read this guide carefully.
|
||||
|
||||
This installation has been validated against a fresh Debian 8 (Jessie) x64 install. It should be nearly the same on other dpkg systems. For RPM based OS's, it should be close, as most of our dependencies come from NPM and not the OS itself.
|
||||
This installation has been validated against a fresh Debian 9 (Stretch) x64 install. It should be nearly the same on other dpkg systems. For RPM based OS's, it should be close, as most of our dependencies come from NPM and not the OS itself.
|
||||
|
||||
As you may have seen,in other parts of the documentation, XO is composed of two parts: [xo-server](https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server/) and [xo-web](https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-web/). They can be installed separately, even on different machines, but for the sake of simplicity we will set them up together.
|
||||
As you may have seen in other parts of the documentation, XO is composed of two parts: [xo-server](https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server/) and [xo-web](https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-web/). They can be installed separately, even on different machines, but for the sake of simplicity we will set them up together.
|
||||
|
||||
## Packages and Pre-requisites
|
||||
|
||||
@@ -49,13 +49,14 @@ You need to use the `git` source code manager to fetch the code. Ideally, you sh
|
||||
git clone -b master http://github.com/vatesfr/xen-orchestra
|
||||
```
|
||||
|
||||
> Note: xo-server and xo-web have been migrated to the [xen-orchestra](https://github.com/vatesfr/xen-orchestra) mono-repository.
|
||||
> Note: xo-server and xo-web have been migrated to the [xen-orchestra](https://github.com/vatesfr/xen-orchestra) mono-repository - so you only need the single clone command above
|
||||
|
||||
## Installing dependencies
|
||||
|
||||
Once you have it, use `yarn`, as the non-root (or root) user owning the fetched code, to install the other dependencies. Enter the `xen-orchestra` directory and run the following commands:
|
||||
Now that you have the code, you can enter the `xen-orchestra` directory and use `yarn` to install other dependencies. Then finally build it using `yarn build`. Be sure to run `yarn` commands as the same user you will be using to run Xen Orchestra:
|
||||
|
||||
```
|
||||
$ cd xen-orchestra
|
||||
$ yarn
|
||||
$ yarn build
|
||||
```
|
||||
@@ -86,7 +87,7 @@ WebServer listening on localhost:80
|
||||
|
||||
## Running XO
|
||||
|
||||
The only part you need to launch is xo-server which is quite easy to do. From the `xen-orchestra/packages/xo-server` directory, run the following:
|
||||
The only part you need to launch is xo-server, which is quite easy to do. From the `xen-orchestra/packages/xo-server` directory, run the following:
|
||||
|
||||
```
|
||||
$ yarn start
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/complex-matcher",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/complex-matcher",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/value-matcher",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/value-matcher",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/vhd-cli",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/vhd-cli",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
@@ -26,7 +27,7 @@
|
||||
"node": ">=6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/fs": "^0.6.1",
|
||||
"@xen-orchestra/fs": "^0.7.0",
|
||||
"cli-progress": "^2.0.0",
|
||||
"exec-promise": "^0.7.0",
|
||||
"getopts": "^2.2.3",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/vhd-lib",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/vhd-lib",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
@@ -34,7 +35,7 @@
|
||||
"@babel/core": "^7.0.0",
|
||||
"@babel/preset-env": "^7.0.0",
|
||||
"@babel/preset-flow": "^7.0.0",
|
||||
"@xen-orchestra/fs": "^0.6.1",
|
||||
"@xen-orchestra/fs": "^0.7.0",
|
||||
"babel-plugin-lodash": "^3.3.2",
|
||||
"cross-env": "^5.1.3",
|
||||
"execa": "^1.0.0",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xapi-explore-sr",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xapi-explore-sr",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
@@ -40,7 +41,7 @@
|
||||
"human-format": "^0.10.0",
|
||||
"lodash": "^4.17.4",
|
||||
"pw": "^0.0.4",
|
||||
"xen-api": "^0.24.2"
|
||||
"xen-api": "^0.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.1.5",
|
||||
|
||||
@@ -95,7 +95,7 @@ root@xen1.company.net> xapi.pool.$master.name_label
|
||||
To ease searches, `find()` and `findAll()` functions are available:
|
||||
|
||||
```
|
||||
root@xen1.company.net> findAll({ $type: 'vm' }).length
|
||||
root@xen1.company.net> findAll({ $type: 'VM' }).length
|
||||
183
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xen-api",
|
||||
"version": "0.24.2",
|
||||
"version": "0.24.3",
|
||||
"license": "ISC",
|
||||
"description": "Connector to the Xen API",
|
||||
"keywords": [
|
||||
@@ -13,6 +13,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xen-api",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xen-api",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
@@ -36,7 +37,7 @@
|
||||
"debug": "^4.0.1",
|
||||
"event-to-promise": "^0.8.0",
|
||||
"exec-promise": "^0.7.0",
|
||||
"http-request-plus": "^0.7.1",
|
||||
"http-request-plus": "^0.7.2",
|
||||
"iterable-backoff": "^0.0.0",
|
||||
"jest-diff": "^23.5.0",
|
||||
"json-rpc-protocol": "^0.13.1",
|
||||
|
||||
@@ -7,8 +7,8 @@ import { BaseError } from 'make-error'
|
||||
import { EventEmitter } from 'events'
|
||||
import { fibonacci } from 'iterable-backoff'
|
||||
import {
|
||||
filter,
|
||||
forEach,
|
||||
forOwn,
|
||||
isArray,
|
||||
isInteger,
|
||||
map,
|
||||
@@ -248,6 +248,11 @@ const RESERVED_FIELDS = {
|
||||
pool: true,
|
||||
ref: true,
|
||||
type: true,
|
||||
xapi: true,
|
||||
}
|
||||
|
||||
function getPool() {
|
||||
return this.$xapi.pool
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -266,17 +271,14 @@ export class Xapi extends EventEmitter {
|
||||
super()
|
||||
|
||||
this._allowUnauthorized = opts.allowUnauthorized
|
||||
this._auth = opts.auth
|
||||
this._callTimeout = makeCallSetting(opts.callTimeout, 0)
|
||||
this._debounce = opts.debounce == null ? 200 : opts.debounce
|
||||
this._pool = null
|
||||
this._readOnly = Boolean(opts.readOnly)
|
||||
this._RecordsByType = createObject(null)
|
||||
this._sessionId = null
|
||||
;(this._objects = new Collection()).getKey = getKey
|
||||
;(this._objectsByRef = createObject(null))[NULL_REF] = undefined
|
||||
const url = (this._url = parseUrl(opts.url))
|
||||
|
||||
this._auth = opts.auth
|
||||
const url = (this._url = parseUrl(opts.url))
|
||||
if (this._auth === undefined) {
|
||||
const user = url.username
|
||||
if (user !== undefined) {
|
||||
@@ -289,10 +291,19 @@ export class Xapi extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Memoize this function _addObject().
|
||||
this._getPool = () => this._pool
|
||||
;(this._objects = new Collection()).getKey = getKey
|
||||
this._debounce = opts.debounce == null ? 200 : opts.debounce
|
||||
this._watchedTypes = undefined
|
||||
this._watching = false
|
||||
|
||||
if (opts.watchEvents !== false) {
|
||||
this.on(DISCONNECTED, this._clearObjects)
|
||||
this._clearObjects()
|
||||
|
||||
const { watchEvents } = opts
|
||||
if (watchEvents !== false) {
|
||||
if (Array.isArray(watchEvents)) {
|
||||
this._watchedTypes = watchEvents
|
||||
}
|
||||
this.watchEvents()
|
||||
}
|
||||
}
|
||||
@@ -300,19 +311,14 @@ export class Xapi extends EventEmitter {
|
||||
watchEvents() {
|
||||
this._eventWatchers = createObject(null)
|
||||
|
||||
this._fromToken = ''
|
||||
|
||||
this._nTasks = 0
|
||||
|
||||
this._taskWatchers = Object.create(null)
|
||||
|
||||
if (this.status === CONNECTED) {
|
||||
this._watchEvents()
|
||||
this._watchEventsWrapper()
|
||||
}
|
||||
|
||||
this.on('connected', this._watchEvents)
|
||||
this.on('connected', this._watchEventsWrapper)
|
||||
this.on('disconnected', () => {
|
||||
this._fromToken = ''
|
||||
this._objects.clear()
|
||||
})
|
||||
}
|
||||
@@ -401,42 +407,55 @@ export class Xapi extends EventEmitter {
|
||||
)
|
||||
}
|
||||
|
||||
connect() {
|
||||
async connect() {
|
||||
const { status } = this
|
||||
|
||||
if (status === CONNECTED) {
|
||||
return Promise.reject(new Error('already connected'))
|
||||
throw new Error('already connected')
|
||||
}
|
||||
|
||||
if (status === CONNECTING) {
|
||||
return Promise.reject(new Error('already connecting'))
|
||||
throw new Error('already connecting')
|
||||
}
|
||||
|
||||
const auth = this._auth
|
||||
if (auth === undefined) {
|
||||
return Promise.reject(new Error('missing credentials'))
|
||||
throw new Error('missing credentials')
|
||||
}
|
||||
|
||||
this._sessionId = CONNECTING
|
||||
|
||||
return this._transportCall('session.login_with_password', [
|
||||
auth.user,
|
||||
auth.password,
|
||||
]).then(
|
||||
async sessionId => {
|
||||
this._sessionId = sessionId
|
||||
this._pool = (await this.getAllRecords('pool'))[0]
|
||||
try {
|
||||
const [methods, sessionId] = await Promise.all([
|
||||
this._transportCall('system.listMethods', []),
|
||||
this._transportCall('session.login_with_password', [
|
||||
auth.user,
|
||||
auth.password,
|
||||
]),
|
||||
])
|
||||
|
||||
debug('%s: connected', this._humanId)
|
||||
// Uses introspection to list available types.
|
||||
const types = (this._types = methods
|
||||
.filter(isGetAllRecordsMethod)
|
||||
.map(method => method.slice(0, method.indexOf('.'))))
|
||||
this._lcToTypes = { __proto__: null }
|
||||
types.forEach(type => {
|
||||
const lcType = type.toLowerCase()
|
||||
if (lcType !== type) {
|
||||
this._lcToTypes[lcType] = type
|
||||
}
|
||||
})
|
||||
|
||||
this.emit(CONNECTED)
|
||||
},
|
||||
error => {
|
||||
this._sessionId = null
|
||||
this._sessionId = sessionId
|
||||
this._pool = (await this.getAllRecords('pool'))[0]
|
||||
|
||||
throw error
|
||||
}
|
||||
)
|
||||
debug('%s: connected', this._humanId)
|
||||
this.emit(CONNECTED)
|
||||
} catch (error) {
|
||||
this._sessionId = null
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
@@ -499,6 +518,10 @@ export class Xapi extends EventEmitter {
|
||||
return promise
|
||||
}
|
||||
|
||||
getField(type, ref, field) {
|
||||
return this._sessionCall(`${type}.get_${field}`, [ref])
|
||||
}
|
||||
|
||||
// Nice getter which returns the object for a given $id (internal to
|
||||
// this lib), UUID (unique identifier that some objects have) or
|
||||
// opaque reference (internal to XAPI).
|
||||
@@ -550,6 +573,10 @@ export class Xapi extends EventEmitter {
|
||||
)
|
||||
}
|
||||
|
||||
getRecords(type, refs) {
|
||||
return Promise.all(refs.map(ref => this.getRecord(type, ref)))
|
||||
}
|
||||
|
||||
async getAllRecords(type) {
|
||||
return map(
|
||||
await this._sessionCall(`${type}.get_all_records`),
|
||||
@@ -565,7 +592,7 @@ export class Xapi extends EventEmitter {
|
||||
}
|
||||
|
||||
@cancelable
|
||||
getResource($cancelToken, pathname, { host, query, task }) {
|
||||
getResource($cancelToken, pathname, { host, query, task } = {}) {
|
||||
return this._autoTask(task, `Xapi#getResource ${pathname}`).then(
|
||||
taskRef => {
|
||||
query = { ...query, session_id: this.sessionId }
|
||||
@@ -718,41 +745,38 @@ export class Xapi extends EventEmitter {
|
||||
)
|
||||
}
|
||||
|
||||
setField({ $type, $ref }, field, value) {
|
||||
return this.call(`${$type}.set_${field}`, $ref, value).then(noop)
|
||||
setField(type, ref, field, value) {
|
||||
return this.call(`${type}.set_${field}`, ref, value).then(noop)
|
||||
}
|
||||
|
||||
setFieldEntries(record, field, entries) {
|
||||
setFieldEntries(type, ref, field, entries) {
|
||||
return Promise.all(
|
||||
getKeys(entries).map(entry => {
|
||||
const value = entries[entry]
|
||||
if (value !== undefined) {
|
||||
return value === null
|
||||
? this.unsetFieldEntry(record, field, entry)
|
||||
: this.setFieldEntry(record, field, entry, value)
|
||||
return this.setFieldEntry(type, ref, field, entry, value)
|
||||
}
|
||||
})
|
||||
).then(noop)
|
||||
}
|
||||
|
||||
async setFieldEntry({ $type, $ref }, field, entry, value) {
|
||||
async setFieldEntry(type, ref, field, entry, value) {
|
||||
if (value === null) {
|
||||
return this.call(`${type}.remove_from_${field}`, ref, entry).then(noop)
|
||||
}
|
||||
while (true) {
|
||||
try {
|
||||
await this.call(`${$type}.add_to_${field}`, $ref, entry, value)
|
||||
await this.call(`${type}.add_to_${field}`, ref, entry, value)
|
||||
return
|
||||
} catch (error) {
|
||||
if (error == null || error.code !== 'MAP_DUPLICATE_KEY') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
await this.unsetFieldEntry({ $type, $ref }, field, entry)
|
||||
await this.call(`${type}.remove_from_${field}`, ref, entry)
|
||||
}
|
||||
}
|
||||
|
||||
unsetFieldEntry({ $type, $ref }, field, entry) {
|
||||
return this.call(`${$type}.remove_from_${field}`, $ref, entry)
|
||||
}
|
||||
|
||||
watchTask(ref) {
|
||||
const watchers = this._taskWatchers
|
||||
if (watchers === undefined) {
|
||||
@@ -786,6 +810,15 @@ export class Xapi extends EventEmitter {
|
||||
return this._objects
|
||||
}
|
||||
|
||||
_clearObjects() {
|
||||
;(this._objectsByRef = createObject(null))[NULL_REF] = undefined
|
||||
this._nTasks = 0
|
||||
this._objects.clear()
|
||||
this.objectsFetched = new Promise(resolve => {
|
||||
this._resolveObjectsFetched = resolve
|
||||
})
|
||||
}
|
||||
|
||||
// return a promise which resolves to a task ref or undefined
|
||||
_autoTask(task = this._taskWatchers !== undefined, name) {
|
||||
if (task === false) {
|
||||
@@ -801,7 +834,7 @@ export class Xapi extends EventEmitter {
|
||||
}
|
||||
|
||||
// Medium level call: handle session errors.
|
||||
_sessionCall(method, args) {
|
||||
_sessionCall(method, args, timeout = this._callTimeout(method, args)) {
|
||||
try {
|
||||
if (startsWith(method, 'session.')) {
|
||||
throw new Error('session.*() methods are disabled from this interface')
|
||||
@@ -825,7 +858,7 @@ export class Xapi extends EventEmitter {
|
||||
return this.connect().then(() => this._sessionCall(method, args))
|
||||
}
|
||||
),
|
||||
this._callTimeout(method, args)
|
||||
timeout
|
||||
)
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
@@ -904,7 +937,12 @@ export class Xapi extends EventEmitter {
|
||||
|
||||
_processEvents(events) {
|
||||
forEach(events, event => {
|
||||
const { class: type, ref } = event
|
||||
let type = event.class
|
||||
const lcToTypes = this._lcToTypes
|
||||
if (type in lcToTypes) {
|
||||
type = lcToTypes[type]
|
||||
}
|
||||
const { ref } = event
|
||||
if (event.operation === 'del') {
|
||||
this._removeObject(type, ref)
|
||||
} else {
|
||||
@@ -913,34 +951,101 @@ export class Xapi extends EventEmitter {
|
||||
})
|
||||
}
|
||||
|
||||
_watchEvents() {
|
||||
const loop = () =>
|
||||
this.status === CONNECTED &&
|
||||
pTimeout
|
||||
.call(
|
||||
this._sessionCall('event.from', [
|
||||
['*'],
|
||||
this._fromToken,
|
||||
EVENT_TIMEOUT + 0.1, // Force float.
|
||||
]),
|
||||
EVENT_TIMEOUT * 1.1e3 // 10% longer than the XenAPI timeout
|
||||
// - prevent multiple watches
|
||||
// - swallow errors
|
||||
async _watchEventsWrapper() {
|
||||
if (!this._watching) {
|
||||
this._watching = true
|
||||
await ignoreErrors.call(this._watchEvents())
|
||||
this._watching = false
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: cancelation
|
||||
async _watchEvents() {
|
||||
this._clearObjects()
|
||||
|
||||
// compute the initial token for the event loop
|
||||
//
|
||||
// we need to do this before the initial fetch to avoid losing events
|
||||
let fromToken
|
||||
try {
|
||||
fromToken = await this._sessionCall('event.inject', [
|
||||
'pool',
|
||||
this._pool.$ref,
|
||||
])
|
||||
} catch (error) {
|
||||
if (isMethodUnknown(error)) {
|
||||
return this._watchEventsLegacy()
|
||||
}
|
||||
}
|
||||
|
||||
const types = this._watchedTypes || this._types
|
||||
|
||||
// initial fetch
|
||||
const flush = this.objects.bufferEvents()
|
||||
try {
|
||||
await Promise.all(
|
||||
types.map(async type => {
|
||||
try {
|
||||
// FIXME: use _transportCall to avoid auto-reconnection
|
||||
forOwn(
|
||||
await this._sessionCall(`${type}.get_all_records`),
|
||||
(record, ref) => {
|
||||
// we can bypass _processEvents here because they are all *add*
|
||||
// event and all objects are of the same type
|
||||
this._addObject(type, ref, record)
|
||||
}
|
||||
)
|
||||
} catch (_) {
|
||||
// there is nothing ideal to do here, do not interrupt event
|
||||
// handling
|
||||
}
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
flush()
|
||||
}
|
||||
this._resolveObjectsFetched()
|
||||
|
||||
// event loop
|
||||
const debounce = this._debounce
|
||||
while (true) {
|
||||
if (debounce != null) {
|
||||
await pDelay(debounce)
|
||||
}
|
||||
|
||||
let result
|
||||
try {
|
||||
result = await this._sessionCall(
|
||||
'event.from',
|
||||
[types, fromToken, EVENT_TIMEOUT],
|
||||
EVENT_TIMEOUT * 1.1
|
||||
)
|
||||
.then(onSuccess, onFailure)
|
||||
} catch (error) {
|
||||
if (error instanceof TimeoutError) {
|
||||
continue
|
||||
}
|
||||
if (areEventsLost(error)) {
|
||||
return this._watchEvents()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const onSuccess = ({ events, token, valid_ref_counts: { task } }) => {
|
||||
this._fromToken = token
|
||||
this._processEvents(events)
|
||||
fromToken = result.token
|
||||
this._processEvents(result.events)
|
||||
|
||||
if (task !== this._nTasks) {
|
||||
this._sessionCall('task.get_all_records')
|
||||
.then(tasks => {
|
||||
// detect and fix disappearing tasks (e.g. when toolstack restarts)
|
||||
if (result.valid_ref_counts.task !== this._nTasks) {
|
||||
await ignoreErrors.call(
|
||||
this._sessionCall('task.get_all_records').then(tasks => {
|
||||
const toRemove = new Set()
|
||||
forEach(this.objects.all, object => {
|
||||
forOwn(this.objects.all, object => {
|
||||
if (object.$type === 'task') {
|
||||
toRemove.add(object.$ref)
|
||||
}
|
||||
})
|
||||
forEach(tasks, (task, ref) => {
|
||||
forOwn(tasks, (task, ref) => {
|
||||
toRemove.delete(ref)
|
||||
this._addObject('task', ref, task)
|
||||
})
|
||||
@@ -948,40 +1053,9 @@ export class Xapi extends EventEmitter {
|
||||
this._removeObject('task', ref)
|
||||
})
|
||||
})
|
||||
.catch(noop)
|
||||
)
|
||||
}
|
||||
|
||||
const debounce = this._debounce
|
||||
return debounce != null ? pDelay(debounce).then(loop) : loop()
|
||||
}
|
||||
const onFailure = error => {
|
||||
if (error instanceof TimeoutError) {
|
||||
return loop()
|
||||
}
|
||||
|
||||
if (areEventsLost(error)) {
|
||||
this._fromToken = ''
|
||||
this._objects.clear()
|
||||
|
||||
return loop()
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
ignoreErrors.call(
|
||||
pCatch.call(
|
||||
loop(),
|
||||
isMethodUnknown,
|
||||
|
||||
// If the server failed, it is probably due to an excessively
|
||||
// large response.
|
||||
// Falling back to legacy events watch should be enough.
|
||||
error => error && error.res && error.res.statusCode === 500,
|
||||
|
||||
() => this._watchEventsLegacy()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// This method watches events using the legacy `event.next` XAPI
|
||||
@@ -989,17 +1063,13 @@ export class Xapi extends EventEmitter {
|
||||
//
|
||||
// It also has to manually get all objects first.
|
||||
_watchEventsLegacy() {
|
||||
const getAllObjects = () => {
|
||||
return this._sessionCall('system.listMethods').then(methods => {
|
||||
// Uses introspection to determine the methods to use to get
|
||||
// all objects.
|
||||
const getAllRecordsMethods = filter(methods, isGetAllRecordsMethod)
|
||||
|
||||
return Promise.all(
|
||||
map(getAllRecordsMethods, method =>
|
||||
this._sessionCall(method).then(
|
||||
const getAllObjects = async () => {
|
||||
const flush = this.objects.bufferEvents()
|
||||
try {
|
||||
await Promise.all(
|
||||
this._types.map(type =>
|
||||
this._sessionCall(`${type}.get_all_records`).then(
|
||||
objects => {
|
||||
const type = method.slice(0, method.indexOf('.')).toLowerCase()
|
||||
forEach(objects, (object, ref) => {
|
||||
this._addObject(type, ref, object)
|
||||
})
|
||||
@@ -1012,7 +1082,10 @@ export class Xapi extends EventEmitter {
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
flush()
|
||||
}
|
||||
this._resolveObjectsFetched()
|
||||
}
|
||||
|
||||
const watchEvents = () =>
|
||||
@@ -1048,13 +1121,13 @@ export class Xapi extends EventEmitter {
|
||||
const nFields = fields.length
|
||||
const xapi = this
|
||||
|
||||
const objectsByRef = this._objectsByRef
|
||||
const getObjectByRef = ref => objectsByRef[ref]
|
||||
const getObjectByRef = ref => this._objectsByRef[ref]
|
||||
|
||||
Record = function(ref, data) {
|
||||
defineProperties(this, {
|
||||
$id: { value: data.uuid || ref },
|
||||
$ref: { value: ref },
|
||||
$xapi: { value: xapi },
|
||||
})
|
||||
for (let i = 0; i < nFields; ++i) {
|
||||
const field = fields[i]
|
||||
@@ -1062,11 +1135,11 @@ export class Xapi extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
const getters = { $pool: this._getPool }
|
||||
const getters = { $pool: getPool }
|
||||
const props = { $type: type }
|
||||
fields.forEach(field => {
|
||||
props[`set_${field}`] = function(value) {
|
||||
return xapi.setField(this, field, value)
|
||||
return xapi.setField(this.$type, this.$ref, field, value)
|
||||
}
|
||||
|
||||
const $field = (field in RESERVED_FIELDS ? '$$' : '$') + field
|
||||
@@ -1090,19 +1163,21 @@ export class Xapi extends EventEmitter {
|
||||
const value = this[field]
|
||||
const result = {}
|
||||
getKeys(value).forEach(key => {
|
||||
result[key] = objectsByRef[value[key]]
|
||||
result[key] = xapi._objectsByRef[value[key]]
|
||||
})
|
||||
return result
|
||||
}
|
||||
props[`update_${field}`] = function(entries) {
|
||||
return xapi.setFieldEntries(this, field, entries)
|
||||
props[`update_${field}`] = function(entries, value) {
|
||||
return typeof entries === 'string'
|
||||
? xapi.setFieldEntry(this.$type, this.$ref, field, entries, value)
|
||||
: xapi.setFieldEntries(this.$type, this.$ref, field, entries)
|
||||
}
|
||||
} else if (value === '' || isOpaqueRef(value)) {
|
||||
// 2019-02-07 - JFT: even if `value` should not be an empty string for
|
||||
// a ref property, an user had the case on XenServer 7.0 on the CD VBD
|
||||
// of a VM created by XenCenter
|
||||
getters[$field] = function() {
|
||||
return objectsByRef[this[field]]
|
||||
return xapi._objectsByRef[this[field]]
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1131,17 +1206,25 @@ export class Xapi extends EventEmitter {
|
||||
Xapi.prototype._transportCall = reduce(
|
||||
[
|
||||
function(method, args) {
|
||||
return this._call(method, args).catch(error => {
|
||||
if (!(error instanceof Error)) {
|
||||
error = wrapError(error)
|
||||
}
|
||||
return pTimeout
|
||||
.call(this._call(method, args), HTTP_TIMEOUT)
|
||||
.catch(error => {
|
||||
if (!(error instanceof Error)) {
|
||||
error = wrapError(error)
|
||||
}
|
||||
|
||||
error.call = {
|
||||
method,
|
||||
params: replaceSensitiveValues(args, '* obfuscated *'),
|
||||
}
|
||||
throw error
|
||||
})
|
||||
// do not log the session ID
|
||||
//
|
||||
// TODO: should log at the session level to avoid logging sensitive
|
||||
// values?
|
||||
const params = args[0] === this._sessionId ? args.slice(1) : args
|
||||
|
||||
error.call = {
|
||||
method,
|
||||
params: replaceSensitiveValues(params, '* obfuscated *'),
|
||||
}
|
||||
throw error
|
||||
})
|
||||
},
|
||||
call =>
|
||||
function() {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-acl-resolver",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-acl-resolver",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-cli",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-cli",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
@@ -33,7 +34,7 @@
|
||||
"chalk": "^2.2.0",
|
||||
"exec-promise": "^0.7.0",
|
||||
"fs-promise": "^2.0.3",
|
||||
"http-request-plus": "^0.7.1",
|
||||
"http-request-plus": "^0.7.2",
|
||||
"human-format": "^0.10.0",
|
||||
"l33teral": "^3.0.3",
|
||||
"lodash": "^4.17.4",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-collection",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-collection",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-common",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-common",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-import-servers-csv",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-import-servers-csv",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-lib",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-lib",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-remote-parser",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-remote-parser",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-auth-github",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-auth-github",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-auth-google",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-auth-google",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-auth-ldap",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-auth-ldap",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server-auth-saml",
|
||||
"version": "0.5.2",
|
||||
"version": "0.5.3",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "SAML authentication plugin for XO-Server",
|
||||
"keywords": [
|
||||
@@ -15,6 +15,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-auth-saml",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-auth-saml",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-backup-reports",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-backup-reports",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -154,6 +154,10 @@ class BackupReportsXoPlugin {
|
||||
}
|
||||
|
||||
_wrapper(status, job, schedule, runJobId) {
|
||||
if (job.type === 'metadataBackup') {
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise(resolve =>
|
||||
resolve(
|
||||
job.type === 'backup'
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-cloud",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-cloud",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
@@ -31,7 +32,7 @@
|
||||
"node": ">=6"
|
||||
},
|
||||
"dependencies": {
|
||||
"http-request-plus": "^0.7.1",
|
||||
"http-request-plus": "^0.7.2",
|
||||
"jsonrpc-websocket-client": "^0.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-load-balancer",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-load-balancer",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-perf-alert",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-perf-alert",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -673,8 +673,9 @@ ${entry.listItem}
|
||||
}
|
||||
}
|
||||
|
||||
async getRrd(xoObject, secondsAgo) {
|
||||
const host = xoObject.$type === 'host' ? xoObject : xoObject.$resident_on
|
||||
async getRrd(xapiObject, secondsAgo) {
|
||||
const host =
|
||||
xapiObject.$type === 'host' ? xapiObject : xapiObject.$resident_on
|
||||
if (host == null) {
|
||||
return null
|
||||
}
|
||||
@@ -685,13 +686,13 @@ ${entry.listItem}
|
||||
host,
|
||||
query: {
|
||||
cf: 'AVERAGE',
|
||||
host: (xoObject.$type === 'host').toString(),
|
||||
host: (xapiObject.$type === 'host').toString(),
|
||||
json: 'true',
|
||||
start: serverTimestamp - secondsAgo,
|
||||
},
|
||||
}
|
||||
if (xoObject.$type === 'vm') {
|
||||
payload['vm_uuid'] = xoObject.uuid
|
||||
if (xapiObject.$type === 'VM') {
|
||||
payload['vm_uuid'] = xapiObject.uuid
|
||||
}
|
||||
// JSON is not well formed, can't use the default node parser
|
||||
return JSON5.parse(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-test-plugin",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-test-plugin",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-transport-email",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-transport-email",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-transport-nagios",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-transport-nagios",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-transport-slack",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-transport-slack",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-transport-xmpp",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-transport-xmpp",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-usage-report",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-usage-report",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server",
|
||||
"version": "5.34.1",
|
||||
"version": "5.36.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Server part of Xen-Orchestra",
|
||||
"keywords": [
|
||||
@@ -12,6 +12,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
@@ -35,7 +36,7 @@
|
||||
"@xen-orchestra/async-map": "^0.0.0",
|
||||
"@xen-orchestra/cron": "^1.0.3",
|
||||
"@xen-orchestra/emit-async": "^0.0.0",
|
||||
"@xen-orchestra/fs": "^0.6.1",
|
||||
"@xen-orchestra/fs": "^0.7.0",
|
||||
"@xen-orchestra/log": "^0.1.4",
|
||||
"@xen-orchestra/mixin": "^0.0.0",
|
||||
"ajv": "^6.1.1",
|
||||
@@ -68,7 +69,7 @@
|
||||
"helmet": "^3.9.0",
|
||||
"highland": "^2.11.1",
|
||||
"http-proxy": "^1.16.2",
|
||||
"http-request-plus": "^0.7.1",
|
||||
"http-request-plus": "^0.7.2",
|
||||
"http-server-plus": "^0.10.0",
|
||||
"human-format": "^0.10.0",
|
||||
"is-redirect": "^1.0.0",
|
||||
@@ -118,7 +119,7 @@
|
||||
"value-matcher": "^0.2.0",
|
||||
"vhd-lib": "^0.5.1",
|
||||
"ws": "^6.0.0",
|
||||
"xen-api": "^0.24.2",
|
||||
"xen-api": "^0.24.3",
|
||||
"xml2js": "^0.4.19",
|
||||
"xo-acl-resolver": "^0.4.1",
|
||||
"xo-collection": "^0.4.1",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
import { forOwn } from 'lodash'
|
||||
|
||||
import pRetry from './_pRetry'
|
||||
|
||||
describe('pRetry()', () => {
|
||||
@@ -43,4 +45,51 @@ describe('pRetry()', () => {
|
||||
expect(i).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not retry if `stop` callback is called', async () => {
|
||||
const e = new Error()
|
||||
let i = 0
|
||||
await expect(
|
||||
pRetry(stop => {
|
||||
++i
|
||||
stop(e)
|
||||
})
|
||||
).rejects.toBe(e)
|
||||
expect(i).toBe(1)
|
||||
})
|
||||
|
||||
describe('`when` option', () => {
|
||||
forOwn(
|
||||
{
|
||||
'with function predicate': _ => _.message === 'foo',
|
||||
'with object predicate': { message: 'foo' },
|
||||
},
|
||||
(when, title) =>
|
||||
describe(title, () => {
|
||||
it('retries when error matches', async () => {
|
||||
let i = 0
|
||||
await pRetry(
|
||||
() => {
|
||||
++i
|
||||
throw new Error('foo')
|
||||
},
|
||||
{ when, tries: 2 }
|
||||
).catch(Function.prototype)
|
||||
expect(i).toBe(2)
|
||||
})
|
||||
|
||||
it('does not retry when error does not match', async () => {
|
||||
let i = 0
|
||||
await pRetry(
|
||||
() => {
|
||||
++i
|
||||
throw new Error('bar')
|
||||
},
|
||||
{ when, tries: 2 }
|
||||
).catch(Function.prototype)
|
||||
expect(i).toBe(1)
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
103
packages/xo-server/src/api/metadata-backup.js
Normal file
103
packages/xo-server/src/api/metadata-backup.js
Normal file
@@ -0,0 +1,103 @@
|
||||
export function createJob({ schedules, ...job }) {
|
||||
job.userId = this.user.id
|
||||
return this.createMetadataBackupJob(job, schedules)
|
||||
}
|
||||
|
||||
createJob.permission = 'admin'
|
||||
createJob.params = {
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
pools: {
|
||||
type: 'object',
|
||||
optional: true,
|
||||
},
|
||||
remotes: {
|
||||
type: 'object',
|
||||
},
|
||||
schedules: {
|
||||
type: 'object',
|
||||
},
|
||||
settings: {
|
||||
type: 'object',
|
||||
},
|
||||
xoMetadata: {
|
||||
type: 'boolean',
|
||||
optional: true,
|
||||
},
|
||||
}
|
||||
|
||||
export function getAllJobs() {
|
||||
return this.getAllJobs('metadataBackup')
|
||||
}
|
||||
|
||||
getAllJobs.permission = 'admin'
|
||||
|
||||
export function getJob({ id }) {
|
||||
return this.getJob(id, 'metadataBackup')
|
||||
}
|
||||
|
||||
getJob.permission = 'admin'
|
||||
getJob.params = {
|
||||
id: {
|
||||
type: 'string',
|
||||
},
|
||||
}
|
||||
|
||||
export function deleteJob({ id }) {
|
||||
return this.deleteMetadataBackupJob(id)
|
||||
}
|
||||
|
||||
deleteJob.permission = 'admin'
|
||||
deleteJob.params = {
|
||||
id: {
|
||||
type: 'string',
|
||||
},
|
||||
}
|
||||
|
||||
export function editJob(props) {
|
||||
return this.updateJob(props)
|
||||
}
|
||||
|
||||
editJob.permission = 'admin'
|
||||
editJob.params = {
|
||||
id: {
|
||||
type: 'string',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
pools: {
|
||||
type: ['object', 'null'],
|
||||
optional: true,
|
||||
},
|
||||
settings: {
|
||||
type: 'object',
|
||||
optional: true,
|
||||
},
|
||||
remotes: {
|
||||
type: 'object',
|
||||
optional: true,
|
||||
},
|
||||
xoMetadata: {
|
||||
type: 'boolean',
|
||||
optional: true,
|
||||
},
|
||||
}
|
||||
|
||||
export async function runJob({ id, schedule }) {
|
||||
return this.runJobSequence([id], await this.getSchedule(schedule))
|
||||
}
|
||||
|
||||
runJob.permission = 'admin'
|
||||
|
||||
runJob.params = {
|
||||
id: {
|
||||
type: 'string',
|
||||
},
|
||||
schedule: {
|
||||
type: 'string',
|
||||
},
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import { dirname, resolve } from 'path'
|
||||
import { utcFormat, utcParse } from 'd3-time-format'
|
||||
import { fromCallback, pAll, pReflect, promisify } from 'promise-toolbox'
|
||||
|
||||
import { type SimpleIdPattern } from './utils'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
export function camelToSnakeCase(string) {
|
||||
@@ -417,3 +419,13 @@ export const getFirstPropertyName = object => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export const unboxIdsFromPattern = (pattern?: SimpleIdPattern): string[] => {
|
||||
if (pattern === undefined) {
|
||||
return []
|
||||
}
|
||||
const { id } = pattern
|
||||
return typeof id === 'string' ? [id] : id.__or
|
||||
}
|
||||
|
||||
@@ -11,3 +11,5 @@ declare export function safeDateFormat(timestamp: number): string
|
||||
declare export function serializeError(error: Error): Object
|
||||
|
||||
declare export function streamToBuffer(stream: Readable): Promise<Buffer>
|
||||
|
||||
export type SimpleIdPattern = {| id: string | {| __or: string[] |}, |}
|
||||
|
||||
@@ -54,12 +54,9 @@ function toTimestamp(date) {
|
||||
return timestamp
|
||||
}
|
||||
|
||||
const ms = parseDateTime(date)
|
||||
if (!ms) {
|
||||
return null
|
||||
}
|
||||
const ms = parseDateTime(date)?.getTime()
|
||||
|
||||
return Math.round(ms.getTime() / 1000)
|
||||
return ms === undefined || ms === 0 ? null : Math.round(ms / 1000)
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
@@ -173,7 +170,7 @@ const TRANSFORMS = {
|
||||
total: 0,
|
||||
}
|
||||
})(),
|
||||
multipathing: obj.multipathing,
|
||||
multipathing: otherConfig.multipathing === 'true',
|
||||
patches: patches || link(obj, 'patches'),
|
||||
powerOnMode: obj.power_on_mode,
|
||||
power_state: metrics ? (isRunning ? 'Running' : 'Halted') : 'Unknown',
|
||||
|
||||
@@ -60,7 +60,6 @@ import {
|
||||
asInteger,
|
||||
extractOpaqueRef,
|
||||
filterUndefineds,
|
||||
getNamespaceForType,
|
||||
getVmDisks,
|
||||
canSrHaveNewVdiOfSize,
|
||||
isVmHvm,
|
||||
@@ -227,7 +226,7 @@ export default class Xapi extends XapiBase {
|
||||
|
||||
_setObjectProperty(object, name, value) {
|
||||
return this.call(
|
||||
`${getNamespaceForType(object.$type)}.set_${camelToSnakeCase(name)}`,
|
||||
`${object.$type}.set_${camelToSnakeCase(name)}`,
|
||||
object.$ref,
|
||||
prepareXapiParam(value)
|
||||
)
|
||||
@@ -236,15 +235,13 @@ export default class Xapi extends XapiBase {
|
||||
_setObjectProperties(object, props) {
|
||||
const { $ref: ref, $type: type } = object
|
||||
|
||||
const namespace = getNamespaceForType(type)
|
||||
|
||||
// TODO: the thrown error should contain the name of the
|
||||
// properties that failed to be set.
|
||||
return Promise.all(
|
||||
mapToArray(props, (value, name) => {
|
||||
if (value != null) {
|
||||
return this.call(
|
||||
`${namespace}.set_${camelToSnakeCase(name)}`,
|
||||
`${type}.set_${camelToSnakeCase(name)}`,
|
||||
ref,
|
||||
prepareXapiParam(value)
|
||||
)
|
||||
@@ -258,9 +255,8 @@ export default class Xapi extends XapiBase {
|
||||
|
||||
prop = camelToSnakeCase(prop)
|
||||
|
||||
const namespace = getNamespaceForType(type)
|
||||
const add = `${namespace}.add_to_${prop}`
|
||||
const remove = `${namespace}.remove_from_${prop}`
|
||||
const add = `${type}.add_to_${prop}`
|
||||
const remove = `${type}.remove_from_${prop}`
|
||||
|
||||
await Promise.all(
|
||||
mapToArray(values, (value, name) => {
|
||||
@@ -327,15 +323,13 @@ export default class Xapi extends XapiBase {
|
||||
async addTag(id, tag) {
|
||||
const { $ref: ref, $type: type } = this.getObject(id)
|
||||
|
||||
const namespace = getNamespaceForType(type)
|
||||
await this.call(`${namespace}.add_tags`, ref, tag)
|
||||
await this.call(`${type}.add_tags`, ref, tag)
|
||||
}
|
||||
|
||||
async removeTag(id, tag) {
|
||||
const { $ref: ref, $type: type } = this.getObject(id)
|
||||
|
||||
const namespace = getNamespaceForType(type)
|
||||
await this.call(`${namespace}.remove_tags`, ref, tag)
|
||||
await this.call(`${type}.remove_tags`, ref, tag)
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
@@ -416,10 +410,23 @@ export default class Xapi extends XapiBase {
|
||||
await this.call('host.enable', this.getObject(hostId).$ref)
|
||||
}
|
||||
|
||||
// Resources:
|
||||
// - Citrix XenServer ® 7.0 Administrator's Guide ch. 5.4
|
||||
// - https://github.com/xcp-ng/xenadmin/blob/60dd70fc36faa0ec91654ec97e24b7af36acff9f/XenModel/Actions/Host/EditMultipathAction.cs
|
||||
// - https://github.com/serencorbett1/xenadmin/blob/1c3fb0c1112e4e316423afc6a028066001d3dea1/XenModel/XenAPI-Extensions/SR.cs
|
||||
@deferrable.onError(log.warn)
|
||||
async setHostMultipathing($defer, hostId, multipathing) {
|
||||
const host = this.getObject(hostId)
|
||||
|
||||
if (host.enabled) {
|
||||
await this.disableHost(hostId)
|
||||
$defer(() => this.enableHost(hostId))
|
||||
}
|
||||
|
||||
// Xen center evacuate running VMs before unplugging the PBDs.
|
||||
// The evacuate method uses the live migration to migrate running VMs
|
||||
// from host to another. It only works when a shared SR is present
|
||||
// in the host. For this reason we chose to show a warning instead.
|
||||
const pluggedPbds = host.$PBDs.filter(pbd => pbd.currently_attached)
|
||||
await asyncMap(pluggedPbds, async pbd => {
|
||||
const ref = pbd.$ref
|
||||
@@ -427,11 +434,6 @@ export default class Xapi extends XapiBase {
|
||||
$defer(() => this.plugPbd(ref))
|
||||
})
|
||||
|
||||
if (host.enabled) {
|
||||
await this.disableHost(hostId)
|
||||
$defer(() => this.enableHost(hostId))
|
||||
}
|
||||
|
||||
return this._updateObjectMapProperty(
|
||||
host,
|
||||
'other_config',
|
||||
@@ -677,17 +679,17 @@ export default class Xapi extends XapiBase {
|
||||
}
|
||||
|
||||
async _deleteVm(
|
||||
vm,
|
||||
vmOrRef,
|
||||
deleteDisks = true,
|
||||
force = false,
|
||||
forceDeleteDefaultTemplate = false
|
||||
) {
|
||||
log.debug(`Deleting VM ${vm.name_label}`)
|
||||
|
||||
const { $ref } = vm
|
||||
const $ref = typeof vmOrRef === 'string' ? vmOrRef : vmOrRef.$ref
|
||||
|
||||
// ensure the vm record is up-to-date
|
||||
vm = await this.barrier($ref)
|
||||
const vm = await this.barrier($ref)
|
||||
|
||||
log.debug(`Deleting VM ${vm.name_label}`)
|
||||
|
||||
if (!force && 'destroy' in vm.blocked_operations) {
|
||||
throw forbiddenOperation('destroy', vm.blocked_operations.destroy.reason)
|
||||
@@ -1539,19 +1541,22 @@ export default class Xapi extends XapiBase {
|
||||
|
||||
@concurrency(2)
|
||||
@cancelable
|
||||
async _snapshotVm($cancelToken, vm, nameLabel = vm.name_label) {
|
||||
async _snapshotVm($cancelToken, { $ref: vmRef }, nameLabel) {
|
||||
const vm = await this.getRecord('VM', vmRef)
|
||||
if (nameLabel === undefined) {
|
||||
nameLabel = vm.name_label
|
||||
}
|
||||
|
||||
log.debug(
|
||||
`Snapshotting VM ${vm.name_label}${
|
||||
nameLabel !== vm.name_label ? ` as ${nameLabel}` : ''
|
||||
}`
|
||||
)
|
||||
|
||||
const vmRef = vm.$ref
|
||||
let ref
|
||||
do {
|
||||
if (!vm.tags.includes('xo-disable-quiesce')) {
|
||||
try {
|
||||
vm = await this.barrier(vmRef)
|
||||
ref = await pRetry(
|
||||
async bail => {
|
||||
try {
|
||||
@@ -1571,12 +1576,11 @@ export default class Xapi extends XapiBase {
|
||||
// see https://github.com/vatesfr/xen-orchestra/issues/3936
|
||||
const prevSnapshotRefs = new Set(vm.snapshots)
|
||||
const snapshotNameLabelPrefix = `Snapshot of ${vm.uuid} [`
|
||||
vm = await this.barrier(vmRef)
|
||||
const createdSnapshots = vm.$snapshots.filter(
|
||||
_ =>
|
||||
!prevSnapshotRefs.has(_.$ref) &&
|
||||
_.name_label.startsWith(snapshotNameLabelPrefix)
|
||||
)
|
||||
vm.snapshots = await this.getField('VM', vmRef, 'snapshots')
|
||||
const createdSnapshots = (await this.getRecords(
|
||||
'VM',
|
||||
vm.snapshots.filter(_ => !prevSnapshotRefs.has(_))
|
||||
)).filter(_ => _.name_label.startsWith(snapshotNameLabelPrefix))
|
||||
|
||||
// be safe: only delete if there was a single match
|
||||
if (createdSnapshots.length === 1) {
|
||||
@@ -1591,7 +1595,7 @@ export default class Xapi extends XapiBase {
|
||||
tries: 3,
|
||||
}
|
||||
).then(extractOpaqueRef)
|
||||
this.addTag(ref, 'quiesce')::ignoreErrors()
|
||||
ignoreErrors.call(this.call('VM.add_tags', ref, 'quiesce'))
|
||||
|
||||
break
|
||||
} catch (error) {
|
||||
@@ -1616,14 +1620,9 @@ export default class Xapi extends XapiBase {
|
||||
).then(extractOpaqueRef)
|
||||
} while (false)
|
||||
|
||||
// Convert the template to a VM and wait to have receive the up-
|
||||
// to-date object.
|
||||
const [, snapshot] = await Promise.all([
|
||||
this.call('VM.set_is_a_template', ref, false),
|
||||
this.barrier(ref),
|
||||
])
|
||||
await this.setField('VM', ref, 'is_a_template', false)
|
||||
|
||||
return snapshot
|
||||
return this.getRecord('VM', ref)
|
||||
}
|
||||
|
||||
async snapshotVm(vmId, nameLabel = undefined) {
|
||||
@@ -1700,7 +1699,7 @@ export default class Xapi extends XapiBase {
|
||||
find(
|
||||
this.objects.all,
|
||||
obj =>
|
||||
obj.$type === 'vm' &&
|
||||
obj.$type === 'VM' &&
|
||||
obj.is_a_template &&
|
||||
obj.name_label === templateNameLabel
|
||||
)
|
||||
@@ -2200,7 +2199,7 @@ export default class Xapi extends XapiBase {
|
||||
const physPif = find(
|
||||
this.objects.all,
|
||||
obj =>
|
||||
obj.$type === 'pif' &&
|
||||
obj.$type === 'PIF' &&
|
||||
(obj.physical || !isEmpty(obj.bond_master_of)) &&
|
||||
obj.$pool === pif.$pool &&
|
||||
obj.device === pif.device
|
||||
@@ -2436,7 +2435,7 @@ export default class Xapi extends XapiBase {
|
||||
return find(
|
||||
this.objects.all,
|
||||
obj =>
|
||||
obj.$type === 'sr' && obj.shared && canSrHaveNewVdiOfSize(obj, minSize)
|
||||
obj.$type === 'SR' && obj.shared && canSrHaveNewVdiOfSize(obj, minSize)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ export default {
|
||||
async _ejectToolsIsos(hostRef) {
|
||||
return Promise.all(
|
||||
mapFilter(this.objects.all, vm => {
|
||||
if (vm.$type !== 'vm' || (hostRef && vm.resident_on !== hostRef)) {
|
||||
if (vm.$type !== 'VM' || (hostRef && vm.resident_on !== hostRef)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
14
packages/xo-server/src/xapi/mixins/pool.js
Normal file
14
packages/xo-server/src/xapi/mixins/pool.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cancelable } from 'promise-toolbox'
|
||||
|
||||
export default {
|
||||
@cancelable
|
||||
exportPoolMetadata($cancelToken) {
|
||||
const { pool } = this
|
||||
return this.getResource($cancelToken, '/pool/xmldbdump', {
|
||||
task: this.createTask(
|
||||
'Pool metadata',
|
||||
pool.name_label ?? pool.$master.name_label
|
||||
),
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -71,44 +71,6 @@ export const extractOpaqueRef = str => {
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const TYPE_TO_NAMESPACE = { __proto__: null }
|
||||
forEach(
|
||||
[
|
||||
'Bond',
|
||||
'DR_task',
|
||||
'GPU_group',
|
||||
'PBD',
|
||||
'PCI',
|
||||
'PGPU',
|
||||
'PIF',
|
||||
'PIF_metrics',
|
||||
'SM',
|
||||
'SR',
|
||||
'VBD',
|
||||
'VBD_metrics',
|
||||
'VDI',
|
||||
'VGPU',
|
||||
'VGPU_type',
|
||||
'VIF',
|
||||
'VLAN',
|
||||
'VM',
|
||||
'VM_appliance',
|
||||
'VM_guest_metrics',
|
||||
'VM_metrics',
|
||||
'VMPP',
|
||||
'VTPM',
|
||||
],
|
||||
namespace => {
|
||||
TYPE_TO_NAMESPACE[namespace.toLowerCase()] = namespace
|
||||
}
|
||||
)
|
||||
|
||||
// Object types given by `xen-api` are always lowercase but the
|
||||
// namespaces in the Xen API can have a different casing.
|
||||
export const getNamespaceForType = type => TYPE_TO_NAMESPACE[type] || type
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export const getVmDisks = vm => {
|
||||
const disks = { __proto__: null }
|
||||
forEach(vm.$VBDs, vbd => {
|
||||
@@ -288,7 +250,7 @@ export const makeEditObject = specs => {
|
||||
const object = this.getObject(id)
|
||||
|
||||
const _objectRef = object.$ref
|
||||
const _setMethodPrefix = `${getNamespaceForType(object.$type)}.set_`
|
||||
const _setMethodPrefix = `${object.$type}.set_`
|
||||
|
||||
// Context used to execute functions.
|
||||
const context = {
|
||||
|
||||
@@ -54,6 +54,8 @@ import {
|
||||
resolveRelativeFromFile,
|
||||
safeDateFormat,
|
||||
serializeError,
|
||||
type SimpleIdPattern,
|
||||
unboxIdsFromPattern,
|
||||
} from '../../utils'
|
||||
|
||||
import { translateLegacyJob } from './migration'
|
||||
@@ -75,10 +77,6 @@ type Settings = {|
|
||||
vmTimeout?: number,
|
||||
|}
|
||||
|
||||
type SimpleIdPattern = {|
|
||||
id: string | {| __or: string[] |},
|
||||
|}
|
||||
|
||||
export type BackupJob = {|
|
||||
...$Exact<Job>,
|
||||
compression?: 'native' | 'zstd' | '',
|
||||
@@ -182,7 +180,7 @@ const getJobCompression = ({ compression: c }) =>
|
||||
|
||||
const listReplicatedVms = (
|
||||
xapi: Xapi,
|
||||
scheduleId: string,
|
||||
scheduleOrJobId: string,
|
||||
srId?: string,
|
||||
vmUuid?: string
|
||||
): Vm[] => {
|
||||
@@ -192,11 +190,12 @@ const listReplicatedVms = (
|
||||
const object = all[key]
|
||||
const oc = object.other_config
|
||||
if (
|
||||
object.$type === 'vm' &&
|
||||
object.$type === 'VM' &&
|
||||
!object.is_a_snapshot &&
|
||||
!object.is_a_template &&
|
||||
'start' in object.blocked_operations &&
|
||||
oc['xo:backup:schedule'] === scheduleId &&
|
||||
(oc['xo:backup:job'] === scheduleOrJobId ||
|
||||
oc['xo:backup:schedule'] === scheduleOrJobId) &&
|
||||
oc['xo:backup:sr'] === srId &&
|
||||
(oc['xo:backup:vm'] === vmUuid ||
|
||||
// 2018-03-28, JFT: to catch VMs replicated before this fix
|
||||
@@ -309,14 +308,6 @@ const parseVmBackupId = (id: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const unboxIds = (pattern?: SimpleIdPattern): string[] => {
|
||||
if (pattern === undefined) {
|
||||
return []
|
||||
}
|
||||
const { id } = pattern
|
||||
return typeof id === 'string' ? [id] : id.__or
|
||||
}
|
||||
|
||||
// similar to Promise.all() but do not gather results
|
||||
async function waitAll<T>(
|
||||
promises: Promise<T>[],
|
||||
@@ -604,7 +595,7 @@ export default class BackupNg {
|
||||
}
|
||||
}
|
||||
const jobId = job.id
|
||||
const srs = unboxIds(job.srs).map(id => {
|
||||
const srs = unboxIdsFromPattern(job.srs).map(id => {
|
||||
const xapi = app.getXapi(id)
|
||||
return {
|
||||
__proto__: xapi.getObject(id),
|
||||
@@ -612,7 +603,7 @@ export default class BackupNg {
|
||||
}
|
||||
})
|
||||
const remotes = await Promise.all(
|
||||
unboxIds(job.remotes).map(async id => ({
|
||||
unboxIdsFromPattern(job.remotes).map(async id => ({
|
||||
id,
|
||||
handler: await app.getRemoteHandler(id),
|
||||
}))
|
||||
@@ -1323,7 +1314,7 @@ export default class BackupNg {
|
||||
for (const { $id: srId, xapi } of srs) {
|
||||
const replicatedVm = listReplicatedVms(
|
||||
xapi,
|
||||
scheduleId,
|
||||
jobId,
|
||||
srId,
|
||||
vmUuid
|
||||
).find(vm => vm.other_config[TAG_COPY_SRC] === baseSnapshot.uuid)
|
||||
|
||||
@@ -427,7 +427,7 @@ export default class {
|
||||
|
||||
let toRemove = filter(
|
||||
targetXapi.objects.all,
|
||||
obj => obj.$type === 'vm' && obj.other_config[TAG_SOURCE_VM] === uuid
|
||||
obj => obj.$type === 'VM' && obj.other_config[TAG_SOURCE_VM] === uuid
|
||||
)
|
||||
const { length } = toRemove
|
||||
const deleteBase = length === 0 // old replications are not captured in toRemove
|
||||
|
||||
264
packages/xo-server/src/xo-mixins/metadata-backups.js
Normal file
264
packages/xo-server/src/xo-mixins/metadata-backups.js
Normal file
@@ -0,0 +1,264 @@
|
||||
// @flow
|
||||
import asyncMap from '@xen-orchestra/async-map'
|
||||
import defer from 'golike-defer'
|
||||
import { fromEvent, ignoreErrors } from 'promise-toolbox'
|
||||
|
||||
import { type Xapi } from '../xapi'
|
||||
import {
|
||||
safeDateFormat,
|
||||
type SimpleIdPattern,
|
||||
unboxIdsFromPattern,
|
||||
} from '../utils'
|
||||
|
||||
import { type Executor, type Job } from './jobs'
|
||||
import { type Schedule } from './scheduling'
|
||||
|
||||
const METADATA_BACKUP_JOB_TYPE = 'metadataBackup'
|
||||
|
||||
type Settings = {|
|
||||
retentionXoMetadata?: number,
|
||||
retentionPoolMetadata?: number,
|
||||
|}
|
||||
|
||||
type MetadataBackupJob = {
|
||||
...$Exact<Job>,
|
||||
pools?: SimpleIdPattern,
|
||||
remotes: SimpleIdPattern,
|
||||
settings: $Dict<Settings>,
|
||||
type: METADATA_BACKUP_JOB_TYPE,
|
||||
xoMetadata?: boolean,
|
||||
}
|
||||
|
||||
// File structure on remotes:
|
||||
//
|
||||
// <remote>
|
||||
// ├─ xo-config-backups
|
||||
// │ └─ <schedule ID>
|
||||
// │ └─ <YYYYMMDD>T<HHmmss>
|
||||
// │ ├─ metadata.json
|
||||
// │ └─ data.json
|
||||
// └─ xo-pool-metadata-backups
|
||||
// └─ <schedule ID>
|
||||
// └─ <pool UUID>
|
||||
// └─ <YYYYMMDD>T<HHmmss>
|
||||
// ├─ metadata.json
|
||||
// └─ data
|
||||
|
||||
export default class metadataBackup {
|
||||
_app: {
|
||||
createJob: (
|
||||
$Diff<MetadataBackupJob, {| id: string |}>
|
||||
) => Promise<MetadataBackupJob>,
|
||||
createSchedule: ($Diff<Schedule, {| id: string |}>) => Promise<Schedule>,
|
||||
deleteSchedule: (id: string) => Promise<void>,
|
||||
getXapi: (id: string) => Xapi,
|
||||
getJob: (
|
||||
id: string,
|
||||
?METADATA_BACKUP_JOB_TYPE
|
||||
) => Promise<MetadataBackupJob>,
|
||||
updateJob: (
|
||||
$Shape<MetadataBackupJob>,
|
||||
?boolean
|
||||
) => Promise<MetadataBackupJob>,
|
||||
removeJob: (id: string) => Promise<void>,
|
||||
}
|
||||
|
||||
constructor(app: any) {
|
||||
this._app = app
|
||||
app.on('start', () => {
|
||||
app.registerJobExecutor(
|
||||
METADATA_BACKUP_JOB_TYPE,
|
||||
this._executor.bind(this)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async _executor({ cancelToken, job: job_, schedule }): Executor {
|
||||
if (schedule === undefined) {
|
||||
throw new Error('backup job cannot run without a schedule')
|
||||
}
|
||||
|
||||
const job: MetadataBackupJob = (job_: any)
|
||||
const remoteIds = unboxIdsFromPattern(job.remotes)
|
||||
if (remoteIds.length === 0) {
|
||||
throw new Error('metadata backup job cannot run without remotes')
|
||||
}
|
||||
|
||||
const poolIds = unboxIdsFromPattern(job.pools)
|
||||
const isEmptyPools = poolIds.length === 0
|
||||
if (!job.xoMetadata && isEmptyPools) {
|
||||
throw new Error('no metadata mode found')
|
||||
}
|
||||
|
||||
const app = this._app
|
||||
const { retentionXoMetadata, retentionPoolMetadata } =
|
||||
job?.settings[schedule.id] || {}
|
||||
|
||||
const timestamp = Date.now()
|
||||
const formattedTimestamp = safeDateFormat(timestamp)
|
||||
const commonMetadata = {
|
||||
jobId: job.id,
|
||||
jobName: job.name,
|
||||
scheduleId: schedule.id,
|
||||
scheduleName: schedule.name,
|
||||
timestamp,
|
||||
}
|
||||
|
||||
const files = []
|
||||
if (job.xoMetadata && retentionXoMetadata > 0) {
|
||||
const xoMetadataDir = `xo-config-backups/${schedule.id}`
|
||||
const dir = `${xoMetadataDir}/${formattedTimestamp}`
|
||||
|
||||
const data = JSON.stringify(await app.exportConfig(), null, 2)
|
||||
const fileName = `${dir}/data.json`
|
||||
|
||||
const metadata = JSON.stringify(commonMetadata, null, 2)
|
||||
const metaDataFileName = `${dir}/metadata.json`
|
||||
|
||||
files.push({
|
||||
executeBackup: defer(($defer, handler) => {
|
||||
$defer.onFailure(() => handler.rmtree(dir))
|
||||
return Promise.all([
|
||||
handler.outputFile(fileName, data),
|
||||
handler.outputFile(metaDataFileName, metadata),
|
||||
])
|
||||
}),
|
||||
dir: xoMetadataDir,
|
||||
retention: retentionXoMetadata,
|
||||
})
|
||||
}
|
||||
if (!isEmptyPools && retentionPoolMetadata > 0) {
|
||||
files.push(
|
||||
...(await Promise.all(
|
||||
poolIds.map(async id => {
|
||||
const poolMetadataDir = `xo-pool-metadata-backups/${
|
||||
schedule.id
|
||||
}/${id}`
|
||||
const dir = `${poolMetadataDir}/${formattedTimestamp}`
|
||||
|
||||
// TODO: export the metadata only once then split the stream between remotes
|
||||
const stream = await app.getXapi(id).exportPoolMetadata(cancelToken)
|
||||
const fileName = `${dir}/data`
|
||||
|
||||
const xapi = this._app.getXapi(id)
|
||||
const metadata = JSON.stringify(
|
||||
{
|
||||
...commonMetadata,
|
||||
pool: xapi.pool,
|
||||
poolMaster: await xapi.getRecord('host', xapi.pool.master),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
const metaDataFileName = `${dir}/metadata.json`
|
||||
|
||||
return {
|
||||
executeBackup: defer(($defer, handler) => {
|
||||
$defer.onFailure(() => handler.rmtree(dir))
|
||||
return Promise.all([
|
||||
(async () => {
|
||||
const outputStream = await handler.createOutputStream(
|
||||
fileName
|
||||
)
|
||||
$defer.onFailure(() => outputStream.destroy())
|
||||
|
||||
// 'readable-stream/pipeline' not call the callback when an error throws
|
||||
// from the readable stream
|
||||
stream.pipe(outputStream)
|
||||
return fromEvent(stream, 'end').catch(error => {
|
||||
if (error.message !== 'aborted') {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
})(),
|
||||
handler.outputFile(metaDataFileName, metadata),
|
||||
])
|
||||
}),
|
||||
dir: poolMetadataDir,
|
||||
retention: retentionPoolMetadata,
|
||||
}
|
||||
})
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error('no retentions corresponding to the metadata modes found')
|
||||
}
|
||||
|
||||
cancelToken.throwIfRequested()
|
||||
|
||||
const timestampReg = /^\d{8}T\d{6}Z$/
|
||||
return asyncMap(
|
||||
// TODO: emit a warning task if a remote is broken
|
||||
asyncMap(remoteIds, id => app.getRemoteHandler(id)::ignoreErrors()),
|
||||
async handler => {
|
||||
if (handler === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
files.map(async ({ executeBackup, dir, retention }) => {
|
||||
await executeBackup(handler)
|
||||
|
||||
// deleting old backups
|
||||
await handler.list(dir).then(list => {
|
||||
list.sort()
|
||||
list = list
|
||||
.filter(timestampDir => timestampReg.test(timestampDir))
|
||||
.slice(0, -retention)
|
||||
return Promise.all(
|
||||
list.map(timestampDir =>
|
||||
handler.rmtree(`${dir}/${timestampDir}`)
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async createMetadataBackupJob(
|
||||
props: $Diff<MetadataBackupJob, {| id: string |}>,
|
||||
schedules: $Dict<$Diff<Schedule, {| id: string |}>>
|
||||
): Promise<MetadataBackupJob> {
|
||||
const app = this._app
|
||||
|
||||
const job: MetadataBackupJob = await app.createJob({
|
||||
...props,
|
||||
type: METADATA_BACKUP_JOB_TYPE,
|
||||
})
|
||||
|
||||
const { id: jobId, settings } = job
|
||||
await asyncMap(schedules, async (schedule, tmpId) => {
|
||||
const { id: scheduleId } = await app.createSchedule({
|
||||
...schedule,
|
||||
jobId,
|
||||
})
|
||||
settings[scheduleId] = settings[tmpId]
|
||||
delete settings[tmpId]
|
||||
})
|
||||
await app.updateJob({ id: jobId, settings })
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
async deleteMetadataBackupJob(id: string): Promise<void> {
|
||||
const app = this._app
|
||||
const [schedules] = await Promise.all([
|
||||
app.getAllSchedules(),
|
||||
// it test if the job is of type metadataBackup
|
||||
app.getJob(id, METADATA_BACKUP_JOB_TYPE),
|
||||
])
|
||||
|
||||
await Promise.all([
|
||||
app.removeJob(id),
|
||||
asyncMap(schedules, schedule => {
|
||||
if (schedule.id === id) {
|
||||
return app.deleteSchedule(id)
|
||||
}
|
||||
}),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -329,7 +329,7 @@ export default class {
|
||||
let id
|
||||
let set
|
||||
if (
|
||||
object.$type !== 'vm' ||
|
||||
object.$type !== 'VM' ||
|
||||
object.is_a_snapshot ||
|
||||
('start' in object.blocked_operations &&
|
||||
(object.tags.includes('Disaster Recovery') ||
|
||||
|
||||
@@ -30,6 +30,15 @@ class PoolAlreadyConnected extends BaseError {
|
||||
|
||||
const log = createLogger('xo:xo-mixins:xen-servers')
|
||||
|
||||
// Server is disconnected:
|
||||
// - _xapis[server.id] is undefined
|
||||
|
||||
// Server is connecting:
|
||||
// - _xapis[server.id] is defined
|
||||
|
||||
// Server is connected:
|
||||
// - _xapis[server.id] id defined
|
||||
// - _serverIdsByPool[xapi.pool.$id] is server.id
|
||||
export default class {
|
||||
constructor(xo, { xapiOptions }) {
|
||||
this._objectConflicts = { __proto__: null } // TODO: clean when a server is disconnected.
|
||||
@@ -267,6 +276,12 @@ export default class {
|
||||
try {
|
||||
await xapi.connect()
|
||||
|
||||
// requesting disconnection on the connecting server
|
||||
if (this._xapis[server.id] === undefined) {
|
||||
xapi.disconnect()::ignoreErrors()
|
||||
return
|
||||
}
|
||||
|
||||
const serverIdsByPool = this._serverIdsByPool
|
||||
const poolId = xapi.pool.$id
|
||||
if (serverIdsByPool[poolId] !== undefined) {
|
||||
@@ -390,15 +405,17 @@ export default class {
|
||||
}
|
||||
|
||||
async disconnectXenServer(id) {
|
||||
const xapi = this._xapis[id]
|
||||
if (!xapi) {
|
||||
throw noSuchObject(id, 'xenServer')
|
||||
const status = this._getXenServerStatus(id)
|
||||
if (status === 'disconnected') {
|
||||
return
|
||||
}
|
||||
|
||||
const xapi = this._xapis[id]
|
||||
delete this._xapis[id]
|
||||
delete this._serverIdsByPool[xapi.pool.$id]
|
||||
|
||||
xapi.xo.uninstall()
|
||||
if (status === 'connected') {
|
||||
delete this._serverIdsByPool[xapi.pool.$id]
|
||||
xapi.xo.uninstall()
|
||||
}
|
||||
return xapi.disconnect()
|
||||
}
|
||||
|
||||
@@ -425,18 +442,22 @@ export default class {
|
||||
return xapi
|
||||
}
|
||||
|
||||
_getXenServerStatus(id) {
|
||||
const xapi = this._xapis[id]
|
||||
return xapi === undefined
|
||||
? 'disconnected'
|
||||
: this._serverIdsByPool[(xapi.pool?.$id)] === id
|
||||
? 'connected'
|
||||
: 'connecting'
|
||||
}
|
||||
|
||||
async getAllXenServers() {
|
||||
const servers = await this._servers.get()
|
||||
const xapis = this._xapis
|
||||
forEach(servers, server => {
|
||||
const xapi = xapis[server.id]
|
||||
if (xapi !== undefined) {
|
||||
server.status = xapi.status
|
||||
|
||||
let pool
|
||||
if (server.label === undefined && (pool = xapi.pool) != null) {
|
||||
server.label = pool.name_label
|
||||
}
|
||||
server.status = this._getXenServerStatus(server.id)
|
||||
if (server.status === 'connected' && server.label === undefined) {
|
||||
server.label = xapis[server.id].pool.name_label
|
||||
}
|
||||
|
||||
// Do not expose password.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-vmdk-to-vhd",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-vmdk-to-vhd",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "xo-web",
|
||||
"version": "5.34.0",
|
||||
"version": "5.36.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Web interface client for Xen-Orchestra",
|
||||
"keywords": [
|
||||
@@ -13,6 +13,7 @@
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-web",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-web",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
|
||||
@@ -104,9 +104,7 @@ class Editable extends Component {
|
||||
)
|
||||
}
|
||||
|
||||
_save() {
|
||||
return this.__save(() => this.value, this.props.onChange)
|
||||
}
|
||||
_save = () => this.__save(() => this.value, this.props.onChange)
|
||||
|
||||
async __save(getValue, saveValue) {
|
||||
const { props } = this
|
||||
@@ -275,7 +273,7 @@ export class Text extends Editable {
|
||||
{...extraProps}
|
||||
autoFocus
|
||||
defaultValue={value}
|
||||
onBlur={this._closeEdition}
|
||||
onBlur={this._save}
|
||||
onInput={this._onInput}
|
||||
onKeyDown={this._onKeyDown}
|
||||
readOnly={saving}
|
||||
@@ -492,10 +490,10 @@ export class Size extends Editable {
|
||||
return this.props.children || formatSize(this.props.value)
|
||||
}
|
||||
|
||||
_closeEditionIfUnfocused = () => {
|
||||
_saveIfUnfocused = () => {
|
||||
this._focused = false
|
||||
setTimeout(() => {
|
||||
!this._focused && this._closeEdition()
|
||||
!this._focused && this._save()
|
||||
}, 10)
|
||||
}
|
||||
|
||||
@@ -512,7 +510,7 @@ export class Size extends Editable {
|
||||
// SizeInput uses `input-group` which makes it behave as a block element (display: table).
|
||||
// `form-inline` to use it as an inline element
|
||||
className='form-inline'
|
||||
onBlur={this._closeEditionIfUnfocused}
|
||||
onBlur={this._saveIfUnfocused}
|
||||
onFocus={this._focus}
|
||||
onKeyDown={this._onKeyDown}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { startsWith } from 'lodash'
|
||||
|
||||
import decorate from '../apply-decorators'
|
||||
|
||||
// it provide `data-*` to add params to the `onChange`
|
||||
const Number_ = decorate([
|
||||
provideState({
|
||||
effects: {
|
||||
@@ -18,7 +20,16 @@ const Number_ = decorate([
|
||||
}
|
||||
}
|
||||
|
||||
props.onChange(value)
|
||||
const params = {}
|
||||
let empty = true
|
||||
Object.keys(props).forEach(key => {
|
||||
if (startsWith(key, 'data-')) {
|
||||
empty = false
|
||||
params[key.slice(5)] = props[key]
|
||||
}
|
||||
})
|
||||
|
||||
props.onChange(value, empty ? undefined : params)
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -37,6 +37,8 @@ const messages = {
|
||||
paths: 'Paths',
|
||||
pbdDisconnected: 'PBD disconnected',
|
||||
hasInactivePath: 'Has an inactive path',
|
||||
pools: 'Pools',
|
||||
remotes: 'Remotes',
|
||||
|
||||
// ----- Modals -----
|
||||
alertOk: 'OK',
|
||||
@@ -101,6 +103,7 @@ const messages = {
|
||||
newMenu: 'New',
|
||||
taskMenu: 'Tasks',
|
||||
taskPage: 'Tasks',
|
||||
newNetworkPage: 'Network',
|
||||
newVmPage: 'VM',
|
||||
newSrPage: 'Storage',
|
||||
newServerPage: 'Server',
|
||||
@@ -124,6 +127,11 @@ const messages = {
|
||||
deltaBackup: 'Delta Backup',
|
||||
disasterRecovery: 'Disaster Recovery',
|
||||
continuousReplication: 'Continuous Replication',
|
||||
backupType: 'Backup type',
|
||||
poolMetadata: 'Pool metadata',
|
||||
xoConfig: 'XO config',
|
||||
backupVms: 'Backup VMs',
|
||||
backupMetadata: 'Backup metadata',
|
||||
jobsOverviewPage: 'Overview',
|
||||
jobsNewPage: 'New',
|
||||
jobsSchedulingPage: 'Scheduling',
|
||||
@@ -182,6 +190,7 @@ const messages = {
|
||||
homeFilterTags: 'Tags',
|
||||
homeSortBy: 'Sort by',
|
||||
homeSortByCpus: 'CPUs',
|
||||
homeSortByStartTime: 'Start time',
|
||||
homeSortByName: 'Name',
|
||||
homeSortByPowerstate: 'Power state',
|
||||
homeSortByRAM: 'RAM',
|
||||
@@ -368,14 +377,17 @@ const messages = {
|
||||
missingBackupMode: 'You need to choose a backup mode!',
|
||||
missingRemotes: 'Missing remotes!',
|
||||
missingSrs: 'Missing SRs!',
|
||||
missingPools: 'Missing pools!',
|
||||
missingSchedules: 'Missing schedules!',
|
||||
missingRetentions:
|
||||
'The modes need at least a schedule with retention higher than 0',
|
||||
missingExportRetention:
|
||||
'The Backup mode and The Delta Backup mode require backup retention to be higher than 0!',
|
||||
missingCopyRetention:
|
||||
'The CR mode and The DR mode require replication retention to be higher than 0!',
|
||||
missingSnapshotRetention:
|
||||
'The Rolling Snapshot mode requires snapshot retention to be higher than 0!',
|
||||
retentionNeeded: 'One of the retentions needs to be higher than 0!',
|
||||
retentionNeeded: 'Requires one retention to be higher than 0!',
|
||||
newScheduleError: 'Invalid schedule',
|
||||
createRemoteMessage:
|
||||
'No remotes found, please click on the remotes settings button to create one!',
|
||||
@@ -562,6 +574,10 @@ const messages = {
|
||||
newSrNfsOptions: 'Comma delimited NFS options',
|
||||
reattachNewSrTooltip: 'Reattach SR',
|
||||
|
||||
// ------ New Newtork -----
|
||||
createNewNetworkNoPermission: 'You have no permission to create a network',
|
||||
createNewNetworkOn: 'Create a new network on {select}',
|
||||
|
||||
// ----- Acls, Users, Groups ------
|
||||
subjectName: 'Users/Groups',
|
||||
objectName: 'Object',
|
||||
@@ -779,6 +795,8 @@ const messages = {
|
||||
hostMultipathingSrs: 'Click to see concerned SRs',
|
||||
hostMultipathingPaths:
|
||||
'{nActives, number} of {nPaths, number} path{nPaths, plural, one {} other {s}} ({ nSessions, number } iSCSI session{nSessions, plural, one {} other {s}})',
|
||||
hostMultipathingRequiredState:
|
||||
'This action will not be fulfilled if a VM is in a running state. Please ensure that all VMs are evacuated or stopped before doing this action!',
|
||||
hostMultipathingWarning:
|
||||
'The host{nHosts, plural, one {} other {s}} will lose the connection to the SRs. Do you want to continue?',
|
||||
hostXenServerVersion: 'Version',
|
||||
@@ -1388,6 +1406,8 @@ const messages = {
|
||||
scheduleExportRetention: 'Backup ret.',
|
||||
scheduleCopyRetention: 'Replication ret.',
|
||||
scheduleSnapshotRetention: 'Snapshot ret.',
|
||||
poolMetadataRetention: 'Pool ret.',
|
||||
xoMetadataRetention: 'XO ret.',
|
||||
getRemote: 'Get remote',
|
||||
listRemote: 'List Remote',
|
||||
simpleBackup: 'simple',
|
||||
@@ -1687,7 +1707,6 @@ const messages = {
|
||||
|
||||
// ----- Network -----
|
||||
newNetworkCreate: 'Create network',
|
||||
newBondedNetworkCreate: 'Create bonded network',
|
||||
newNetworkInterface: 'Interface',
|
||||
newNetworkName: 'Name',
|
||||
newNetworkDescription: 'Description',
|
||||
@@ -1695,13 +1714,14 @@ const messages = {
|
||||
newNetworkDefaultVlan: 'No VLAN if empty',
|
||||
newNetworkMtu: 'MTU',
|
||||
newNetworkDefaultMtu: 'Default: 1500',
|
||||
newNetworkNoNameErrorTitle: 'Name required',
|
||||
newNetworkNoNameErrorMessage: 'A name is required to create a network',
|
||||
newNetworkBondMode: 'Bond mode',
|
||||
newNetworkInfo: 'Info',
|
||||
newNetworkType: 'Type',
|
||||
deleteNetwork: 'Delete network',
|
||||
deleteNetworkConfirm: 'Are you sure you want to delete this network?',
|
||||
networkInUse: 'This network is currently in use',
|
||||
pillBonded: 'Bonded',
|
||||
bondedNetwork: 'Bonded network',
|
||||
|
||||
// ----- Add host -----
|
||||
addHostSelectHost: 'Host',
|
||||
@@ -1866,6 +1886,7 @@ const messages = {
|
||||
logError: 'Error',
|
||||
logTitle: 'Logs',
|
||||
logDisplayDetails: 'Display details',
|
||||
logDownload: 'Download log',
|
||||
logTime: 'Date',
|
||||
logNoStackTrace: 'No stack trace',
|
||||
logNoParams: 'No params',
|
||||
|
||||
@@ -120,6 +120,8 @@ const getObjectsById = objects =>
|
||||
class GenericSelect extends React.Component {
|
||||
static propTypes = {
|
||||
allowMissingObjects: PropTypes.bool,
|
||||
compareContainers: PropTypes.func,
|
||||
compareOptions: PropTypes.func,
|
||||
hasSelectAll: PropTypes.bool,
|
||||
multi: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
@@ -178,7 +180,9 @@ class GenericSelect extends React.Component {
|
||||
_getOptions = createSelector(
|
||||
() => this.props.xoContainers,
|
||||
this._getObjects,
|
||||
(containers, objects) => {
|
||||
() => this.props.compareContainers,
|
||||
() => this.props.compareOptions,
|
||||
(containers, objects, compareContainers, compareOptions) => {
|
||||
// createCollectionWrapper with a depth?
|
||||
const { name } = this.constructor
|
||||
|
||||
@@ -190,7 +194,10 @@ class GenericSelect extends React.Component {
|
||||
)
|
||||
}
|
||||
|
||||
options = map(objects, getOption)
|
||||
options = (compareOptions !== undefined
|
||||
? objects.sort(compareOptions)
|
||||
: objects
|
||||
).map(getOption)
|
||||
} else {
|
||||
if (__DEV__ && isArray(objects)) {
|
||||
throw new Error(
|
||||
@@ -199,13 +206,21 @@ class GenericSelect extends React.Component {
|
||||
}
|
||||
|
||||
options = []
|
||||
forEach(containers, container => {
|
||||
const _containers =
|
||||
compareContainers !== undefined
|
||||
? containers.sort(compareContainers)
|
||||
: containers
|
||||
forEach(_containers, container => {
|
||||
options.push({
|
||||
disabled: true,
|
||||
xoItem: container,
|
||||
})
|
||||
|
||||
forEach(objects[container.id], object => {
|
||||
const _objects =
|
||||
compareOptions !== undefined
|
||||
? objects[container.id].sort(compareOptions)
|
||||
: objects[container.id]
|
||||
forEach(_objects, object => {
|
||||
options.push(getOption(object, container))
|
||||
})
|
||||
})
|
||||
@@ -375,9 +390,20 @@ export const SelectHost = makeStoreSelect(
|
||||
const getHostsByPool = createGetObjectsOfType('host')
|
||||
.filter(getPredicate)
|
||||
.sort()
|
||||
.groupBy('$pool')
|
||||
|
||||
const getPools = createGetObjectsOfType('pool')
|
||||
.pick(
|
||||
createSelector(
|
||||
getHostsByPool,
|
||||
hostsByPool => Object.keys(hostsByPool)
|
||||
)
|
||||
)
|
||||
.sort()
|
||||
|
||||
return {
|
||||
xoObjects: getHostsByPool,
|
||||
xoContainers: getPools,
|
||||
}
|
||||
},
|
||||
{ placeholder: _('selectHosts') }
|
||||
|
||||
@@ -601,3 +601,20 @@ export const getIscsiPaths = pbd => {
|
||||
const pathsInfo = pbd.otherConfig[`mpath-${pbd.device_config.SCSIid}`]
|
||||
return pathsInfo !== undefined ? JSON.parse(pathsInfo) : []
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
export const downloadLog = ({ log, date, type }) => {
|
||||
const file = new window.Blob([log], {
|
||||
type: 'text/plain',
|
||||
})
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = window.URL.createObjectURL(file)
|
||||
anchor.download = `${new Date(date)
|
||||
.toISOString()
|
||||
.replace(/:/g, '_')} - ${type}.log`
|
||||
anchor.style.display = 'none'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
document.body.removeChild(anchor)
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import Component from 'base-component'
|
||||
import map from 'lodash/map'
|
||||
import React from 'react'
|
||||
import { createGetObject, createSelector } from 'selectors'
|
||||
import { getBondModes } from 'xo'
|
||||
import { injectIntl } from 'react-intl'
|
||||
|
||||
import _, { messages } from '../../intl'
|
||||
import { Col } from '../../grid'
|
||||
import { connectStore } from '../../utils'
|
||||
import { SelectPif } from '../../select-objects'
|
||||
import SingleLineRow from '../../single-line-row'
|
||||
|
||||
@connectStore(
|
||||
() => ({
|
||||
poolMaster: createSelector(
|
||||
createGetObject((_, props) => props.pool),
|
||||
pool => pool.master
|
||||
),
|
||||
}),
|
||||
{ withRef: true }
|
||||
)
|
||||
class CreateBondedNetworkModalBody extends Component {
|
||||
componentWillMount() {
|
||||
getBondModes().then(bondModes =>
|
||||
this.setState({ bondModes, bondMode: bondModes[0] })
|
||||
)
|
||||
}
|
||||
|
||||
_getPifPredicate = createSelector(
|
||||
() => this.props.poolMaster,
|
||||
hostId => pif => pif.$host === hostId && pif.vlan === -1
|
||||
)
|
||||
|
||||
get value() {
|
||||
const { name, description, pifs, mtu, bondMode } = this.state
|
||||
return {
|
||||
pool: this.props.pool,
|
||||
name,
|
||||
description,
|
||||
pifs: map(pifs, pif => pif.id),
|
||||
mtu,
|
||||
bondMode,
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { formatMessage } = this.props.intl
|
||||
return (
|
||||
<div>
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('newNetworkInterface')}</Col>
|
||||
<Col size={6}>
|
||||
<SelectPif
|
||||
multi
|
||||
onChange={this.linkState('pifs')}
|
||||
predicate={this._getPifPredicate()}
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('newNetworkName')}</Col>
|
||||
<Col size={6}>
|
||||
<input
|
||||
className='form-control'
|
||||
onChange={this.linkState('name')}
|
||||
type='text'
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('newNetworkDescription')}</Col>
|
||||
<Col size={6}>
|
||||
<input
|
||||
className='form-control'
|
||||
onChange={this.linkState('description')}
|
||||
type='text'
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('newNetworkMtu')}</Col>
|
||||
<Col size={6}>
|
||||
<input
|
||||
className='form-control'
|
||||
onChange={this.linkState('mtu')}
|
||||
placeholder={formatMessage(messages.newNetworkDefaultMtu)}
|
||||
type='text'
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('newNetworkBondMode')}</Col>
|
||||
<Col size={6}>
|
||||
<select
|
||||
className='form-control'
|
||||
onChange={this.linkState('bondMode')}
|
||||
>
|
||||
{map(this.state.bondModes, mode => (
|
||||
<option value={mode}>{mode}</option>
|
||||
))}
|
||||
</select>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
export default injectIntl(CreateBondedNetworkModalBody, { withRef: true })
|
||||
@@ -1,84 +0,0 @@
|
||||
import React, { Component } from 'react'
|
||||
import { injectIntl } from 'react-intl'
|
||||
import { createSelector } from 'selectors'
|
||||
|
||||
import SingleLineRow from '../../single-line-row'
|
||||
import _, { messages } from '../../intl'
|
||||
import { SelectPif } from '../../select-objects'
|
||||
import { Col } from '../../grid'
|
||||
|
||||
class CreateNetworkModalBody extends Component {
|
||||
_getPifPredicate = createSelector(
|
||||
() => {
|
||||
const { container } = this.props
|
||||
return container.type === 'pool' ? container.master : container.id
|
||||
},
|
||||
hostId => pif => pif.$host === hostId && pif.vlan === -1
|
||||
)
|
||||
|
||||
get value() {
|
||||
const { refs } = this
|
||||
const { container } = this.props
|
||||
return {
|
||||
pool: container.$pool,
|
||||
name: refs.name.value,
|
||||
description: refs.description.value,
|
||||
pif: refs.pif.value && refs.pif.value.id,
|
||||
mtu: refs.mtu.value,
|
||||
vlan: refs.vlan.value,
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { formatMessage } = this.props.intl
|
||||
return (
|
||||
<div>
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('newNetworkInterface')}</Col>
|
||||
<Col size={6}>
|
||||
<SelectPif predicate={this._getPifPredicate()} ref='pif' />
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('newNetworkName')}</Col>
|
||||
<Col size={6}>
|
||||
<input className='form-control' ref='name' type='text' />
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('newNetworkDescription')}</Col>
|
||||
<Col size={6}>
|
||||
<input className='form-control' ref='description' type='text' />
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('newNetworkVlan')}</Col>
|
||||
<Col size={6}>
|
||||
<input
|
||||
className='form-control'
|
||||
placeholder={formatMessage(messages.newNetworkDefaultVlan)}
|
||||
ref='vlan'
|
||||
type='text'
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('newNetworkMtu')}</Col>
|
||||
<Col size={6}>
|
||||
<input
|
||||
className='form-control'
|
||||
placeholder={formatMessage(messages.newNetworkDefaultMtu)}
|
||||
ref='mtu'
|
||||
type='text'
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
export default injectIntl(CreateNetworkModalBody, { withRef: true })
|
||||
@@ -395,7 +395,12 @@ export const subscribeNotifications = createSubscription(async () => {
|
||||
return []
|
||||
}
|
||||
|
||||
const notifications = await updater._call('getMessages')
|
||||
let notifications
|
||||
try {
|
||||
notifications = await updater._call('getMessages')
|
||||
} catch (err) {
|
||||
return []
|
||||
}
|
||||
const notificationCookie = getNotificationCookie()
|
||||
return map(
|
||||
user != null && user.permission === 'admin'
|
||||
@@ -1622,39 +1627,10 @@ export const setVif = (
|
||||
export const editNetwork = (network, props) =>
|
||||
_call('network.set', { ...props, id: resolveId(network) })
|
||||
|
||||
import CreateNetworkModalBody from './create-network-modal' // eslint-disable-line import/first
|
||||
export const createNetwork = container =>
|
||||
confirm({
|
||||
icon: 'network',
|
||||
title: _('newNetworkCreate'),
|
||||
body: <CreateNetworkModalBody container={container} />,
|
||||
}).then(params => {
|
||||
if (!params.name) {
|
||||
return error(
|
||||
_('newNetworkNoNameErrorTitle'),
|
||||
_('newNetworkNoNameErrorMessage')
|
||||
)
|
||||
}
|
||||
return _call('network.create', params)
|
||||
}, noop)
|
||||
|
||||
export const getBondModes = () => _call('network.getBondModes')
|
||||
|
||||
import CreateBondedNetworkModalBody from './create-bonded-network-modal' // eslint-disable-line import/first
|
||||
export const createBondedNetwork = container =>
|
||||
confirm({
|
||||
icon: 'network',
|
||||
title: _('newBondedNetworkCreate'),
|
||||
body: <CreateBondedNetworkModalBody pool={container.$pool} />,
|
||||
}).then(params => {
|
||||
if (!params.name) {
|
||||
return error(
|
||||
_('newNetworkNoNameErrorTitle'),
|
||||
_('newNetworkNoNameErrorMessage')
|
||||
)
|
||||
}
|
||||
return _call('network.createBonded', params)
|
||||
}, noop)
|
||||
export const createNetwork = params => _call('network.create', params)
|
||||
export const createBondedNetwork = params =>
|
||||
_call('network.createBonded', params)
|
||||
|
||||
export const deleteNetwork = network =>
|
||||
confirm({
|
||||
@@ -1946,15 +1922,22 @@ export const subscribeBackupNgLogs = createSubscription(() =>
|
||||
_call('backupNg.getAllLogs')
|
||||
)
|
||||
|
||||
export const subscribeMetadataBackupJobs = createSubscription(() =>
|
||||
_call('metadataBackup.getAllJobs')
|
||||
)
|
||||
|
||||
export const createBackupNgJob = props =>
|
||||
_call('backupNg.createJob', props)::tap(subscribeBackupNgJobs.forceRefresh)
|
||||
|
||||
export const deleteBackupNgJobs = async ids => {
|
||||
const { length } = ids
|
||||
if (length === 0) {
|
||||
export const deleteBackupJobs = async ({
|
||||
backupIds = [],
|
||||
metadataBackupIds = [],
|
||||
}) => {
|
||||
const nJobs = backupIds.length + metadataBackupIds.length
|
||||
if (nJobs === 0) {
|
||||
return
|
||||
}
|
||||
const vars = { nJobs: length }
|
||||
const vars = { nJobs }
|
||||
try {
|
||||
await confirm({
|
||||
title: _('confirmDeleteBackupJobsTitle', vars),
|
||||
@@ -1964,9 +1947,25 @@ export const deleteBackupNgJobs = async ids => {
|
||||
return
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
ids.map(id => _call('backupNg.deleteJob', { id: resolveId(id) }))
|
||||
)::tap(subscribeBackupNgJobs.forceRefresh)
|
||||
const promises = []
|
||||
if (backupIds.length !== 0) {
|
||||
promises.push(
|
||||
Promise.all(
|
||||
backupIds.map(id => _call('backupNg.deleteJob', { id: resolveId(id) }))
|
||||
)::tap(subscribeBackupNgJobs.forceRefresh)
|
||||
)
|
||||
}
|
||||
if (metadataBackupIds.length !== 0) {
|
||||
promises.push(
|
||||
Promise.all(
|
||||
metadataBackupIds.map(id =>
|
||||
_call('metadataBackup.deleteJob', { id: resolveId(id) })
|
||||
)
|
||||
)::tap(subscribeMetadataBackupJobs.forceRefresh)
|
||||
)
|
||||
}
|
||||
|
||||
return Promise.all(promises)::tap(subscribeSchedules.forceRefresh)
|
||||
}
|
||||
|
||||
export const editBackupNgJob = props =>
|
||||
@@ -2003,6 +2002,19 @@ export const deleteBackups = async backups => {
|
||||
}
|
||||
}
|
||||
|
||||
export const createMetadataBackupJob = props =>
|
||||
_call('metadataBackup.createJob', props)
|
||||
::tap(subscribeMetadataBackupJobs.forceRefresh)
|
||||
::tap(subscribeSchedules.forceRefresh)
|
||||
|
||||
export const editMetadataBackupJob = props =>
|
||||
_call('metadataBackup.editJob', props)
|
||||
::tap(subscribeMetadataBackupJobs.forceRefresh)
|
||||
::tap(subscribeSchedules.forceRefresh)
|
||||
|
||||
export const runMetadataBackupJob = params =>
|
||||
_call('metadataBackup.runJob', params)
|
||||
|
||||
// Plugins -----------------------------------------------------------
|
||||
|
||||
export const loadPlugin = async id =>
|
||||
|
||||
@@ -214,6 +214,11 @@ export default class MigrateVmModalBody extends BaseComponent {
|
||||
})
|
||||
}
|
||||
|
||||
compareContainers = (pool1, pool2) => {
|
||||
const { $pool: poolId } = this.props.vm
|
||||
return pool1.id === poolId ? -1 : pool2.id === poolId ? 1 : 0
|
||||
}
|
||||
|
||||
_selectMigrationNetwork = migrationNetwork =>
|
||||
this.setState({ migrationNetworkId: migrationNetwork.id })
|
||||
|
||||
@@ -234,6 +239,7 @@ export default class MigrateVmModalBody extends BaseComponent {
|
||||
<Col size={4}>{_('migrateVmSelectHost')}</Col>
|
||||
<Col size={8}>
|
||||
<SelectHost
|
||||
compareContainers={this.compareContainers}
|
||||
onChange={this._selectHost}
|
||||
predicate={this._getHostPredicate()}
|
||||
required
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { Component } from 'react'
|
||||
|
||||
import _ from '../../intl'
|
||||
import Collapse from '../../collapse'
|
||||
import Icon from '../../icon'
|
||||
import { connectStore } from '../../utils'
|
||||
import { createGetObjectsOfType, createSelector } from '../../selectors'
|
||||
import { Sr } from '../../render-xo-item'
|
||||
@@ -36,6 +37,10 @@ export default class MultipathingModal extends Component {
|
||||
{_('hostMultipathingWarning', {
|
||||
nHosts: hostIds.length,
|
||||
})}
|
||||
<br />
|
||||
<span className='text-info'>
|
||||
<Icon icon='info' /> {_('hostMultipathingRequiredState')}
|
||||
</span>
|
||||
<Collapse
|
||||
buttonText={_('hostMultipathingSrs')}
|
||||
size='small'
|
||||
|
||||
@@ -108,6 +108,10 @@
|
||||
@extend .fa;
|
||||
@extend .fa-clipboard;
|
||||
}
|
||||
&-download {
|
||||
@extend .fa;
|
||||
@extend .fa-download;
|
||||
}
|
||||
&-shortcuts {
|
||||
@extend .fa;
|
||||
@extend .fa-keyboard-o;
|
||||
@@ -489,7 +493,8 @@
|
||||
}
|
||||
|
||||
// SR
|
||||
&-sr, &-vdi {
|
||||
&-sr,
|
||||
&-vdi {
|
||||
&-reconnect-all {
|
||||
@extend .fa;
|
||||
@extend .fa-retweet;
|
||||
@@ -563,7 +568,8 @@
|
||||
}
|
||||
|
||||
// Host and VM actions
|
||||
&-host, &-vm {
|
||||
&-host,
|
||||
&-vm {
|
||||
&-start {
|
||||
@extend .fa;
|
||||
@extend .fa-play;
|
||||
@@ -611,12 +617,12 @@
|
||||
|
||||
&-filters {
|
||||
@extend .fa;
|
||||
@extend .fa-filter
|
||||
@extend .fa-filter;
|
||||
}
|
||||
|
||||
&-tags {
|
||||
@extend .fa;
|
||||
@extend .fa-tags
|
||||
@extend .fa-tags;
|
||||
}
|
||||
|
||||
&-remove-tag {
|
||||
@@ -656,11 +662,11 @@
|
||||
}
|
||||
&-minus {
|
||||
@extend .fa;
|
||||
@extend .fa-minus
|
||||
@extend .fa-minus;
|
||||
}
|
||||
&-plus {
|
||||
@extend .fa;
|
||||
@extend .fa-plus
|
||||
@extend .fa-plus;
|
||||
}
|
||||
&-clear-search {
|
||||
@extend .fa;
|
||||
@@ -854,6 +860,10 @@
|
||||
@extend .fa;
|
||||
@extend .fa-database;
|
||||
}
|
||||
&-network {
|
||||
@extend .fa;
|
||||
@extend .fa-sitemap;
|
||||
}
|
||||
&-import {
|
||||
@extend .fa;
|
||||
@extend .fa-file-archive-o;
|
||||
@@ -867,7 +877,7 @@
|
||||
&-new-vm {
|
||||
&-infos {
|
||||
@extend .fa;
|
||||
@extend .fa-info-circle
|
||||
@extend .fa-info-circle;
|
||||
}
|
||||
&-perf {
|
||||
@extend .fa;
|
||||
@@ -906,6 +916,13 @@
|
||||
@extend .fa-times;
|
||||
}
|
||||
}
|
||||
// New network
|
||||
&-new-network {
|
||||
&-create {
|
||||
@extend .fa;
|
||||
@extend .fa-play;
|
||||
}
|
||||
}
|
||||
// OS Icons
|
||||
&-centos {
|
||||
@extend .fa;
|
||||
@@ -979,7 +996,7 @@
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
// About
|
||||
// About
|
||||
&-bug {
|
||||
@extend .fa;
|
||||
@extend .fa-bug;
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import addSubscriptions from 'add-subscriptions'
|
||||
import decorate from 'apply-decorators'
|
||||
import defined from '@xen-orchestra/defined'
|
||||
import React from 'react'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { subscribeBackupNgJobs, subscribeSchedules } from 'xo'
|
||||
import { find, groupBy, keyBy } from 'lodash'
|
||||
import {
|
||||
subscribeBackupNgJobs,
|
||||
subscribeMetadataBackupJobs,
|
||||
subscribeSchedules,
|
||||
} from 'xo'
|
||||
|
||||
import Metadata from './new/metadata'
|
||||
import New from './new'
|
||||
|
||||
export default decorate([
|
||||
addSubscriptions({
|
||||
jobs: subscribeBackupNgJobs,
|
||||
metadataJobs: subscribeMetadataBackupJobs,
|
||||
schedulesByJob: cb =>
|
||||
subscribeSchedules(schedules => {
|
||||
cb(groupBy(schedules, 'jobId'))
|
||||
@@ -17,11 +24,17 @@ export default decorate([
|
||||
}),
|
||||
provideState({
|
||||
computed: {
|
||||
job: (_, { jobs, routeParams: { id } }) => find(jobs, { id }),
|
||||
job: (_, { jobs, metadataJobs, routeParams: { id } }) =>
|
||||
defined(find(jobs, { id }), find(metadataJobs, { id })),
|
||||
schedules: (_, { schedulesByJob, routeParams: { id } }) =>
|
||||
schedulesByJob && keyBy(schedulesByJob[id], 'id'),
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ state: { job, schedules } }) => <New job={job} schedules={schedules} />,
|
||||
({ state: { job = {}, schedules } }) =>
|
||||
job.type === 'backup' ? (
|
||||
<New job={job} schedules={schedules} />
|
||||
) : (
|
||||
<Metadata job={job} schedules={schedules} />
|
||||
),
|
||||
])
|
||||
|
||||
@@ -2,6 +2,7 @@ import _ from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import addSubscriptions from 'add-subscriptions'
|
||||
import Button from 'button'
|
||||
import ButtonLink from 'button-link'
|
||||
import Copiable from 'copiable'
|
||||
import CopyToClipboard from 'react-copy-to-clipboard'
|
||||
import decorate from 'apply-decorators'
|
||||
@@ -16,29 +17,31 @@ import { confirm } from 'modal'
|
||||
import { connectStore, routes } from 'utils'
|
||||
import { constructQueryString } from 'smart-backup'
|
||||
import { Container, Row, Col } from 'grid'
|
||||
import { createGetLoneSnapshots } from 'selectors'
|
||||
import { createGetLoneSnapshots, createSelector } from 'selectors'
|
||||
import { get } from '@xen-orchestra/defined'
|
||||
import { isEmpty, map, groupBy, some } from 'lodash'
|
||||
import { NavLink, NavTabs } from 'nav'
|
||||
import {
|
||||
cancelJob,
|
||||
deleteBackupNgJobs,
|
||||
deleteBackupJobs,
|
||||
disableSchedule,
|
||||
enableSchedule,
|
||||
runBackupNgJob,
|
||||
runMetadataBackupJob,
|
||||
subscribeBackupNgJobs,
|
||||
subscribeBackupNgLogs,
|
||||
subscribeMetadataBackupJobs,
|
||||
subscribeSchedules,
|
||||
} from 'xo'
|
||||
|
||||
import LogsTable, { LogStatus } from '../logs/backup-ng'
|
||||
import Page from '../page'
|
||||
|
||||
import NewVmBackup, { NewMetadataBackup } from './new'
|
||||
import Edit from './edit'
|
||||
import New from './new'
|
||||
import FileRestore from './file-restore'
|
||||
import Restore from './restore'
|
||||
import Health from './health'
|
||||
import Restore from './restore'
|
||||
import { destructPattern } from './utils'
|
||||
|
||||
const Ul = props => <ul {...props} style={{ listStyleType: 'none' }} />
|
||||
@@ -51,14 +54,26 @@ const Li = props => (
|
||||
/>
|
||||
)
|
||||
|
||||
const _runBackupNgJob = ({ id, name, schedule }) =>
|
||||
const _runBackupJob = ({ id, name, schedule, type }) =>
|
||||
confirm({
|
||||
title: _('runJob'),
|
||||
body: _('runBackupNgJobConfirm', {
|
||||
id: id.slice(0, 5),
|
||||
name: <strong>{name}</strong>,
|
||||
}),
|
||||
}).then(() => runBackupNgJob({ id, schedule }))
|
||||
}).then(() =>
|
||||
type === 'backup'
|
||||
? runBackupNgJob({ id, schedule })
|
||||
: runMetadataBackupJob({ id, schedule })
|
||||
)
|
||||
|
||||
const _deleteBackupJobs = items => {
|
||||
const { backup: backupIds, metadataBackup: metadataBackupIds } = groupBy(
|
||||
items,
|
||||
'type'
|
||||
)
|
||||
return deleteBackupJobs({ backupIds, metadataBackupIds })
|
||||
}
|
||||
|
||||
const SchedulePreviewBody = decorate([
|
||||
addSubscriptions(({ schedule }) => ({
|
||||
@@ -119,7 +134,8 @@ const SchedulePreviewBody = decorate([
|
||||
data-id={job.id}
|
||||
data-name={job.name}
|
||||
data-schedule={schedule.id}
|
||||
handler={_runBackupNgJob}
|
||||
data-type={job.type}
|
||||
handler={_runBackupJob}
|
||||
icon='run-schedule'
|
||||
key='run'
|
||||
size='small'
|
||||
@@ -159,10 +175,19 @@ const MODES = [
|
||||
test: job =>
|
||||
job.mode === 'full' && !isEmpty(get(() => destructPattern(job.srs))),
|
||||
},
|
||||
{
|
||||
label: 'poolMetadata',
|
||||
test: job => !isEmpty(destructPattern(job.pools)),
|
||||
},
|
||||
{
|
||||
label: 'xoConfig',
|
||||
test: job => job.xoMetadata,
|
||||
},
|
||||
]
|
||||
|
||||
@addSubscriptions({
|
||||
jobs: subscribeBackupNgJobs,
|
||||
metadataJobs: subscribeMetadataBackupJobs,
|
||||
schedulesByJob: cb =>
|
||||
subscribeSchedules(schedules => {
|
||||
cb(groupBy(schedules, 'jobId'))
|
||||
@@ -176,7 +201,7 @@ class JobsTable extends React.Component {
|
||||
static tableProps = {
|
||||
actions: [
|
||||
{
|
||||
handler: deleteBackupNgJobs,
|
||||
handler: _deleteBackupJobs,
|
||||
label: _('deleteBackupSchedule'),
|
||||
icon: 'delete',
|
||||
level: 'danger',
|
||||
@@ -261,6 +286,7 @@ class JobsTable extends React.Component {
|
||||
pathname: '/home',
|
||||
query: { t: 'VM', s: constructQueryString(job.vms) },
|
||||
}),
|
||||
disabled: job => job.type !== 'backup',
|
||||
label: _('redirectToMatchingVms'),
|
||||
icon: 'preview',
|
||||
},
|
||||
@@ -277,11 +303,17 @@ class JobsTable extends React.Component {
|
||||
this.context.router.push(path)
|
||||
}
|
||||
|
||||
_getCollection = createSelector(
|
||||
() => this.props.jobs,
|
||||
() => this.props.metadataJobs,
|
||||
(jobs = [], metadataJobs = []) => [...jobs, ...metadataJobs]
|
||||
)
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SortedTable
|
||||
{...JobsTable.tableProps}
|
||||
collection={this.props.jobs}
|
||||
collection={this._getCollection()}
|
||||
data-goTo={this._goTo}
|
||||
data-schedulesByJob={this.props.schedulesByJob}
|
||||
/>
|
||||
@@ -353,9 +385,31 @@ const HEADER = (
|
||||
</Container>
|
||||
)
|
||||
|
||||
const ChooseBackupType = () => (
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
<Card>
|
||||
<CardHeader>{_('backupType')}</CardHeader>
|
||||
<CardBlock className='text-md-center'>
|
||||
<ButtonLink to='backup-ng/new/vms'>
|
||||
<Icon icon='backup' /> {_('backupVms')}
|
||||
</ButtonLink>{' '}
|
||||
<ButtonLink to='backup-ng/new/metadata'>
|
||||
<Icon icon='database' /> {_('backupMetadata')}
|
||||
</ButtonLink>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
|
||||
export default routes('overview', {
|
||||
':id/edit': Edit,
|
||||
new: New,
|
||||
new: ChooseBackupType,
|
||||
'new/vms': NewVmBackup,
|
||||
'new/metadata': NewMetadataBackup,
|
||||
overview: Overview,
|
||||
restore: Restore,
|
||||
'file-restore': FileRestore,
|
||||
|
||||
224
packages/xo-web/src/xo-app/backup-ng/new/_schedules/index.js
Normal file
224
packages/xo-web/src/xo-app/backup-ng/new/_schedules/index.js
Normal file
@@ -0,0 +1,224 @@
|
||||
import _ from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import decorate from 'apply-decorators'
|
||||
import Icon from 'icon'
|
||||
import moment from 'moment-timezone'
|
||||
import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
import SortedTable from 'sorted-table'
|
||||
import StateButton from 'state-button'
|
||||
import UserError from 'user-error'
|
||||
import { Card, CardBlock, CardHeader } from 'card'
|
||||
import { form } from 'modal'
|
||||
import { generateRandomId } from 'utils'
|
||||
import { get } from '@xen-orchestra/defined'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
|
||||
import { FormFeedback } from '../../utils'
|
||||
|
||||
import NewSchedule from './new'
|
||||
|
||||
const DEFAULT_SCHEDULE = {
|
||||
cron: '0 0 * * *',
|
||||
timezone: moment.tz.guess(),
|
||||
}
|
||||
|
||||
const setDefaultRetentions = (schedule, retentions) => {
|
||||
retentions.forEach(({ defaultValue, valuePath }) => {
|
||||
if (schedule[valuePath] === undefined) {
|
||||
schedule[valuePath] = defaultValue
|
||||
}
|
||||
})
|
||||
return schedule
|
||||
}
|
||||
|
||||
export const areRetentionsMissing = (value, retentions) =>
|
||||
retentions.length !== 0 &&
|
||||
!retentions.some(({ valuePath }) => value[valuePath] > 0)
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
valuePath: 'name',
|
||||
name: _('scheduleName'),
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
itemRenderer: (schedule, { toggleScheduleState }) => (
|
||||
<StateButton
|
||||
disabledLabel={_('stateDisabled')}
|
||||
disabledTooltip={_('logIndicationToEnable')}
|
||||
enabledLabel={_('stateEnabled')}
|
||||
enabledTooltip={_('logIndicationToDisable')}
|
||||
handler={toggleScheduleState}
|
||||
handlerParam={schedule.id}
|
||||
state={schedule.enabled}
|
||||
/>
|
||||
),
|
||||
sortCriteria: 'enabled',
|
||||
name: _('state'),
|
||||
},
|
||||
{
|
||||
valuePath: 'cron',
|
||||
name: _('scheduleCron'),
|
||||
},
|
||||
{
|
||||
valuePath: 'timezone',
|
||||
name: _('scheduleTimezone'),
|
||||
},
|
||||
]
|
||||
|
||||
const INDIVIDUAL_ACTIONS = [
|
||||
{
|
||||
handler: (schedule, { showModal }) => showModal(schedule),
|
||||
icon: 'edit',
|
||||
label: _('scheduleEdit'),
|
||||
level: 'primary',
|
||||
},
|
||||
{
|
||||
handler: ({ id }, { deleteSchedule }) => deleteSchedule(id),
|
||||
icon: 'delete',
|
||||
label: _('scheduleDelete'),
|
||||
level: 'danger',
|
||||
},
|
||||
]
|
||||
|
||||
const Schedules = decorate([
|
||||
provideState({
|
||||
effects: {
|
||||
deleteSchedule: (_, id) => (state, props) => {
|
||||
const schedules = { ...props.schedules }
|
||||
delete schedules[id]
|
||||
props.handlerSchedules(schedules)
|
||||
|
||||
const settings = { ...props.settings }
|
||||
delete settings[id]
|
||||
props.handlerSettings(settings)
|
||||
},
|
||||
showModal: (
|
||||
effects,
|
||||
{ id = generateRandomId(), name, cron, timezone } = DEFAULT_SCHEDULE
|
||||
) => async (state, props) => {
|
||||
const schedule = get(() => props.schedules[id])
|
||||
const setting = get(() => props.settings[id])
|
||||
|
||||
const {
|
||||
cron: newCron,
|
||||
name: newName,
|
||||
timezone: newTimezone,
|
||||
...newSetting
|
||||
} = await form({
|
||||
defaultValue: setDefaultRetentions(
|
||||
{ cron, name, timezone, ...setting },
|
||||
state.retentions
|
||||
),
|
||||
render: props => (
|
||||
<NewSchedule retentions={state.retentions} {...props} />
|
||||
),
|
||||
header: (
|
||||
<span>
|
||||
<Icon icon='schedule' /> {_('schedule')}
|
||||
</span>
|
||||
),
|
||||
size: 'large',
|
||||
handler: value => {
|
||||
if (areRetentionsMissing(value, state.retentions)) {
|
||||
throw new UserError(_('newScheduleError'), _('retentionNeeded'))
|
||||
}
|
||||
return value
|
||||
},
|
||||
})
|
||||
|
||||
props.handlerSchedules({
|
||||
...props.schedules,
|
||||
[id]: {
|
||||
...schedule,
|
||||
cron: newCron,
|
||||
id,
|
||||
name: newName,
|
||||
timezone: newTimezone,
|
||||
},
|
||||
})
|
||||
props.handlerSettings({
|
||||
...props.settings,
|
||||
[id]: {
|
||||
...setting,
|
||||
...newSetting,
|
||||
},
|
||||
})
|
||||
},
|
||||
toggleScheduleState: (_, id) => (
|
||||
state,
|
||||
{ handlerSchedules, schedules }
|
||||
) => {
|
||||
const schedule = schedules[id]
|
||||
handlerSchedules({
|
||||
...schedules,
|
||||
[id]: {
|
||||
...schedule,
|
||||
enabled: !schedule.enabled,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
columns: (_, { retentions }) => [
|
||||
...COLUMNS,
|
||||
...retentions.map(({ defaultValue, ...props }) => props),
|
||||
],
|
||||
rowTransform: (_, { settings = {}, retentions }) => schedule => {
|
||||
schedule = { ...schedule, ...settings[schedule.id] }
|
||||
return setDefaultRetentions(schedule, retentions)
|
||||
},
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ state, effects, schedules, missingSchedules, missingRetentions }) => (
|
||||
<FormFeedback
|
||||
component={Card}
|
||||
error={missingSchedules || missingRetentions}
|
||||
message={
|
||||
missingSchedules ? _('missingSchedules') : _('missingRetentions')
|
||||
}
|
||||
>
|
||||
<CardHeader>
|
||||
{_('backupSchedules')}*
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
className='pull-right'
|
||||
handler={effects.showModal}
|
||||
icon='add'
|
||||
tooltip={_('scheduleAdd')}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardBlock>
|
||||
<SortedTable
|
||||
collection={schedules}
|
||||
columns={state.columns}
|
||||
data-deleteSchedule={effects.deleteSchedule}
|
||||
data-showModal={effects.showModal}
|
||||
data-toggleScheduleState={effects.toggleScheduleState}
|
||||
individualActions={INDIVIDUAL_ACTIONS}
|
||||
rowTransform={state.rowTransform}
|
||||
/>
|
||||
</CardBlock>
|
||||
</FormFeedback>
|
||||
),
|
||||
])
|
||||
|
||||
Schedules.propTypes = {
|
||||
handlerSchedules: PropTypes.func.isRequired,
|
||||
handlerSettings: PropTypes.func.isRequired,
|
||||
missingRetentions: PropTypes.bool,
|
||||
missingSchedules: PropTypes.bool,
|
||||
retentions: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
defaultValue: PropTypes.number,
|
||||
name: PropTypes.node.isRequired,
|
||||
valuePath: PropTypes.string.isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
schedules: PropTypes.object,
|
||||
settings: PropTypes.object,
|
||||
}
|
||||
|
||||
export { Schedules as default }
|
||||
94
packages/xo-web/src/xo-app/backup-ng/new/_schedules/new.js
Normal file
94
packages/xo-web/src/xo-app/backup-ng/new/_schedules/new.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import _ from 'intl'
|
||||
import decorate from 'apply-decorators'
|
||||
import defined from '@xen-orchestra/defined'
|
||||
import Icon from 'icon'
|
||||
import React from 'react'
|
||||
import Scheduler, { SchedulePreview } from 'scheduling'
|
||||
import { generateId } from 'reaclette-utils'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { Number } from 'form'
|
||||
|
||||
import { FormGroup, Input } from '../../utils'
|
||||
|
||||
import { areRetentionsMissing } from '.'
|
||||
|
||||
export default decorate([
|
||||
provideState({
|
||||
effects: {
|
||||
setSchedule: (_, params) => (_, { value, onChange }) => {
|
||||
onChange({
|
||||
...value,
|
||||
...params,
|
||||
})
|
||||
},
|
||||
setCronTimezone: (
|
||||
{ setSchedule },
|
||||
{ cronPattern: cron, timezone }
|
||||
) => () => {
|
||||
setSchedule({
|
||||
cron,
|
||||
timezone,
|
||||
})
|
||||
},
|
||||
setName: ({ setSchedule }, { target: { value } }) => () => {
|
||||
setSchedule({
|
||||
name: value.trim() === '' ? null : value,
|
||||
})
|
||||
},
|
||||
setRetention: ({ setSchedule }, value, { name }) => () => {
|
||||
setSchedule({
|
||||
[name]: defined(value, null),
|
||||
})
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
idInputName: generateId,
|
||||
|
||||
missingRetentions: (_, { value, retentions }) =>
|
||||
areRetentionsMissing(value, retentions),
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ effects, state, retentions, value: schedule }) => (
|
||||
<div>
|
||||
{state.missingRetentions && (
|
||||
<div className='text-danger text-md-center'>
|
||||
<Icon icon='alarm' /> {_('retentionNeeded')}
|
||||
</div>
|
||||
)}
|
||||
<FormGroup>
|
||||
<label htmlFor={state.idInputName}>
|
||||
<strong>{_('formName')}</strong>
|
||||
</label>
|
||||
<Input
|
||||
id={state.idInputName}
|
||||
onChange={effects.setName}
|
||||
value={schedule.name}
|
||||
/>
|
||||
</FormGroup>
|
||||
{/* retentions effects are defined on initialize() */}
|
||||
{retentions.map(({ name, valuePath }) => (
|
||||
<FormGroup key={valuePath}>
|
||||
<label>
|
||||
<strong>{name}</strong>
|
||||
</label>
|
||||
<Number
|
||||
data-name={valuePath}
|
||||
min='0'
|
||||
onChange={effects.setRetention}
|
||||
value={schedule[valuePath]}
|
||||
/>
|
||||
</FormGroup>
|
||||
))}
|
||||
<Scheduler
|
||||
onChange={effects.setCronTimezone}
|
||||
cronPattern={schedule.cron}
|
||||
timezone={schedule.timezone}
|
||||
/>
|
||||
<SchedulePreview
|
||||
cronPattern={schedule.cron}
|
||||
timezone={schedule.timezone}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
])
|
||||
@@ -46,6 +46,7 @@ import Schedules from './schedules'
|
||||
import SmartBackup from './smart-backup'
|
||||
import {
|
||||
canDeltaBackup,
|
||||
constructPattern,
|
||||
destructPattern,
|
||||
FormFeedback,
|
||||
FormGroup,
|
||||
@@ -54,6 +55,8 @@ import {
|
||||
Ul,
|
||||
} from './../utils'
|
||||
|
||||
export NewMetadataBackup from './metadata'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const DEFAULT_RETENTION = 1
|
||||
@@ -102,17 +105,6 @@ const normalizeSettings = ({ settings, exportMode, copyMode, snapshotMode }) =>
|
||||
: setting
|
||||
)
|
||||
|
||||
const constructPattern = values =>
|
||||
values.length === 1
|
||||
? {
|
||||
id: resolveId(values[0]),
|
||||
}
|
||||
: {
|
||||
id: {
|
||||
__or: resolveIds(values),
|
||||
},
|
||||
}
|
||||
|
||||
const destructVmsPattern = pattern =>
|
||||
pattern.id === undefined
|
||||
? {
|
||||
|
||||
410
packages/xo-web/src/xo-app/backup-ng/new/metadata/index.js
Normal file
410
packages/xo-web/src/xo-app/backup-ng/new/metadata/index.js
Normal file
@@ -0,0 +1,410 @@
|
||||
import _ from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import addSubscriptions from 'add-subscriptions'
|
||||
import decorate from 'apply-decorators'
|
||||
import defined from '@xen-orchestra/defined'
|
||||
import Icon from 'icon'
|
||||
import Link from 'link'
|
||||
import React from 'react'
|
||||
import Upgrade from 'xoa-upgrade'
|
||||
import { Card, CardBlock, CardHeader } from 'card'
|
||||
import { Container, Col, Row } from 'grid'
|
||||
import { generateId, linkState } from 'reaclette-utils'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { every, isEmpty, mapValues, map } from 'lodash'
|
||||
import { Remote } from 'render-xo-item'
|
||||
import { SelectPool, SelectRemote } from 'select-objects'
|
||||
import {
|
||||
createMetadataBackupJob,
|
||||
createSchedule,
|
||||
deleteSchedule,
|
||||
editMetadataBackupJob,
|
||||
editSchedule,
|
||||
subscribeRemotes,
|
||||
} from 'xo'
|
||||
|
||||
import {
|
||||
constructPattern,
|
||||
destructPattern,
|
||||
FormFeedback,
|
||||
FormGroup,
|
||||
Input,
|
||||
Li,
|
||||
Ul,
|
||||
} from '../../utils'
|
||||
|
||||
import Schedules from '../_schedules'
|
||||
|
||||
// A retention can be:
|
||||
// - number: set by user
|
||||
// - undefined: will be replaced by the default value in the display(table + modal) and on submitting the form
|
||||
// - null: when a user voluntarily deletes its value.
|
||||
const DEFAULT_RETENTION = 1
|
||||
|
||||
const RETENTION_POOL_METADATA = {
|
||||
defaultValue: DEFAULT_RETENTION,
|
||||
name: _('poolMetadataRetention'),
|
||||
valuePath: 'retentionPoolMetadata',
|
||||
}
|
||||
const RETENTION_XO_METADATA = {
|
||||
defaultValue: DEFAULT_RETENTION,
|
||||
name: _('xoMetadataRetention'),
|
||||
valuePath: 'retentionXoMetadata',
|
||||
}
|
||||
|
||||
const getInitialState = () => ({
|
||||
_modePoolMetadata: undefined,
|
||||
_modeXoMetadata: undefined,
|
||||
_name: undefined,
|
||||
_pools: undefined,
|
||||
_remotes: undefined,
|
||||
_schedules: undefined,
|
||||
_settings: undefined,
|
||||
showErrors: false,
|
||||
})
|
||||
|
||||
export default decorate([
|
||||
New => props => (
|
||||
<Upgrade place='newMetadataBackup' required={3}>
|
||||
<New {...props} />
|
||||
</Upgrade>
|
||||
),
|
||||
addSubscriptions({
|
||||
remotes: subscribeRemotes,
|
||||
}),
|
||||
provideState({
|
||||
initialState: getInitialState,
|
||||
effects: {
|
||||
createJob: () => async state => {
|
||||
if (state.isJobInvalid) {
|
||||
return { showErrors: true }
|
||||
}
|
||||
|
||||
await createMetadataBackupJob({
|
||||
name: state.name,
|
||||
pools: state.modePoolMetadata
|
||||
? constructPattern(state.pools)
|
||||
: undefined,
|
||||
remotes: constructPattern(state.remotes),
|
||||
xoMetadata: state.modeXoMetadata,
|
||||
schedules: mapValues(
|
||||
state.schedules,
|
||||
({ id, ...schedule }) => schedule
|
||||
),
|
||||
settings: mapValues(
|
||||
state.settings,
|
||||
({ retentionPoolMetadata, retentionXoMetadata }) => ({
|
||||
retentionPoolMetadata: state.modePoolMetadata
|
||||
? defined(retentionPoolMetadata, DEFAULT_RETENTION)
|
||||
: undefined,
|
||||
retentionXoMetadata: state.modeXoMetadata
|
||||
? defined(retentionXoMetadata, DEFAULT_RETENTION)
|
||||
: undefined,
|
||||
})
|
||||
),
|
||||
})
|
||||
},
|
||||
editJob: () => async (state, props) => {
|
||||
if (state.isJobInvalid) {
|
||||
return { showErrors: true }
|
||||
}
|
||||
|
||||
const settings = { ...state.settings }
|
||||
await Promise.all([
|
||||
...map(props.schedules, ({ id }) => {
|
||||
const schedule = state.schedules[id]
|
||||
if (schedule === undefined) {
|
||||
return deleteSchedule(id)
|
||||
}
|
||||
|
||||
return editSchedule({
|
||||
id,
|
||||
cron: schedule.cron,
|
||||
name: schedule.name,
|
||||
timezone: schedule.timezone,
|
||||
enabled: schedule.enabled,
|
||||
})
|
||||
}),
|
||||
...map(state.schedules, async schedule => {
|
||||
if (props.schedules[schedule.id] === undefined) {
|
||||
const { id } = await createSchedule(props.job.id, {
|
||||
cron: schedule.cron,
|
||||
name: schedule.name,
|
||||
timezone: schedule.timezone,
|
||||
enabled: schedule.enabled,
|
||||
})
|
||||
settings[id] = settings[schedule.id]
|
||||
delete settings[schedule.id]
|
||||
}
|
||||
}),
|
||||
])
|
||||
|
||||
await editMetadataBackupJob({
|
||||
id: props.job.id,
|
||||
name: state.name,
|
||||
pools: state.modePoolMetadata ? constructPattern(state.pools) : null,
|
||||
remotes: constructPattern(state.remotes),
|
||||
xoMetadata: state.modeXoMetadata,
|
||||
settings: mapValues(
|
||||
settings,
|
||||
({ retentionPoolMetadata, retentionXoMetadata }) => ({
|
||||
retentionPoolMetadata: state.modePoolMetadata
|
||||
? defined(retentionPoolMetadata, DEFAULT_RETENTION)
|
||||
: undefined,
|
||||
retentionXoMetadata: state.modeXoMetadata
|
||||
? defined(retentionXoMetadata, DEFAULT_RETENTION)
|
||||
: undefined,
|
||||
})
|
||||
),
|
||||
})
|
||||
},
|
||||
|
||||
linkState,
|
||||
reset: () => getInitialState,
|
||||
setPools: (_, _pools) => () => ({
|
||||
_pools,
|
||||
}),
|
||||
setSchedules: (_, _schedules) => () => ({
|
||||
_schedules,
|
||||
}),
|
||||
setSettings: (_, _settings) => () => ({
|
||||
_settings,
|
||||
}),
|
||||
toggleMode: (_, { mode }) => state => ({
|
||||
[`_${mode}`]: !state[mode],
|
||||
}),
|
||||
addRemote: (_, { id }) => state => ({
|
||||
_remotes: [...state.remotes, id],
|
||||
}),
|
||||
deleteRemote: (_, key) => state => {
|
||||
const _remotes = [...state.remotes]
|
||||
_remotes.splice(key, 1)
|
||||
return {
|
||||
_remotes,
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
idForm: generateId,
|
||||
|
||||
modePoolMetadata: ({ _modePoolMetadata }, { job }) =>
|
||||
defined(_modePoolMetadata, () => !isEmpty(destructPattern(job.pools))),
|
||||
modeXoMetadata: ({ _modeXoMetadata }, { job }) =>
|
||||
defined(_modeXoMetadata, () => job.xoMetadata),
|
||||
name: (state, { job }) => defined(state._name, () => job.name, ''),
|
||||
pools: ({ _pools }, { job }) =>
|
||||
defined(_pools, () => destructPattern(job.pools)),
|
||||
retentions: ({ modePoolMetadata, modeXoMetadata }) => {
|
||||
const retentions = []
|
||||
if (modePoolMetadata) {
|
||||
retentions.push(RETENTION_POOL_METADATA)
|
||||
}
|
||||
if (modeXoMetadata) {
|
||||
retentions.push(RETENTION_XO_METADATA)
|
||||
}
|
||||
return retentions
|
||||
},
|
||||
schedules: ({ _schedules }, { schedules }) =>
|
||||
defined(_schedules, schedules),
|
||||
settings: ({ _settings }, { job }) =>
|
||||
defined(_settings, () => job.settings),
|
||||
remotes: ({ _remotes }, { job }) =>
|
||||
defined(_remotes, () => destructPattern(job.remotes), []),
|
||||
remotesPredicate: ({ remotes }) => ({ id }) => !remotes.includes(id),
|
||||
|
||||
isJobInvalid: state =>
|
||||
state.missingModes ||
|
||||
state.missingPools ||
|
||||
state.missingRemotes ||
|
||||
state.missingRetentionPoolMetadata ||
|
||||
state.missingRetentionXoMetadata ||
|
||||
state.missingSchedules,
|
||||
|
||||
missingModes: state => !state.modeXoMetadata && !state.modePoolMetadata,
|
||||
missingPools: state => state.modePoolMetadata && isEmpty(state.pools),
|
||||
missingRemotes: state => isEmpty(state.remotes),
|
||||
missingRetentionPoolMetadata: state =>
|
||||
state.modePoolMetadata &&
|
||||
every(
|
||||
state.settings,
|
||||
({ retentionPoolMetadata }) => retentionPoolMetadata === null
|
||||
),
|
||||
missingRetentionXoMetadata: state =>
|
||||
state.modeXoMetadata &&
|
||||
every(
|
||||
state.settings,
|
||||
({ retentionXoMetadata }) => retentionXoMetadata === null
|
||||
),
|
||||
missingSchedules: state => isEmpty(state.schedules),
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ state, effects, job, remotes }) => {
|
||||
const [submitHandler, submitTitle] =
|
||||
job === undefined
|
||||
? [effects.createJob, 'formCreate']
|
||||
: [effects.editJob, 'formSave']
|
||||
const {
|
||||
missingModes,
|
||||
missingPools,
|
||||
missingRemotes,
|
||||
missingRetentionPoolMetadata,
|
||||
missingRetentionXoMetadata,
|
||||
missingSchedules,
|
||||
} = state.showErrors ? state : {}
|
||||
|
||||
return (
|
||||
<form id={state.idForm}>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col mediumSize={6}>
|
||||
<Card>
|
||||
<CardHeader>{_('backupName')}*</CardHeader>
|
||||
<CardBlock>
|
||||
<Input
|
||||
onChange={effects.linkState}
|
||||
name='_name'
|
||||
value={state.name}
|
||||
/>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
<FormFeedback
|
||||
component={Card}
|
||||
error={missingModes}
|
||||
message={_('missingBackupMode')}
|
||||
>
|
||||
<CardBlock>
|
||||
<div className='text-xs-center'>
|
||||
<ActionButton
|
||||
active={state.modePoolMetadata}
|
||||
data-mode='modePoolMetadata'
|
||||
handler={effects.toggleMode}
|
||||
icon='pool'
|
||||
>
|
||||
{_('poolMetadata')}
|
||||
</ActionButton>{' '}
|
||||
<ActionButton
|
||||
active={state.modeXoMetadata}
|
||||
data-mode='modeXoMetadata'
|
||||
handler={effects.toggleMode}
|
||||
icon='file'
|
||||
>
|
||||
{_('xoConfig')}
|
||||
</ActionButton>{' '}
|
||||
</div>
|
||||
</CardBlock>
|
||||
</FormFeedback>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
{_('remotes')}
|
||||
<Link
|
||||
className='btn btn-primary pull-right'
|
||||
target='_blank'
|
||||
to='/settings/remotes'
|
||||
>
|
||||
<Icon icon='settings' />{' '}
|
||||
<strong>{_('remotesSettings')}</strong>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardBlock>
|
||||
{isEmpty(remotes) ? (
|
||||
<span className='text-warning'>
|
||||
<Icon icon='alarm' /> {_('createRemoteMessage')}
|
||||
</span>
|
||||
) : (
|
||||
<FormGroup>
|
||||
<label>
|
||||
<strong>{_('backupTargetRemotes')}</strong>
|
||||
</label>
|
||||
<FormFeedback
|
||||
component={SelectRemote}
|
||||
message={_('missingRemotes')}
|
||||
onChange={effects.addRemote}
|
||||
predicate={state.remotesPredicate}
|
||||
error={missingRemotes}
|
||||
value={null}
|
||||
/>
|
||||
<br />
|
||||
<Ul>
|
||||
{state.remotes.map((id, key) => (
|
||||
<Li key={id}>
|
||||
<Remote id={id} />
|
||||
<div className='pull-right'>
|
||||
<ActionButton
|
||||
btnStyle='danger'
|
||||
handler={effects.deleteRemote}
|
||||
handlerParam={key}
|
||||
icon='delete'
|
||||
size='small'
|
||||
/>
|
||||
</div>
|
||||
</Li>
|
||||
))}
|
||||
</Ul>
|
||||
</FormGroup>
|
||||
)}
|
||||
</CardBlock>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col mediumSize={6}>
|
||||
{state.modePoolMetadata && (
|
||||
<Card>
|
||||
<CardHeader>{_('pools')}*</CardHeader>
|
||||
<CardBlock>
|
||||
<FormFeedback
|
||||
component={SelectPool}
|
||||
message={_('missingPools')}
|
||||
multi
|
||||
onChange={effects.setPools}
|
||||
error={missingPools}
|
||||
value={state.pools}
|
||||
/>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
)}
|
||||
<Schedules
|
||||
handlerSchedules={effects.setSchedules}
|
||||
handlerSettings={effects.setSettings}
|
||||
missingRetentions={
|
||||
missingRetentionPoolMetadata || missingRetentionXoMetadata
|
||||
}
|
||||
missingSchedules={missingSchedules}
|
||||
retentions={state.retentions}
|
||||
schedules={state.schedules}
|
||||
settings={state.settings}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
<Card>
|
||||
<CardBlock>
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
form={state.idForm}
|
||||
handler={submitHandler}
|
||||
icon='save'
|
||||
redirectOnSuccess={
|
||||
state.isJobInvalid ? undefined : '/backup-ng'
|
||||
}
|
||||
size='large'
|
||||
>
|
||||
{_(submitTitle)}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
handler={effects.reset}
|
||||
icon='undo'
|
||||
className='pull-right'
|
||||
size='large'
|
||||
>
|
||||
{_('formReset')}
|
||||
</ActionButton>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</form>
|
||||
)
|
||||
},
|
||||
])
|
||||
@@ -24,8 +24,6 @@ export default decorate([
|
||||
provideState({
|
||||
computed: {
|
||||
disabledDeletion: state => size(state.schedules) <= 1,
|
||||
disabledEdition: state =>
|
||||
!state.exportMode && !state.copyMode && !state.snapshotMode,
|
||||
error: state => find(FEEDBACK_ERRORS, error => state[error]),
|
||||
individualActions: (
|
||||
{ disabledDeletion, disabledEdition },
|
||||
@@ -129,7 +127,6 @@ export default decorate([
|
||||
btnStyle='primary'
|
||||
className='pull-right'
|
||||
handler={effects.showScheduleModal}
|
||||
disabled={state.disabledEdition}
|
||||
icon='add'
|
||||
tooltip={_('scheduleAdd')}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import Icon from 'icon'
|
||||
import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
import { resolveId, resolveIds } from 'utils'
|
||||
|
||||
export const FormGroup = props => <div {...props} className='form-group' />
|
||||
export const Input = props => <input {...props} className='form-control' />
|
||||
export const Ul = props => <ul {...props} className='list-group' />
|
||||
export const Li = props => <li {...props} className='list-group-item' />
|
||||
|
||||
export const destructPattern = pattern => pattern.id.__or || [pattern.id]
|
||||
export const destructPattern = pattern =>
|
||||
pattern && (pattern.id.__or || [pattern.id])
|
||||
export const constructPattern = values =>
|
||||
values.length === 1
|
||||
? {
|
||||
id: resolveId(values[0]),
|
||||
}
|
||||
: {
|
||||
id: {
|
||||
__or: resolveIds(values),
|
||||
},
|
||||
}
|
||||
|
||||
export const FormFeedback = ({
|
||||
component: Component,
|
||||
|
||||
@@ -206,6 +206,11 @@ const OPTIONS = {
|
||||
sortBy: 'container.name_label',
|
||||
sortOrder: 'asc',
|
||||
},
|
||||
{
|
||||
labelId: 'homeSortByStartTime',
|
||||
sortBy: 'startTime',
|
||||
sortOrder: 'desc',
|
||||
},
|
||||
],
|
||||
},
|
||||
pool: {
|
||||
|
||||
@@ -54,6 +54,11 @@ export default class VmItem extends Component {
|
||||
return vm && vm.power_state === 'Running'
|
||||
}
|
||||
|
||||
compareContainers = (pool1, pool2) => {
|
||||
const { $pool: poolId } = this.props.item
|
||||
return pool1.id === poolId ? -1 : pool2.id === poolId ? 1 : 0
|
||||
}
|
||||
|
||||
_getResourceSet = createFinder(
|
||||
() => this.props.resourceSets,
|
||||
createSelector(
|
||||
@@ -174,6 +179,7 @@ export default class VmItem extends Component {
|
||||
<Col mediumSize={2} className='hidden-sm-down'>
|
||||
{this._isRunning && container ? (
|
||||
<XoSelect
|
||||
compareContainers={this.compareContainers}
|
||||
labelProp='name_label'
|
||||
onChange={this._migrateVm}
|
||||
placeholder={_('homeMigrateTo')}
|
||||
|
||||
@@ -13,7 +13,11 @@ import { createGetObjectsOfType } from 'selectors'
|
||||
import { get } from '@xen-orchestra/defined'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { isEmpty, filter, map, keyBy } from 'lodash'
|
||||
import { subscribeBackupNgJobs, subscribeBackupNgLogs } from 'xo'
|
||||
import {
|
||||
subscribeBackupNgJobs,
|
||||
subscribeBackupNgLogs,
|
||||
subscribeMetadataBackupJobs,
|
||||
} from 'xo'
|
||||
|
||||
import LogAlertBody from './log-alert-body'
|
||||
import LogAlertHeader from './log-alert-header'
|
||||
@@ -34,6 +38,7 @@ export const LogStatus = ({ log, tooltip = _('logDisplayDetails') }) => {
|
||||
return (
|
||||
<ActionButton
|
||||
btnStyle={className}
|
||||
disabled={log.status !== 'failure' && isEmpty(log.tasks)}
|
||||
handler={showTasks}
|
||||
handlerParam={log.id}
|
||||
icon='preview'
|
||||
@@ -84,8 +89,8 @@ const COLUMNS = [
|
||||
},
|
||||
{
|
||||
name: _('labelSize'),
|
||||
itemRenderer: ({ tasks: vmTasks }) => {
|
||||
if (isEmpty(vmTasks)) {
|
||||
itemRenderer: ({ tasks: vmTasks, jobId }, { jobs }) => {
|
||||
if (get(() => jobs[jobId].type) !== 'backup' || isEmpty(vmTasks)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -151,6 +156,8 @@ export default decorate([
|
||||
cb(logs && filter(logs, log => log.message !== 'restore'))
|
||||
),
|
||||
jobs: cb => subscribeBackupNgJobs(jobs => cb(keyBy(jobs, 'id'))),
|
||||
metadataJobs: cb =>
|
||||
subscribeMetadataBackupJobs(jobs => cb(keyBy(jobs, 'id'))),
|
||||
}),
|
||||
provideState({
|
||||
computed: {
|
||||
@@ -167,6 +174,7 @@ export default decorate([
|
||||
}
|
||||
: log
|
||||
),
|
||||
jobs: (_, { jobs, metadataJobs }) => ({ ...jobs, ...metadataJobs }),
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
@@ -180,7 +188,7 @@ export default decorate([
|
||||
collection={state.logs}
|
||||
columns={COLUMNS}
|
||||
component={SortedTable}
|
||||
data-jobs={jobs}
|
||||
data-jobs={state.jobs}
|
||||
emptyMessage={_('noLogs')}
|
||||
filters={LOG_FILTERS}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import Icon from 'icon'
|
||||
import React from 'react'
|
||||
import ReportBugButton, { CAN_REPORT_BUG } from 'report-bug-button'
|
||||
import Tooltip from 'tooltip'
|
||||
import { downloadLog } from 'utils'
|
||||
import { get } from '@xen-orchestra/defined'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { keyBy } from 'lodash'
|
||||
@@ -28,6 +29,8 @@ export default decorate([
|
||||
})),
|
||||
provideState({
|
||||
effects: {
|
||||
_downloadLog: () => ({ formattedLog }, { log }) =>
|
||||
downloadLog({ log: formattedLog, date: log.start, type: 'backup NG' }),
|
||||
restartFailedVms: () => async (
|
||||
_,
|
||||
{ log: { jobId: id, scheduleId: schedule, tasks, infos } }
|
||||
@@ -81,6 +84,11 @@ export default decorate([
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
<Tooltip content={_('logDownload')}>
|
||||
<Button size='small' onClick={effects._downloadLog}>
|
||||
<Icon icon='download' />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{CAN_REPORT_BUG && (
|
||||
<ReportBugButton
|
||||
message={`\`\`\`json\n${state.formattedLog}\n\`\`\``}
|
||||
|
||||
@@ -369,6 +369,11 @@ export default class Menu extends Component {
|
||||
label: 'newVmPage',
|
||||
},
|
||||
isAdmin && { to: '/new/sr', icon: 'menu-new-sr', label: 'newSrPage' },
|
||||
isPoolAdmin && {
|
||||
to: '/new/network',
|
||||
icon: 'menu-new-network',
|
||||
label: 'newNetworkPage',
|
||||
},
|
||||
isAdmin && {
|
||||
to: '/settings/servers',
|
||||
icon: 'menu-settings-servers',
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { routes } from 'utils'
|
||||
|
||||
import Network from './network'
|
||||
import Sr from './sr'
|
||||
|
||||
const New = routes('vm', {
|
||||
network: Network,
|
||||
sr: Sr,
|
||||
})(({ children }) => children)
|
||||
|
||||
|
||||
5
packages/xo-web/src/xo-app/new/network/index.css
Normal file
5
packages/xo-web/src/xo-app/new/network/index.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.inlineSelect {
|
||||
display: inline-block;
|
||||
font-size: 1rem;
|
||||
width: 20em;
|
||||
}
|
||||
242
packages/xo-web/src/xo-app/new/network/index.js
Normal file
242
packages/xo-web/src/xo-app/new/network/index.js
Normal file
@@ -0,0 +1,242 @@
|
||||
import _, { messages } from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import decorate from 'apply-decorators'
|
||||
import PropTypes from 'prop-types'
|
||||
import React, { Component } from 'react'
|
||||
import Wizard, { Section } from 'wizard'
|
||||
import { connectStore } from 'utils'
|
||||
import { createBondedNetwork, createNetwork, getBondModes } from 'xo'
|
||||
import { createGetObject, getIsPoolAdmin } from 'selectors'
|
||||
import { injectIntl } from 'react-intl'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { linkState } from 'reaclette-utils'
|
||||
import { map } from 'lodash'
|
||||
import { Select, Toggle } from 'form'
|
||||
import { SelectPif, SelectPool } from 'select-objects'
|
||||
|
||||
import Page from '../../page'
|
||||
import styles from './index.css'
|
||||
|
||||
const EMPTY = {
|
||||
bonded: false,
|
||||
bondMode: undefined,
|
||||
description: '',
|
||||
mtu: '',
|
||||
name: '',
|
||||
pif: undefined,
|
||||
pifs: [],
|
||||
vlan: '',
|
||||
}
|
||||
|
||||
const NewNetwork = decorate([
|
||||
connectStore(() => ({
|
||||
isPoolAdmin: getIsPoolAdmin,
|
||||
pool: createGetObject((_, props) => props.location.query.pool),
|
||||
})),
|
||||
injectIntl,
|
||||
provideState({
|
||||
initialState: () => ({ ...EMPTY, bondModes: undefined }),
|
||||
effects: {
|
||||
initialize: async () => ({ bondModes: await getBondModes() }),
|
||||
linkState,
|
||||
onChangeMode: (_, bondMode) => ({ bondMode }),
|
||||
onChangePif: (_, value) => ({ bonded }) =>
|
||||
bonded ? { pifs: value } : { pif: value },
|
||||
reset: () => EMPTY,
|
||||
toggleBonded: () => ({ bonded }) => ({
|
||||
...EMPTY,
|
||||
bonded: !bonded,
|
||||
}),
|
||||
},
|
||||
computed: {
|
||||
modeOptions: ({ bondModes }) =>
|
||||
bondModes !== undefined
|
||||
? bondModes.map(mode => ({
|
||||
label: mode,
|
||||
value: mode,
|
||||
}))
|
||||
: [],
|
||||
pifPredicate: (_, { pool }) => pif =>
|
||||
pif.vlan === -1 && pif.$host === (pool && pool.master),
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
class extends Component {
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
}
|
||||
|
||||
_create = () => {
|
||||
const { pool, state } = this.props
|
||||
const {
|
||||
bonded,
|
||||
bondMode,
|
||||
description,
|
||||
mtu,
|
||||
name,
|
||||
pif,
|
||||
pifs,
|
||||
vlan,
|
||||
} = state
|
||||
return bonded
|
||||
? createBondedNetwork({
|
||||
bondMode: bondMode.value,
|
||||
description,
|
||||
mtu,
|
||||
name,
|
||||
pifs: map(pifs, 'id'),
|
||||
pool: pool.id,
|
||||
vlan,
|
||||
})
|
||||
: createNetwork({
|
||||
description,
|
||||
mtu,
|
||||
name,
|
||||
pif: pif.id,
|
||||
pool: pool.id,
|
||||
vlan,
|
||||
})
|
||||
}
|
||||
|
||||
_selectPool = pool => {
|
||||
const {
|
||||
effects,
|
||||
location: { pathname },
|
||||
} = this.props
|
||||
effects.reset()
|
||||
this.context.router.push({
|
||||
pathname,
|
||||
query: pool !== null && { pool: pool.id },
|
||||
})
|
||||
}
|
||||
|
||||
_renderHeader = () => {
|
||||
const { isPoolAdmin, pool } = this.props
|
||||
return (
|
||||
<h2>
|
||||
{isPoolAdmin
|
||||
? _('createNewNetworkOn', {
|
||||
select: (
|
||||
<span className={styles.inlineSelect}>
|
||||
<SelectPool onChange={this._selectPool} value={pool} />
|
||||
</span>
|
||||
),
|
||||
})
|
||||
: _('createNewNetworkNoPermission')}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { state, effects, intl, pool } = this.props
|
||||
const {
|
||||
bonded,
|
||||
bondMode,
|
||||
description,
|
||||
modeOptions,
|
||||
mtu,
|
||||
name,
|
||||
pif,
|
||||
pifPredicate,
|
||||
pifs,
|
||||
vlan,
|
||||
} = state
|
||||
const { formatMessage } = intl
|
||||
return (
|
||||
<Page header={this._renderHeader()}>
|
||||
{pool !== undefined && (
|
||||
<form id='networkCreation'>
|
||||
<Wizard>
|
||||
<Section icon='network' title='newNetworkType'>
|
||||
<div>
|
||||
<Toggle onChange={effects.toggleBonded} value={bonded} />{' '}
|
||||
<label>{_('bondedNetwork')}</label>
|
||||
</div>
|
||||
</Section>
|
||||
<Section icon='info' title='newNetworkInfo'>
|
||||
<div className='form-group'>
|
||||
<label>{_('newNetworkInterface')}</label>
|
||||
<SelectPif
|
||||
multi={bonded}
|
||||
onChange={effects.onChangePif}
|
||||
predicate={pifPredicate}
|
||||
required
|
||||
value={bonded ? pifs : pif}
|
||||
/>
|
||||
<label>{_('newNetworkName')}</label>
|
||||
<input
|
||||
className='form-control'
|
||||
name='name'
|
||||
onChange={effects.linkState}
|
||||
required
|
||||
type='text'
|
||||
value={name}
|
||||
/>
|
||||
<label>{_('newNetworkDescription')}</label>
|
||||
<input
|
||||
className='form-control'
|
||||
name='description'
|
||||
onChange={effects.linkState}
|
||||
type='text'
|
||||
value={description}
|
||||
/>
|
||||
<label>{_('newNetworkMtu')}</label>
|
||||
<input
|
||||
className='form-control'
|
||||
name='mtu'
|
||||
onChange={effects.linkState}
|
||||
placeholder={formatMessage(messages.newNetworkDefaultMtu)}
|
||||
type='text'
|
||||
value={mtu}
|
||||
/>
|
||||
{bonded ? (
|
||||
<div>
|
||||
<label>{_('newNetworkBondMode')}</label>
|
||||
<Select
|
||||
onChange={effects.onChangeMode}
|
||||
options={modeOptions}
|
||||
required
|
||||
value={bondMode}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label>{_('newNetworkVlan')}</label>
|
||||
<input
|
||||
className='form-control'
|
||||
name='vlan'
|
||||
onChange={effects.linkState}
|
||||
placeholder={formatMessage(
|
||||
messages.newNetworkDefaultVlan
|
||||
)}
|
||||
type='text'
|
||||
value={vlan}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</Wizard>
|
||||
<div className='form-group pull-right'>
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
className='mr-1'
|
||||
form='networkCreation'
|
||||
handler={this._create}
|
||||
icon='new-network-create'
|
||||
redirectOnSuccess={`pools/${pool.id}/network`}
|
||||
>
|
||||
{_('newNetworkCreate')}
|
||||
</ActionButton>
|
||||
<ActionButton handler={effects.reset} icon='reset'>
|
||||
{_('formReset')}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
},
|
||||
])
|
||||
export { NewNetwork as default }
|
||||
@@ -10,10 +10,10 @@ import map from 'lodash/map'
|
||||
import React, { Component } from 'react'
|
||||
import some from 'lodash/some'
|
||||
import SortedTable from 'sorted-table'
|
||||
import TabButton from 'tab-button'
|
||||
import Tooltip from 'tooltip'
|
||||
import { connectStore } from 'utils'
|
||||
import { Container, Row, Col } from 'grid'
|
||||
import { TabButtonLink } from 'tab-button'
|
||||
import { Text, Number } from 'editable'
|
||||
import { Toggle } from 'form'
|
||||
import {
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
} from 'selectors'
|
||||
import {
|
||||
connectPif,
|
||||
createBondedNetwork,
|
||||
createNetwork,
|
||||
deleteNetwork,
|
||||
disconnectPif,
|
||||
editNetwork,
|
||||
@@ -362,19 +360,10 @@ export default class TabNetworks extends Component {
|
||||
<Container>
|
||||
<Row>
|
||||
<Col className='text-xs-right'>
|
||||
<TabButton
|
||||
btnStyle='primary'
|
||||
handler={createBondedNetwork}
|
||||
handlerParam={this.props.pool}
|
||||
icon='add'
|
||||
labelId='networkCreateBondedButton'
|
||||
/>
|
||||
<TabButton
|
||||
btnStyle='primary'
|
||||
handler={createNetwork}
|
||||
handlerParam={this.props.pool}
|
||||
<TabButtonLink
|
||||
icon='add'
|
||||
labelId='networkCreateButton'
|
||||
to={`new/network?pool=${this.props.pool.id}`}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -8,7 +8,7 @@ import Copiable from 'copiable'
|
||||
import NoObjects from 'no-objects'
|
||||
import SortedTable from 'sorted-table'
|
||||
import styles from './index.css'
|
||||
import { addSubscriptions } from 'utils'
|
||||
import { addSubscriptions, downloadLog } from 'utils'
|
||||
import { alert } from 'modal'
|
||||
import { createSelector } from 'selectors'
|
||||
import { CAN_REPORT_BUG, reportBug } from 'report-bug-button'
|
||||
@@ -26,6 +26,13 @@ const formatMessage = data =>
|
||||
2
|
||||
)}\n${JSON.stringify(data.error, null, 2).replace(/\\n/g, '\n')}\n\`\`\``
|
||||
|
||||
const formatLog = log =>
|
||||
`${log.data.method}\n${JSON.stringify(
|
||||
log.data.params,
|
||||
null,
|
||||
2
|
||||
)}\n${JSON.stringify(log.data.error, null, 2).replace(/\\n/g, '\n')}`
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
name: _('logUser'),
|
||||
@@ -87,19 +94,16 @@ const ACTIONS = [
|
||||
const INDIVIDUAL_ACTIONS = [
|
||||
{
|
||||
handler: log =>
|
||||
alert(
|
||||
_('logError'),
|
||||
<Copiable tagName='pre'>
|
||||
{`${log.data.method}\n${JSON.stringify(
|
||||
log.data.params,
|
||||
null,
|
||||
2
|
||||
)}\n${JSON.stringify(log.data.error, null, 2).replace(/\\n/g, '\n')}`}
|
||||
</Copiable>
|
||||
),
|
||||
alert(_('logError'), <Copiable tagName='pre'>{formatLog(log)}</Copiable>),
|
||||
icon: 'preview',
|
||||
label: _('logDisplayDetails'),
|
||||
},
|
||||
{
|
||||
handler: log =>
|
||||
downloadLog({ log: formatLog(log), date: log.time, type: 'XO' }),
|
||||
icon: 'download',
|
||||
label: _('logDownload'),
|
||||
},
|
||||
{
|
||||
disabled: !CAN_REPORT_BUG,
|
||||
handler: log =>
|
||||
|
||||
@@ -264,6 +264,10 @@ export default class Import extends Component {
|
||||
}
|
||||
|
||||
_handleDrop = async files => {
|
||||
this.setState({
|
||||
vms: [],
|
||||
})
|
||||
|
||||
const vms = await Promise.all(
|
||||
mapPlus(files, (file, push) => {
|
||||
const { name } = file
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user