Compare commits

...

101 Commits

Author SHA1 Message Date
Julien Fontanet
ed5460273f feat(vhd-lib): 0.1.2 2018-06-15 15:27:21 +02:00
Julien Fontanet
b91f8b21b9 feat(fs): 0.1.0 2018-06-15 15:26:48 +02:00
Julien Fontanet
5cea18e577 feat(delta NG): check VDIs before export (#3069) 2018-06-15 15:22:12 +02:00
Julien Fontanet
148eaa6a72 fix(xo-server/backup NG): retention checks (#3072)
They were broken by the introduction of `copyRetention`.
2018-06-15 12:13:55 +02:00
Rajaa.BARHTAOUI
80794211af feat(xo-server,xo-web/snapshots): fast-clone to create VM from snapshot (#3030)
Fixes #2937
2018-06-14 15:01:19 +02:00
Rajaa.BARHTAOUI
75dcbae417 chore(xo-web/backup): deprecate legacy backup creation (#3035)
Fixes #2956
2018-06-14 14:18:17 +02:00
Rajaa.BARHTAOUI
b19682b3c5 fix(xo-web/New VM): networks predicate in Self Service mode (#3027)
Fixes #3011
2018-06-14 13:25:08 +02:00
badrAZ
dd3b97cae2 feat(backup/overview): add tooltip to migrate action button (#3067)
Fixes #3042
2018-06-14 11:06:28 +02:00
Julien Fontanet
79891235f3 chore(CHANGELOG): add missing entry related to prev commit 2018-06-13 15:34:11 +02:00
Julien Fontanet
1e2f72ab6b feat(backup NG): new setting copyRetention (#2976)
Fixes #2895
2018-06-13 15:24:46 +02:00
Julien Fontanet
66d02e3808 feat(xo-server/backup NG): more logs (#3066) 2018-06-13 13:11:03 +02:00
Julien Fontanet
275e1f8f4c chore(xo-server): Xapi#_assertHealthyVdiChains is sync 2018-06-13 12:01:41 +02:00
Julien Fontanet
84dbbb0fbb chore(xo-server/backups-ng): add FIXME 2018-06-13 11:33:05 +02:00
Julien Fontanet
a36ef5209c fix(backup NG): only display the concerned tasks (#3063)
Allows us to add other tasks if necessary without breaking xo-web listing and the backup reports.
2018-06-12 13:18:46 +02:00
badrAZ
3497889302 fix(backup-ng): only display the concerned tasks 2018-06-12 11:53:43 +02:00
badrAZ
0a2f6b4ce7 feat(xo-web/backupNg/new): improve backupNg feedback (#2873)
See #2711
2018-06-12 11:52:19 +02:00
Julien Fontanet
f8be44d746 chore(xo-server): remove Xapi#barrier legacy type param 2018-06-11 18:00:16 +02:00
Julien Fontanet
379253c5ae chore(xen-api): lodash.forEach is unnecessary here 2018-06-11 17:57:02 +02:00
Julien Fontanet
aed1ba474c chore(xen-api): remove unnecessary test
`eventWatchers` is defined if event watching is enabled which is always the case here.
2018-06-11 17:55:47 +02:00
badrAZ
bc72e67442 feat(backup NG): new option to shutdown VMs before snapshot (#3060)
Fixes #3058
2018-06-11 17:25:18 +02:00
Julien Fontanet
26c965faa9 feat(xen-api/examples): handle cancelation 2018-06-11 15:48:43 +02:00
Pierre Donias
b3a3965ed2 feat(xo-web/SR): copy VDIs' UUIDs from Disks tab (#3059)
Fixes #3051
2018-06-11 14:57:37 +02:00
Julien Fontanet
7f88b46f4c chore(xen-api/examples): update deps 2018-06-08 15:36:29 +02:00
badrAZ
dd60d82d3d fix(xo-web/Backup-ng/logs): ability to retry a single failed/interrupted VM backup (#3052)
Fixes #2912
2018-06-08 10:52:38 +02:00
Julien Fontanet
4eeb995340 chore(CHANGELOG): update for next release 2018-06-08 10:32:00 +02:00
Julien Fontanet
1d29348e30 feat(xo-server-backup-reports): 0.12.1 2018-06-07 18:46:53 +02:00
Pierre Donias
a24db3f896 fix(xo-web/SortedTable): show grouped actions when all items selected (#3049)
Fixes #3048
2018-06-07 17:26:58 +02:00
Julien Fontanet
cffac27d0a feat(xo-server/jobs): implement cancelation (#3046)
Related to #3047

This is the first step toward Backup NG cancelation, the server side stuff should be ok (but need testing), so the next step is to expose it in the UI.
2018-06-07 17:20:06 +02:00
Julien Fontanet
b207cbdd77 feat(fs/read): read part of a file in an existing Buffer (#3036)
Easier to use and probably more efficient than `createReadStream` for this specific usage.
2018-06-07 17:19:33 +02:00
badrAZ
10baecefb9 fix(xo-server-backup-reports): not display size and speed if not have a transfer/merge (#3038) 2018-06-07 16:34:49 +02:00
badrAZ
42620323a9 fix(xo-web/Backup-ng): add label to Edit action (#3045)
Fixes #3043
2018-06-07 16:21:31 +02:00
Julien Fontanet
4d91006994 feat(xo-server/backupNg.getAllLogs): sort tasks (#3041) 2018-06-07 14:04:46 +02:00
badrAZ
a81f0b9a93 feat(xo-web/Backup NG/logs): display whether the export is delta/full (#3023)
See #2711
2018-06-07 12:30:44 +02:00
Julien Fontanet
2cee413ae1 chore(PR template): should reference issue 2018-06-07 12:14:58 +02:00
Nicolas Raynaud
53099eacc8 chore(xo-vmdk-to-vhd): split a file and rename some consts (#2966) 2018-06-06 16:49:18 +02:00
badrAZ
b628c5c07e fix(xo-server-backup-reports): handle the case when a transfer/merge fail (#3020) 2018-06-06 15:21:46 +02:00
Julien Fontanet
12889b6a09 fix(xo-server/Xapi#importDeltaVm): correctly copy task prop (#3034) 2018-06-06 14:26:19 +02:00
badrAZ
0c23ca5b66 feat(xo-web/Backup NG/logs): details are now dynamic (#3031) 2018-06-06 14:25:31 +02:00
Pierre Donias
d732ee3ade fix(xo-web/file restore NG): restrict to Premium (#3032) 2018-06-06 13:37:28 +02:00
badrAZ
65cb0bc4cf fix(xo-server-backup-reports): correctly send status to Nagios (#3019)
Fixes #2991
2018-06-05 17:55:06 +02:00
badrAZ
1ba68a94e3 fix(xo-server): vm.xenTools.* must be numbers (#3022) 2018-06-05 14:30:30 +02:00
Julien Fontanet
084430451a feat(xo-web): 5.20.1 2018-05-31 21:00:34 +02:00
Julien Fontanet
458a4d4efe fix(xo-web/backup NG logs): dont display size/speed if size is 0 2018-05-31 20:59:47 +02:00
Julien Fontanet
62eeab2a74 feat(xo-web): 5.20.0 2018-05-31 18:33:13 +02:00
Julien Fontanet
790b43910d feat(xo-server): 5.20.0 2018-05-31 18:33:13 +02:00
Julien Fontanet
ba65461c4d feat(xo-server-usage-report): 0.5.0 2018-05-31 18:29:45 +02:00
Julien Fontanet
5bd468791f feat(xo-server-backup-reports): 0.12.0 2018-05-31 18:28:21 +02:00
Julien Fontanet
37f71bb36c feat(xo-acl-resolver): 0.2.4 2018-05-31 18:26:53 +02:00
Julien Fontanet
2ed4b7ad3f feat(vhd-lib): 0.1.1 2018-05-31 17:59:02 +02:00
Julien Fontanet
7eb970f22a feat(fs): 0.0.1 2018-05-31 17:57:13 +02:00
badrAZ
13db4a8411 feat(Backup NG): improve logs (#3013) 2018-05-31 17:54:35 +02:00
badrAZ
49a7a89bbf feat(xo-web/new-vm): ability to use template vars in the CloudConfig (#3006)
Fixes #2140
2018-05-31 17:44:52 +02:00
Rajaa.BARHTAOUI
0af8a60c1c fix(xo-web/SR/disks): show VM templates attached to VDIs (#3012)
Fixes #2974
2018-05-31 17:32:34 +02:00
Rajaa.BARHTAOUI
e1650b376c fix(xo-web/Self new VM): do not auto-select resource set's 1st SR (#3007)
New behaviour: either auto-select the template's pool's default SR if
it's in the resource set or do not auto-select any SR at all

Fixes #3001
2018-05-31 16:57:52 +02:00
Rajaa.BARHTAOUI
873b40cc70 feat(xo-server,xo-web): allow setting remote syslog host (#2958)
Fixes #2900
2018-05-31 16:22:39 +02:00
Rajaa.BARHTAOUI
d45265b180 feat(xo-web): create single-server private network (#3004)
Fixes #2944
2018-05-31 11:25:08 +02:00
badrAZ
ff50b2848e feat(xo-server,xo-web): allow pool admins to create VMs (#2995)
Fixes #2350
2018-05-31 10:42:03 +02:00
Julien Fontanet
d67fae22ab chore: initial PR template 2018-05-30 18:10:11 +02:00
badrAZ
d809002558 feat(Backup NG): ability to retry a single failed VM backup (#3009)
Fixes 2912
2018-05-30 17:04:50 +02:00
Pierre Donias
5c30559d15 feat(xo-server,xo-web): handle XCP-ng patches (#3005) 2018-05-30 16:03:43 +02:00
Rajaa.BARHTAOUI
cbb5b011e1 fix(xo-web/SR/disks): show control domain VMs attached to VDIs (#3002)
Fixes #2999
2018-05-30 15:55:44 +02:00
Julien Fontanet
f5bff408a8 feat(xo-server/exportDeltaVm): dont check chain if no snapshot
Should fix a race condition with XenServer DB during Delta Backup NG.
2018-05-30 12:22:10 +02:00
Nicolas Raynaud
d7cfe4d3dc fix(vhd/merge): fix performance enhancement (#2980) 2018-05-30 10:26:15 +02:00
badrAZ
7be8f38c6b fix(xo-web/jobs/edit): support all types of inputs (#2997) 2018-05-30 10:20:22 +02:00
badrAZ
08a7e605ce feat(xo-web/backup logs): show # of calls for each state (#2860) 2018-05-30 09:58:48 +02:00
Julien Fontanet
4b57db5893 feat(xo-server/delta NG): validate parent VHD 2018-05-29 16:36:33 +02:00
Julien Fontanet
8b1ae3f3c9 fix(xo-server/delta NG): dont select tmp file as parent 2018-05-29 16:36:10 +02:00
Julien Fontanet
77d35a5928 chore(fs/Handler#list): prepend dir after filtering 2018-05-29 16:34:51 +02:00
Julien Fontanet
323d409e6c chore(package): require yarn >1.7
Previous releases prevented xo-web from being built to an incorrect resolution of dependencies.
2018-05-29 16:29:14 +02:00
Pierre Donias
9f2f2b7b69 fix(xo-web/render-xo-item): show SR container name (#3003)
Fixes #3000
2018-05-29 12:01:50 +02:00
Julien Fontanet
b44fa7beca chore(xo-server): bump xo:perf threshold to 500ms 2018-05-29 10:53:14 +02:00
Julien Fontanet
6d4e310b8e chore(vhd-cli/build): migrate to Babel 7 2018-05-28 18:18:44 +02:00
Julien Fontanet
6726530229 fix(tests): run all test with Babel 7
Temporarily disable xo-web tests which uses Babel 6.
2018-05-28 18:18:44 +02:00
Julien Fontanet
8351352541 chore(xo-remote-parser/build): migrate to Babel 7 2018-05-28 18:18:44 +02:00
Julien Fontanet
3f9e8d79ea chore(xo-collection/build): migrate to Babel 7 2018-05-28 18:18:44 +02:00
Julien Fontanet
685f2328bd chore(package): update dependencies 2018-05-28 14:54:55 +02:00
Pierre Donias
746567a8a7 fix(xo-acl-resolver,xo-web): SR & PIF ACLs inheritance (#2994) 2018-05-25 15:26:45 +02:00
Julien Fontanet
c116c41c42 chore(package): update dependencies 2018-05-25 11:42:55 +02:00
Pierre Donias
3768a7de37 fix(xo-web/select): do not auto-select disabled option (#2992) 2018-05-25 11:11:21 +02:00
Julien Fontanet
11ef0ee54f feat(rolling snapshots legacy): check VDI chains (#2986) 2018-05-24 16:39:06 +02:00
Julien Fontanet
33ae531e3a fix(package): update hashy to 0.7.1
Correctly handle hash with identifier `2b`.
2018-05-24 14:52:38 +02:00
Julien Fontanet
8cc9924751 feat(xo-server/importDeltaVm): add UUID of missing base VM 2018-05-24 00:04:52 +02:00
Julien Fontanet
c329ab863b feat(Backup NG): configurable concurrency (#2918) 2018-05-23 16:29:31 +02:00
Julien Fontanet
41820ea316 fix(xo-server/backup legacy): fix reports (#2979) 2018-05-23 15:56:40 +02:00
badrAZ
bf00f80716 fix(xo-web): update @julien-f/freactal to 0.1.1 (#2972)
This fix prevent the cursor from jumping to the end of the input when editing the backup's name.
2018-05-23 14:47:28 +02:00
Julien Fontanet
9baf0c74e4 chore(vhd-lib): move some deps to devDeps 2018-05-23 11:49:59 +02:00
Julien Fontanet
b59ccdf26f chore(package): update dependencies 2018-05-23 11:23:25 +02:00
Julien Fontanet
9cae978923 feat(xo-server): 5.19.9 2018-05-22 19:43:19 +02:00
Julien Fontanet
311d914b96 feat(xo-web/import): remove restriction for Free 2018-05-22 16:27:17 +02:00
Julien Fontanet
592cb4ef9e feat(xo-web): 5.19.8 2018-05-22 15:53:45 +02:00
Julien Fontanet
ec2db7f2d0 feat(xo-vmdk-to-vhd): 0.1.2 2018-05-22 15:53:03 +02:00
Julien Fontanet
71eab7ba9b feat(xo-web): 5.19.7 2018-05-22 15:36:54 +02:00
badrAZ
5e07171d60 fix(xo-server/backup-ng): fix incorrect condition (#2971) 2018-05-22 11:45:02 +02:00
Rajaa.BARHTAOUI
3f73e3d964 fix(xo-server,xo-web): message when no Xen tools (#2916)
Fixes #2911
2018-05-22 09:57:46 +02:00
badrAZ
0ebe78b4a2 feat(xo-server-usage-report): improve the report (#2970)
Fixes #2968
2018-05-21 16:25:40 +02:00
Julien Fontanet
61c3379298 feat(xo-server): 5.19.8 2018-05-21 11:00:19 +02:00
Julien Fontanet
44866f3316 feat(xo-web): 5.19.6 2018-05-21 10:42:37 +02:00
Julien Fontanet
4bb8ce8779 feat(vhd-lib): 0.1.0 2018-05-21 10:03:37 +02:00
Julien Fontanet
58eb6a8b5f feat(xo-server): 5.19.7 2018-05-18 18:44:34 +02:00
Julien Fontanet
52f6a79e01 fix(xo-server/backupNg/logs): include merge/transfer size (#2965) 2018-05-18 18:44:07 +02:00
Julien Fontanet
129f79d44b feat(xo-web): 5.19.5 2018-05-18 18:42:31 +02:00
99 changed files with 4533 additions and 3190 deletions

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@
/packages/vhd-cli/src/commands/index.js
/packages/xen-api/examples/node_modules/
/packages/xen-api/plot.dat
/packages/xo-server/.xo-server.*

View File

@@ -1,40 +1,56 @@
'use strict'
const PLUGINS_RE = /^(?:@babel\/plugin-.+|babel-plugin-lodash)$/
const PLUGINS_RE = /^(?:@babel\/|babel-)plugin-.+$/
const PRESETS_RE = /^@babel\/preset-.+$/
const NODE_ENV = process.env.NODE_ENV || 'development'
const __PROD__ = NODE_ENV === 'production'
const __TEST__ = NODE_ENV === 'test'
const configs = {
'@babel/plugin-proposal-decorators': {
legacy: true,
},
'@babel/preset-env' (pkg) {
return {
debug: !__TEST__,
loose: true,
shippedProposals: true,
targets: __PROD__
? (() => {
let node = (pkg.engines || {}).node
if (node !== undefined) {
const trimChars = '^=>~'
while (trimChars.includes(node[0])) {
node = node.slice(1)
}
return { node: node }
}
})()
: { browsers: '', node: 'current' },
useBuiltIns: '@babel/polyfill' in (pkg.dependencies || {}) && 'usage',
}
},
}
const getConfig = (key, ...args) => {
const config = configs[key]
return config === undefined
? {}
: typeof config === 'function'
? config(...args)
: config
}
module.exports = function (pkg, plugins, presets) {
plugins === undefined && (plugins = {})
presets === undefined && (presets = {})
presets['@babel/preset-env'] = {
debug: !__TEST__,
loose: true,
shippedProposals: true,
targets: __PROD__
? (() => {
let node = (pkg.engines || {}).node
if (node !== undefined) {
const trimChars = '^=>~'
while (trimChars.includes(node[0])) {
node = node.slice(1)
}
return { node: node }
}
})()
: { browsers: '', node: 'current' },
useBuiltIns: '@babel/polyfill' in (pkg.dependencies || {}) && 'usage',
}
Object.keys(pkg.devDependencies || {}).forEach(name => {
if (!(name in presets) && PLUGINS_RE.test(name)) {
plugins[name] = {}
plugins[name] = getConfig(name, pkg)
} else if (!(name in presets) && PRESETS_RE.test(name)) {
presets[name] = {}
presets[name] = getConfig(name, pkg)
}
})

View File

@@ -41,10 +41,10 @@
"moment-timezone": "^0.5.14"
},
"devDependencies": {
"@babel/cli": "7.0.0-beta.44",
"@babel/core": "7.0.0-beta.44",
"@babel/preset-env": "7.0.0-beta.44",
"@babel/preset-flow": "7.0.0-beta.44",
"@babel/cli": "7.0.0-beta.49",
"@babel/core": "7.0.0-beta.49",
"@babel/preset-env": "7.0.0-beta.49",
"@babel/preset-flow": "7.0.0-beta.49",
"cross-env": "^5.1.3",
"rimraf": "^2.6.2"
},

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/fs",
"version": "0.0.0",
"version": "0.1.0",
"license": "AGPL-3.0",
"description": "The File System for Xen Orchestra backups.",
"keywords": [],
@@ -20,10 +20,10 @@
"node": ">=6"
},
"dependencies": {
"@babel/runtime": "^7.0.0-beta.44",
"@babel/runtime": "^7.0.0-beta.49",
"@marsaud/smb2-promise": "^0.2.1",
"execa": "^0.10.0",
"fs-extra": "^5.0.0",
"fs-extra": "^6.0.1",
"get-stream": "^3.0.0",
"lodash": "^4.17.4",
"promise-toolbox": "^0.9.5",
@@ -32,12 +32,12 @@
"xo-remote-parser": "^0.3"
},
"devDependencies": {
"@babel/cli": "7.0.0-beta.44",
"@babel/core": "7.0.0-beta.44",
"@babel/plugin-proposal-function-bind": "7.0.0-beta.44",
"@babel/plugin-transform-runtime": "^7.0.0-beta.44",
"@babel/preset-env": "7.0.0-beta.44",
"@babel/preset-flow": "7.0.0-beta.44",
"@babel/cli": "7.0.0-beta.49",
"@babel/core": "7.0.0-beta.49",
"@babel/plugin-proposal-function-bind": "7.0.0-beta.49",
"@babel/plugin-transform-runtime": "^7.0.0-beta.49",
"@babel/preset-env": "7.0.0-beta.49",
"@babel/preset-flow": "7.0.0-beta.49",
"babel-plugin-lodash": "^3.3.2",
"cross-env": "^5.1.3",
"index-modules": "^0.3.0",

View File

@@ -92,6 +92,22 @@ export default class RemoteHandlerAbstract {
await promise
}
async read (
file: File,
buffer: Buffer,
position?: number
): Promise<{| bytesRead: number, buffer: Buffer |}> {
return this._read(file, buffer, position)
}
_read (
file: File,
buffer: Buffer,
position?: number
): Promise<{| bytesRead: number, buffer: Buffer |}> {
throw new Error('Not implemented')
}
async readFile (file: string, options?: Object): Promise<Buffer> {
return this._readFile(file, options)
}
@@ -126,7 +142,10 @@ export default class RemoteHandlerAbstract {
prependDir = false,
}: { filter?: (name: string) => boolean, prependDir?: boolean } = {}
): Promise<string[]> {
const entries = await this._list(dir)
let entries = await this._list(dir)
if (filter !== undefined) {
entries = entries.filter(filter)
}
if (prependDir) {
entries.forEach((entry, i) => {
@@ -134,7 +153,7 @@ export default class RemoteHandlerAbstract {
})
}
return filter === undefined ? entries : entries.filter(filter)
return entries
}
async _list (dir: string): Promise<string[]> {

View File

@@ -50,6 +50,24 @@ export default class LocalHandler extends RemoteHandlerAbstract {
await fs.writeFile(path, data, options)
}
async _read (file, buffer, position) {
const needsClose = typeof file === 'string'
file = needsClose ? await fs.open(this._getFilePath(file), 'r') : file.fd
try {
return await fs.read(
file,
buffer,
0,
buffer.length,
position === undefined ? null : position
)
} finally {
if (needsClose) {
await fs.close(file)
}
}
}
async _readFile (file, options) {
return fs.readFile(this._getFilePath(file), options)
}

View File

@@ -1,6 +1,26 @@
# ChangeLog
## **5.20.0** (planned 2018-05-31)
## *next*
### Enhancements
- Hide legacy backup creation view [#2956](https://github.com/vatesfr/xen-orchestra/issues/2956)
- [Delta Backup NG logs] Display wether the export is a full or a delta [#2711](https://github.com/vatesfr/xen-orchestra/issues/2711)
- Copy VDIs' UUID from SR/disks view [#3051](https://github.com/vatesfr/xen-orchestra/issues/3051)
- [Backup NG] New option to shutdown VMs before snapshotting them [#3058](https://github.com/vatesfr/xen-orchestra/issues/3058#event-1673756438)
- [Backup NG form] Improve feedback [#2711](https://github.com/vatesfr/xen-orchestra/issues/2711)
- [Backup NG] Different retentions for backup and replication [#2895](https://github.com/vatesfr/xen-orchestra/issues/2895)
- Possibility to use a fast clone when creating a VM from a snapshot [#2937](https://github.com/vatesfr/xen-orchestra/issues/2937)
### Bugs
- update the xentools search item to return the version number of installed xentools [#3015](https://github.com/vatesfr/xen-orchestra/issues/3015)
- Fix Nagios backup reports [#2991](https://github.com/vatesfr/xen-orchestra/issues/2991)
- Fix the retry of a single failed/interrupted VM backup [#2912](https://github.com/vatesfr/xen-orchestra/issues/2912#issuecomment-395480321)
- New VM with Self: filter out networks that are not in the template's pool [#3011](https://github.com/vatesfr/xen-orchestra/issues/3011)
- [Backup NG] Auto-detect when a full export is necessary.
## **5.20.0** (2018-05-31)
### Enhancements
@@ -9,8 +29,6 @@
- [Patches] ignore XS upgrade in missing patches counter [#2866](https://github.com/vatesfr/xen-orchestra/issues/2866)
- [Health] List VM snapshots related to non-existing backup jobs/schedules [#2828](https://github.com/vatesfr/xen-orchestra/issues/2828)
### Bugs
## **5.19.0** (2018-05-01)
### Enhancements

19
PULL_REQUEST_TEMPLATE.md Normal file
View File

@@ -0,0 +1,19 @@
### Check list
> Check items when done or if not relevant
- [ ] PR reference the relevant issue (e.g. `Fixes #007`)
- [ ] if UI changes, a screenshot has been added to the PR
- [ ] CHANGELOG updated
- [ ] documentation updated
### Process
1. create a PR as soon as possible
1. mark it as `WiP:` (Work in Progress) if not ready to be merged
1. when you want a review, add a reviewer
1. if necessary, update your PR, and readd a reviewer
### List of packages to release
> No need to mention xo-server and xo-web.

5
babel.config.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
// Necessary for jest to be able to find the `.babelrc.js` closest to the file
// instead of only the one in this directory.
babelrcRoots: true,
}

View File

@@ -0,0 +1,6 @@
declare module 'limit-concurrency-decorator' {
declare function limitConcurrencyDecorator(
concurrency: number
): <T: Function>(T) => T
declare export default typeof limitConcurrencyDecorator
}

View File

@@ -1,4 +1,8 @@
declare module 'lodash' {
declare export function countBy<K, V>(
object: { [K]: V },
iteratee: K | ((V, K) => string)
): { [string]: number }
declare export function forEach<K, V>(
object: { [K]: V },
iteratee: (V, K) => void
@@ -20,5 +24,10 @@ declare module 'lodash' {
iteratee: (V1, K) => V2
): { [K]: V2 }
declare export function noop(...args: mixed[]): void
declare export function some<T>(
collection: T[],
iteratee: (T, number) => boolean
): boolean
declare export function sum(values: number[]): number
declare export function values<K, V>(object: { [K]: V }): V[]
}

View File

@@ -1,8 +1,10 @@
{
"devDependencies": {
"@babel/register": "^7.0.0-beta.44",
"babel-7-jest": "^21.3.2",
"@babel/core": "^7.0.0-beta.49",
"@babel/register": "^7.0.0-beta.49",
"babel-core": "^7.0.0-0",
"babel-eslint": "^8.1.2",
"babel-jest": "^23.0.1",
"benchmark": "^2.1.4",
"eslint": "^4.14.0",
"eslint-config-standard": "^11.0.0-beta.0",
@@ -13,23 +15,22 @@
"eslint-plugin-react": "^7.6.1",
"eslint-plugin-standard": "^3.0.1",
"exec-promise": "^0.7.0",
"flow-bin": "^0.69.0",
"flow-bin": "^0.73.0",
"globby": "^8.0.0",
"husky": "^0.14.3",
"jest": "^22.0.4",
"jest": "^23.0.1",
"lodash": "^4.17.4",
"prettier": "^1.10.2",
"promise-toolbox": "^0.9.5",
"sorted-object": "^2.0.1"
},
"engines": {
"yarn": "^1.2.1"
"yarn": "^1.7.0"
},
"jest": {
"collectCoverage": true,
"projects": [
"<rootDir>",
"<rootDir>/packages/xo-web"
"<rootDir>"
],
"testEnvironment": "node",
"testPathIgnorePatterns": [
@@ -38,14 +39,6 @@
],
"testRegex": "\\.spec\\.js$",
"transform": {
"/@xen-orchestra/cron/.+\\.jsx?$": "babel-7-jest",
"/@xen-orchestra/fs/.+\\.jsx?$": "babel-7-jest",
"/packages/complex-matcher/.+\\.jsx?$": "babel-7-jest",
"/packages/value-matcher/.+\\.jsx?$": "babel-7-jest",
"/packages/vhd-lib/.+\\.jsx?$": "babel-7-jest",
"/packages/xo-cli/.+\\.jsx?$": "babel-7-jest",
"/packages/xo-server/.+\\.jsx?$": "babel-7-jest",
"/packages/xo-vmdk-to-vhd/.+\\.jsx?$": "babel-7-jest",
"\\.jsx?$": "babel-jest"
}
},

View File

@@ -30,9 +30,9 @@
"lodash": "^4.17.4"
},
"devDependencies": {
"@babel/cli": "7.0.0-beta.44",
"@babel/core": "7.0.0-beta.44",
"@babel/preset-env": "7.0.0-beta.44",
"@babel/cli": "7.0.0-beta.49",
"@babel/core": "7.0.0-beta.49",
"@babel/preset-env": "7.0.0-beta.49",
"babel-plugin-lodash": "^3.3.2",
"cross-env": "^5.1.1",
"rimraf": "^2.6.2"

View File

@@ -28,10 +28,10 @@
},
"dependencies": {},
"devDependencies": {
"@babel/cli": "7.0.0-beta.44",
"@babel/core": "7.0.0-beta.44",
"@babel/preset-env": "7.0.0-beta.44",
"@babel/preset-flow": "7.0.0-beta.44",
"@babel/cli": "7.0.0-beta.49",
"@babel/core": "7.0.0-beta.49",
"@babel/preset-env": "7.0.0-beta.49",
"@babel/preset-flow": "7.0.0-beta.49",
"cross-env": "^5.1.3",
"rimraf": "^2.6.2"
},

View File

@@ -0,0 +1,3 @@
module.exports = require('../../@xen-orchestra/babel-config')(
require('./package.json')
)

View File

@@ -23,21 +23,20 @@
"dist/"
],
"engines": {
"node": ">=4"
"node": ">=6"
},
"dependencies": {
"@xen-orchestra/fs": "^0.0.0",
"babel-runtime": "^6.22.0",
"@xen-orchestra/fs": "^0.1.0",
"exec-promise": "^0.7.0",
"struct-fu": "^1.2.0",
"vhd-lib": "^0.0.0"
"vhd-lib": "^0.1.2"
},
"devDependencies": {
"babel-cli": "^6.24.1",
"@babel/cli": "^7.0.0-beta.49",
"@babel/core": "^7.0.0-beta.49",
"@babel/plugin-transform-runtime": "^7.0.0-beta.49",
"@babel/preset-env": "^7.0.0-beta.49",
"babel-plugin-lodash": "^3.3.2",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-env": "^1.5.2",
"babel-preset-stage-3": "^6.24.1",
"cross-env": "^5.1.3",
"execa": "^0.10.0",
"index-modules": "^0.3.0",
@@ -51,22 +50,5 @@
"prebuild": "rimraf dist/ && index-modules --cjs-lazy src/commands",
"predev": "yarn run prebuild",
"prepare": "yarn run build"
},
"babel": {
"plugins": [
"lodash",
"transform-runtime"
],
"presets": [
[
"env",
{
"targets": {
"node": 4
}
}
],
"stage-3"
]
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "vhd-lib",
"version": "0.0.0",
"version": "0.1.2",
"license": "AGPL-3.0",
"description": "Primitives for VHD file handling",
"keywords": [],
@@ -20,30 +20,30 @@
"node": ">=6"
},
"dependencies": {
"@babel/runtime": "^7.0.0-beta.44",
"@xen-orchestra/fs": "^0.0.0",
"@babel/runtime": "^7.0.0-beta.49",
"async-iterator-to-stream": "^1.0.2",
"execa": "^0.10.0",
"from2": "^2.3.0",
"fs-extra": "^5.0.0",
"get-stream": "^3.0.0",
"fs-extra": "^6.0.1",
"limit-concurrency-decorator": "^0.4.0",
"promise-toolbox": "^0.9.5",
"struct-fu": "^1.2.0",
"uuid": "^3.0.1",
"tmp": "^0.0.33"
"uuid": "^3.0.1"
},
"devDependencies": {
"@babel/cli": "7.0.0-beta.44",
"@babel/core": "7.0.0-beta.44",
"@babel/plugin-transform-runtime": "^7.0.0-beta.44",
"@babel/preset-env": "7.0.0-beta.44",
"@babel/preset-flow": "7.0.0-beta.44",
"@babel/cli": "7.0.0-beta.49",
"@babel/core": "7.0.0-beta.49",
"@babel/plugin-transform-runtime": "^7.0.0-beta.49",
"@babel/preset-env": "7.0.0-beta.49",
"@babel/preset-flow": "7.0.0-beta.49",
"@xen-orchestra/fs": "^0.1.0",
"babel-plugin-lodash": "^3.3.2",
"cross-env": "^5.1.3",
"execa": "^0.10.0",
"fs-promise": "^2.0.0",
"get-stream": "^3.0.0",
"index-modules": "^0.3.0",
"rimraf": "^2.6.2"
"rimraf": "^2.6.2",
"tmp": "^0.0.33"
},
"scripts": {
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",

View File

@@ -28,7 +28,7 @@ function createBAT (
) {
let currentVhdPositionSector = firstBlockPosition / SECTOR_SIZE
blockAddressList.forEach(blockPosition => {
assert.strictEqual(blockPosition % 512, 0)
assert.strictEqual(blockPosition % SECTOR_SIZE, 0)
const vhdTableIndex = Math.floor(blockPosition / VHD_BLOCK_SIZE_BYTES)
if (bat.readUInt32BE(vhdTableIndex * 4) === BLOCK_UNUSED) {
bat.writeUInt32BE(currentVhdPositionSector, vhdTableIndex * 4)
@@ -57,7 +57,8 @@ export default asyncIteratorToStream(async function * (
}
const maxTableEntries = Math.ceil(diskSize / VHD_BLOCK_SIZE_BYTES) + 1
const tablePhysicalSizeBytes = Math.ceil(maxTableEntries * 4 / 512) * 512
const tablePhysicalSizeBytes =
Math.ceil(maxTableEntries * 4 / SECTOR_SIZE) * SECTOR_SIZE
const batPosition = FOOTER_SIZE + HEADER_SIZE
const firstBlockPosition = batPosition + tablePhysicalSizeBytes
@@ -101,13 +102,14 @@ export default asyncIteratorToStream(async function * (
if (currentVhdBlockIndex >= 0) {
yield * yieldAndTrack(
currentBlockWithBitmap,
bat.readUInt32BE(currentVhdBlockIndex * 4) * 512
bat.readUInt32BE(currentVhdBlockIndex * 4) * SECTOR_SIZE
)
}
currentBlockWithBitmap = Buffer.alloc(bitmapSize + VHD_BLOCK_SIZE_BYTES)
currentVhdBlockIndex = batIndex
}
const blockOffset = (next.offsetBytes / 512) % VHD_BLOCK_SIZE_SECTORS
const blockOffset =
(next.offsetBytes / SECTOR_SIZE) % VHD_BLOCK_SIZE_SECTORS
for (let bitPos = 0; bitPos < VHD_BLOCK_SIZE_SECTORS / ratio; bitPos++) {
setBitmap(currentBlockWithBitmap, blockOffset + bitPos)
}

View File

@@ -1,5 +1,4 @@
import assert from 'assert'
import getStream from 'get-stream'
import { fromEvent } from 'promise-toolbox'
import constantStream from './_constant-stream'
@@ -93,20 +92,14 @@ export default class Vhd {
// Read functions.
// =================================================================
_readStream (start, n) {
return this._handler.createReadStream(this._path, {
start,
end: start + n - 1, // end is inclusive
})
}
_read (start, n) {
return this._readStream(start, n)
.then(getStream.buffer)
.then(buf => {
assert.equal(buf.length, n)
return buf
})
async _read (start, n) {
const { bytesRead, buffer } = await this._handler.read(
this._path,
Buffer.alloc(n),
start
)
assert.equal(bytesRead, n)
return buffer
}
containsBlock (id) {
@@ -336,11 +329,11 @@ export default class Vhd {
`freeFirstBlockSpace: move first block ${firstSector} -> ${newFirstSector}`
)
// copy the first block at the end
const stream = await this._readStream(
const block = await this._read(
sectorsToBytes(firstSector),
fullBlockSize
)
await this._write(stream, sectorsToBytes(newFirstSector))
await this._write(block, sectorsToBytes(newFirstSector))
await this._setBatEntry(first, newFirstSector)
await this.writeFooter(true)
spaceNeededBytes -= this.fullBlockSize
@@ -476,12 +469,12 @@ export default class Vhd {
// For each sector of block data...
const { sectorsPerBlock } = child
let parentBitmap = null
for (let i = 0; i < sectorsPerBlock; i++) {
// If no changes on one sector, skip.
if (!mapTestBit(bitmap, i)) {
continue
}
let parentBitmap = null
let endSector = i + 1
// Count changed sectors.

View File

@@ -4,7 +4,7 @@ process.env.DEBUG = '*'
const defer = require('golike-defer').default
const pump = require('pump')
const { fromCallback } = require('promise-toolbox')
const { CancelToken, fromCallback } = require('promise-toolbox')
const { createClient } = require('../')
@@ -30,8 +30,11 @@ defer(async ($defer, args) => {
await xapi.connect()
$defer(() => xapi.disconnect())
const { cancel, token } = CancelToken.source()
process.on('SIGINT', cancel)
// https://xapi-project.github.io/xen-api/snapshots.html#downloading-a-disk-or-snapshot
const exportStream = await xapi.getResource('/export_raw_vdi/', {
const exportStream = await xapi.getResource(token, '/export_raw_vdi/', {
query: {
format: raw ? 'raw' : 'vhd',
vdi: await resolveRef(xapi, 'VDI', args[1])

View File

@@ -4,7 +4,7 @@ process.env.DEBUG = '*'
const defer = require('golike-defer').default
const pump = require('pump')
const { fromCallback } = require('promise-toolbox')
const { CancelToken, fromCallback } = require('promise-toolbox')
const { createClient } = require('../')
@@ -24,8 +24,11 @@ defer(async ($defer, args) => {
await xapi.connect()
$defer(() => xapi.disconnect())
const { cancel, token } = CancelToken.source()
process.on('SIGINT', cancel)
// https://xapi-project.github.io/xen-api/importexport.html
const exportStream = await xapi.getResource('/export/', {
const exportStream = await xapi.getResource(token, '/export/', {
query: {
ref: await resolveRef(xapi, 'VM', args[1]),
use_compression: 'true'

View File

@@ -3,6 +3,7 @@
process.env.DEBUG = '*'
const defer = require('golike-defer').default
const { CancelToken } = require('promise-toolbox')
const { createClient } = require('../')
@@ -28,8 +29,11 @@ defer(async ($defer, args) => {
await xapi.connect()
$defer(() => xapi.disconnect())
const { cancel, token } = CancelToken.source()
process.on('SIGINT', cancel)
// https://xapi-project.github.io/xen-api/snapshots.html#uploading-a-disk-or-snapshot
await xapi.putResource(createInputStream(args[2]), '/import_raw_vdi/', {
await xapi.putResource(token, createInputStream(args[2]), '/import_raw_vdi/', {
query: {
format: raw ? 'raw' : 'vhd',
vdi: await resolveRef(xapi, 'VDI', args[1])

View File

@@ -3,6 +3,7 @@
process.env.DEBUG = '*'
const defer = require('golike-defer').default
const { CancelToken } = require('promise-toolbox')
const { createClient } = require('../')
@@ -22,8 +23,11 @@ defer(async ($defer, args) => {
await xapi.connect()
$defer(() => xapi.disconnect())
const { cancel, token } = CancelToken.source()
process.on('SIGINT', cancel)
// https://xapi-project.github.io/xen-api/importexport.html
await xapi.putResource(createInputStream(args[1]), '/import/', {
await xapi.putResource(token, createInputStream(args[1]), '/import/', {
query: args[2] && { sr_id: await resolveRef(xapi, 'SR', args[2]) }
})
})(process.argv.slice(2)).catch(

View File

@@ -1,6 +1,6 @@
{
"dependencies": {
"golike-defer": "^0.1.0",
"pump": "^1.0.2"
"golike-defer": "^0.4.1",
"pump": "^3.0.0"
}
}

View File

@@ -0,0 +1,30 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
end-of-stream@^1.1.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43"
dependencies:
once "^1.4.0"
golike-defer@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/golike-defer/-/golike-defer-0.4.1.tgz#7a1cd435d61e461305805d980b133a0f3db4e1cc"
once@^1.3.1, once@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
dependencies:
wrappy "1"
pump@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
dependencies:
end-of-stream "^1.1.0"
once "^1.3.1"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"

View File

@@ -595,7 +595,10 @@ export class Xapi extends EventEmitter {
if (error != null && (response = error.response) != null) {
response.req.abort()
const { headers: { location }, statusCode } = response
const {
headers: { location },
statusCode,
} = response
if (statusCode === 302 && location !== undefined) {
return doRequest(location)
}
@@ -777,15 +780,13 @@ export class Xapi extends EventEmitter {
this._pool = object
const eventWatchers = this._eventWatchers
if (eventWatchers !== undefined) {
forEach(object.other_config, (_, key) => {
const eventWatcher = eventWatchers[key]
if (eventWatcher !== undefined) {
delete eventWatchers[key]
eventWatcher(object)
}
})
}
Object.keys(object.other_config).forEach(key => {
const eventWatcher = eventWatchers[key]
if (eventWatcher !== undefined) {
delete eventWatchers[key]
eventWatcher(object)
}
})
} else if (type === 'task') {
if (prev === undefined) {
++this._nTasks

View File

@@ -1,6 +1,6 @@
{
"name": "xo-acl-resolver",
"version": "0.2.3",
"version": "0.2.4",
"license": "ISC",
"description": "Xen-Orchestra internal: do ACLs resolution",
"keywords": [],

View File

@@ -50,7 +50,9 @@ const checkAuthorizationByTypes = {
network: or(checkSelf, checkMember('$pool')),
SR: or(checkSelf, checkMember('$pool')),
PIF: checkMember('$host'),
SR: or(checkSelf, checkMember('$container')),
task: checkMember('$host'),

View File

@@ -28,7 +28,7 @@
"node": ">=6"
},
"dependencies": {
"@babel/polyfill": "7.0.0-beta.44",
"@babel/polyfill": "7.0.0-beta.49",
"bluebird": "^3.5.1",
"chalk": "^2.2.0",
"event-to-promise": "^0.8.0",
@@ -49,10 +49,10 @@
"xo-lib": "^0.9.0"
},
"devDependencies": {
"@babel/cli": "7.0.0-beta.44",
"@babel/core": "7.0.0-beta.44",
"@babel/preset-env": "7.0.0-beta.44",
"@babel/preset-flow": "7.0.0-beta.44",
"@babel/cli": "7.0.0-beta.49",
"@babel/core": "7.0.0-beta.49",
"@babel/preset-env": "7.0.0-beta.49",
"@babel/preset-flow": "7.0.0-beta.49",
"babel-plugin-lodash": "^3.3.2",
"cross-env": "^5.1.3",
"rimraf": "^2.6.2"

View File

@@ -0,0 +1,3 @@
module.exports = require('../../@xen-orchestra/babel-config')(
require('./package.json')
)

View File

@@ -25,17 +25,16 @@
"node": ">=4"
},
"dependencies": {
"babel-runtime": "^6.18.0",
"@babel/runtime": "^7.0.0-beta.49",
"kindof": "^2.0.0",
"lodash": "^4.17.2",
"make-error": "^1.0.2"
},
"devDependencies": {
"babel-cli": "^6.24.1",
"babel-plugin-lodash": "^3.3.2",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-env": "^1.5.2",
"babel-preset-stage-3": "^6.24.1",
"@babel/cli": "^7.0.0-beta.49",
"@babel/core": "^7.0.0-beta.49",
"@babel/plugin-transform-runtime": "^7.0.0-beta.49",
"@babel/preset-env": "^7.0.0-beta.49",
"cross-env": "^5.1.3",
"event-to-promise": "^0.8.0",
"rimraf": "^2.6.1"
@@ -46,22 +45,5 @@
"prebuild": "rimraf dist/",
"predev": "yarn run prebuild",
"prepublishOnly": "yarn run build"
},
"babel": {
"plugins": [
"lodash",
"transform-runtime"
],
"presets": [
[
"env",
{
"targets": {
"node": 4
}
}
],
"stage-3"
]
}
}

View File

@@ -0,0 +1,3 @@
module.exports = require('../../@xen-orchestra/babel-config')(
require('./package.json')
)

View File

@@ -27,10 +27,10 @@
"lodash": "^4.13.1"
},
"devDependencies": {
"babel-cli": "^6.24.1",
"@babel/cli": "^7.0.0-beta.49",
"@babel/core": "^7.0.0-beta.49",
"@babel/preset-env": "^7.0.0-beta.49",
"babel-plugin-lodash": "^3.3.2",
"babel-preset-env": "^1.5.2",
"babel-preset-stage-3": "^6.24.1",
"cross-env": "^5.1.3",
"deep-freeze": "^0.0.1",
"rimraf": "^2.6.1"
@@ -41,22 +41,5 @@
"prebuild": "rimraf dist/",
"predev": "yarn run prebuild",
"prepare": "yarn run build"
},
"babel": {
"plugins": [
"lodash"
],
"presets": [
[
"env",
{
"targets": {
"browsers": "> 5%",
"node": 4
}
}
],
"stage-3"
]
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server-backup-reports",
"version": "0.11.0",
"version": "0.12.1",
"license": "AGPL-3.0",
"description": "Backup reports plugin for XO-Server",
"keywords": [

View File

@@ -1,7 +1,6 @@
import humanFormat from 'human-format'
import moment from 'moment-timezone'
import { find, forEach, get, startCase } from 'lodash'
import { forEach, get, startCase } from 'lodash'
import pkg from '../package'
export const configurationSchema = {
@@ -37,6 +36,12 @@ const ICON_FAILURE = '🚨'
const ICON_SKIPPED = '⏩'
const ICON_SUCCESS = '✔'
const STATUS_ICON = {
skipped: ICON_SKIPPED,
success: ICON_SUCCESS,
failure: ICON_FAILURE,
}
const DATE_FORMAT = 'dddd, MMMM Do YYYY, h:mm:ss a'
const createDateFormater = timezone =>
timezone !== undefined
@@ -57,10 +62,12 @@ const formatSize = bytes =>
})
const formatSpeed = (bytes, milliseconds) =>
humanFormat(bytes * 1e3 / milliseconds, {
scale: 'binary',
unit: 'B/s',
})
milliseconds > 0
? humanFormat((bytes * 1e3) / milliseconds, {
scale: 'binary',
unit: 'B/s',
})
: 'N/A'
const logError = e => {
console.error('backup report error:', e)
@@ -95,43 +102,42 @@ class BackupReportsXoPlugin {
this._xo.removeListener('job:terminated', this._report)
}
_wrapper (status, job, schedule) {
_wrapper (status, job, schedule, runJobId) {
return new Promise(resolve =>
resolve(
job.type === 'backup'
? this._backupNgListener(status, job, schedule)
: this._listener(status, job, schedule)
? this._backupNgListener(status, job, schedule, runJobId)
: this._listener(status, job, schedule, runJobId)
)
).catch(logError)
}
async _backupNgListener (runJobId, _, { timezone }) {
async _backupNgListener (_1, _2, { timezone }, runJobId) {
const xo = this._xo
const logs = await xo.getBackupNgLogs(runJobId)
const jobLog = logs['roots'][0]
const vmsTaskLog = logs[jobLog.id]
const log = await xo.getBackupNgLogs(runJobId)
const { reportWhen, mode } = jobLog.data || {}
if (reportWhen === 'never') {
const { reportWhen, mode } = log.data || {}
if (
reportWhen === 'never' ||
(log.status === 'success' && reportWhen === 'failure')
) {
return
}
const jobName = (await xo.getJob(log.jobId, 'backup')).name
const formatDate = createDateFormater(timezone)
const jobName = (await xo.getJob(jobLog.jobId, 'backup')).name
if (jobLog.error !== undefined) {
const [globalStatus, icon] =
jobLog.error.message === NO_VMS_MATCH_THIS_PATTERN
? ['Skipped', ICON_SKIPPED]
: ['Failure', ICON_FAILURE]
if (
(log.status === 'failure' || log.status === 'skipped') &&
log.result !== undefined
) {
let markdown = [
`## Global status: ${globalStatus}`,
`## Global status: ${log.status}`,
'',
`- **mode**: ${mode}`,
`- **Start time**: ${formatDate(jobLog.start)}`,
`- **End time**: ${formatDate(jobLog.end)}`,
`- **Duration**: ${formatDuration(jobLog.duration)}`,
`- **Error**: ${jobLog.error.message}`,
`- **Start time**: ${formatDate(log.start)}`,
`- **End time**: ${formatDate(log.end)}`,
`- **Duration**: ${formatDuration(log.end - log.start)}`,
`- **Error**: ${log.result.message}`,
'---',
'',
`*${pkg.name} v${pkg.version}*`,
@@ -139,12 +145,14 @@ class BackupReportsXoPlugin {
markdown = markdown.join('\n')
return this._sendReport({
subject: `[Xen Orchestra] ${globalStatus} Backup report for ${jobName} ${icon}`,
subject: `[Xen Orchestra] ${
log.status
} Backup report for ${jobName} ${STATUS_ICON[log.status]}`,
markdown,
nagiosStatus: 2,
nagiosMarkdown: `[Xen Orchestra] [${globalStatus}] Backup report for ${jobName} - Error : ${
jobLog.error.message
}`,
nagiosMarkdown: `[Xen Orchestra] [${
log.status
}] Backup report for ${jobName} - Error : ${log.result.message}`,
})
}
@@ -157,14 +165,12 @@ class BackupReportsXoPlugin {
let globalTransferSize = 0
let nFailures = 0
let nSkipped = 0
for (const vmTaskLog of vmsTaskLog || []) {
const vmTaskStatus = vmTaskLog.status
if (vmTaskStatus === 'success' && reportWhen === 'failure') {
for (const taskLog of log.tasks) {
if (taskLog.status === 'success' && reportWhen === 'failure') {
return
}
const vmId = vmTaskLog.data.id
const vmId = taskLog.data.id
let vm
try {
vm = xo.getObject(vmId)
@@ -173,136 +179,170 @@ class BackupReportsXoPlugin {
`### ${vm !== undefined ? vm.name_label : 'VM not found'}`,
'',
`- **UUID**: ${vm !== undefined ? vm.uuid : vmId}`,
`- **Start time**: ${formatDate(vmTaskLog.start)}`,
`- **End time**: ${formatDate(vmTaskLog.end)}`,
`- **Duration**: ${formatDuration(vmTaskLog.duration)}`,
`- **Start time**: ${formatDate(taskLog.start)}`,
`- **End time**: ${formatDate(taskLog.end)}`,
`- **Duration**: ${formatDuration(taskLog.end - taskLog.start)}`,
]
const failedSubTasks = []
const operationsText = []
const snapshotText = []
const srsText = []
const remotesText = []
for (const subTaskLog of logs[vmTaskLog.taskId] || []) {
const { data, status, result, message } = subTaskLog
const icon =
subTaskLog.status === 'success' ? ICON_SUCCESS : ICON_FAILURE
const errorMessage = ` **Error**: ${get(result, 'message')}`
if (message === 'snapshot') {
operationsText.push(`- **Snapshot** ${icon}`)
if (status === 'failure') {
failedSubTasks.push('Snapshot')
operationsText.push('', errorMessage)
}
} else if (data.type === 'remote') {
const remoteId = data.id
const remote = await xo.getRemote(remoteId).catch(() => {})
remotesText.push(
`- **${
remote !== undefined ? remote.name : `Remote Not found`
}** (${remoteId}) ${icon}`
for (const subTaskLog of taskLog.tasks || []) {
if (
subTaskLog.message !== 'export' &&
subTaskLog.message !== 'snapshot'
) {
continue
}
const icon = STATUS_ICON[subTaskLog.status]
const errorMessage = ` - **Error**: ${get(
subTaskLog.result,
'message'
)}`
if (subTaskLog.message === 'snapshot') {
snapshotText.push(
`- **Snapshot** ${icon}`,
` - **Start time**: ${formatDate(subTaskLog.start)}`,
` - **End time**: ${formatDate(subTaskLog.end)}`
)
if (status === 'failure') {
failedSubTasks.push(remote !== undefined ? remote.name : remoteId)
} else if (subTaskLog.data.type === 'remote') {
const id = subTaskLog.data.id
const remote = await xo.getRemote(id).catch(() => {})
remotesText.push(
` - **${
remote !== undefined ? remote.name : `Remote Not found`
}** (${id}) ${icon}`,
` - **Start time**: ${formatDate(subTaskLog.start)}`,
` - **End time**: ${formatDate(subTaskLog.end)}`,
` - **Duration**: ${formatDuration(
subTaskLog.end - subTaskLog.start
)}`
)
if (subTaskLog.status === 'failure') {
failedSubTasks.push(remote !== undefined ? remote.name : id)
remotesText.push('', errorMessage)
}
} else {
const srId = data.id
const id = subTaskLog.data.id
let sr
try {
sr = xo.getObject(srId)
sr = xo.getObject(id)
} catch (e) {}
const [srName, srUuid] =
sr !== undefined ? [sr.name_label, sr.uuid] : [`SR Not found`, srId]
srsText.push(`- **${srName}** (${srUuid}) ${icon}`)
if (status === 'failure') {
failedSubTasks.push(sr !== undefined ? sr.name_label : srId)
sr !== undefined ? [sr.name_label, sr.uuid] : [`SR Not found`, id]
srsText.push(
` - **${srName}** (${srUuid}) ${icon}`,
` - **Start time**: ${formatDate(subTaskLog.start)}`,
` - **End time**: ${formatDate(subTaskLog.end)}`,
` - **Duration**: ${formatDuration(
subTaskLog.end - subTaskLog.start
)}`
)
if (subTaskLog.status === 'failure') {
failedSubTasks.push(sr !== undefined ? sr.name_label : id)
srsText.push('', errorMessage)
}
}
forEach(subTaskLog.tasks, operationLog => {
if (
operationLog.message !== 'merge' &&
operationLog.message !== 'transfer'
) {
return
}
const operationInfoText = []
if (operationLog.status === 'success') {
const size = operationLog.result.size
if (operationLog.message === 'merge') {
globalMergeSize += size
} else {
globalTransferSize += size
}
operationInfoText.push(
` - **Size**: ${formatSize(size)}`,
` - **Speed**: ${formatSpeed(
size,
operationLog.end - operationLog.start
)}`
)
} else {
operationInfoText.push(
` - **Error**: ${get(operationLog.result, 'message')}`
)
}
const operationText = [
` - **${operationLog.message}** ${
STATUS_ICON[operationLog.status]
}`,
` - **Start time**: ${formatDate(operationLog.start)}`,
` - **End time**: ${formatDate(operationLog.end)}`,
` - **Duration**: ${formatDuration(
operationLog.end - operationLog.start
)}`,
...operationInfoText,
].join('\n')
if (get(subTaskLog, 'data.type') === 'remote') {
remotesText.push(operationText)
remotesText.join('\n')
}
if (get(subTaskLog, 'data.type') === 'SR') {
srsText.push(operationText)
srsText.join('\n')
}
})
}
if (operationsText.length !== 0) {
operationsText.unshift(`#### Operations`, '')
}
if (srsText.length !== 0) {
srsText.unshift(`#### SRs`, '')
srsText.unshift(`- **SRs**`)
}
if (remotesText.length !== 0) {
remotesText.unshift(`#### remotes`, '')
remotesText.unshift(`- **Remotes**`)
}
const subText = [...operationsText, '', ...srsText, '', ...remotesText]
const result = vmTaskLog.result
if (vmTaskStatus === 'failure' && result !== undefined) {
const { message } = result
if (isSkippedError(result)) {
const subText = [...snapshotText, '', ...srsText, '', ...remotesText]
if (taskLog.result !== undefined) {
if (taskLog.status === 'skipped') {
++nSkipped
skippedVmsText.push(
...text,
`- **Reason**: ${
message === UNHEALTHY_VDI_CHAIN_ERROR
taskLog.result.message === UNHEALTHY_VDI_CHAIN_ERROR
? UNHEALTHY_VDI_CHAIN_MESSAGE
: message
: taskLog.result.message
}`,
''
)
nagiosText.push(
`[(Skipped) ${
vm !== undefined ? vm.name_label : 'undefined'
} : ${message} ]`
`[(Skipped) ${vm !== undefined ? vm.name_label : 'undefined'} : ${
taskLog.result.message
} ]`
)
} else {
++nFailures
failedVmsText.push(...text, `- **Error**: ${message}`, '')
failedVmsText.push(
...text,
`- **Error**: ${taskLog.result.message}`,
''
)
nagiosText.push(
`[(Failed) ${
vm !== undefined ? vm.name_label : 'undefined'
} : ${message} ]`
`[(Failed) ${vm !== undefined ? vm.name_label : 'undefined'} : ${
taskLog.result.message
} ]`
)
}
} else {
let transferSize, transferDuration, mergeSize, mergeDuration
forEach(logs[vmTaskLog.taskId], ({ taskId }) => {
if (transferSize !== undefined) {
return false
}
const transferTask = find(logs[taskId], { message: 'transfer' })
if (transferTask !== undefined) {
transferSize = transferTask.result.size
transferDuration = transferTask.end - transferTask.start
}
const mergeTask = find(logs[taskId], { message: 'merge' })
if (mergeTask !== undefined) {
mergeSize = mergeTask.result.size
mergeDuration = mergeTask.end - mergeTask.start
}
})
if (transferSize !== undefined) {
globalTransferSize += transferSize
text.push(
`- **Transfer size**: ${formatSize(transferSize)}`,
`- **Transfer speed**: ${formatSpeed(
transferSize,
transferDuration
)}`
)
}
if (mergeSize !== undefined) {
globalMergeSize += mergeSize
text.push(
`- **Merge size**: ${formatSize(mergeSize)}`,
`- **Merge speed**: ${formatSpeed(mergeSize, mergeDuration)}`
)
}
if (vmTaskStatus === 'failure') {
if (taskLog.status === 'failure') {
++nFailures
failedVmsText.push(...text, '', '', ...subText, '')
nagiosText.push(
`[(Failed) ${
`[${
vm !== undefined ? vm.name_label : 'undefined'
}: (failed)[${failedSubTasks.toString()}]]`
)
@@ -311,23 +351,16 @@ class BackupReportsXoPlugin {
}
}
}
const globalSuccess = nFailures === 0 && nSkipped === 0
if (reportWhen === 'failure' && globalSuccess) {
return
}
const nVms = vmsTaskLog.length
const nVms = log.tasks.length
const nSuccesses = nVms - nFailures - nSkipped
const globalStatus = globalSuccess
? `Success`
: nFailures !== 0 ? `Failure` : `Skipped`
let markdown = [
`## Global status: ${globalStatus}`,
`## Global status: ${log.status}`,
'',
`- **mode**: ${mode}`,
`- **Start time**: ${formatDate(jobLog.start)}`,
`- **End time**: ${formatDate(jobLog.end)}`,
`- **Duration**: ${formatDuration(jobLog.duration)}`,
`- **Start time**: ${formatDate(log.start)}`,
`- **End time**: ${formatDate(log.end)}`,
`- **Duration**: ${formatDuration(log.start - log.end)}`,
`- **Successes**: ${nSuccesses} / ${nVms}`,
]
@@ -367,17 +400,16 @@ class BackupReportsXoPlugin {
markdown = markdown.join('\n')
return this._sendReport({
markdown,
subject: `[Xen Orchestra] ${globalStatus} Backup report for ${jobName} ${
globalSuccess
? ICON_SUCCESS
: nFailures !== 0 ? ICON_FAILURE : ICON_SKIPPED
subject: `[Xen Orchestra] ${log.status} Backup report for ${jobName} ${
STATUS_ICON[log.status]
}`,
nagiosStatus: globalSuccess ? 0 : 2,
nagiosMarkdown: globalSuccess
? `[Xen Orchestra] [Success] Backup report for ${jobName}`
: `[Xen Orchestra] [${
nFailures !== 0 ? 'Failure' : 'Skipped'
}] Backup report for ${jobName} - VMs : ${nagiosText.join(' ')}`,
nagiosStatus: log.status === 'success' ? 0 : 2,
nagiosMarkdown:
log.status === 'success'
? `[Xen Orchestra] [Success] Backup report for ${jobName}`
: `[Xen Orchestra] [${
nFailures !== 0 ? 'Failure' : 'Skipped'
}] Backup report for ${jobName} - VMs : ${nagiosText.join(' ')}`,
})
}
@@ -401,7 +433,7 @@ class BackupReportsXoPlugin {
}),
xo.sendPassiveCheck !== undefined &&
xo.sendPassiveCheck({
nagiosStatus,
status: nagiosStatus,
message: nagiosMarkdown,
}),
])
@@ -567,7 +599,9 @@ class BackupReportsXoPlugin {
const nSuccesses = nCalls - nFailures - nSkipped
const globalStatus = globalSuccess
? `Success`
: nFailures !== 0 ? `Failure` : `Skipped`
: nFailures !== 0
? `Failure`
: `Skipped`
let markdown = [
`## Global status: ${globalStatus}`,
@@ -625,7 +659,9 @@ class BackupReportsXoPlugin {
subject: `[Xen Orchestra] ${globalStatus} Backup report for ${tag} ${
globalSuccess
? ICON_SUCCESS
: nFailures !== 0 ? ICON_FAILURE : ICON_SKIPPED
: nFailures !== 0
? ICON_FAILURE
: ICON_SKIPPED
}`,
nagiosStatus: globalSuccess ? 0 : 2,
nagiosMarkdown: globalSuccess

View File

@@ -26,10 +26,10 @@
"lodash": "^4.17.4"
},
"devDependencies": {
"@babel/cli": "7.0.0-beta.44",
"@babel/core": "7.0.0-beta.44",
"@babel/preset-env": "7.0.0-beta.44",
"@babel/preset-flow": "^7.0.0-beta.44",
"@babel/cli": "7.0.0-beta.49",
"@babel/core": "7.0.0-beta.49",
"@babel/preset-env": "7.0.0-beta.49",
"@babel/preset-flow": "^7.0.0-beta.49",
"babel-plugin-lodash": "^3.3.2",
"cross-env": "^5.1.3",
"rimraf": "^2.6.2"

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server-usage-report",
"version": "0.4.2",
"version": "0.5.0",
"license": "AGPL-3.0",
"description": "",
"keywords": [

View File

@@ -139,8 +139,8 @@ Handlebars.registerHelper(
new Handlebars.SafeString(
isFinite(+value) && +value !== 0
? (value = round(value, 2)) > 0
? `(<b style="color: green;">▲ ${value}</b>)`
: `(<b style="color: red;">▼ ${String(value).slice(1)}</b>)`
? `(<b style="color: green;">▲ ${value}%</b>)`
: `(<b style="color: red;">▼ ${String(value).slice(1)}%</b>)`
: ''
)
)
@@ -270,12 +270,16 @@ async function getHostsStats ({ runningHosts, xo }) {
function getSrsStats (xoObjects) {
return orderBy(
map(filter(xoObjects, { type: 'SR' }), sr => {
map(filter(xoObjects, obj => obj.type === 'SR' && obj.size > 0), sr => {
const total = sr.size / gibPower
const used = sr.physical_usage / gibPower
let name = sr.name_label
if (!sr.shared) {
name += ` (${find(xoObjects, { id: sr.$container }).name_label})`
}
return {
uuid: sr.uuid,
name: sr.name_label,
name,
total,
used,
free: total - used,

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server",
"version": "5.19.6",
"version": "5.20.0",
"license": "AGPL-3.0",
"description": "Server part of Xen-Orchestra",
"keywords": [
@@ -31,15 +31,15 @@
"node": ">=6"
},
"dependencies": {
"@babel/polyfill": "7.0.0-beta.44",
"@babel/polyfill": "7.0.0-beta.49",
"@marsaud/smb2-promise": "^0.2.1",
"@xen-orchestra/cron": "^1.0.3",
"@xen-orchestra/fs": "^0.0.0",
"@xen-orchestra/fs": "^0.1.0",
"ajv": "^6.1.1",
"app-conf": "^0.5.0",
"archiver": "^2.1.0",
"async-iterator-to-stream": "^1.0.1",
"base64url": "^2.0.0",
"base64url": "^3.0.0",
"bind-property-descriptor": "^1.0.0",
"blocked": "^1.2.1",
"bluebird": "^3.5.1",
@@ -59,10 +59,10 @@
"express-session": "^1.15.6",
"fatfs": "^0.10.4",
"from2": "^2.3.0",
"fs-extra": "^5.0.0",
"fs-extra": "^6.0.1",
"get-stream": "^3.0.0",
"golike-defer": "^0.4.1",
"hashy": "^0.6.2",
"hashy": "^0.7.1",
"helmet": "^3.9.0",
"highland": "^2.11.1",
"http-proxy": "^1.16.2",
@@ -70,14 +70,14 @@
"http-server-plus": "^0.10.0",
"human-format": "^0.10.0",
"is-redirect": "^1.0.0",
"jest-worker": "^22.4.3",
"jest-worker": "^23.0.0",
"js-yaml": "^3.10.0",
"json-rpc-peer": "^0.15.3",
"json5": "^1.0.0",
"julien-f-source-map-support": "0.1.0",
"julien-f-unzip": "^0.2.1",
"kindof": "^2.0.0",
"level": "^3.0.0",
"level": "^4.0.0",
"level-party": "^3.0.4",
"level-sublevel": "^6.6.1",
"limit-concurrency-decorator": "^0.4.0",
@@ -93,16 +93,16 @@
"partial-stream": "0.0.0",
"passport": "^0.4.0",
"passport-local": "^1.0.0",
"pretty-format": "^22.0.3",
"pretty-format": "^23.0.0",
"promise-toolbox": "^0.9.5",
"proxy-agent": "^2.1.0",
"proxy-agent": "^3.0.0",
"pug": "^2.0.0-rc.4",
"pw": "^0.0.4",
"redis": "^2.8.0",
"schema-inspector": "^1.6.8",
"semver": "^5.4.1",
"serve-static": "^1.13.1",
"split-lines": "^1.1.0",
"split-lines": "^2.0.0",
"stack-chain": "^2.0.0",
"stoppable": "^1.0.5",
"struct-fu": "^1.2.0",
@@ -111,29 +111,29 @@
"tmp": "^0.0.33",
"uuid": "^3.0.1",
"value-matcher": "^0.2.0",
"vhd-lib": "^0.0.0",
"vhd-lib": "^0.1.2",
"ws": "^5.0.0",
"xen-api": "^0.16.9",
"xml2js": "^0.4.19",
"xo-acl-resolver": "^0.2.3",
"xo-acl-resolver": "^0.2.4",
"xo-collection": "^0.4.1",
"xo-common": "^0.1.1",
"xo-remote-parser": "^0.3",
"xo-vmdk-to-vhd": "^0.1.1",
"xo-vmdk-to-vhd": "^0.1.2",
"yazl": "^2.4.3"
},
"devDependencies": {
"@babel/cli": "7.0.0-beta.44",
"@babel/core": "7.0.0-beta.44",
"@babel/plugin-proposal-decorators": "7.0.0-beta.44",
"@babel/plugin-proposal-export-default-from": "7.0.0-beta.44",
"@babel/plugin-proposal-export-namespace-from": "7.0.0-beta.44",
"@babel/plugin-proposal-function-bind": "7.0.0-beta.44",
"@babel/plugin-proposal-optional-chaining": "^7.0.0-beta.44",
"@babel/plugin-proposal-pipeline-operator": "^7.0.0-beta.44",
"@babel/plugin-proposal-throw-expressions": "^7.0.0-beta.44",
"@babel/preset-env": "7.0.0-beta.44",
"@babel/preset-flow": "7.0.0-beta.44",
"@babel/cli": "7.0.0-beta.49",
"@babel/core": "7.0.0-beta.49",
"@babel/plugin-proposal-decorators": "7.0.0-beta.49",
"@babel/plugin-proposal-export-default-from": "7.0.0-beta.49",
"@babel/plugin-proposal-export-namespace-from": "7.0.0-beta.49",
"@babel/plugin-proposal-function-bind": "7.0.0-beta.49",
"@babel/plugin-proposal-optional-chaining": "^7.0.0-beta.49",
"@babel/plugin-proposal-pipeline-operator": "^7.0.0-beta.49",
"@babel/plugin-proposal-throw-expressions": "^7.0.0-beta.49",
"@babel/preset-env": "7.0.0-beta.49",
"@babel/preset-flow": "7.0.0-beta.49",
"babel-plugin-lodash": "^3.3.2",
"cross-env": "^5.1.3",
"index-modules": "^0.3.0",

View File

@@ -1,4 +1,5 @@
import { basename } from 'path'
import { isEmpty, pickBy } from 'lodash'
import { safeDateFormat } from '../utils'
@@ -117,8 +118,8 @@ getJob.params = {
},
}
export async function runJob ({ id, schedule }) {
return this.runJobSequence([id], await this.getSchedule(schedule))
export async function runJob ({ id, schedule, vm }) {
return this.runJobSequence([id], await this.getSchedule(schedule), vm)
}
runJob.permission = 'admin'
@@ -130,12 +131,17 @@ runJob.params = {
schedule: {
type: 'string',
},
vm: {
type: 'string',
optional: true,
},
}
// -----------------------------------------------------------------------------
export function getAllLogs () {
return this.getBackupNgLogs()
export async function getAllLogs (filter) {
const logs = await this.getBackupNgLogs()
return isEmpty(filter) ? logs : pickBy(logs, filter)
}
getAllLogs.permission = 'admin'

View File

@@ -76,6 +76,21 @@ export { restartAgent as restart_agent } // eslint-disable-line camelcase
// -------------------------------------------------------------------
export function setRemoteSyslogHost ({ host, syslogDestination }) {
return this.getXapi(host).setRemoteSyslogHost(host._xapiId, syslogDestination)
}
setRemoteSyslogHost.params = {
id: { type: 'string' },
syslogDestination: { type: 'string' },
}
setRemoteSyslogHost.resolve = {
host: ['id', 'host', 'administrate'],
}
// -------------------------------------------------------------------
export function start ({ host }) {
return this.getXapi(host).powerOnHost(host._xapiId)
}

View File

@@ -1,5 +1,12 @@
// FIXME so far, no acls for jobs
export function cancel ({ runId }) {
return this.cancelJobRun(runId)
}
cancel.permission = 'admin'
cancel.description = 'Cancel a current run'
export async function getAll () {
return /* await */ this.getAllJobs('call')
}

View File

@@ -25,8 +25,10 @@ function checkPermissionOnSrs (vm, permission = 'operate') {
if (vbd.is_cd_drive || !vdiId) {
return
}
return permissions.push([this.getObject(vdiId, 'VDI').$SR, permission])
return permissions.push([
this.getObject(vdiId, ['VDI', 'VDI-snapshot']).$SR,
permission,
])
})
return this.hasPermissions(this.session.get('user_id'), permissions).then(
@@ -50,11 +52,16 @@ const extract = (obj, prop) => {
export async function create (params) {
const { user } = this
const resourceSet = extract(params, 'resourceSet')
if (resourceSet === undefined && user.permission !== 'admin') {
const template = extract(params, 'template')
if (
resourceSet === undefined &&
!(await this.hasPermissions(this.user.id, [
[template.$pool, 'administrate'],
]))
) {
throw unauthorized()
}
const template = extract(params, 'template')
params.template = template._xapiId
const xapi = this.getXapi(template)
@@ -467,7 +474,7 @@ export async function migrate ({
})
}
if (!await this.hasPermissions(this.session.get('user_id'), permissions)) {
if (!(await this.hasPermissions(this.session.get('user_id'), permissions))) {
throw unauthorized()
}
@@ -656,8 +663,7 @@ clone.params = {
}
clone.resolve = {
// TODO: is it necessary for snapshots?
vm: ['id', 'VM', 'administrate'],
vm: ['id', ['VM', 'VM-snapshot'], 'administrate'],
}
// -------------------------------------------------------------------
@@ -707,9 +713,9 @@ copy.resolve = {
export async function convertToTemplate ({ vm }) {
// Convert to a template requires pool admin permission.
if (
!await this.hasPermissions(this.session.get('user_id'), [
!(await this.hasPermissions(this.session.get('user_id'), [
[vm.$pool, 'administrate'],
])
]))
) {
throw unauthorized()
}
@@ -1012,13 +1018,12 @@ export async function stop ({ vm, force }) {
// Hard shutdown
if (force) {
await xapi.call('VM.hard_shutdown', vm._xapiRef)
return
return xapi.shutdownVm(vm._xapiRef, { hard: true })
}
// Clean shutdown
try {
await xapi.call('VM.clean_shutdown', vm._xapiRef)
await xapi.shutdownVm(vm._xapiRef)
} catch (error) {
const { code } = error
if (
@@ -1269,7 +1274,9 @@ export async function createInterface ({
await this.checkResourceSetConstraints(resourceSet, this.user.id, [
network.id,
])
} else if (!await this.hasPermissions(this.user.id, [[network.id, 'view']])) {
} else if (
!(await this.hasPermissions(this.user.id, [[network.id, 'view']]))
) {
throw unauthorized()
}

View File

@@ -507,7 +507,7 @@ const setUpConsoleProxy = (webServer, xo) => {
const { token } = parseCookies(req.headers.cookie)
const user = await xo.authenticateUser({ token })
if (!await xo.hasPermissions(user.id, [[id, 'operate']])) {
if (!(await xo.hasPermissions(user.id, [[id, 'operate']]))) {
throw invalidCredentials()
}
@@ -551,7 +551,7 @@ export default async function main (args) {
debug('blocked for %sms', ms | 0)
},
{
threshold: 50,
threshold: 500,
}
)
}

View File

@@ -13,6 +13,10 @@ export default {
type: 'string',
description: 'identifier of this job',
},
scheduleId: {
type: 'string',
description: 'identifier of the schedule which ran the job',
},
key: {
type: 'string',
},

View File

@@ -146,6 +146,7 @@ const TRANSFORMS = {
license_params: obj.license_params,
license_server: obj.license_server,
license_expiry: toTimestamp(obj.license_params.expiry),
logging: obj.logging,
name_description: obj.name_description,
name_label: obj.name_label,
memory: (function () {
@@ -186,9 +187,14 @@ const TRANSFORMS = {
}
}),
agentStartTime: toTimestamp(otherConfig.agent_start_time),
rebootRequired: !isEmpty(obj.updates_requiring_reboot),
rebootRequired:
softwareVersion.product_brand === 'XCP-ng'
? toTimestamp(otherConfig.boot_time) <
+otherConfig.rpm_patch_installation_time
: !isEmpty(obj.updates_requiring_reboot),
tags: obj.tags,
version: softwareVersion.product_version,
productBrand: softwareVersion.product_brand,
// TODO: dedupe.
PIFs: link(obj, 'PIFs'),
@@ -227,15 +233,20 @@ const TRANSFORMS = {
return
}
if (!guestMetrics) {
if (guestMetrics === undefined) {
return false
}
const { major, minor } = guestMetrics.PV_drivers_version
if (major === undefined || minor === undefined) {
return false
}
return {
major,
minor,
major: +major,
minor: +minor,
version: +`${major}.${minor}`,
}
})()

View File

@@ -70,7 +70,7 @@ import {
// ===================================================================
const TAG_BASE_DELTA = 'xo:base_delta'
const TAG_COPY_SRC = 'xo:copy_of'
export const TAG_COPY_SRC = 'xo:copy_of'
// ===================================================================
@@ -426,6 +426,14 @@ export default class Xapi extends XapiBase {
await this.call('host.restart_agent', this.getObject(hostId).$ref)
}
async setRemoteSyslogHost (hostId, syslogDestination) {
const host = this.getObject(hostId)
await this.call('host.set_logging', host.$ref, {
syslog_destination: syslogDestination,
})
await this.call('host.syslog_reconfigure', host.$ref)
}
async shutdownHost (hostId, force = false) {
const host = this.getObject(hostId)
@@ -656,7 +664,7 @@ export default class Xapi extends XapiBase {
}
// ensure the vm record is up-to-date
vm = await this.barrier('VM', $ref)
vm = await this.barrier($ref)
return Promise.all([
forceDeleteDefaultTemplate &&
@@ -816,12 +824,14 @@ export default class Xapi extends XapiBase {
} = {}
): Promise<DeltaVmExport> {
let vm = this.getObject(vmId)
if (!bypassVdiChainsCheck) {
this._assertHealthyVdiChains(vm)
}
// do not use the snapshot name in the delta export
const exportedNameLabel = vm.name_label
if (!vm.is_a_snapshot) {
if (!bypassVdiChainsCheck) {
this._assertHealthyVdiChains(vm)
}
vm = await this._snapshotVm($cancelToken, vm, snapshotNameLabel)
$defer.onFailure(() => this._deleteVm(vm))
}
@@ -958,7 +968,9 @@ export default class Xapi extends XapiBase {
)
if (!baseVm) {
throw new Error('could not find the base VM')
throw new Error(
`could not find the base VM (copy of ${remoteBaseVmUuid})`
)
}
}
}
@@ -1071,7 +1083,7 @@ export default class Xapi extends XapiBase {
.once('finish', () => {
transferSize += sizeStream.size
})
stream.task = sizeStream.task
sizeStream.task = stream.task
await this._importVdiContent(vdi, sizeStream, VDI_FORMAT_VHD)
}
}),
@@ -1142,7 +1154,9 @@ export default class Xapi extends XapiBase {
vdis[vdi.$ref] =
mapVdisSrs && mapVdisSrs[vdi.$id]
? hostXapi.getObject(mapVdisSrs[vdi.$id]).$ref
: sr !== undefined ? hostXapi.getObject(sr).$ref : defaultSr.$ref // Will error if there are no default SR.
: sr !== undefined
? hostXapi.getObject(sr).$ref
: defaultSr.$ref // Will error if there are no default SR.
}
}

View File

@@ -35,11 +35,24 @@ declare class XapiObject {
}
type Id = string | XapiObject
declare export class Vbd extends XapiObject {
type: string;
VDI: string;
}
declare export class Vdi extends XapiObject {
$snapshot_of: Vdi;
uuid: string;
}
declare export class Vm extends XapiObject {
$snapshots: Vm[];
$VBDs: Vbd[];
is_a_snapshot: boolean;
is_a_template: boolean;
name_label: string;
power_state: 'Running' | 'Halted' | 'Paused' | 'Suspended';
other_config: $Dict<string>;
snapshot_time: number;
uuid: string;
@@ -67,21 +80,24 @@ declare export class Xapi {
_snapshotVm(cancelToken: mixed, vm: Vm, nameLabel?: string): Promise<Vm>;
addTag(object: Id, tag: string): Promise<void>;
barrier(): void;
barrier(ref: string): XapiObject;
barrier(): Promise<void>;
barrier(ref: string): Promise<XapiObject>;
deleteVm(vm: Id): Promise<void>;
editVm(vm: Id, $Dict<mixed>): Promise<void>;
exportDeltaVm(
cancelToken: mixed,
snapshot: Id,
baseSnapshot ?: Id
): Promise<DeltaVmExport>;
exportVm(
cancelToken: mixed,
vm: Vm,
options ?: Object
): Promise<AugmentedReadable>;
getObject(object: Id): XapiObject;
importDeltaVm(data: DeltaVmImport, options: Object): Promise<{ vm: Vm }>;
importVm(stream: AugmentedReadable, options: Object): Promise<Vm>;
exportDeltaVm(
cancelToken: mixed,
snapshot: Id,
baseSnapshot ?: Id,
opts?: { fullVdisRequired?: string[] }
): Promise<DeltaVmExport>;
exportVm(
cancelToken: mixed,
vm: Vm,
options ?: Object
): Promise<AugmentedReadable>;
getObject(object: Id): XapiObject;
importDeltaVm(data: DeltaVmImport, options: Object): Promise<{ vm: Vm }>;
importVm(stream: AugmentedReadable, options: Object): Promise<Vm>;
shutdownVm(object: Id): Promise<void>;
startVm(object: Id): Promise<void>;
}

View File

@@ -1,5 +1,6 @@
import deferrable from 'golike-defer'
import every from 'lodash/every'
import filter from 'lodash/filter'
import find from 'lodash/find'
import includes from 'lodash/includes'
import isObject from 'lodash/isObject'
@@ -11,6 +12,7 @@ import unzip from 'julien-f-unzip'
import { debounce } from '../../decorators'
import {
asyncMap,
ensureArray,
forEach,
mapFilter,
@@ -149,9 +151,12 @@ export default {
},
async listMissingPoolPatchesOnHost (hostId) {
const host = this.getObject(hostId)
// Returns an array to not break compatibility.
return mapToArray(
await this._listMissingPoolPatchesOnHost(this.getObject(hostId))
await (host.software_version.product_brand === 'XCP-ng'
? this._xcpListHostUpdates(host)
: this._listMissingPoolPatchesOnHost(host))
)
},
@@ -440,8 +445,14 @@ export default {
},
async installAllPoolPatchesOnHost (hostId) {
let host = this.getObject(hostId)
const host = this.getObject(hostId)
if (host.software_version.product_brand === 'XCP-ng') {
return this._xcpInstallHostUpdates(host)
}
return this._installAllPoolPatchesOnHost(host)
},
async _installAllPoolPatchesOnHost (host) {
const installableByUuid =
host.license_params.sku_type !== 'free'
? await this._listMissingPoolPatchesOnHost(host)
@@ -479,6 +490,13 @@ export default {
},
async installAllPoolPatchesOnAllHosts () {
if (this.pool.$master.software_version.product_brand === 'XCP-ng') {
return this._xcpInstallAllPoolUpdatesOnHost()
}
return this._installAllPoolPatchesOnAllHosts()
},
async _installAllPoolPatchesOnAllHosts () {
const installableByUuid = assign(
{},
...(await Promise.all(
@@ -518,4 +536,47 @@ export default {
})
}
},
// ----------------------------------
// XCP-ng dedicated zone for patching
// ----------------------------------
// list all yum updates available for a XCP-ng host
async _xcpListHostUpdates (host) {
return JSON.parse(
await this.call(
'host.call_plugin',
host.$ref,
'updater.py',
'check_update',
{}
)
)
},
// install all yum updates for a XCP-ng host
async _xcpInstallHostUpdates (host) {
const update = await this.call(
'host.call_plugin',
host.$ref,
'updater.py',
'update',
{}
)
if (JSON.parse(update).exit !== 0) {
throw new Error('Update install failed')
} else {
await this._updateObjectMapProperty(host, 'other_config', {
rpm_patch_installation_time: String(Date.now() / 1000),
})
}
},
// install all yum updates for all XCP-ng hosts in a give pool
async _xcpInstallAllPoolUpdatesOnHost () {
await asyncMap(filter(this.objects.all, { $type: 'host' }), host =>
this._xcpInstallHostUpdates(host)
)
},
}

View File

@@ -1,6 +1,6 @@
import deferrable from 'golike-defer'
import { catchPlus as pCatch, ignoreErrors } from 'promise-toolbox'
import { find, gte, includes, isEmpty, lte } from 'lodash'
import { find, gte, includes, isEmpty, lte, noop } from 'lodash'
import { forEach, mapToArray, parseSize } from '../../utils'
@@ -204,7 +204,7 @@ export default {
if (cloudConfig != null) {
// Refresh the record.
await this.barrier('VM', vm.$ref)
await this.barrier(vm.$ref)
vm = this.getObjectByRef(vm.$ref)
// Find the SR of the first VDI.
@@ -224,7 +224,7 @@ export default {
}
// wait for the record with all the VBDs and VIFs
return this.barrier('VM', vm.$ref)
return this.barrier(vm.$ref)
},
// High level method to edit a VM.
@@ -429,4 +429,11 @@ export default {
// the force parameter is always true
return this.call('VM.resume', this.getObject(vmId).$ref, false, true)
},
shutdownVm (vmId, { hard = false } = {}) {
return this.call(
`VM.${hard ? 'hard' : 'clean'}_shutdown`,
this.getObject(vmId).$ref
).then(noop)
},
}

View File

@@ -0,0 +1,138 @@
import { forEach } from 'lodash'
const isSkippedError = error =>
error.message === 'no disks found' ||
error.message === 'no such object' ||
error.message === 'no VMs match this pattern' ||
error.message === 'unhealthy VDI chain'
const getStatus = (
error,
status = error === undefined ? 'success' : 'failure'
) => (status === 'failure' && isSkippedError(error) ? 'skipped' : status)
const computeStatusAndSortTasks = (status, tasks) => {
if (status === 'failure' || tasks === undefined) {
return status
}
for (let i = 0, n = tasks.length; i < n; ++i) {
const taskStatus = tasks[i].status
if (taskStatus === 'failure') {
return taskStatus
}
if (taskStatus === 'skipped') {
status = taskStatus
}
}
tasks.sort(taskTimeComparator)
return status
}
const taskTimeComparator = ({ start: s1, end: e1 }, { start: s2, end: e2 }) => {
if (e1 !== undefined) {
if (e2 !== undefined) {
// finished tasks are ordered by their end times
return e1 - e2
}
// finished task before unfinished tasks
return -1
} else if (e2 === undefined) {
// unfinished tasks are ordered by their start times
return s1 - s2
}
// unfinished task after finished tasks
return 1
}
export default {
async getBackupNgLogs (runId?: string) {
const { runningJobs } = this
const consolidated = {}
const started = {}
forEach(await this.getLogs('jobs'), ({ data, time, message }, id) => {
const { event } = data
if (event === 'job.start') {
if (
(data.type === 'backup' || data.key === undefined) &&
(runId === undefined || runId === id)
) {
const { scheduleId, jobId } = data
consolidated[id] = started[id] = {
data: data.data,
id,
jobId,
scheduleId,
start: time,
status: runningJobs[jobId] === id ? 'pending' : 'interrupted',
}
}
} else if (event === 'job.end') {
const { runJobId } = data
const log = started[runJobId]
if (log !== undefined) {
delete started[runJobId]
log.end = time
log.status = computeStatusAndSortTasks(
getStatus((log.result = data.error)),
log.tasks
)
}
} else if (event === 'task.start') {
const parent = started[data.parentId]
if (parent !== undefined) {
;(parent.tasks || (parent.tasks = [])).push(
(started[id] = {
data: data.data,
id,
message,
start: time,
status: parent.status,
})
)
}
} else if (event === 'task.end') {
const { taskId } = data
const log = started[taskId]
if (log !== undefined) {
// TODO: merge/transfer work-around
delete started[taskId]
log.end = time
log.status = computeStatusAndSortTasks(
getStatus((log.result = data.result), data.status),
log.tasks
)
}
} else if (event === 'jobCall.start') {
const parent = started[data.runJobId]
if (parent !== undefined) {
;(parent.tasks || (parent.tasks = [])).push(
(started[id] = {
data: {
type: 'VM',
id: data.params.id,
},
id,
start: time,
status: parent.status,
})
)
}
} else if (event === 'jobCall.end') {
const { runCallId } = data
const log = started[runCallId]
if (log !== undefined) {
delete started[runCallId]
log.end = time
log.status = computeStatusAndSortTasks(
getStatus((log.result = data.error)),
log.tasks
)
}
}
})
return runId === undefined ? consolidated : consolidated[runId]
},
}

View File

@@ -3,20 +3,27 @@
// $FlowFixMe
import type RemoteHandler from '@xen-orchestra/fs'
import defer from 'golike-defer'
import limitConcurrency from 'limit-concurrency-decorator'
import { type Pattern, createPredicate } from 'value-matcher'
import { type Readable, PassThrough } from 'stream'
import { AssertionError } from 'assert'
import { basename, dirname } from 'path'
import {
countBy,
forEach,
groupBy,
isEmpty,
last,
mapValues,
noop,
some,
sum,
values,
} from 'lodash'
import { fromEvent as pFromEvent, timeout as pTimeout } from 'promise-toolbox'
import {
fromEvent as pFromEvent,
ignoreErrors,
timeout as pTimeout,
} from 'promise-toolbox'
import Vhd, {
chainVhd,
createSyntheticStream as createVhdReadStream,
@@ -29,9 +36,12 @@ import createSizeStream from '../../size-stream'
import {
type DeltaVmExport,
type DeltaVmImport,
type Vdi,
type Vm,
type Xapi,
TAG_COPY_SRC,
} from '../../xapi'
import { getVmDisks } from '../../xapi/utils'
import {
asyncMap,
resolveRelativeFromFile,
@@ -41,12 +51,15 @@ import {
import { translateLegacyJob } from './migration'
type Mode = 'full' | 'delta'
type ReportWhen = 'always' | 'failure' | 'never'
export type Mode = 'full' | 'delta'
export type ReportWhen = 'always' | 'failure' | 'never'
type Settings = {|
concurrency?: number,
deleteFirst?: boolean,
copyRetention?: number,
exportRetention?: number,
offlineSnapshot?: boolean,
reportWhen?: ReportWhen,
snapshotRetention?: number,
vmTimeout?: number,
@@ -91,33 +104,6 @@ type MetadataFull = {|
|}
type Metadata = MetadataDelta | MetadataFull
type ConsolidatedJob = {|
duration?: number,
end?: number,
error?: Object,
id: string,
jobId: string,
mode: Mode,
start: number,
type: 'backup' | 'call',
userId: string,
|}
type ConsolidatedTask = {|
data?: Object,
duration?: number,
end?: number,
parentId: string,
message: string,
result?: Object,
start: number,
status: 'canceled' | 'failure' | 'success',
taskId: string,
|}
type ConsolidatedBackupNgLog = {
roots: Array<ConsolidatedJob>,
[parentId: string]: Array<ConsolidatedTask>,
}
const compareSnapshotTime = (a: Vm, b: Vm): number =>
a.snapshot_time < b.snapshot_time ? -1 : 1
@@ -131,20 +117,25 @@ const compareTimestamp = (a: Metadata, b: Metadata): number =>
const getOldEntries = <T>(retention: number, entries?: T[]): T[] =>
entries === undefined
? []
: --retention > 0 ? entries.slice(0, -retention) : entries
: --retention > 0
? entries.slice(0, -retention)
: entries
const defaultSettings: Settings = {
concurrency: 0,
deleteFirst: false,
exportRetention: 0,
offlineSnapshot: false,
reportWhen: 'failure',
snapshotRetention: 0,
vmTimeout: 0,
}
const getSetting = (
const getSetting = <T>(
settings: $Dict<Settings>,
name: $Keys<Settings>,
...keys: string[]
): any => {
keys: string[],
defaultValue?: T
): T | any => {
for (let i = 0, n = keys.length; i < n; ++i) {
const objectSettings = settings[keys[i]]
if (objectSettings !== undefined) {
@@ -154,12 +145,16 @@ const getSetting = (
}
}
}
if (defaultValue !== undefined) {
return defaultValue
}
return defaultSettings[name]
}
const BACKUP_DIR = 'xo-vm-backups'
const getVmBackupDir = (uuid: string) => `${BACKUP_DIR}/${uuid}`
const isHiddenFile = (filename: string) => filename[0] === '.'
const isMetadataFile = (filename: string) => filename.endsWith('.json')
const isVhd = (filename: string) => filename.endsWith('.vhd')
@@ -329,10 +324,7 @@ const wrapTask = async <T>(opts: any, task: Promise<T>): Promise<T> => {
value => {
logger.notice(message, {
event: 'task.end',
result:
result === undefined
? value
: typeof result === 'function' ? result(value) : result,
result: typeof result === 'function' ? result(value) : result,
status: 'success',
taskId,
})
@@ -368,10 +360,7 @@ const wrapTaskFn = <T>(
const value = await task.apply(this, [taskId, ...arguments])
logger.notice(message, {
event: 'task.end',
result:
result === undefined
? value
: typeof result === 'function' ? result(value) : result,
result: typeof result === 'function' ? result(value) : result,
status: 'success',
taskId,
})
@@ -433,6 +422,7 @@ export default class BackupNg {
app.on('start', () => {
const executor: Executor = async ({
cancelToken,
data: vmId,
job: job_,
logger,
runJobId,
@@ -443,18 +433,36 @@ export default class BackupNg {
}
const job: BackupJob = (job_: any)
const vms: $Dict<Vm> = app.getObjects({
filter: createPredicate({
type: 'VM',
...job.vms,
}),
})
if (isEmpty(vms)) {
throw new Error('no VMs match this pattern')
let vms: $Dict<Vm> | void
if (vmId === undefined) {
vms = app.getObjects({
filter: createPredicate({
type: 'VM',
...job.vms,
}),
})
if (isEmpty(vms)) {
throw new Error('no VMs match this pattern')
}
}
const jobId = job.id
const scheduleId = schedule.id
await asyncMap(vms, async vm => {
const srs = unboxIds(job.srs).map(id => {
const xapi = app.getXapi(id)
return {
__proto__: xapi.getObject(id),
xapi,
}
})
const remotes = await Promise.all(
unboxIds(job.remotes).map(async id => ({
id,
handler: await app.getRemoteHandler(id),
}))
)
let handleVm = async vm => {
const { name_label: name, uuid } = vm
const taskId: string = logger.notice(
`Starting backup of ${name}. (${jobId})`,
@@ -476,16 +484,14 @@ export default class BackupNg {
job,
schedule,
logger,
taskId
taskId,
srs,
remotes
)
const vmTimeout: number = getSetting(
job.settings,
'vmTimeout',
const vmTimeout: number = getSetting(job.settings, 'vmTimeout', [
uuid,
scheduleId,
logger,
taskId
)
])
if (vmTimeout !== 0) {
p = pTimeout.call(p, vmTimeout)
}
@@ -506,7 +512,19 @@ export default class BackupNg {
: serializeError(error),
})
}
})
}
if (vms === undefined) {
return handleVm(await app.getObject(vmId))
}
const concurrency: number = getSetting(job.settings, 'concurrency', [
'',
])
if (concurrency !== 0) {
handleVm = limitConcurrency(concurrency)(handleVm)
}
await asyncMap(vms, handleVm)
}
app.registerJobExecutor('backup', executor)
})
@@ -703,7 +721,9 @@ export default class BackupNg {
job: BackupJob,
schedule: Schedule,
logger: any,
taskId: string
taskId: string,
srs: any[],
remotes: any[]
): Promise<void> {
const app = this._app
const xapi = app.getXapi(vmUuid)
@@ -712,31 +732,66 @@ export default class BackupNg {
// ensure the VM itself does not have any backup metadata which would be
// copied on manual snapshots and interfere with the backup jobs
if ('xo:backup:job' in vm.other_config) {
await xapi._updateObjectMapProperty(vm, 'other_config', {
'xo:backup:job': null,
'xo:backup:schedule': null,
'xo:backup:vm': null,
})
await wrapTask(
{
logger,
message: 'clean backup metadata on VM',
parentId: taskId,
},
xapi._updateObjectMapProperty(vm, 'other_config', {
'xo:backup:job': null,
'xo:backup:schedule': null,
'xo:backup:vm': null,
})
)
}
const { id: jobId, settings } = job
const { id: scheduleId } = schedule
const exportRetention: number = getSetting(
settings,
'exportRetention',
scheduleId
)
let exportRetention: number = getSetting(settings, 'exportRetention', [
scheduleId,
])
let copyRetention: number | void = getSetting(settings, 'copyRetention', [
scheduleId,
])
if (copyRetention === undefined) {
// if copyRetention is not defined, it uses exportRetention's value due to
// previous implementation which did not support copyRetention
copyRetention = srs.length === 0 ? 0 : exportRetention
if (remotes.length === 0) {
exportRetention = 0
}
} else if (exportRetention !== 0 && remotes.length === 0) {
throw new Error('export retention must be 0 without remotes')
}
if (copyRetention !== 0 && srs.length === 0) {
throw new Error('copy retention must be 0 without SRs')
}
if (
remotes.length !== 0 &&
srs.length !== 0 &&
(copyRetention === 0) !== (exportRetention === 0)
) {
throw new Error('both or neither copy and export retentions must be 0')
}
const snapshotRetention: number = getSetting(
settings,
'snapshotRetention',
scheduleId
[scheduleId]
)
if (exportRetention === 0) {
if (snapshotRetention === 0) {
throw new Error('export and snapshots retentions cannot both be 0')
}
if (
copyRetention === 0 &&
exportRetention === 0 &&
snapshotRetention === 0
) {
throw new Error('copy, export and snapshot retentions cannot both be 0')
}
if (
@@ -752,13 +807,29 @@ export default class BackupNg {
.filter(_ => _.other_config['xo:backup:job'] === jobId)
.sort(compareSnapshotTime)
await xapi._assertHealthyVdiChains(vm)
xapi._assertHealthyVdiChains(vm)
const offlineSnapshot: boolean = getSetting(settings, 'offlineSnapshot', [
vmUuid,
'',
])
const startAfterSnapshot = offlineSnapshot && vm.power_state === 'Running'
if (startAfterSnapshot) {
await wrapTask(
{
logger,
message: 'shutdown VM',
parentId: taskId,
},
xapi.shutdownVm(vm)
)
}
let snapshot: Vm = (await wrapTask(
{
parentId: taskId,
logger,
message: 'snapshot',
parentId: taskId,
result: _ => _.uuid,
},
xapi._snapshotVm(
@@ -767,11 +838,23 @@ export default class BackupNg {
`[XO Backup ${job.name}] ${vm.name_label}`
)
): any)
await xapi._updateObjectMapProperty(snapshot, 'other_config', {
'xo:backup:job': jobId,
'xo:backup:schedule': scheduleId,
'xo:backup:vm': vmUuid,
})
if (startAfterSnapshot) {
ignoreErrors.call(xapi.startVm(vm))
}
await wrapTask(
{
logger,
message: 'add metadata to snapshot',
parentId: taskId,
},
xapi._updateObjectMapProperty(snapshot, 'other_config', {
'xo:backup:job': jobId,
'xo:backup:schedule': scheduleId,
'xo:backup:vm': vmUuid,
})
)
$defer(() =>
asyncMap(
@@ -785,18 +868,20 @@ export default class BackupNg {
)
)
snapshot = ((await xapi.barrier(snapshot.$ref): any): Vm)
snapshot = ((await wrapTask(
{
logger,
message: 'waiting for uptodate snapshot record',
parentId: taskId,
},
xapi.barrier(snapshot.$ref)
): any): Vm)
if (exportRetention === 0) {
if (copyRetention === 0 && exportRetention === 0) {
return
}
const remotes = unboxIds(job.remotes)
const srs = unboxIds(job.srs)
const nTargets = remotes.length + srs.length
if (nTargets === 0) {
throw new Error('export retention must be 0 without remotes and SRs')
}
const now = Date.now()
const vmDir = getVmBackupDir(vmUuid)
@@ -812,9 +897,16 @@ export default class BackupNg {
$defer.call(xapi, 'deleteVm', snapshot)
}
let xva: any = await xapi.exportVm($cancelToken, snapshot, {
compress: job.compression === 'native',
})
let xva: any = await wrapTask(
{
logger,
message: 'start snapshot export',
parentId: taskId,
},
xapi.exportVm($cancelToken, snapshot, {
compress: job.compression === 'native',
})
)
const exportTask = xva.task
xva = xva.pipe(createSizeStream())
@@ -847,17 +939,15 @@ export default class BackupNg {
[
...remotes.map(
wrapTaskFn(
id => ({
({ id }) => ({
data: { id, type: 'remote' },
logger,
message: 'export',
parentId: taskId,
}),
async (taskId, remoteId) => {
async (taskId, { handler, id: remoteId }) => {
const fork = forkExport()
const handler = await app.getRemoteHandler(remoteId)
const oldBackups: MetadataFull[] = (getOldEntries(
exportRetention,
await this._listVmBackups(
@@ -867,11 +957,9 @@ export default class BackupNg {
)
): any)
const deleteFirst = getSetting(
settings,
'deleteFirst',
remoteId
)
const deleteFirst = getSetting(settings, 'deleteFirst', [
remoteId,
])
if (deleteFirst) {
await this._deleteFullVmBackups(handler, oldBackups)
}
@@ -881,9 +969,7 @@ export default class BackupNg {
logger,
message: 'transfer',
parentId: taskId,
result: {
size: 0,
},
result: () => ({ size: xva.size }),
},
writeStream(fork, handler, dataFilename)
)
@@ -898,24 +984,23 @@ export default class BackupNg {
),
...srs.map(
wrapTaskFn(
id => ({
({ $id: id }) => ({
data: { id, type: 'SR' },
logger,
message: 'export',
parentId: taskId,
}),
async (taskId, srId) => {
async (taskId, sr) => {
const fork = forkExport()
const xapi = app.getXapi(srId)
const sr = xapi.getObject(srId)
const { $id: srId, xapi } = sr
const oldVms = getOldEntries(
exportRetention,
copyRetention,
listReplicatedVms(xapi, scheduleId, srId, vmUuid)
)
const deleteFirst = getSetting(settings, 'deleteFirst', srId)
const deleteFirst = getSetting(settings, 'deleteFirst', [srId])
if (deleteFirst) {
await this._deleteVms(xapi, oldVms)
}
@@ -926,9 +1011,7 @@ export default class BackupNg {
logger,
message: 'transfer',
parentId: taskId,
result: {
size: 0,
},
result: () => ({ size: xva.size }),
},
xapi._importVm($cancelToken, fork, sr, vm =>
xapi._setObjectProperties(vm, {
@@ -966,17 +1049,88 @@ export default class BackupNg {
$defer.onFailure.call(xapi, 'deleteVm', snapshot)
}
const baseSnapshot = last(snapshots)
if (baseSnapshot !== undefined) {
console.log(baseSnapshot.$id) // TODO: remove
// check current state
// await Promise.all([asyncMap(remotes, remoteId => {})])
}
let baseSnapshot, fullVdisRequired
await (async () => {
baseSnapshot = (last(snapshots): Vm | void)
if (baseSnapshot === undefined) {
return
}
const deltaExport = await xapi.exportDeltaVm(
$cancelToken,
snapshot,
baseSnapshot
const fullRequired = { __proto__: null }
const vdis: $Dict<Vdi> = getVmDisks(baseSnapshot)
for (const { $id: srId, xapi } of srs) {
const replicatedVm = listReplicatedVms(
xapi,
scheduleId,
srId,
vmUuid
).find(vm => vm.other_config[TAG_COPY_SRC] === baseSnapshot.uuid)
if (replicatedVm === undefined) {
baseSnapshot = undefined
return
}
const replicatedVdis = countBy(
getVmDisks(replicatedVm),
vdi => vdi.other_config[TAG_COPY_SRC]
)
forEach(vdis, vdi => {
if (!(vdi.uuid in replicatedVdis)) {
fullRequired[vdi.$snapshot_of.$id] = true
}
})
}
await asyncMap(remotes, ({ handler }) => {
return asyncMap(vdis, async vdi => {
const snapshotOf = vdi.$snapshot_of
const dir = `${vmDir}/vdis/${jobId}/${snapshotOf.uuid}`
const files = await handler
.list(dir, { filter: isVhd })
.catch(_ => [])
let full = true
await asyncMap(files, async file => {
if (file[0] !== '.') {
try {
const vhd = new Vhd(handler, `${dir}/${file}`)
await vhd.readHeaderAndFooter()
if (
Buffer.from(vhd.footer.uuid).toString('hex') ===
vdi.uuid.split('-').join('')
) {
full = false
}
return
} catch (error) {
if (!(error instanceof AssertionError)) {
throw error
}
}
}
// either a temporary file or an invalid VHD
await handler.unlink(`${dir}/${file}`)
})
if (full) {
fullRequired[snapshotOf.$id] = true
}
})
})
fullVdisRequired = Object.keys(fullRequired)
})()
const deltaExport = await wrapTask(
{
logger,
message: 'start snapshot export',
parentId: taskId,
},
xapi.exportDeltaVm($cancelToken, snapshot, baseSnapshot, {
fullVdisRequired,
})
)
const metadata: MetadataDelta = {
@@ -1031,21 +1185,23 @@ export default class BackupNg {
}
})()
const isFull = some(
deltaExport.vdis,
vdi => vdi.other_config['xo:base_delta'] === undefined
)
await waitAll(
[
...remotes.map(
wrapTaskFn(
id => ({
data: { id, type: 'remote' },
({ id }) => ({
data: { id, isFull, type: 'remote' },
logger,
message: 'export',
parentId: taskId,
}),
async (taskId, remoteId) => {
async (taskId, { handler, id: remoteId }) => {
const fork = forkExport()
const handler = await app.getRemoteHandler(remoteId)
const oldBackups: MetadataDelta[] = (getOldEntries(
exportRetention,
await this._listVmBackups(
@@ -1060,16 +1216,14 @@ export default class BackupNg {
logger,
message: 'merge',
parentId: taskId,
result: {
size: 0,
},
result: size => ({ size }),
},
this._deleteDeltaVmBackups(handler, oldBackups)
)
const deleteFirst =
exportRetention > 1 &&
getSetting(settings, 'deleteFirst', remoteId)
getSetting(settings, 'deleteFirst', [remoteId])
if (deleteFirst) {
await deleteOldBackups()
}
@@ -1079,9 +1233,7 @@ export default class BackupNg {
logger,
message: 'transfer',
parentId: taskId,
result: {
size: 0,
},
result: size => ({ size }),
},
asyncMap(
fork.vdis,
@@ -1093,13 +1245,19 @@ export default class BackupNg {
let parentPath
if (isDelta) {
const vdiDir = dirname(path)
const parent = (await handler.list(vdiDir))
.filter(isVhd)
parentPath = (await handler.list(vdiDir, {
filter: filename =>
!isHiddenFile(filename) && isVhd(filename),
prependDir: true,
}))
.sort()
.pop()
parentPath = `${vdiDir}/${parent}`
// ensure parent exists and is a valid VHD
await new Vhd(handler, parentPath).readHeaderAndFooter()
}
// FIXME: should only be renamed after the metadata file has been written
await writeStream(
fork.streams[`${id}.vhd`](),
handler,
@@ -1115,8 +1273,20 @@ export default class BackupNg {
if (isDelta) {
await chainVhd(handler, parentPath, handler, path)
}
// set the correct UUID in the VHD
const vhd = new Vhd(handler, path)
await vhd.readHeaderAndFooter()
vhd.footer.uuid = Buffer.from(
vdi.uuid.split('-').join(''),
'hex'
)
await vhd.readBlockAllocationTable() // required by writeFooter()
await vhd.writeFooter()
return handler.getSize(path)
})
)
).then(sum)
)
await handler.outputFile(metadataFilename, jsonMetadata)
@@ -1128,24 +1298,23 @@ export default class BackupNg {
),
...srs.map(
wrapTaskFn(
id => ({
data: { id, type: 'SR' },
({ $id: id }) => ({
data: { id, isFull, type: 'SR' },
logger,
message: 'export',
parentId: taskId,
}),
async (taskId, srId) => {
async (taskId, sr) => {
const fork = forkExport()
const xapi = app.getXapi(srId)
const sr = xapi.getObject(srId)
const { $id: srId, xapi } = sr
const oldVms = getOldEntries(
exportRetention,
copyRetention,
listReplicatedVms(xapi, scheduleId, srId, vmUuid)
)
const deleteFirst = getSetting(settings, 'deleteFirst', srId)
const deleteFirst = getSetting(settings, 'deleteFirst', [srId])
if (deleteFirst) {
await this._deleteVms(xapi, oldVms)
}
@@ -1155,16 +1324,14 @@ export default class BackupNg {
logger,
message: 'transfer',
parentId: taskId,
result: {
size: 0,
},
result: ({ transferSize }) => ({ size: transferSize }),
},
xapi.importDeltaVm(fork, {
disableStartAfterImport: false, // we'll take care of that
name_label: `${metadata.vm.name_label} (${safeDateFormat(
metadata.timestamp
)})`,
srId: sr.$id,
srId,
})
)
@@ -1196,18 +1363,17 @@ export default class BackupNg {
async _deleteDeltaVmBackups (
handler: RemoteHandler,
backups: MetadataDelta[]
): Promise<void> {
await asyncMap(backups, async backup => {
): Promise<number> {
return asyncMap(backups, async backup => {
const filename = ((backup._filename: any): string)
return Promise.all([
handler.unlink(filename),
asyncMap(backup.vhds, _ =>
// $FlowFixMe injected $defer param
this._deleteVhd(handler, resolveRelativeFromFile(filename, _))
),
])
})
await handler.unlink(filename)
return asyncMap(backup.vhds, _ =>
// $FlowFixMe injected $defer param
this._deleteVhd(handler, resolveRelativeFromFile(filename, _))
).then(sum)
}).then(sum)
}
async _deleteFullVmBackups (
@@ -1225,7 +1391,11 @@ export default class BackupNg {
// FIXME: synchronize by job/VDI, otherwise it can cause issues with the merge
@defer
async _deleteVhd ($defer: any, handler: RemoteHandler, path: string) {
async _deleteVhd (
$defer: any,
handler: RemoteHandler,
path: string
): Promise<number> {
const vhds = await asyncMap(
await handler.list(dirname(path), { filter: isVhd, prependDir: true }),
async path => {
@@ -1250,19 +1420,21 @@ export default class BackupNg {
_ => _ !== undefined && _.header.parentUnicodeName === base
)
if (child === undefined) {
return handler.unlink(path)
await handler.unlink(path)
return 0
}
$defer.onFailure.call(handler, 'unlink', path)
const childPath = child.path
await this._app.worker.mergeVhd(
const mergedDataSize: number = await this._app.worker.mergeVhd(
handler._remote,
path,
handler._remote,
childPath
)
await handler.rename(path, childPath)
return mergedDataSize
}
async _deleteVms (xapi: Xapi, vms: Vm[]): Promise<void> {
@@ -1307,62 +1479,4 @@ export default class BackupNg {
return backups.sort(compareTimestamp)
}
async getBackupNgLogs (runId?: string): Promise<ConsolidatedBackupNgLog> {
const rawLogs = await this._app.getLogs('jobs')
const logs: $Dict<ConsolidatedJob & ConsolidatedTask> = {}
forEach(rawLogs, (log, id) => {
const { data, time, message } = log
const { event } = data
delete data.event
switch (event) {
case 'job.start':
if (data.type === 'backup' && (runId === undefined || runId === id)) {
logs[id] = {
...data,
id,
start: time,
}
}
break
case 'job.end':
const job = logs[data.runJobId]
if (job !== undefined) {
job.end = time
job.duration = time - job.start
job.error = data.error
}
break
case 'task.start':
if (logs[data.parentId] !== undefined) {
logs[id] = {
...data,
start: time,
message,
}
}
break
case 'task.end':
const task = logs[data.taskId]
if (task !== undefined) {
// work-around
if (
time === task.start &&
(message === 'merge' || message === 'tranfer')
) {
delete logs[data.taskId]
} else {
task.status = data.status
task.taskId = data.taskId
task.result = data.result
task.end = time
task.duration = time - task.start
}
}
}
})
return groupBy(logs, log => log.parentId || 'roots')
}
}

View File

@@ -141,7 +141,9 @@ const listPartitions = (() => {
valueTransform: (value, key) =>
key === 'start' || key === 'size'
? +value
: key === 'type' ? TYPES[+value] || value : value,
: key === 'type'
? TYPES[+value] || value
: value,
})
return device =>
@@ -903,6 +905,8 @@ export default class {
const xapi = this._xo.getXapi(vm)
vm = xapi.getObject(vm._xapiId)
xapi._assertHealthyVdiChains(vm)
const reg = new RegExp(
'^rollingSnapshot_[^_]+_' + escapeStringRegexp(tag) + '_'
)

View File

@@ -2,7 +2,7 @@
import type { Pattern } from 'value-matcher'
import { cancelable } from 'promise-toolbox'
import { CancelToken } from 'promise-toolbox'
import { map as mapToArray } from 'lodash'
import { noSuchObject } from 'xo-common/api-errors'
@@ -60,6 +60,7 @@ export type CallJob = {|
export type Executor = ({|
app: Object,
cancelToken: any,
data: any,
job: Job,
logger: Logger,
runJobId: string,
@@ -120,7 +121,12 @@ export default class Jobs {
_executors: { __proto__: null, [string]: Executor }
_jobs: JobsDb
_logger: Logger
_runningJobs: { __proto__: null, [string]: boolean }
_runningJobs: { __proto__: null, [string]: string }
_runs: { __proto__: null, [string]: () => void }
get runningJobs () {
return this._runningJobs
}
constructor (xo: any) {
this._app = xo
@@ -132,6 +138,7 @@ export default class Jobs {
}))
this._logger = undefined
this._runningJobs = { __proto__: null }
this._runs = { __proto__: null }
executors.call = executeCall
@@ -150,6 +157,13 @@ export default class Jobs {
})
}
cancelJobRun (id: string) {
const run = this._runs[id]
if (run !== undefined) {
return run.cancel()
}
}
async getAllJobs (type?: string): Promise<Array<Job>> {
// $FlowFixMe don't know what is the problem (JFT)
const jobs = await this._jobs.get()
@@ -201,7 +215,7 @@ export default class Jobs {
return /* await */ this._jobs.remove(id)
}
async _runJob (cancelToken: any, job: Job, schedule?: Schedule) {
async _runJob (job: Job, schedule?: Schedule, data_?: any) {
const { id } = job
const runningJobs = this._runningJobs
@@ -232,6 +246,7 @@ export default class Jobs {
event: 'job.start',
userId: job.userId,
jobId: id,
scheduleId: schedule?.id,
// $FlowFixMe only defined for CallJob
key: job.key,
type,
@@ -239,15 +254,21 @@ export default class Jobs {
runningJobs[id] = runJobId
const runs = this._runs
const { cancel, token } = CancelToken.source()
runs[runJobId] = { cancel }
let session
try {
const app = this._app
session = app.createUserConnection()
session.set('user_id', job.userId)
await executor({
const status = await executor({
app,
cancelToken,
cancelToken: token,
data: data_,
job,
logger,
runJobId,
@@ -259,7 +280,7 @@ export default class Jobs {
runJobId,
})
app.emit('job:terminated', runJobId, job, schedule)
app.emit('job:terminated', status, job, schedule, runJobId)
} catch (error) {
logger.error(`The execution of ${id} has failed.`, {
event: 'job.end',
@@ -269,27 +290,24 @@ export default class Jobs {
throw error
} finally {
delete runningJobs[id]
delete runs[runJobId]
if (session !== undefined) {
session.close()
}
}
}
@cancelable
async runJobSequence (
$cancelToken: any,
idSequence: Array<string>,
schedule?: Schedule
schedule?: Schedule,
data?: any
) {
const jobs = await Promise.all(
mapToArray(idSequence, id => this.getJob(id))
)
for (const job of jobs) {
if ($cancelToken.requested) {
break
}
await this._runJob($cancelToken, job, schedule)
await this._runJob(job, schedule, data)
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "xo-vmdk-to-vhd",
"version": "0.1.1",
"version": "0.1.2",
"license": "AGPL-3.0",
"description": "JS lib streaming a vmdk file to a vhd",
"keywords": [
@@ -23,23 +23,23 @@
"node": ">=4"
},
"dependencies": {
"@babel/runtime": "^7.0.0-beta.44",
"@babel/runtime": "^7.0.0-beta.49",
"child-process-promise": "^2.0.3",
"pipette": "^0.9.3",
"promise-toolbox": "^0.9.5",
"tmp": "^0.0.33",
"vhd-lib": "^0.0.0"
"vhd-lib": "^0.1.2"
},
"devDependencies": {
"@babel/cli": "7.0.0-beta.44",
"@babel/core": "7.0.0-beta.44",
"@babel/plugin-transform-runtime": "^7.0.0-beta.44",
"@babel/preset-env": "7.0.0-beta.44",
"@babel/cli": "7.0.0-beta.49",
"@babel/core": "7.0.0-beta.49",
"@babel/plugin-transform-runtime": "^7.0.0-beta.49",
"@babel/preset-env": "7.0.0-beta.49",
"babel-plugin-lodash": "^3.3.2",
"cross-env": "^5.1.3",
"event-to-promise": "^0.8.0",
"execa": "^0.10.0",
"fs-extra": "^5.0.0",
"fs-extra": "^6.0.1",
"get-stream": "^3.0.0",
"index-modules": "^0.3.0",
"rimraf": "^2.6.2"

View File

@@ -1,6 +1,7 @@
import { createReadableSparseStream } from 'vhd-lib'
import { VMDKDirectParser, readVmdkGrainTable } from './vmdk-read'
import VMDKDirectParser from './vmdk-read'
import readVmdkGrainTable from './vmdk-read-table'
async function convertFromVMDK (vmdkReadStream, table) {
const parser = new VMDKDirectParser(vmdkReadStream)

View File

@@ -0,0 +1,97 @@
const SECTOR_SIZE = 512
const HEADER_SIZE = 512
const FOOTER_POSITION = -1024
const DISK_CAPACITY_OFFSET = 12
const GRAIN_SIZE_OFFSET = 20
const NUM_GTE_PER_GT_OFFSET = 44
const GRAIN_ADDRESS_OFFSET = 56
/**
*
* the grain table is the array of LBAs (in byte, not in sector) ordered by their position in the VDMK file
* THIS CODE RUNS ON THE BROWSER
*/
export default async function readVmdkGrainTable (fileAccessor) {
const getLongLong = (buffer, offset, name) => {
if (buffer.length < offset + 8) {
throw new Error(
`buffer ${name} is too short, expecting ${offset + 8} minimum, got ${
buffer.length
}`
)
}
const dataView = new DataView(buffer)
const res = dataView.getUint32(offset, true)
const highBits = dataView.getUint32(offset + 4, true)
const MANTISSA_BITS_IN_DOUBLE = 53
if (highBits >= Math.pow(2, MANTISSA_BITS_IN_DOUBLE - 32)) {
throw new Error(
'Unsupported file, high order bits are to high in field ' + name
)
}
return res + highBits * Math.pow(2, 32)
}
let headerBuffer = await fileAccessor(0, HEADER_SIZE)
let grainAddrBuffer = headerBuffer.slice(
GRAIN_ADDRESS_OFFSET,
GRAIN_ADDRESS_OFFSET + 8
)
if (
new Int8Array(grainAddrBuffer).reduce((acc, val) => acc && val === -1, true)
) {
headerBuffer = await fileAccessor(
FOOTER_POSITION,
FOOTER_POSITION + HEADER_SIZE
)
grainAddrBuffer = headerBuffer.slice(
GRAIN_ADDRESS_OFFSET,
GRAIN_ADDRESS_OFFSET + 8
)
}
const grainDirPosBytes =
getLongLong(grainAddrBuffer, 0, 'grain directory address') * SECTOR_SIZE
const capacity =
getLongLong(headerBuffer, DISK_CAPACITY_OFFSET, 'capacity') * SECTOR_SIZE
const grainSize =
getLongLong(headerBuffer, GRAIN_SIZE_OFFSET, 'grain size') * SECTOR_SIZE
const grainCount = Math.ceil(capacity / grainSize)
const numGTEsPerGT = getLongLong(
headerBuffer,
NUM_GTE_PER_GT_OFFSET,
'num GTE per GT'
)
const grainTablePhysicalSize = numGTEsPerGT * 4
const grainDirectoryEntries = Math.ceil(grainCount / numGTEsPerGT)
const grainDirectoryPhysicalSize = grainDirectoryEntries * 4
const grainDirBuffer = await fileAccessor(
grainDirPosBytes,
grainDirPosBytes + grainDirectoryPhysicalSize
)
const grainDir = new Uint32Array(grainDirBuffer)
const cachedGrainTables = []
for (let i = 0; i < grainDirectoryEntries; i++) {
const grainTableAddr = grainDir[i] * SECTOR_SIZE
if (grainTableAddr !== 0) {
cachedGrainTables[i] = new Uint32Array(
await fileAccessor(
grainTableAddr,
grainTableAddr + grainTablePhysicalSize
)
)
}
}
const extractedGrainTable = []
for (let i = 0; i < grainCount; i++) {
const directoryEntry = Math.floor(i / numGTEsPerGT)
const grainTable = cachedGrainTables[directoryEntry]
if (grainTable !== undefined) {
const grainAddr = grainTable[i % numGTEsPerGT]
if (grainAddr !== 0) {
extractedGrainTable.push([i, grainAddr])
}
}
}
extractedGrainTable.sort(
([i1, grainAddress1], [i2, grainAddress2]) => grainAddress1 - grainAddress2
)
return extractedGrainTable.map(([index, grainAddress]) => index * grainSize)
}

View File

@@ -6,7 +6,7 @@ import { fromCallback as pFromCallback } from 'promise-toolbox'
import rimraf from 'rimraf'
import tmp from 'tmp'
import { VMDKDirectParser } from './vmdk-read'
import VMDKDirectParser from './vmdk-read'
jest.setTimeout(10000)

View File

@@ -4,7 +4,9 @@ import zlib from 'zlib'
import { VirtualBuffer } from './virtual-buffer'
const sectorSize = 512
const SECTOR_SIZE = 512
const HEADER_SIZE = 512
const VERSION_OFFSET = 4
const compressionDeflate = 'COMPRESSION_DEFLATE'
const compressionNone = 'COMPRESSION_NONE'
const compressionMap = [compressionNone, compressionDeflate]
@@ -119,7 +121,7 @@ function parseHeader (buffer) {
}
}
async function readGrain (offsetSectors, buffer, compressed) {
const offset = offsetSectors * sectorSize
const offset = offsetSectors * SECTOR_SIZE
const size = buffer.readUInt32LE(offset + 8)
const grainBuffer = buffer.slice(offset + 12, offset + 12 + size)
const grainContent = compressed
@@ -130,7 +132,7 @@ async function readGrain (offsetSectors, buffer, compressed) {
offsetSectors: offsetSectors,
offset,
lba,
lbaBytes: lba * sectorSize,
lbaBytes: lba * SECTOR_SIZE,
size,
buffer: grainBuffer,
grain: grainContent,
@@ -146,10 +148,10 @@ function tryToParseMarker (buffer) {
}
function alignSectors (number) {
return Math.ceil(number / sectorSize) * sectorSize
return Math.ceil(number / SECTOR_SIZE) * SECTOR_SIZE
}
export class VMDKDirectParser {
export default class VMDKDirectParser {
constructor (readStream) {
this.virtualBuffer = new VirtualBuffer(readStream)
this.header = null
@@ -177,9 +179,9 @@ export class VMDKDirectParser {
l2IsContiguous = l2IsContiguous && l1Entry - previousL1Entry === 4
} else {
l2IsContiguous =
l1Entry * sectorSize === this.virtualBuffer.position ||
l1Entry * sectorSize === this.virtualBuffer.position + 512
l2Start = l1Entry * sectorSize
l1Entry * SECTOR_SIZE === this.virtualBuffer.position ||
l1Entry * SECTOR_SIZE === this.virtualBuffer.position + SECTOR_SIZE
l2Start = l1Entry * SECTOR_SIZE
}
}
if (!l2IsContiguous) {
@@ -200,37 +202,29 @@ export class VMDKDirectParser {
l2ByteSize,
'L2 table ' + position
)
let grainsAreInAscendingOrder = true
let previousL2Entry = 0
let firstGrain = null
for (let i = 0; i < l2entries; i++) {
const l2Entry = l2Buffer.readUInt32LE(i * 4)
if (i > 0 && previousL2Entry !== 0 && l2Entry !== 0) {
grainsAreInAscendingOrder =
grainsAreInAscendingOrder && previousL2Entry < l2Entry
}
previousL2Entry = l2Entry
if (firstGrain === null) {
firstGrain = l2Entry
}
}
if (!grainsAreInAscendingOrder) {
// TODO: here we could transform the file to a sparse VHD on the fly because we have the complete table
throw new Error('Unsupported file format')
}
const freeSpace = firstGrain * sectorSize - this.virtualBuffer.position
const freeSpace = firstGrain * SECTOR_SIZE - this.virtualBuffer.position
if (freeSpace > 0) {
await this.virtualBuffer.readChunk(freeSpace, 'freeSpace after L2')
}
}
async readHeader () {
const headerBuffer = await this.virtualBuffer.readChunk(512, 'readHeader')
const headerBuffer = await this.virtualBuffer.readChunk(
HEADER_SIZE,
'readHeader'
)
const magicString = headerBuffer.slice(0, 4).toString('ascii')
if (magicString !== 'KDMV') {
throw new Error('not a VMDK file')
}
const version = headerBuffer.readUInt32LE(4)
const version = headerBuffer.readUInt32LE(VERSION_OFFSET)
if (version !== 1 && version !== 3) {
throw new Error(
'unsupported VMDK version ' +
@@ -240,7 +234,7 @@ export class VMDKDirectParser {
}
this.header = parseHeader(headerBuffer)
// I think the multiplications are OK, because the descriptor is always at the beginning of the file
const descriptorLength = this.header.descriptorSizeSectors * sectorSize
const descriptorLength = this.header.descriptorSizeSectors * SECTOR_SIZE
const descriptorBuffer = await this.virtualBuffer.readChunk(
descriptorLength,
'descriptor'
@@ -251,16 +245,16 @@ export class VMDKDirectParser {
this.header.grainDirectoryOffsetSectors !== -1 &&
this.header.grainDirectoryOffsetSectors !== 0
) {
l1PositionBytes = this.header.grainDirectoryOffsetSectors * sectorSize
l1PositionBytes = this.header.grainDirectoryOffsetSectors * SECTOR_SIZE
}
const endOfDescriptor = this.virtualBuffer.position
if (
l1PositionBytes !== null &&
(l1PositionBytes === endOfDescriptor ||
l1PositionBytes === endOfDescriptor + sectorSize)
l1PositionBytes === endOfDescriptor + SECTOR_SIZE)
) {
if (l1PositionBytes === endOfDescriptor + sectorSize) {
await this.virtualBuffer.readChunk(sectorSize, 'skipping L1 marker')
if (l1PositionBytes === endOfDescriptor + SECTOR_SIZE) {
await this.virtualBuffer.readChunk(SECTOR_SIZE, 'skipping L1 marker')
}
await this._readL1()
}
@@ -271,7 +265,7 @@ export class VMDKDirectParser {
while (!this.virtualBuffer.isDepleted) {
const position = this.virtualBuffer.position
const sector = await this.virtualBuffer.readChunk(
512,
SECTOR_SIZE,
'marker start ' + position
)
if (sector.length === 0) {
@@ -281,14 +275,14 @@ export class VMDKDirectParser {
if (marker.size === 0) {
if (marker.value !== 0) {
await this.virtualBuffer.readChunk(
marker.value * sectorSize,
marker.value * SECTOR_SIZE,
'other marker value ' + this.virtualBuffer.position
)
}
} else if (marker.size > 10) {
const grainDiskSize = marker.size + 12
const alignedGrainDiskSize = alignSectors(grainDiskSize)
const remainOfBufferSize = alignedGrainDiskSize - sectorSize
const remainOfBufferSize = alignedGrainDiskSize - SECTOR_SIZE
const remainderOfGrainBuffer = await this.virtualBuffer.readChunk(
remainOfBufferSize,
'grain remainder ' + this.virtualBuffer.position
@@ -305,60 +299,3 @@ export class VMDKDirectParser {
}
}
}
export async function readVmdkGrainTable (fileAccessor) {
let headerBuffer = await fileAccessor(0, 512)
let grainAddrBuffer = headerBuffer.slice(56, 56 + 8)
if (
new Int8Array(grainAddrBuffer).reduce((acc, val) => acc && val === -1, true)
) {
headerBuffer = await fileAccessor(-1024, -1024 + 512)
grainAddrBuffer = headerBuffer.slice(56, 56 + 8)
}
const grainDirPosBytes =
new DataView(grainAddrBuffer).getUint32(0, true) * 512
const capacity =
new DataView(headerBuffer.slice(12, 12 + 8)).getUint32(0, true) * 512
const grainSize =
new DataView(headerBuffer.slice(20, 20 + 8)).getUint32(0, true) * 512
const grainCount = Math.ceil(capacity / grainSize)
const numGTEsPerGT = new DataView(headerBuffer.slice(44, 44 + 8)).getUint32(
0,
true
)
const grainTablePhysicalSize = numGTEsPerGT * 4
const grainDirectoryEntries = Math.ceil(grainCount / numGTEsPerGT)
const grainDirectoryPhysicalSize = grainDirectoryEntries * 4
const grainDirBuffer = await fileAccessor(
grainDirPosBytes,
grainDirPosBytes + grainDirectoryPhysicalSize
)
const grainDir = new Uint32Array(grainDirBuffer)
const cachedGrainTables = []
for (let i = 0; i < grainDirectoryEntries; i++) {
const grainTableAddr = grainDir[i] * 512
if (grainTableAddr !== 0) {
cachedGrainTables[i] = new Uint32Array(
await fileAccessor(
grainTableAddr,
grainTableAddr + grainTablePhysicalSize
)
)
}
}
const extractedGrainTable = []
for (let i = 0; i < grainCount; i++) {
const directoryEntry = Math.floor(i / numGTEsPerGT)
const grainTable = cachedGrainTables[directoryEntry]
if (grainTable !== undefined) {
const grainAddr = grainTable[i % numGTEsPerGT]
if (grainAddr !== 0) {
extractedGrainTable.push([i, grainAddr])
}
}
}
extractedGrainTable.sort(
([i1, grainAddress1], [i2, grainAddress2]) => grainAddress1 - grainAddress2
)
return extractedGrainTable.map(([index, grainAddress]) => index * grainSize)
}

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "xo-web",
"version": "5.19.4",
"version": "5.20.1",
"license": "AGPL-3.0",
"description": "Web interface client for Xen-Orchestra",
"keywords": [
@@ -30,7 +30,7 @@
"node": ">=6"
},
"devDependencies": {
"@julien-f/freactal": "0.1.0",
"@julien-f/freactal": "0.1.1",
"@nraynaud/novnc": "0.6.1",
"@xen-orchestra/cron": "^1.0.3",
"ansi_up": "^3.0.0",
@@ -89,8 +89,8 @@
"lodash": "^4.6.1",
"loose-envify": "^1.1.0",
"make-error": "^1.3.2",
"marked": "^0.3.9",
"modular-cssify": "^8.0.0",
"marked": "^0.4.0",
"modular-cssify": "^10.0.0",
"moment": "^2.20.1",
"moment-timezone": "^0.5.14",
"notifyjs": "^3.0.0",
@@ -120,7 +120,7 @@
"react-test-renderer": "^15.6.2",
"react-virtualized": "^9.15.0",
"readable-stream": "^2.3.3",
"redux": "^3.7.2",
"redux": "^4.0.0",
"redux-thunk": "^2.0.1",
"reselect": "^2.5.4",
"rimraf": "^2.6.2",
@@ -134,11 +134,11 @@
"watchify": "^3.7.0",
"whatwg-fetch": "^2.0.3",
"xml2js": "^0.4.19",
"xo-acl-resolver": "^0.2.3",
"xo-acl-resolver": "^0.2.4",
"xo-common": "^0.1.1",
"xo-lib": "^0.8.0",
"xo-remote-parser": "^0.3",
"xo-vmdk-to-vhd": "^0.1.1"
"xo-vmdk-to-vhd": "^0.1.2"
},
"scripts": {
"build": "NODE_ENV=production gulp build",

View File

@@ -7,26 +7,22 @@ const call = fn => fn()
// callbacks have been correctly initialized when there are circular dependencies
const addSubscriptions = subscriptions => Component =>
class SubscriptionWrapper extends React.PureComponent {
constructor () {
super()
// provide all props since the beginning (better behavior with Freactal)
const state = (this.state = {})
Object.keys(subscriptions).forEach(key => {
state[key] = undefined
})
}
_unsubscribes = null
componentWillMount () {
const state = {}
this._unsubscribes = map(
typeof subscriptions === 'function'
? subscriptions(this.props)
: subscriptions,
(subscribe, prop) =>
subscribe(value => this.setState({ [prop]: value }))
(subscribe, prop) => {
state[prop] = undefined
return subscribe(value => this.setState({ [prop]: value }))
}
)
// provide all props since the beginning (better behavior with Freactal)
this.setState(state)
}
componentWillUnmount () {

View File

@@ -20,7 +20,10 @@ export const Card = propTypes({
shadow: propTypes.bool,
})(({ shadow, ...props }) => {
props.className = 'card'
props.style = shadow ? CARD_STYLE_WITH_SHADOW : CARD_STYLE
props.style = {
...props.style,
...(shadow ? CARD_STYLE_WITH_SHADOW : CARD_STYLE),
}
return <div {...props} />
})

View File

@@ -142,15 +142,15 @@ export default class Select extends React.PureComponent {
simpleValue,
value,
} = props
let option
if (
autoSelectSingleOption &&
options != null &&
options.length === 1 &&
(value == null ||
(simpleValue && value === '') ||
(multi && value.length === 0))
(multi && value.length === 0)) &&
([option] = options.filter(_ => !_.disabled)).length === 1
) {
const option = options[0]
props.onChange(
simpleValue ? option[props.valueKey] : multi ? [option] : option
)

View File

@@ -83,6 +83,9 @@ const messages = {
newServerPage: 'Server',
newImport: 'Import',
xosan: 'XOSAN',
backupDeprecatedMessage:
'Backup is deprecated, use Backup NG instead to create new backups.',
backupNgNewPage: 'New backup NG',
backupOverviewPage: 'Overview',
backupNewPage: 'New',
backupRemotesPage: 'Remotes',
@@ -188,6 +191,7 @@ const messages = {
// ----- Forms -----
formCancel: 'Cancel',
formCreate: 'Create',
formEdit: 'Edit',
formReset: 'Reset',
formSave: 'Save',
add: 'Add',
@@ -259,6 +263,9 @@ const messages = {
jobCallInProgess: 'In progress',
jobTransferredDataSize: 'Transfer size:',
jobTransferredDataSpeed: 'Transfer speed:',
operationSize: 'Size',
operationSpeed: 'Speed',
exportType: 'Type',
jobMergedDataSize: 'Merge size:',
jobMergedDataSpeed: 'Merge speed:',
allJobCalls: 'All',
@@ -306,6 +313,7 @@ const messages = {
taskMergedDataSize: 'Merge size',
taskMergedDataSpeed: 'Merge speed',
taskError: 'Error',
taskReason: 'Reason',
saveBackupJob: 'Save',
deleteBackupSchedule: 'Remove backup job',
deleteBackupScheduleQuestion:
@@ -317,6 +325,19 @@ const messages = {
jobEditMessage:
'You are editing job {name} ({id}). Saving will override previous job state.',
scheduleEdit: 'Edit schedule',
missingBackupName: "A name is required to create the backup's job!",
missingVms: 'Missing VMs!',
missingBackupMode: 'You need to choose a backup mode!',
missingRemotes: 'Missing remotes!',
missingSrs: 'Missing SRs!',
missingSchedules: 'Missing schedules!',
missingExportRetention:
'The Backup mode and The Delta Backup mode require export retention to be higher than 0!',
missingCopyRetention:
'The CR mode and The DR mode require copy 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!',
scheduleAdd: 'Add a schedule',
scheduleDelete: 'Delete',
scheduleRun: 'Run schedule',
@@ -335,6 +356,7 @@ const messages = {
jobUserNotFound: "This job's creator no longer exists",
backupUserNotFound: "This backup's creator no longer exists",
redirectToMatchingVms: 'Click here to see the matching VMs',
migrateToBackupNg: 'Migrate to backup NG',
noMatchingVms: 'There are no matching VMs!',
allMatchingVms: '{icon} See the matching VMs ({nMatchingVms, number})',
backupOwner: 'Backup owner',
@@ -349,15 +371,18 @@ const messages = {
reportWhenFailure: 'Failure',
reportWhenNever: 'Never',
reportWhen: 'Report when',
concurrency: 'Concurrency',
newBackupSelection: 'Select your backup type:',
smartBackupModeSelection: 'Select backup mode:',
normalBackup: 'Normal backup',
smartBackup: 'Smart backup',
exportRetention: 'Export retention',
copyRetention: 'Copy retention',
snapshotRetention: 'Snapshot retention',
backupName: 'Name',
useDelta: 'Use delta',
useCompression: 'Use compression',
offlineSnapshot: 'Offline snapshot',
dbAndDrRequireEntreprisePlan: 'Delta Backup and DR require Entreprise plan',
crRequiresPremiumPlan: 'CR requires Premium plan',
smartBackupModeTitle: 'Smart mode',
@@ -597,11 +622,15 @@ const messages = {
vmsTabName: 'Vms',
srsTabName: 'Srs',
// ----- Pool advanced tab -----
poolEditAll: 'Edit all',
poolEditRemoteSyslog: 'Edit remote syslog for all hosts',
poolHaStatus: 'High Availability',
poolHaEnabled: 'Enabled',
poolHaDisabled: 'Disabled',
setpoolMaster: 'Master',
poolGpuGroups: 'GPU groups',
poolRemoteSyslogPlaceHolder: 'Logging host',
setpoolMaster: 'Master',
syslogRemoteHost: 'Remote syslog host',
// ----- Pool host tab -----
hostNameLabel: 'Name',
hostDescription: 'Description',
@@ -681,6 +710,7 @@ const messages = {
hostLicenseType: 'Type',
hostLicenseSocket: 'Socket',
hostLicenseExpiry: 'Expiry',
hostRemoteSyslog: 'Remote syslog',
supplementalPacks: 'Installed supplemental packs',
supplementalPackNew: 'Install new supplemental pack',
supplementalPackPoolNew: 'Install supplemental pack on every host',
@@ -735,6 +765,7 @@ const messages = {
patchNameLabel: 'Name',
patchUpdateButton: 'Install all patches',
patchDescription: 'Description',
patchVersion: 'Version',
patchApplied: 'Applied date',
patchSize: 'Size',
patchStatus: 'Status',
@@ -752,6 +783,15 @@ const messages = {
'This will install a patch only on this host. This is NOT the recommended way: please go into the Pool patch view and follow instructions there. If you are sure about this, you can continue anyway',
installPatchWarningReject: 'Go to pool',
installPatchWarningResolve: 'Install',
patchRelease: 'Release',
updatePluginNotInstalled:
'Update plugin is not installed on this host. Please run `yum install xcp-ng-updater` first.',
showChangelog: 'Show changelog',
changelog: 'Changelog',
changelogPatch: 'Patch',
changelogAuthor: 'Author',
changelogDate: 'Date',
changelogDescription: 'Description',
// ----- Pool patch tabs -----
refreshPatches: 'Refresh patches',
installPoolPatches: 'Install pool patches',
@@ -933,6 +973,7 @@ const messages = {
defaultCpuCap: 'Default ({value, number})',
pvArgsLabel: 'PV args',
xenToolsStatus: 'Xen tools version',
xenToolsNotInstalled: 'Not installed',
osName: 'OS name',
osKernel: 'OS kernel',
autoPowerOn: 'Auto power on',
@@ -1106,6 +1147,11 @@ const messages = {
newVmSshKey: 'SSH key',
newVmConfigDrive: 'Config drive',
newVmCustomConfig: 'Custom config',
availableTemplateVarsInfo:
'Click here to see the available template variables',
availableTemplateVarsTitle: 'Available template variables',
templateNameInfo: 'the VM\'s name. It must not contain "_"',
templateIndexInfo: "the VM's index, it will take 0 in case of single VM",
newVmBootAfterCreate: 'Boot VM after creation',
newVmMacPlaceholder: 'Auto-generated if empty',
newVmCpuWeightLabel: 'CPU weight',
@@ -1212,6 +1258,7 @@ const messages = {
scheduleName: 'Name',
scheduleTimezone: 'Timezone',
scheduleExportRetention: 'Export ret.',
scheduleCopyRetention: 'Copy ret.',
scheduleSnapshotRetention: 'Snapshot ret.',
getRemote: 'Get remote',
listRemote: 'List Remote',
@@ -1674,6 +1721,7 @@ const messages = {
logIndicationToDisable: 'Click to disable',
reportBug: 'Report a bug',
unhealthyVdiChainError: 'Job canceled to protect the VDI chain',
backupRestartVm: "Restart VM's backup",
clickForMoreInformation: 'Click for more information',
// ----- IPs ------

View File

@@ -50,19 +50,17 @@ const SrItem = propTypes({
return (state, props) => ({
container: getContainer(state, props),
})
})(({ sr, container }) => {
let label = `${sr.name_label || sr.id}`
if (isSrWritable(sr)) {
label += ` (${formatSize(sr.size - sr.physical_usage)} free)`
}
return (
<span>
<Icon icon='sr' /> {label}
</span>
)
})
})(({ sr, container }) => (
<span>
<Icon icon='sr' /> {sr.name_label || sr.id}
{container !== undefined && (
<span className='text-muted'> - {container.name_label}</span>
)}
{isSrWritable(sr) && (
<span>{` (${formatSize(sr.size - sr.physical_usage)} free)`}</span>
)}
</span>
))
)
// VM.

View File

@@ -6,7 +6,6 @@ import {
filter,
flatten,
forEach,
get,
groupBy,
includes,
isArray,
@@ -36,6 +35,7 @@ import {
createGetObjectsOfType,
createGetTags,
createSelector,
createSort,
getObject,
} from './selectors'
import { addSubscriptions, connectStore, resolveResourceSets } from './utils'
@@ -61,7 +61,9 @@ const ADDON_BUTTON_STYLE = { lineHeight: '1.4' }
const getIds = value =>
value == null || isString(value) || isInteger(value)
? value
: isArray(value) ? map(value, getIds) : value.id
: isArray(value)
? map(value, getIds)
: value.id
const getOption = (object, container) => ({
label: container
@@ -362,40 +364,10 @@ export const SelectSr = makeStoreSelect(
const getPools = createGetObjectsOfType('pool')
const getHosts = createGetObjectsOfType('host')
const getSrsByContainer = createSelector(
createGetObjectsOfType('SR')
.filter((_, { predicate }) => predicate || isSrWritable)
.sort(),
createSelector(getHosts, getPools, (hosts, pools) => id =>
hosts[id] || pools[id]
),
(srs, containerFinder) => {
const { length } = srs
if (length >= 2) {
let sr1, sr2
const srsToModify = {}
for (let i = 1; i < length; ++i) {
sr1 = srs[i]
for (let j = 0; j < i; ++j) {
sr2 = srs[j]
if (sr1.name_label === sr2.name_label) {
srsToModify[sr1.id] = sr1
srsToModify[sr2.id] = sr2
}
}
}
forEach(srsToModify, sr => {
sr.name_label = `(${get(
containerFinder(sr.$container),
'name_label'
)}) ${sr.name_label}`
})
}
return groupBy(srs, '$container')
}
)
const getSrsByContainer = createGetObjectsOfType('SR')
.filter((_, { predicate }) => predicate || isSrWritable)
.sort()
.groupBy('$container')
const getContainerIds = createSelector(getSrsByContainer, srsByContainer =>
keys(srsByContainer)
@@ -888,16 +860,15 @@ export class SelectResourceSetsNetwork extends React.PureComponent {
this.refs.select.value = value
}
_getNetworks = createSelector(
() => this.props.resourceSet,
({ objectsByType }) => {
const { predicate } = this.props
const networks = objectsByType['network']
return sortBy(
predicate ? filter(networks, predicate) : networks,
'name_label'
_getNetworks = createSort(
createFilter(
() => this.props.resourceSet.objectsByType.network,
createSelector(
() => this.props.predicate,
predicate => predicate || (() => true)
)
}
),
'name_label'
)
render () {

View File

@@ -15,6 +15,7 @@ import {
pickBy,
size,
slice,
some,
} from 'lodash'
import invoke from './invoke'
@@ -147,7 +148,9 @@ export const createFilter = (collection, predicate) =>
_createCollectionWrapper(
(collection, predicate) =>
predicate === false
? isArrayLike(collection) ? EMPTY_ARRAY : EMPTY_OBJECT
? isArrayLike(collection)
? EMPTY_ARRAY
: EMPTY_OBJECT
: predicate
? (isArrayLike(collection) ? filter : pickBy)(collection, predicate)
: collection
@@ -541,3 +544,9 @@ export const createGetVmDisks = vmSelector =>
)
)
)
export const getIsPoolAdmin = create(
create(createGetObjectsOfType('pool'), _createCollectionWrapper(Object.keys)),
getCheckPermissions,
(poolsIds, check) => some(poolsIds, poolId => check(poolId, 'administrate'))
)

View File

@@ -167,7 +167,10 @@ class ColumnHead extends Component {
})
class Checkbox extends Component {
componentDidUpdate () {
const { props: { indeterminate }, ref } = this
const {
props: { indeterminate },
ref,
} = this
if (ref !== null) {
ref.indeterminate = indeterminate
}
@@ -487,8 +490,8 @@ export default class SortedTable extends Component {
) {
this.setState({
highlighted:
(itemIndex + visibleItems.length + 1) % visibleItems.length ||
0,
(itemIndex + visibleItems.length + 1) %
visibleItems.length || 0,
})
}
break
@@ -500,8 +503,8 @@ export default class SortedTable extends Component {
) {
this.setState({
highlighted:
(itemIndex + visibleItems.length - 1) % visibleItems.length ||
0,
(itemIndex + visibleItems.length - 1) %
visibleItems.length || 0,
})
}
break
@@ -893,7 +896,7 @@ export default class SortedTable extends Component {
</span>
)
)}
{nSelectedItems !== 0 && (
{(nSelectedItems !== 0 || all) && (
<div className='pull-right'>
<ButtonGroup>
{map(groupedActions, (props, key) => (

View File

@@ -23,7 +23,7 @@ class CreateNetworkModalBody extends Component {
pool: container.$pool,
name: refs.name.value,
description: refs.description.value,
pif: refs.pif.value.id,
pif: refs.pif.value && refs.pif.value.id,
mtu: refs.mtu.value,
vlan: refs.vlan.value,
}

View File

@@ -9,6 +9,7 @@ import {
assign,
filter,
forEach,
get,
includes,
isEmpty,
isEqual,
@@ -576,6 +577,15 @@ export const editHost = (host, props) =>
export const fetchHostStats = (host, granularity) =>
_call('host.stats', { host: resolveId(host), granularity })
export const setRemoteSyslogHost = (host, syslogDestination) =>
_call('host.setRemoteSyslogHost', {
id: resolveId(host),
syslogDestination,
})
export const setRemoteSyslogHosts = (hosts, syslogDestination) =>
Promise.all(map(hosts, host => setRemoteSyslogHost(host, syslogDestination)))
export const restartHost = (host, force = false) =>
confirm({
title: _('restartHostModalTitle'),
@@ -655,14 +665,26 @@ export const enableHost = host => _call('host.enable', { id: resolveId(host) })
export const disableHost = host =>
_call('host.disable', { id: resolveId(host) })
export const getHostMissingPatches = host =>
_call('host.listMissingPatches', { host: resolveId(host) }).then(
patches =>
// Hide paid patches to XS-free users
host.license_params.sku_type !== 'free'
? patches
: filter(patches, ['paid', false])
)
const missingUpdatePluginByHost = { __proto__: null }
export const getHostMissingPatches = async host => {
const hostId = resolveId(host)
if (host.productBrand !== 'XCP-ng') {
const patches = await _call('host.listMissingPatches', { host: hostId })
// Hide paid patches to XS-free users
return host.license_params.sku_type !== 'free'
? patches
: filter(patches, { paid: false })
}
if (missingUpdatePluginByHost[hostId]) {
return null
}
try {
return await _call('host.listMissingPatches', { host: hostId })
} catch (_) {
missingUpdatePluginByHost[hostId] = true
return null
}
}
export const emergencyShutdownHost = host =>
confirm({
@@ -1107,7 +1129,7 @@ export const migrateVms = vms =>
export const createVm = args => _call('vm.create', args)
export const createVms = (args, nameLabels) =>
export const createVms = (args, nameLabels, cloudConfigs) =>
confirm({
title: _('newVmCreateVms'),
body: _('newVmCreateVmsConfirm', { nbVms: nameLabels.length }),
@@ -1115,8 +1137,15 @@ export const createVms = (args, nameLabels) =>
() =>
Promise.all(
map(nameLabels, (
name_label // eslint-disable-line camelcase
) => _call('vm.create', { ...args, name_label }))
name_label, // eslint-disable-line camelcase
i
) =>
_call('vm.create', {
...args,
name_label,
cloudConfig: get(cloudConfigs, i),
})
)
),
noop
)

View File

@@ -206,7 +206,7 @@ export default class Restore extends Component {
render () {
return (
<Upgrade place='restoreBackup' available={2}>
<Upgrade place='restoreBackup' available={4}>
<div>
<div className='mb-1'>
<ActionButton

View File

@@ -44,6 +44,7 @@ const SchedulePreviewBody = ({ item: job, userData: { schedulesByJob } }) => (
<th>{_('scheduleCron')}</th>
<th>{_('scheduleTimezone')}</th>
<th>{_('scheduleExportRetention')}</th>
<th>{_('scheduleCopyRetention')}</th>
<th>{_('scheduleSnapshotRetention')}</th>
<th>{_('scheduleRun')}</th>
</tr>
@@ -52,6 +53,7 @@ const SchedulePreviewBody = ({ item: job, userData: { schedulesByJob } }) => (
<td>{schedule.cron}</td>
<td>{schedule.timezone}</td>
<td>{job.settings[schedule.id].exportRetention}</td>
<td>{job.settings[schedule.id].copyRetention}</td>
<td>{job.settings[schedule.id].snapshotRetention}</td>
<td>
<StateButton
@@ -137,7 +139,7 @@ class JobsTable extends React.Component {
},
{
handler: (job, { goTo }) => goTo(`/backup-ng/${job.id}/edit`),
label: '',
label: _('formEdit'),
icon: 'edit',
level: 'primary',
},

View File

@@ -8,22 +8,22 @@ import Tooltip from 'tooltip'
import Upgrade from 'xoa-upgrade'
import { addSubscriptions, resolveId, resolveIds } from 'utils'
import { Card, CardBlock, CardHeader } from 'card'
import { constructSmartPattern, destructSmartPattern } from 'smart-backup'
import { Container, Col, Row } from 'grid'
import { injectState, provideState } from '@julien-f/freactal'
import { SelectRemote, SelectSr, SelectVm } from 'select-objects'
import { Toggle } from 'form'
import {
find,
findKey,
flatten,
get,
forEach,
includes,
isEmpty,
keyBy,
map,
some,
} from 'lodash'
import { injectState, provideState } from '@julien-f/freactal'
import { Toggle } from 'form'
import { constructSmartPattern, destructSmartPattern } from 'smart-backup'
import { SelectRemote, SelectSr, SelectVm } from 'select-objects'
import {
createBackupNgJob,
createSchedule,
@@ -35,12 +35,50 @@ import {
import Schedules from './schedules'
import SmartBackup from './smart-backup'
import { FormGroup, getRandomId, Input, Ul, Li } from './utils'
import {
FormFeedback,
FormGroup,
getRandomId,
Input,
Number,
Ul,
Li,
} from './utils'
// ===================================================================
const normaliseTagValues = values => resolveIds(values).map(value => [value])
const normaliseCopyRentention = settings => {
forEach(settings, schedule => {
if (schedule.copyRetention === undefined) {
schedule.copyRetention = schedule.exportRetention
}
})
}
const normaliseSettings = ({
settings,
exportMode,
copyMode,
snapshotMode,
}) => {
forEach(settings, setting => {
if (!exportMode) {
setting.exportRetention = undefined
}
if (!copyMode) {
setting.copyRetention = undefined
}
if (!snapshotMode) {
setting.snapshotRetention = undefined
}
})
return settings
}
const constructPattern = values =>
values.length === 1
? {
@@ -65,13 +103,16 @@ const destructVmsPattern = pattern =>
vms: destructPattern(pattern),
}
const getNewSettings = schedules => {
const getNewSettings = ({ schedules, exportMode, copyMode, snapshotMode }) => {
const newSettings = {}
for (const id in schedules) {
newSettings[id] = {
exportRetention: schedules[id].exportRetention,
snapshotRetention: schedules[id].snapshotRetention,
exportRetention: exportMode ? schedules[id].exportRetention : undefined,
copyRetention: copyMode ? schedules[id].copyRetention : undefined,
snapshotRetention: snapshotMode
? schedules[id].snapshotRetention
: undefined,
}
}
@@ -112,6 +153,7 @@ const getInitialState = () => ({
$pool: {},
backupMode: false,
compression: true,
concurrency: 0,
crMode: false,
deltaMode: false,
drMode: false,
@@ -119,12 +161,14 @@ const getInitialState = () => ({
formId: getRandomId(),
name: '',
newSchedules: {},
offlineSnapshot: false,
paramsUpdated: false,
powerState: 'All',
remotes: [],
reportWhen: 'failure',
schedules: [],
settings: {},
showErrors: false,
smartMode: false,
snapshotMode: false,
srs: [],
@@ -149,15 +193,29 @@ export default [
initialState: getInitialState,
effects: {
createJob: () => async state => {
if (state.isJobInvalid) {
return {
...state,
showErrors: true,
}
}
await createBackupNgJob({
name: state.name,
mode: state.isDelta ? 'delta' : 'full',
compression: state.compression ? 'native' : '',
schedules: getNewSchedules(state.newSchedules),
settings: {
...getNewSettings(state.newSchedules),
...getNewSettings({
schedules: state.newSchedules,
exportMode: state.exportMode,
copyMode: state.copyMode,
snapshotMode: state.snapshotMode,
}),
'': {
reportWhen: state.reportWhen,
concurrency: state.concurrency,
offlineSnapshot: state.offlineSnapshot,
},
},
remotes:
@@ -174,6 +232,13 @@ export default [
})
},
editJob: () => async (state, props) => {
if (state.isJobInvalid) {
return {
...state,
showErrors: true,
}
}
const newSettings = {}
if (!isEmpty(state.newSchedules)) {
await Promise.all(
@@ -184,6 +249,7 @@ export default [
})).id
newSettings[scheduleId] = {
exportRetention: schedule.exportRetention,
copyRetention: schedule.copyRetention,
snapshotRetention: schedule.snapshotRetention,
}
})
@@ -227,14 +293,18 @@ export default [
if (id === '') {
oldSetting.reportWhen = state.reportWhen
oldSetting.concurrency = state.concurrency
oldSetting.offlineSnapshot = state.offlineSnapshot
} else if (!(id in settings)) {
delete oldSettings[id]
} else if (
oldSetting.snapshotRetention !== newSetting.snapshotRetention ||
oldSetting.exportRetention !== newSetting.exportRetention
oldSetting.exportRetention !== newSetting.exportRetention ||
oldSetting.copyRetention !== newSetting.copyRetention
) {
newSettings[id] = {
exportRetention: newSetting.exportRetention,
copyRetention: newSetting.copyRetention,
snapshotRetention: newSetting.snapshotRetention,
}
}
@@ -245,10 +315,15 @@ export default [
name: state.name,
mode: state.isDelta ? 'delta' : 'full',
compression: state.compression ? 'native' : '',
settings: {
...oldSettings,
...newSettings,
},
settings: normaliseSettings({
settings: {
...oldSettings,
...newSettings,
},
exportMode: state.exportMode,
copyMode: state.copyMode,
snapshotMode: state.snapshotMode,
}),
remotes:
state.deltaMode || state.backupMode
? constructPattern(state.remotes)
@@ -266,9 +341,9 @@ export default [
...state,
[mode]: !state[mode],
}),
setCompression: (_, { target: { checked } }) => state => ({
setCheckboxValue: (_, { target: { checked, name } }) => state => ({
...state,
compression: checked,
[name]: checked,
}),
toggleSmartMode: (_, smartMode) => state => ({
...state,
@@ -309,9 +384,16 @@ export default [
const remotes =
job.remotes !== undefined ? destructPattern(job.remotes) : []
const srs = job.srs !== undefined ? destructPattern(job.srs) : []
const globalSettings = job.settings['']
const { concurrency, reportWhen, offlineSnapshot } =
job.settings[''] || {}
const settings = { ...job.settings }
delete settings['']
const drMode = job.mode === 'full' && !isEmpty(srs)
const crMode = job.mode === 'delta' && !isEmpty(srs)
if (drMode || crMode) {
normaliseCopyRentention(settings)
}
return {
...state,
@@ -325,11 +407,13 @@ export default [
),
backupMode: job.mode === 'full' && !isEmpty(remotes),
deltaMode: job.mode === 'delta' && !isEmpty(remotes),
drMode: job.mode === 'full' && !isEmpty(srs),
crMode: job.mode === 'delta' && !isEmpty(srs),
drMode,
crMode,
remotes,
srs,
reportWhen: get(globalSettings, 'reportWhen') || 'failure',
reportWhen: reportWhen || 'failure',
concurrency: concurrency || 0,
offlineSnapshot,
settings,
schedules,
...destructVmsPattern(job.vms),
@@ -345,13 +429,14 @@ export default [
editionMode: undefined,
}),
editSchedule: (_, schedule) => state => {
const { snapshotRetention, exportRetention } =
const { snapshotRetention, exportRetention, copyRetention } =
state.settings[schedule.id] || {}
return {
...state,
editionMode: 'editSchedule',
tmpSchedule: {
exportRetention,
copyRetention,
snapshotRetention,
...schedule,
},
@@ -383,15 +468,8 @@ export default [
},
saveSchedule: (
_,
{ cron, timezone, exportRetention, snapshotRetention }
{ cron, timezone, exportRetention, copyRetention, snapshotRetention }
) => async (state, props) => {
if (!state.exportMode) {
exportRetention = 0
}
if (!state.snapshotMode) {
snapshotRetention = 0
}
if (state.editionMode === 'creation') {
return {
...state,
@@ -402,6 +480,7 @@ export default [
cron,
timezone,
exportRetention,
copyRetention,
snapshotRetention,
},
},
@@ -421,6 +500,7 @@ export default [
const settings = { ...state.settings }
settings[id] = {
exportRetention,
copyRetention,
snapshotRetention,
}
@@ -443,6 +523,7 @@ export default [
cron,
timezone,
exportRetention,
copyRetention,
snapshotRetention,
},
},
@@ -491,31 +572,58 @@ export default [
...state,
reportWhen: value,
}),
setConcurrency: (_, concurrency) => state => ({
...state,
concurrency,
}),
},
computed: {
needUpdateParams: (state, { job, schedules }) =>
job !== undefined && schedules !== undefined && !state.paramsUpdated,
isJobInvalid: state =>
state.name.trim() === '' ||
(isEmpty(state.schedules) && isEmpty(state.newSchedules)) ||
(isEmpty(state.vms) && !state.smartMode) ||
((state.backupMode || state.deltaMode) && isEmpty(state.remotes)) ||
((state.drMode || state.crMode) && isEmpty(state.srs)) ||
(state.exportMode && !state.exportRetentionExists) ||
(state.snapshotMode && !state.snapshotRetentionExists) ||
(!state.isDelta && !state.isFull && !state.snapshotMode),
showCompression: state => state.isFull && state.exportRetentionExists,
exportMode: state =>
state.backupMode || state.deltaMode || state.drMode || state.crMode,
state.missingName ||
state.missingVms ||
state.missingBackupMode ||
state.missingSchedules ||
state.missingRemotes ||
state.missingSrs ||
state.missingExportRetention ||
state.missingCopyRetention ||
state.missingSnapshotRetention,
missingName: state => state.name.trim() === '',
missingVms: state => isEmpty(state.vms) && !state.smartMode,
missingBackupMode: state =>
!state.isDelta && !state.isFull && !state.snapshotMode,
missingRemotes: state =>
(state.backupMode || state.deltaMode) && isEmpty(state.remotes),
missingSrs: state => (state.drMode || state.crMode) && isEmpty(state.srs),
missingSchedules: state =>
isEmpty(state.schedules) && isEmpty(state.newSchedules),
missingExportRetention: state =>
state.exportMode && !state.exportRetentionExists,
missingCopyRetention: state =>
state.copyMode && !state.copyRetentionExists,
missingSnapshotRetention: state =>
state.snapshotMode && !state.snapshotRetentionExists,
showCompression: state =>
state.isFull &&
(state.exportRetentionExists || state.copyRetentionExists),
exportMode: state => state.backupMode || state.deltaMode,
copyMode: state => state.drMode || state.crMode,
exportRetentionExists: ({ newSchedules, settings }) =>
some(
{ ...newSchedules, ...settings },
({ exportRetention }) => exportRetention !== 0
({ exportRetention }) => exportRetention > 0
),
copyRetentionExists: ({ newSchedules, settings }) =>
some(
{ ...newSchedules, ...settings },
({ copyRetention }) => copyRetention > 0
),
snapshotRetentionExists: ({ newSchedules, settings }) =>
some(
{ ...newSchedules, ...settings },
({ snapshotRetention }) => snapshotRetention !== 0
({ snapshotRetention }) => snapshotRetention > 0
),
isDelta: state => state.deltaMode || state.crMode,
isFull: state => state.backupMode || state.drMode,
@@ -542,7 +650,7 @@ export default [
<Col mediumSize={6}>
<Card>
<CardHeader>
{_('backupName')}
{_('backupName')}*
<Tooltip content={_('smartBackupModeTitle')}>
<Toggle
className='pull-right'
@@ -557,7 +665,13 @@ export default [
<label>
<strong>{_('backupName')}</strong>
</label>
<Input onChange={effects.setName} value={state.name} />
<FormFeedback
component={Input}
message={_('missingBackupName')}
onChange={effects.setName}
error={state.showErrors ? state.missingName : undefined}
value={state.name}
/>
</FormGroup>
{state.smartMode ? (
<Upgrade place='newBackup' required={3}>
@@ -568,9 +682,12 @@ export default [
<label>
<strong>{_('vmsToBackup')}</strong>
</label>
<SelectVm
<FormFeedback
component={SelectVm}
message={_('missingVms')}
multi
onChange={effects.setVms}
error={state.showErrors ? state.missingVms : undefined}
value={state.vms}
/>
</FormGroup>
@@ -578,16 +695,21 @@ export default [
{state.showCompression && (
<label>
<input
type='checkbox'
onChange={effects.setCompression}
checked={state.compression}
name='compression'
onChange={effects.setCheckboxValue}
type='checkbox'
/>{' '}
<strong>{_('useCompression')}</strong>
</label>
)}
</CardBlock>
</Card>
<Card>
<FormFeedback
component={Card}
error={state.showErrors ? state.missingBackupMode : undefined}
message={_('missingBackupMode')}
>
<CardBlock>
<div className='text-xs-center'>
<ActionButton
@@ -655,7 +777,8 @@ export default [
)}
</div>
</CardBlock>
</Card>
</FormFeedback>
<br />
{(state.backupMode || state.deltaMode) && (
<Card>
<CardHeader>
@@ -666,9 +789,14 @@ export default [
<label>
<strong>{_('backupTargetRemotes')}</strong>
</label>
<SelectRemote
<FormFeedback
component={SelectRemote}
message={_('missingRemotes')}
onChange={effects.addRemote}
predicate={state.remotePredicate}
error={
state.showErrors ? state.missingRemotes : undefined
}
value={null}
/>
<br />
@@ -709,9 +837,12 @@ export default [
<label>
<strong>{_('backupTargetSrs')}</strong>
</label>
<SelectSr
<FormFeedback
component={SelectSr}
message={_('missingSrs')}
onChange={effects.addSr}
predicate={state.srPredicate}
error={state.showErrors ? state.missingSrs : undefined}
value={null}
/>
<br />
@@ -751,6 +882,26 @@ export default [
valueKey='value'
/>
</FormGroup>
<FormGroup>
<label>
<strong>{_('concurrency')}</strong>
</label>
<Number
onChange={effects.setConcurrency}
value={state.concurrency}
/>
</FormGroup>
<FormGroup>
<label>
<strong>{_('offlineSnapshot')}</strong>{' '}
<input
checked={state.offlineSnapshot}
name='offlineSnapshot'
onChange={effects.setCheckboxValue}
type='checkbox'
/>
</label>
</FormGroup>
</CardBlock>
</Card>
</Col>
@@ -764,11 +915,12 @@ export default [
{state.paramsUpdated ? (
<ActionButton
btnStyle='primary'
disabled={state.isJobInvalid}
form={state.formId}
handler={effects.editJob}
icon='save'
redirectOnSuccess='/backup-ng'
redirectOnSuccess={
state.isJobInvalid ? undefined : '/backup-ng'
}
size='large'
>
{_('formSave')}
@@ -776,11 +928,12 @@ export default [
) : (
<ActionButton
btnStyle='primary'
disabled={state.isJobInvalid}
form={state.formId}
handler={effects.createJob}
icon='save'
redirectOnSuccess='/backup-ng'
redirectOnSuccess={
state.isJobInvalid ? undefined : '/backup-ng'
}
size='large'
>
{_('formCreate')}

View File

@@ -1,61 +1,32 @@
import _ from 'intl'
import ActionButton from 'action-button'
import moment from 'moment-timezone'
import PropTypes from 'prop-types'
import React from 'react'
import Scheduler, { SchedulePreview } from 'scheduling'
import { Card, CardBlock } from 'card'
import { injectState, provideState } from '@julien-f/freactal'
import { isEqual } from 'lodash'
import { FormGroup, getRandomId, Input } from './utils'
const Number = [
provideState({
effects: {
onChange: (_, { target: { value } }) => (state, props) => {
if (value === '') {
return
}
props.onChange(+value)
},
},
}),
injectState,
({ effects, state, value }) => (
<Input
type='number'
onChange={effects.onChange}
value={String(value)}
min='0'
/>
),
].reduceRight((value, decorator) => decorator(value))
Number.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.number.isRequired,
}
import { FormFeedback, FormGroup, getRandomId, Number } from './utils'
export default [
injectState,
provideState({
initialState: ({
copyMode,
exportMode,
snapshotMode,
schedule: {
cron = '0 0 * * *',
exportRetention = 1,
snapshotRetention = 1,
exportRetention = exportMode ? 1 : undefined,
copyRetention = copyMode ? 1 : undefined,
snapshotRetention = snapshotMode ? 1 : undefined,
timezone = moment.tz.guess(),
},
}) => ({
oldSchedule: {
cron,
exportRetention,
snapshotRetention,
timezone,
},
cron,
exportRetention,
copyRetention,
formId: getRandomId(),
snapshotRetention,
timezone,
@@ -65,6 +36,10 @@ export default [
...state,
exportRetention: value,
}),
setCopyRetention: (_, value) => state => ({
...state,
copyRetention: value,
}),
setSnapshotRetention: (_, value) => state => ({
...state,
snapshotRetention: value,
@@ -81,34 +56,54 @@ export default [
retentionNeeded: ({
exportMode,
exportRetention,
copyMode,
copyRetention,
snapshotMode,
snapshotRetention,
}) =>
!(
(exportMode && exportRetention !== 0) ||
(snapshotMode && snapshotRetention !== 0)
(exportMode && exportRetention > 0) ||
(copyMode && copyRetention > 0) ||
(snapshotMode && snapshotRetention > 0)
),
scheduleNotEdited: ({
cron,
editionMode,
exportRetention,
oldSchedule,
snapshotRetention,
timezone,
}) =>
editionMode !== 'creation' &&
isEqual(oldSchedule, {
scheduleNotEdited: (
{
cron,
editionMode,
exportRetention,
copyRetention,
snapshotRetention,
timezone,
}),
},
{ schedule }
) =>
editionMode !== 'creation' &&
isEqual(
{
cron: schedule.cron,
exportRetention: schedule.exportRetention,
copyRetention: schedule.copyRetention,
snapshotRetention: schedule.snapshotRetention,
timezone: schedule.timezone,
},
{
cron,
exportRetention,
copyRetention,
snapshotRetention,
timezone,
}
),
},
}),
injectState,
({ effects, state }) => (
<form id={state.formId}>
<Card>
<FormFeedback
component={Card}
error={state.retentionNeeded}
message={_('retentionNeeded')}
>
<CardBlock>
{state.exportMode && (
<FormGroup>
@@ -118,6 +113,19 @@ export default [
<Number
onChange={effects.setExportRetention}
value={state.exportRetention}
optional
/>
</FormGroup>
)}
{state.copyMode && (
<FormGroup>
<label>
<strong>{_('copyRetention')}</strong>
</label>
<Number
onChange={effects.setCopyRetention}
value={state.copyRetention}
optional
/>
</FormGroup>
)}
@@ -129,6 +137,7 @@ export default [
<Number
onChange={effects.setSnapshotRetention}
value={state.snapshotRetention}
optional
/>
</FormGroup>
)}
@@ -143,6 +152,7 @@ export default [
btnStyle='primary'
data-cron={state.cron}
data-exportRetention={state.exportRetention}
data-copyRetention={state.copyRetention}
data-snapshotRetention={state.snapshotRetention}
data-timezone={state.timezone}
disabled={state.isScheduleInvalid}
@@ -162,7 +172,7 @@ export default [
{_('formCancel')}
</ActionButton>
</CardBlock>
</Card>
</FormFeedback>
</form>
),
].reduceRight((value, decorator) => decorator(value))

View File

@@ -4,52 +4,21 @@ import React from 'react'
import SortedTable from 'sorted-table'
import { Card, CardBlock, CardHeader } from 'card'
import { injectState, provideState } from '@julien-f/freactal'
import { isEmpty, findKey, size } from 'lodash'
import { isEmpty, find, findKey, size } from 'lodash'
import NewSchedule from './new-schedule'
import { FormGroup } from './utils'
import { FormFeedback, FormGroup } from './utils'
// ===================================================================
const SCHEDULES_COLUMNS = [
{
itemRenderer: _ => _.cron,
sortCriteria: 'cron',
name: _('scheduleCron'),
},
{
itemRenderer: _ => _.timezone,
sortCriteria: 'timezone',
name: _('scheduleTimezone'),
},
{
itemRenderer: _ => _.exportRetention,
sortCriteria: _ => _.exportRetention,
name: _('scheduleExportRetention'),
},
{
itemRenderer: _ => _.snapshotRetention,
sortCriteria: _ => _.snapshotRetention,
name: _('scheduleSnapshotRetention'),
},
]
const SAVED_SCHEDULES_COLUMNS = [
{
itemRenderer: _ => _.name,
sortCriteria: 'name',
name: _('scheduleName'),
default: true,
},
...SCHEDULES_COLUMNS,
]
const rowTransform = (schedule, { settings }) => {
const { exportRetention, snapshotRetention } = settings[schedule.id] || {}
const { exportRetention, copyRetention, snapshotRetention } =
settings[schedule.id] || {}
return {
...schedule,
exportRetention,
copyRetention,
snapshotRetention,
}
}
@@ -94,6 +63,13 @@ const NEW_SCHEDULES_INDIVIDUAL_ACTIONS = [
// ===================================================================
const FEEDBACK_ERRORS = [
'missingSchedules',
'missingCopyRetention',
'missingExportRetention',
'missingSnapshotRetention',
]
export default [
injectState,
provideState({
@@ -102,15 +78,68 @@ export default [
state.schedules.length + size(state.newSchedules) <= 1,
disabledEdition: state =>
state.editionMode !== undefined ||
(!state.exportMode && !state.snapshotMode),
(!state.exportMode && !state.copyMode && !state.snapshotMode),
error: state => find(FEEDBACK_ERRORS, error => state[error]),
schedulesColumns: state => {
const columns = [
{
itemRenderer: _ => _.cron,
sortCriteria: 'cron',
name: _('scheduleCron'),
},
{
itemRenderer: _ => _.timezone,
sortCriteria: 'timezone',
name: _('scheduleTimezone'),
},
]
if (state.exportMode) {
columns.push({
itemRenderer: _ => _.exportRetention,
sortCriteria: _ => _.exportRetention,
name: _('scheduleExportRetention'),
})
}
if (state.copyMode) {
columns.push({
itemRenderer: _ => _.copyRetention,
sortCriteria: _ => _.copyRetention,
name: _('scheduleCopyRetention'),
})
}
if (state.snapshotMode) {
columns.push({
itemRenderer: _ => _.snapshotRetention,
sortCriteria: _ => _.snapshotRetention,
name: _('scheduleSnapshotRetention'),
})
}
return columns
},
savedSchedulesColumns: state => [
{
itemRenderer: _ => _.name,
sortCriteria: 'name',
name: _('scheduleName'),
default: true,
},
...state.schedulesColumns,
],
},
}),
injectState,
({ effects, state }) => (
<div>
<Card>
<FormFeedback
component={Card}
error={state.showErrors ? state.error !== undefined : undefined}
message={state.error !== undefined && _(state.error)}
>
<CardHeader>
{_('backupSchedules')}
{_('backupSchedules')}*
<ActionButton
btnStyle='primary'
className='pull-right'
@@ -132,7 +161,7 @@ export default [
</label>
<SortedTable
collection={state.schedules}
columns={SAVED_SCHEDULES_COLUMNS}
columns={state.savedSchedulesColumns}
data-deleteSchedule={effects.deleteSchedule}
data-disabledDeletion={state.disabledDeletion}
data-disabledEdition={state.disabledEdition}
@@ -150,7 +179,7 @@ export default [
</label>
<SortedTable
collection={state.newSchedules}
columns={SCHEDULES_COLUMNS}
columns={state.schedulesColumns}
data-deleteNewSchedule={effects.deleteNewSchedule}
data-disabledEdition={state.disabledEdition}
data-editNewSchedule={effects.editNewSchedule}
@@ -160,9 +189,14 @@ export default [
</FormGroup>
)}
</CardBlock>
</Card>
</FormFeedback>
{state.editionMode !== undefined && (
<NewSchedule schedule={state.tmpSchedule} />
<NewSchedule
copyMode={state.copyMode}
exportMode={state.exportMode}
schedule={state.tmpSchedule}
snapshotMode={state.snapshotMode}
/>
)}
</div>
),

View File

@@ -1,4 +1,7 @@
import Icon from 'icon'
import PropTypes from 'prop-types'
import React from 'react'
import { injectState, provideState } from '@julien-f/freactal'
export const FormGroup = props => <div {...props} className='form-group' />
export const Input = props => <input {...props} className='form-control' />
@@ -9,3 +12,68 @@ export const getRandomId = () =>
Math.random()
.toString(36)
.slice(2)
export const Number = [
provideState({
effects: {
onChange: (_, { target: { value } }) => (state, props) => {
if (value === '') {
if (!props.optional) {
return
}
props.onChange(undefined)
return
}
props.onChange(+value)
},
},
}),
injectState,
({ effects, state, value, optional }) => (
<Input
type='number'
onChange={effects.onChange}
value={value === undefined ? undefined : String(value)}
min='0'
/>
),
].reduceRight((value, decorator) => decorator(value))
Number.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.number.isRequired,
optional: PropTypes.bool,
}
export const FormFeedback = ({
component: Component,
error,
message,
...props
}) => (
<div>
<Component
{...props}
style={
error === undefined
? undefined
: {
borderColor: error ? 'red' : 'green',
...props.style,
}
}
/>
{error && (
<span className='text-danger'>
<Icon icon='alarm' /> {message}
</span>
)}
</div>
)
FormFeedback.propTypes = {
component: PropTypes.node.isRequired,
error: PropTypes.bool,
message: PropTypes.node.isRequired,
}

View File

@@ -1,5 +1,6 @@
import _ from 'intl'
import Icon from 'icon'
import Link from 'link'
import Page from '../page'
import React from 'react'
import { routes } from 'utils'
@@ -7,11 +8,18 @@ import { Container, Row, Col } from 'grid'
import { NavLink, NavTabs } from 'nav'
import Edit from './edit'
import New from './new'
import Overview from './overview'
import Restore from './restore'
import FileRestore from './file-restore'
const DeprecatedMsg = () => (
<div>
<em>{_('backupDeprecatedMessage')}</em>
<br />
<Link to='/backup-ng/new'>{_('backupNgNewPage')}</Link>
</div>
)
const HEADER = (
<Container>
<Row>
@@ -43,7 +51,7 @@ const HEADER = (
const Backup = routes('overview', {
':id/edit': Edit,
new: New,
new: DeprecatedMsg,
overview: Overview,
restore: Restore,
'file-restore': FileRestore,

View File

@@ -126,10 +126,11 @@ const JOB_COLUMNS = [
icon='run-schedule'
/>
<ActionRowButton
icon='migrate-job'
btnStyle='danger'
handler={migrateBackupSchedule}
handlerParam={schedule.jobId}
icon='migrate-job'
tooltip={_('migrateToBackupNg')}
/>
<ActionRowButton
btnStyle='danger'
@@ -164,12 +165,12 @@ export default class Overview extends Component {
jobs === undefined || schedules === undefined
? []
: orderBy(
filter(schedules, schedule => {
const job = jobs[schedule.jobId]
return job && jobKeyToLabel[job.key]
}),
'id'
)
filter(schedules, schedule => {
const job = jobs[schedule.jobId]
return job && jobKeyToLabel[job.key]
}),
'id'
)
)
_redirectToMatchingVms = pattern => {

View File

@@ -74,6 +74,7 @@ import {
createPager,
createSelector,
createSort,
getIsPoolAdmin,
getUser,
isAdmin,
} from 'selectors'
@@ -302,6 +303,7 @@ const DEFAULT_TYPE = 'VM'
})
@propTypes({
isAdmin: propTypes.bool.isRequired,
isPoolAdmin: propTypes.bool.isRequired,
noResourceSets: propTypes.bool.isRequired,
})
class NoObjects_ extends Component {
@@ -309,6 +311,7 @@ class NoObjects_ extends Component {
const {
areObjectsFetched,
isAdmin,
isPoolAdmin,
noRegisteredServers,
noResourceSets,
noServersConnected,
@@ -378,7 +381,9 @@ class NoObjects_ extends Component {
<CenterPanel>
<Card shadow>
<CardHeader>{_('homeNoVms')}</CardHeader>
{(isAdmin || !noResourceSets) && (
{(isAdmin ||
(isPoolAdmin && process.env.XOA_PLAN > 3) ||
!noResourceSets) && (
<CardBlock>
<Row>
<Col>
@@ -428,6 +433,7 @@ class NoObjects_ extends Component {
return {
isAdmin,
isPoolAdmin: getIsPoolAdmin,
items: createSelector(
createSelector(
createGetObjectsOfType('host'),
@@ -818,7 +824,7 @@ export default class Home extends Component {
const customFilters = this._getCustomFilters()
const filteredItems = this._getFilteredItems()
const nItems = this._getNumberOfItems()
const { isAdmin, items, noResourceSets, type } = this.props
const { isAdmin, isPoolAdmin, items, noResourceSets, type } = this.props
const {
selectedHosts,
@@ -906,7 +912,9 @@ export default class Home extends Component {
</span>
</div>
</Col>
{(isAdmin || !noResourceSets) && (
{(isAdmin ||
(isPoolAdmin && process.env.XOA_PLAN > 3) ||
!noResourceSets) && (
<Col mediumSize={3} className='text-xs-right'>
<Link className='btn btn-success' to='/vms/new'>
<Icon icon='vm-new' /> {_('homeNewVm')}
@@ -1105,12 +1113,18 @@ export default class Home extends Component {
// ---------------------------------------------------------------------------
render () {
const { isAdmin, noResourceSets } = this.props
const { isAdmin, isPoolAdmin, noResourceSets } = this.props
const nItems = this._getNumberOfItems()
if (nItems < 1) {
return <NoObjects_ isAdmin={isAdmin} noResourceSets={noResourceSets} />
return (
<NoObjects_
isAdmin={isAdmin}
isPoolAdmin={isPoolAdmin}
noResourceSets={noResourceSets}
/>
)
}
const filteredItems = this._getFilteredItems()

View File

@@ -8,13 +8,7 @@ import React, { cloneElement, Component } from 'react'
import Tooltip from 'tooltip'
import { Text } from 'editable'
import { Container, Row, Col } from 'grid'
import {
editHost,
fetchHostStats,
installAllHostPatches,
installHostPatch,
subscribeHostMissingPatches,
} from 'xo'
import { editHost, fetchHostStats, subscribeHostMissingPatches } from 'xo'
import { connectStore, routes } from 'utils'
import {
createDoesHostNeedRestart,
@@ -110,7 +104,8 @@ const isRunning = host => host && host.power_state === 'Running'
return {
host,
hostPatches: getHostPatches(state, props),
hostPatches:
host.productBrand !== 'XCP-ng' && getHostPatches(state, props),
logs: getLogs(state, props),
memoryUsed: getMemoryUsed(state, props),
needsRestart: doesNeedRestart(state, props),
@@ -207,21 +202,12 @@ export default class Host extends Component {
host,
missingPatches =>
this.setState({
missingPatches: sortBy(missingPatches, patch => -patch.time),
missingPatches:
missingPatches && sortBy(missingPatches, patch => -patch.time),
})
)
}
_installAllPatches = () => {
const { host } = this.props
return installAllHostPatches(host)
}
_installPatch = patch => {
const { host } = this.props
return installHostPatch(host, patch)
}
_setNameDescription = nameDescription =>
editHost(this.props.host, { name_description: nameDescription })
_setNameLabel = nameLabel =>
@@ -331,11 +317,7 @@ export default class Host extends Component {
'vmController',
'vms',
]),
pick(this.state, ['missingPatches', 'statsOverview']),
{
installAllPatches: this._installAllPatches,
installPatch: this._installPatch,
}
pick(this.state, ['missingPatches', 'statsOverview'])
)
return (
<Page

View File

@@ -5,20 +5,22 @@ import React from 'react'
import TabButton from 'tab-button'
import SelectFiles from 'select-files'
import Upgrade from 'xoa-upgrade'
import { compareVersions, connectStore } from 'utils'
import { Text } from 'editable'
import { Toggle } from 'form'
import { compareVersions, connectStore } from 'utils'
import { FormattedRelative, FormattedTime } from 'react-intl'
import { Container, Row, Col } from 'grid'
import { forEach, map, noop } from 'lodash'
import { createGetObjectsOfType, createSelector } from 'selectors'
import {
enableHost,
detachHost,
disableHost,
forgetHost,
setRemoteSyslogHost,
restartHost,
installSupplementalPack,
} from 'xo'
import { FormattedRelative, FormattedTime } from 'react-intl'
import { Container, Row, Col } from 'grid'
import { createGetObjectsOfType, createSelector } from 'selectors'
import { forEach, map, noop } from 'lodash'
const ALLOW_INSTALL_SUPP_PACK = process.env.XOA_PLAN > 1
@@ -66,6 +68,7 @@ export default class extends Component {
return uniqPacks
}
)
_setRemoteSyslogHost = value => setRemoteSyslogHost(this.props.host, value)
render () {
const { host, pcis, pgpus } = this.props
@@ -182,6 +185,15 @@ export default class extends Component {
<th>{_('hostIscsiName')}</th>
<Copiable tagName='td'>{host.iSCSI_name}</Copiable>
</tr>
<tr>
<th>{_('hostRemoteSyslog')}</th>
<td>
<Text
value={host.logging.syslog_destination || ''}
onChange={this._setRemoteSyslogHost}
/>
</td>
</tr>
</tbody>
</table>
<br />

View File

@@ -85,9 +85,9 @@ export default ({
</Col>
<Col mediumSize={3}>
<p>
{host.license_params.sku_marketing_name} {host.version} ({
host.license_params.sku_type
})
{host.productBrand} {host.version} ({host.productBrand !== 'XCP-ng'
? host.license_params.sku_type
: 'GPLv2'})
</p>
</Col>
<Col mediumSize={3}>

View File

@@ -15,6 +15,7 @@ import { connectStore, noop } from 'utils'
import { Container, Row, Col } from 'grid'
import { createGetObjectsOfType } from 'selectors'
import { error } from 'notification'
import { get } from 'xo-defined'
import { Select, Number } from 'editable'
import { Toggle } from 'form'
import {
@@ -192,12 +193,18 @@ class PifItemLock extends Component {
render () {
const { networks, pif, vifsByNetwork } = this.props
const network = networks[pif.$network]
if (network === undefined) {
return null
}
const pifInUse = some(vifsByNetwork[pif.$network], vif => vif.attached)
return _toggleDefaultLockingMode(
<Toggle
disabled={pifInUse}
onChange={this._editNetwork}
value={networks[pif.$network].defaultIsLocked}
value={network.defaultIsLocked}
/>,
pifInUse && _('pifInUse')
)
@@ -212,9 +219,11 @@ const COLUMNS = [
sortCriteria: 'device',
},
{
itemRenderer: (pif, userData) => userData.networks[pif.$network].name_label,
itemRenderer: (pif, userData) =>
get(() => userData.networks[pif.$network].name_label),
name: _('pifNetworkLabel'),
sortCriteria: (pif, userData) => userData.networks[pif.$network].name_label,
sortCriteria: (pif, userData) =>
get(() => userData.networks[pif.$network].name_label),
},
{
component: PifItemVlan,

View File

@@ -1,15 +1,14 @@
import _ from 'intl'
import ActionRowButton from 'action-row-button'
import React, { Component } from 'react'
import SortedTable from 'sorted-table'
import TabButton from 'tab-button'
import Upgrade from 'xoa-upgrade'
import { chooseAction } from 'modal'
import { alert, chooseAction } from 'modal'
import { connectStore, formatSize } from 'utils'
import { Container, Row, Col } from 'grid'
import { createDoesHostNeedRestart, createSelector } from 'selectors'
import { FormattedRelative, FormattedTime } from 'react-intl'
import { restartHost } from 'xo'
import { restartHost, installAllHostPatches, installHostPatch } from 'xo'
import { isEmpty, isString } from 'lodash'
const MISSING_PATCH_COLUMNS = [
@@ -48,15 +47,76 @@ const MISSING_PATCH_COLUMNS = [
itemRenderer: patch => patch.guidance,
sortCriteria: patch => patch.guidance,
},
]
const MISSING_PATCH_COLUMNS_XCP = [
{
name: _('patchAction'),
itemRenderer: (patch, { installPatch, _installPatchWarning }) => (
<ActionRowButton
btnStyle='primary'
handler={() => _installPatchWarning(patch, installPatch)}
icon='host-patch-update'
/>
),
name: _('patchNameLabel'),
itemRenderer: patch => patch.name,
sortCriteria: 'name',
},
{
name: _('patchDescription'),
itemRenderer: patch => patch.description,
sortCriteria: 'description',
},
{
name: _('patchVersion'),
itemRenderer: patch => patch.version,
},
{
name: _('patchRelease'),
itemRenderer: patch => patch.release,
},
{
name: _('patchSize'),
itemRenderer: patch => formatSize(patch.size),
sortCriteria: 'size',
},
]
const INDIVIDUAL_ACTIONS_XCP = [
{
disabled: patch => patch.changelog === null,
handler: patch =>
alert(
_('changelog'),
<Container>
<Row className='mb-1'>
<Col size={3}>
<strong>{_('changelogPatch')}</strong>
</Col>
<Col size={9}>{patch.name}</Col>
</Row>
<Row className='mb-1'>
<Col size={3}>
<strong>{_('changelogDate')}</strong>
</Col>
<Col size={9}>
<FormattedTime
value={patch.changelog.date * 1000}
day='numeric'
month='long'
year='numeric'
/>
</Col>
</Row>
<Row className='mb-1'>
<Col size={3}>
<strong>{_('changelogAuthor')}</strong>
</Col>
<Col size={9}>{patch.changelog.author}</Col>
</Row>
<Row>
<Col size={3}>
<strong>{_('changelogDescription')}</strong>
</Col>
<Col size={9}>{patch.changelog.description}</Col>
</Row>
</Container>
),
icon: 'preview',
label: _('showChangelog'),
},
]
@@ -118,10 +178,139 @@ const INSTALLED_PATCH_COLUMNS_2 = [
},
]
class XcpPatches extends Component {
render () {
const { missingPatches, host, installAllPatches } = this.props
const hasMissingPatches = !isEmpty(missingPatches)
return (
<Container>
<Row>
<Col className='text-xs-right'>
{this.props.needsRestart && (
<TabButton
btnStyle='warning'
handler={restartHost}
handlerParam={host}
icon='host-reboot'
labelId='rebootUpdateHostLabel'
/>
)}
<TabButton
disabled={!hasMissingPatches}
btnStyle={hasMissingPatches ? 'primary' : undefined}
handler={installAllPatches}
icon={hasMissingPatches ? 'host-patch-update' : 'success'}
labelId={hasMissingPatches ? 'patchUpdateButton' : 'hostUpToDate'}
/>
</Col>
</Row>
{hasMissingPatches && (
<Row>
<Col>
<SortedTable
columns={MISSING_PATCH_COLUMNS_XCP}
collection={missingPatches}
individualActions={INDIVIDUAL_ACTIONS_XCP}
/>
</Col>
</Row>
)}
</Container>
)
}
}
@connectStore(() => ({
needsRestart: createDoesHostNeedRestart((_, props) => props.host),
}))
export default class HostPatches extends Component {
class XenServerPatches extends Component {
_getPatches = createSelector(
() => this.props.host,
() => this.props.hostPatches,
(host, hostPatches) => {
if (isEmpty(host.patches) && isEmpty(hostPatches)) {
return { patches: null }
}
if (isString(host.patches[0])) {
return {
patches: hostPatches,
columns: INSTALLED_PATCH_COLUMNS,
}
}
return {
patches: host.patches,
columns: INSTALLED_PATCH_COLUMNS_2,
}
}
)
_individualActions = [
{
name: _('patchAction'),
level: 'primary',
handler: this.props.installPatch,
icon: 'host-patch-update',
},
]
render () {
const { host, missingPatches, installAllPatches } = this.props
const { patches, columns } = this._getPatches()
const hasMissingPatches = !isEmpty(missingPatches)
return (
<Container>
<Row>
<Col className='text-xs-right'>
{this.props.needsRestart && (
<TabButton
btnStyle='warning'
handler={restartHost}
handlerParam={host}
icon='host-reboot'
labelId='rebootUpdateHostLabel'
/>
)}
<TabButton
disabled={!hasMissingPatches}
btnStyle={hasMissingPatches ? 'primary' : undefined}
handler={installAllPatches}
icon={hasMissingPatches ? 'host-patch-update' : 'success'}
labelId={hasMissingPatches ? 'patchUpdateButton' : 'hostUpToDate'}
/>
</Col>
</Row>
{hasMissingPatches && (
<Row>
<Col>
<h3>{_('hostMissingPatches')}</h3>
<SortedTable
individualActions={this._individualActions}
collection={missingPatches}
columns={MISSING_PATCH_COLUMNS}
/>
</Col>
</Row>
)}
<Row>
<Col>
{patches ? (
<span>
<h3>{_('hostAppliedPatches')}</h3>
<SortedTable collection={patches} columns={columns} />
</span>
) : (
<h4 className='text-xs-center'>{_('patchNothing')}</h4>
)}
</Col>
</Row>
</Container>
)
}
}
export default class TabPatches extends Component {
static contextTypes = {
router: React.PropTypes.object,
}
@@ -145,93 +334,31 @@ export default class HostPatches extends Component {
: this.context.router.push(`/pools/${this.props.host.$pool}/patches`)
}
_installPatchWarning = (patch, installPatch) =>
this._chooseActionPatch(() => installPatch(patch))
_installAllPatches = () =>
this._chooseActionPatch(() => installAllHostPatches(this.props.host))
_installAllPatchesWarning = installAllPatches =>
this._chooseActionPatch(installAllPatches)
_getPatches = createSelector(
() => this.props.host,
() => this.props.hostPatches,
(host, hostPatches) => {
if (isEmpty(host.patches) && isEmpty(hostPatches)) {
return { patches: null }
}
if (isString(host.patches[0])) {
return {
patches: hostPatches,
columns: INSTALLED_PATCH_COLUMNS,
}
}
return {
patches: host.patches,
columns: INSTALLED_PATCH_COLUMNS_2,
}
}
)
_installPatch = patch =>
this._chooseActionPatch(() => installHostPatch(this.props.host, patch))
render () {
const { host, missingPatches, installAllPatches, installPatch } = this.props
const { patches, columns } = this._getPatches()
const hasMissingPatches = !isEmpty(missingPatches)
return process.env.XOA_PLAN > 1 ? (
<Container>
<Row>
<Col className='text-xs-right'>
{this.props.needsRestart && (
<TabButton
btnStyle='warning'
handler={restartHost}
handlerParam={host}
icon='host-reboot'
labelId='rebootUpdateHostLabel'
/>
)}
<TabButton
disabled={!hasMissingPatches}
btnStyle={hasMissingPatches ? 'primary' : undefined}
handler={this._installAllPatchesWarning}
handlerParam={installAllPatches}
icon={hasMissingPatches ? 'host-patch-update' : 'success'}
labelId={hasMissingPatches ? 'patchUpdateButton' : 'hostUpToDate'}
/>
</Col>
</Row>
{hasMissingPatches && (
<Row>
<Col>
<h3>{_('hostMissingPatches')}</h3>
<SortedTable
collection={missingPatches}
userData={{
installPatch,
_installPatchWarning: this._installPatchWarning,
}}
columns={MISSING_PATCH_COLUMNS}
/>
</Col>
</Row>
)}
<Row>
<Col>
{patches ? (
<span>
<h3>{_('hostAppliedPatches')}</h3>
<SortedTable collection={patches} columns={columns} />
</span>
) : (
<h4 className='text-xs-center'>{_('patchNothing')}</h4>
)}
</Col>
</Row>
</Container>
if (process.env.XOA_PLAN < 2) {
return (
<Container>
<Upgrade place='hostPatches' available={2} />
</Container>
)
}
if (this.props.missingPatches === null) {
return <em>{_('updatePluginNotInstalled')}</em>
}
return this.props.host.productBrand === 'XCP-ng' ? (
<XcpPatches {...this.props} installAllPatches={this._installAllPatches} />
) : (
<Container>
<Upgrade place='hostPatches' available={2} />
</Container>
<XenServerPatches
{...this.props}
installAllPatches={this._installAllPatches}
installPatch={this._installPatch}
/>
)
}
}

View File

@@ -374,9 +374,12 @@ export default class Jobs extends Component {
forEach(item.values, valueItem => {
forEach(valueItem, (value, key) => {
if (data[key] === undefined) {
data[key] = []
data[key] = value
} else if (Array.isArray(data[key])) {
data[key].push(value)
} else {
data[key] = [data[key], value]
}
data[key].push(value)
})
})
})

View File

@@ -6,7 +6,7 @@ import React from 'react'
import SortedTable from 'sorted-table'
import { alert } from 'modal'
import { Card, CardHeader, CardBlock } from 'card'
import { forEach, keyBy } from 'lodash'
import { keyBy } from 'lodash'
import { FormattedDate } from 'react-intl'
import { get } from 'xo-defined'
import {
@@ -16,7 +16,6 @@ import {
} from 'xo'
import LogAlertBody from './log-alert-body'
import { isSkippedError, NO_VMS_MATCH_THIS_PATTERN } from './utils'
const STATUS_LABELS = {
failure: {
@@ -31,7 +30,7 @@ const STATUS_LABELS = {
className: 'success',
label: 'jobSuccess',
},
started: {
pending: {
className: 'warning',
label: 'jobStarted',
},
@@ -94,10 +93,10 @@ const LOG_COLUMNS = [
{
name: _('jobDuration'),
itemRenderer: log =>
log.duration !== undefined && (
<FormattedDuration duration={log.duration} />
log.end !== undefined && (
<FormattedDuration duration={log.end - log.start} />
),
sortCriteria: log => log.duration,
sortCriteria: log => log.end - log.start,
},
{
name: _('jobStatus'),
@@ -108,15 +107,20 @@ const LOG_COLUMNS = [
},
]
const showCalls = (log, { logs, jobs }) =>
const showTasks = log =>
alert(
_('jobModalTitle', { job: log.jobId.slice(4, 8) }),
<LogAlertBody log={log} job={get(() => jobs[log.jobId])} logs={logs} />
<span>
{_('jobModalTitle', { job: log.jobId.slice(4, 8) })}{' '}
<span style={{ fontSize: '0.5em' }} className='text-muted'>
{log.id}
</span>
</span>,
<LogAlertBody id={log.id} />
)
const LOG_INDIVIDUAL_ACTIONS = [
{
handler: showCalls,
handler: showTasks,
icon: 'preview',
label: _('logDisplayDetails'),
},
@@ -138,38 +142,6 @@ const LOG_FILTERS = {
jobSuccess: 'status: success',
}
const rowTransform = (log, { logs, jobs }) => {
let status
if (log.end !== undefined) {
if (log.error !== undefined) {
status =
log.error.message === NO_VMS_MATCH_THIS_PATTERN ? 'skipped' : 'failure'
} else {
let hasError = false
let hasTaskSkipped = false
forEach(logs[log.id], ({ status, result }) => {
if (status !== 'failure') {
return
}
if (result === undefined || !isSkippedError(result)) {
hasError = true
return false
}
hasTaskSkipped = true
})
status = hasError ? 'failure' : hasTaskSkipped ? 'skipped' : 'success'
}
} else {
status =
log.id === get(() => jobs[log.jobId].runId) ? 'started' : 'interrupted'
}
return {
...log,
status,
}
}
export default [
addSubscriptions({
logs: subscribeBackupNgLogs,
@@ -183,15 +155,13 @@ export default [
<CardBlock>
<NoObjects
actions={LOG_ACTIONS}
collection={get(() => logs['roots'])}
collection={logs}
columns={LOG_COLUMNS}
component={SortedTable}
data-jobs={jobs}
data-logs={logs}
emptyMessage={_('noLogs')}
filters={LOG_FILTERS}
individualActions={LOG_INDIVIDUAL_ACTIONS}
rowTransform={rowTransform}
/>
</CardBlock>
</Card>

View File

@@ -13,8 +13,8 @@ import Tooltip from 'tooltip'
import { alert } from 'modal'
import { Card, CardHeader, CardBlock } from 'card'
import { connectStore, formatSize, formatSpeed } from 'utils'
import { createFilter, createGetObject, createSelector } from 'selectors'
import { forEach, includes, keyBy, map, orderBy } from 'lodash'
import { createGetObject, createSelector } from 'selectors'
import { filter, forEach, includes, keyBy, map, orderBy } from 'lodash'
import { FormattedDate } from 'react-intl'
import { get } from 'xo-defined'
import {
@@ -141,8 +141,6 @@ const isSkippedError = error =>
error.message === UNHEALTHY_VDI_CHAIN_ERROR ||
error.message === NO_SUCH_OBJECT_ERROR
const filterOptionRenderer = ({ label }) => _(label)
class Log extends BaseComponent {
state = {
filter: DEFAULT_CALL_FILTER,
@@ -154,21 +152,29 @@ class Log extends BaseComponent {
(logId, runId) => logId !== runId
)
_getFilteredCalls = createFilter(
_getCallsByState = createSelector(
() => this.props.log.calls,
createSelector(
() => this.state.filter.value,
this._getIsJobInterrupted,
(value, isInterrupted) => PREDICATES[value](isInterrupted)
)
this._getIsJobInterrupted,
(calls, isInterrupted) => {
const callsByState = {}
forEach(CALL_FILTER_OPTIONS, ({ value }) => {
callsByState[value] = filter(calls, PREDICATES[value](isInterrupted))
})
return callsByState
}
)
_filterValueRenderer = createSelector(
() => this._getFilteredCalls().length,
({ label }) => label,
(size, label) => (
_getFilteredCalls = createSelector(
() => this.state.filter.value,
this._getCallsByState,
(value, calls) => calls[value]
)
_getFilterOptionRenderer = createSelector(
this._getCallsByState,
calls => ({ label, value }) => (
<span>
{_(label)} ({size})
{_(label)} ({calls[value].length})
</span>
)
)
@@ -190,12 +196,11 @@ class Log extends BaseComponent {
<Select
labelKey='label'
onChange={this.linkState('filter')}
optionRenderer={filterOptionRenderer}
optionRenderer={this._getFilterOptionRenderer()}
options={CALL_FILTER_OPTIONS}
required
value={this.state.filter}
valueKey='value'
valueRenderer={this._filterValueRenderer}
/>
<br />
<ul className='list-group'>
@@ -394,7 +399,9 @@ const LOG_COLUMNS = [
'tag',
log.hasErrors
? 'tag-danger'
: log.callSkipped ? 'tag-info' : 'tag-success'
: log.callSkipped
? 'tag-info'
: 'tag-success'
)}
>
{_('jobFinished')}

View File

@@ -1,4 +1,5 @@
import _, { FormattedDuration } from 'intl'
import ActionButton from 'action-button'
import Copiable from 'copiable'
import Icon from 'icon'
import React from 'react'
@@ -6,37 +7,10 @@ import renderXoItem, { renderXoItemFromId } from 'render-xo-item'
import Select from 'form/select'
import Tooltip from 'tooltip'
import { addSubscriptions, formatSize, formatSpeed } from 'utils'
import { createSelector } from 'selectors'
import { find, filter, isEmpty, get, keyBy, map, forEach } from 'lodash'
import { countBy, filter, get, keyBy, map } from 'lodash'
import { FormattedDate } from 'react-intl'
import { injectState, provideState } from '@julien-f/freactal'
import { subscribeRemotes } from 'xo'
import {
isSkippedError,
NO_VMS_MATCH_THIS_PATTERN,
UNHEALTHY_VDI_CHAIN_ERROR,
} from './utils'
const getTaskStatus = createSelector(
taskLog => taskLog,
isJobRunning => isJobRunning,
({ end, status, result }, isJobRunning) =>
end !== undefined
? status === 'success'
? 'success'
: result !== undefined && isSkippedError(result) ? 'skipped' : 'failure'
: isJobRunning ? 'started' : 'interrupted'
)
const getSubTaskStatus = createSelector(
taskLog => taskLog,
isJobRunning => isJobRunning,
({ end, status, result }, isJobRunning) =>
end !== undefined
? status === 'success' ? 'success' : 'failure'
: isJobRunning ? 'started' : 'interrupted'
)
import { runBackupNgJob, subscribeBackupNgLogs, subscribeRemotes } from 'xo'
const TASK_STATUS = {
failure: {
@@ -51,7 +25,7 @@ const TASK_STATUS = {
icon: 'running',
label: 'taskSuccess',
},
started: {
pending: {
icon: 'busy',
label: 'taskStarted',
},
@@ -70,138 +44,112 @@ const TaskStateInfos = ({ status }) => {
)
}
const VmTaskDataInfos = ({ logs, vmTaskId }) => {
let transferSize, transferDuration, mergeSize, mergeDuration
forEach(logs[vmTaskId], ({ taskId }) => {
if (transferSize !== undefined) {
return false
}
const transferTask = find(logs[taskId], { message: 'transfer' })
if (transferTask !== undefined) {
transferSize = transferTask.result.size
transferDuration = transferTask.end - transferTask.start
}
const mergeTask = find(logs[taskId], { message: 'merge' })
if (mergeTask !== undefined) {
mergeSize = mergeTask.result.size
mergeDuration = mergeTask.end - mergeTask.start
}
})
if (transferSize === undefined) {
return null
}
return (
<div>
{_.keyValue(_('taskTransferredDataSize'), formatSize(transferSize))}
<br />
{_.keyValue(
_('taskTransferredDataSpeed'),
formatSpeed(transferSize, transferDuration)
)}
{mergeSize !== undefined && (
<div>
{_.keyValue(_('taskMergedDataSize'), formatSize(mergeSize))}
<br />
{_.keyValue(
_('taskMergedDataSpeed'),
formatSpeed(mergeSize, mergeDuration)
)}
</div>
)}
</div>
const TaskDate = ({ label, value }) =>
_.keyValue(
_(label),
<FormattedDate
value={new Date(value)}
month='short'
day='numeric'
year='numeric'
hour='2-digit'
minute='2-digit'
second='2-digit'
/>
)
}
const UNHEALTHY_VDI_CHAIN_ERROR = 'unhealthy VDI chain'
const UNHEALTHY_VDI_CHAIN_LINK =
'https://xen-orchestra.com/docs/backup_troubleshooting.html#vdi-chain-protection'
const ALL_FILTER_OPTION = { label: 'allTasks', value: 'all' }
const FAILURE_FILTER_OPTION = { label: 'taskFailed', value: 'failure' }
const STARTED_FILTER_OPTION = { label: 'taskStarted', value: 'started' }
const PENDING_FILTER_OPTION = { label: 'taskStarted', value: 'pending' }
const INTERRUPTED_FILTER_OPTION = {
label: 'taskInterrupted',
value: 'interrupted',
}
const TASK_FILTER_OPTIONS = [
ALL_FILTER_OPTION,
FAILURE_FILTER_OPTION,
STARTED_FILTER_OPTION,
{ label: 'taskInterrupted', value: 'interrupted' },
PENDING_FILTER_OPTION,
INTERRUPTED_FILTER_OPTION,
{ label: 'taskSkipped', value: 'skipped' },
{ label: 'taskSuccess', value: 'success' },
]
const getFilteredTaskLogs = (logs, isJobRunning, filterValue) =>
filterValue === 'all'
? logs
: filter(logs, log => getTaskStatus(log, isJobRunning) === filterValue)
const getInitialFilter = (job, logs, log) => {
const isEmptyFilter = filterValue =>
isEmpty(
getFilteredTaskLogs(
logs[log.id],
get(job, 'runId') === log.id,
filterValue
)
)
if (!isEmptyFilter('started')) {
return STARTED_FILTER_OPTION
}
if (!isEmptyFilter('failure')) {
return FAILURE_FILTER_OPTION
}
return ALL_FILTER_OPTION
}
export default [
addSubscriptions({
addSubscriptions(({ id }) => ({
remotes: cb =>
subscribeRemotes(remotes => {
cb(keyBy(remotes, 'id'))
}),
}),
log: cb =>
subscribeBackupNgLogs(logs => {
cb(logs[id])
}),
})),
provideState({
initialState: ({ job, logs, log }) => ({
filter: getInitialFilter(job, logs, log),
initialState: () => ({
filter: undefined,
}),
effects: {
setFilter: (_, filter) => state => ({
...state,
filter,
}),
restartVmJob: (_, { vm }) => async (
_,
{ log: { scheduleId, jobId } }
) => {
await runBackupNgJob({
id: jobId,
vm,
schedule: scheduleId,
})
},
},
computed: {
isJobRunning: (_, { job, log }) => get(job, 'runId') === log.id,
filteredTaskLogs: ({ filter: { value }, isJobRunning }, { log, logs }) =>
getFilteredTaskLogs(logs[log.id], isJobRunning, value),
optionRenderer: ({ isJobRunning }, { log, logs }) => ({
label,
value,
}) => (
filteredTaskLogs: (
{ defaultFilter, filter: { value } = defaultFilter },
{ log = {} }
) =>
value === 'all'
? log.tasks
: filter(log.tasks, ({ status }) => status === value),
optionRenderer: ({ countByStatus }) => ({ label, value }) => (
<span>
{_(label)} ({
getFilteredTaskLogs(logs[log.id], isJobRunning, value).length
})
{_(label)} ({countByStatus[value] || 0})
</span>
),
countByStatus: (_, { log = {} }) => ({
all: get(log.tasks, 'length'),
...countBy(log.tasks, 'status'),
}),
defaultFilter: ({ countByStatus }) => {
if (countByStatus.pending > 0) {
return PENDING_FILTER_OPTION
}
if (countByStatus.failure > 0) {
return FAILURE_FILTER_OPTION
}
if (countByStatus.interrupted > 0) {
return INTERRUPTED_FILTER_OPTION
}
return ALL_FILTER_OPTION
},
},
}),
injectState,
({ job, log, logs, remotes, state, effects }) =>
log.error !== undefined ? (
<span
className={
log.error.message === NO_VMS_MATCH_THIS_PATTERN
? 'text-info'
: 'text-danger'
}
>
<Copiable tagName='p' data={JSON.stringify(log.error, null, 2)}>
<Icon icon='alarm' /> {log.error.message}
({ log = {}, remotes, state, effects }) => {
const { status, result, scheduleId } = log
return (status === 'failure' || status === 'skipped') &&
result !== undefined ? (
<span className={status === 'skipped' ? 'text-info' : 'text-danger'}>
<Copiable tagName='p' data={JSON.stringify(result, null, 2)}>
<Icon icon='alarm' /> {result.message}
</Copiable>
</span>
) : (
@@ -212,137 +160,273 @@ export default [
optionRenderer={state.optionRenderer}
options={TASK_FILTER_OPTIONS}
required
value={state.filter}
value={state.filter || state.defaultFilter}
valueKey='value'
/>
<br />
<ul className='list-group'>
{map(state.filteredTaskLogs, vmTaskLog => (
<li key={vmTaskLog.data.id} className='list-group-item'>
{renderXoItemFromId(vmTaskLog.data.id)} ({vmTaskLog.data.id.slice(
4,
8
)}){' '}
<TaskStateInfos
status={getTaskStatus(vmTaskLog, state.isJobRunning)}
/>
<ul>
{map(logs[vmTaskLog.taskId], subTaskLog => (
<li key={subTaskLog.taskId}>
{subTaskLog.message === 'snapshot' ? (
<span>
<Icon icon='task' /> {_('snapshotVmLabel')}
</span>
) : subTaskLog.data.type === 'remote' ? (
<span>
{get(remotes, subTaskLog.data.id) !== undefined
? renderXoItem({
type: 'remote',
value: remotes[subTaskLog.data.id],
})
: _('errorNoSuchItem')}{' '}
({subTaskLog.data.id.slice(4, 8)})
</span>
) : (
<span>
{renderXoItemFromId(subTaskLog.data.id)} ({subTaskLog.data.id.slice(
4,
8
)})
</span>
)}{' '}
<TaskStateInfos
status={getSubTaskStatus(subTaskLog, state.isJobRunning)}
{map(state.filteredTaskLogs, taskLog => {
let globalIsFull
return (
<li key={taskLog.data.id} className='list-group-item'>
{renderXoItemFromId(taskLog.data.id)} ({taskLog.data.id.slice(
4,
8
)}) <TaskStateInfos status={taskLog.status} />{' '}
{scheduleId !== undefined &&
taskLog.status !== 'success' &&
taskLog.status !== 'pending' && (
<ActionButton
handler={effects.restartVmJob}
icon='run'
size='small'
tooltip={_('backupRestartVm')}
data-vm={taskLog.data.id}
/>
<br />
{subTaskLog.status === 'failure' && (
<Copiable
tagName='p'
data={JSON.stringify(subTaskLog.result, null, 2)}
>
{_.keyValue(
_('taskError'),
<span className={'text-danger'}>
{subTaskLog.result.message}
)}
<ul>
{map(taskLog.tasks, subTaskLog => {
if (
subTaskLog.message !== 'export' &&
subTaskLog.message !== 'snapshot'
) {
return
}
const isFull = get(subTaskLog.data, 'isFull')
if (isFull !== undefined && globalIsFull === undefined) {
globalIsFull = isFull
}
return (
<li key={subTaskLog.id}>
{subTaskLog.message === 'snapshot' ? (
<span>
<Icon icon='task' /> {_('snapshotVmLabel')}
</span>
)}
</Copiable>
)}
</li>
))}
</ul>
{_.keyValue(
_('taskStart'),
<FormattedDate
value={new Date(vmTaskLog.start)}
month='short'
day='numeric'
year='numeric'
hour='2-digit'
minute='2-digit'
second='2-digit'
/>
)}
{vmTaskLog.end !== undefined && (
<div>
{_.keyValue(
_('taskEnd'),
<FormattedDate
value={new Date(vmTaskLog.end)}
month='short'
day='numeric'
year='numeric'
hour='2-digit'
minute='2-digit'
second='2-digit'
/>
)}
<br />
{_.keyValue(
_('taskDuration'),
<FormattedDuration duration={vmTaskLog.duration} />
)}
<br />
{vmTaskLog.status === 'failure' &&
vmTaskLog.result !== undefined ? (
vmTaskLog.result.message === UNHEALTHY_VDI_CHAIN_ERROR ? (
<Tooltip content={_('clickForMoreInformation')}>
<a
className='text-info'
href={UNHEALTHY_VDI_CHAIN_LINK}
rel='noopener noreferrer'
target='_blank'
>
<Icon icon='info' /> {_('unhealthyVdiChainError')}
</a>
</Tooltip>
) : (
<Copiable
tagName='p'
data={JSON.stringify(vmTaskLog.result, null, 2)}
>
{_.keyValue(
_('taskError'),
<span
className={
isSkippedError(vmTaskLog.result)
? 'text-info'
: 'text-danger'
) : subTaskLog.data.type === 'remote' ? (
<span>
{get(remotes, subTaskLog.data.id) !== undefined
? renderXoItem({
type: 'remote',
value: remotes[subTaskLog.data.id],
})
: _('errorNoSuchItem')}{' '}
({subTaskLog.data.id.slice(4, 8)})
</span>
) : (
<span>
{renderXoItemFromId(subTaskLog.data.id)} ({subTaskLog.data.id.slice(
4,
8
)})
</span>
)}{' '}
<TaskStateInfos status={subTaskLog.status} />
<ul>
{map(subTaskLog.tasks, operationLog => {
if (
operationLog.message !== 'merge' &&
operationLog.message !== 'transfer'
) {
return
}
>
{vmTaskLog.result.message}
</span>
return (
<li key={operationLog.id}>
<span>
<Icon icon='task' /> {operationLog.message}
</span>{' '}
<TaskStateInfos status={operationLog.status} />
<br />
<TaskDate
label='taskStart'
value={operationLog.start}
/>
{operationLog.end !== undefined && (
<div>
<TaskDate
label='taskEnd'
value={operationLog.end}
/>
<br />
{_.keyValue(
_('taskDuration'),
<FormattedDuration
duration={
operationLog.end - operationLog.start
}
/>
)}
<br />
{operationLog.status === 'failure' ? (
<Copiable
tagName='p'
data={JSON.stringify(
operationLog.result,
null,
2
)}
>
{_.keyValue(
_('taskError'),
<span className='text-danger'>
{operationLog.result.message}
</span>
)}
</Copiable>
) : (
operationLog.result.size > 0 && (
<div>
{_.keyValue(
_('operationSize'),
formatSize(operationLog.result.size)
)}
<br />
{_.keyValue(
_('operationSpeed'),
formatSpeed(
operationLog.result.size,
operationLog.end -
operationLog.start
)
)}
</div>
)
)}
</div>
)}
</li>
)
})}
</ul>
<TaskDate label='taskStart' value={subTaskLog.start} />
{subTaskLog.end !== undefined && (
<div>
<TaskDate label='taskEnd' value={subTaskLog.end} />
<br />
{subTaskLog.message !== 'snapshot' &&
_.keyValue(
_('taskDuration'),
<FormattedDuration
duration={subTaskLog.end - subTaskLog.start}
/>
)}
<br />
{subTaskLog.status === 'failure' &&
subTaskLog.result !== undefined && (
<Copiable
tagName='p'
data={JSON.stringify(
subTaskLog.result,
null,
2
)}
>
{_.keyValue(
_('taskError'),
<span className='text-danger'>
{subTaskLog.result.message}
</span>
)}
</Copiable>
)}
</div>
)}
</Copiable>
</li>
)
) : (
<VmTaskDataInfos logs={logs} vmTaskId={vmTaskLog.taskId} />
)}
</div>
)}
</li>
))}
})}
</ul>
<TaskDate label='taskStart' value={taskLog.start} />
<br />
{taskLog.end !== undefined && (
<div>
<TaskDate label='taskEnd' value={taskLog.end} />
<br />
{_.keyValue(
_('taskDuration'),
<FormattedDuration
duration={taskLog.end - taskLog.start}
/>
)}
<br />
{taskLog.result !== undefined ? (
taskLog.result.message === UNHEALTHY_VDI_CHAIN_ERROR ? (
<Tooltip content={_('clickForMoreInformation')}>
<a
className='text-info'
href={UNHEALTHY_VDI_CHAIN_LINK}
rel='noopener noreferrer'
target='_blank'
>
<Icon icon='info' /> {_('unhealthyVdiChainError')}
</a>
</Tooltip>
) : (
<Copiable
tagName='p'
data={JSON.stringify(taskLog.result, null, 2)}
>
{_.keyValue(
taskLog.status === 'skipped'
? _('taskReason')
: _('taskError'),
<span
className={
taskLog.status === 'skipped'
? 'text-info'
: 'text-danger'
}
>
{taskLog.result.message}
</span>
)}
</Copiable>
)
) : (
<div>
{taskLog.transfer !== undefined && (
<div>
{_.keyValue(
_('taskTransferredDataSize'),
formatSize(taskLog.transfer.size)
)}
<br />
{_.keyValue(
_('taskTransferredDataSpeed'),
formatSpeed(
taskLog.transfer.size,
taskLog.transfer.duration
)
)}
</div>
)}
{taskLog.merge !== undefined && (
<div>
{_.keyValue(
_('taskMergedDataSize'),
formatSize(taskLog.merge.size)
)}
<br />
{_.keyValue(
_('taskMergedDataSpeed'),
formatSpeed(
taskLog.merge.size,
taskLog.merge.duration
)
)}
</div>
)}
</div>
)}
</div>
)}
{globalIsFull !== undefined &&
_.keyValue(_('exportType'), globalIsFull ? 'full' : 'delta')}
</li>
)
})}
</ul>
</div>
),
)
},
].reduceRight((value, decorator) => decorator(value))

View File

@@ -1,7 +0,0 @@
export const NO_VMS_MATCH_THIS_PATTERN = 'no VMs match this pattern'
export const UNHEALTHY_VDI_CHAIN_ERROR = 'unhealthy VDI chain'
const NO_SUCH_OBJECT_ERROR = 'no such object'
export const isSkippedError = error =>
error.message === UNHEALTHY_VDI_CHAIN_ERROR ||
error.message === NO_SUCH_OBJECT_ERROR

View File

@@ -19,6 +19,7 @@ import {
createFilter,
createGetObjectsOfType,
createSelector,
getIsPoolAdmin,
getStatus,
getUser,
isAdmin,
@@ -31,6 +32,7 @@ const returnTrue = () => true
@connectStore(
() => ({
isAdmin,
isPoolAdmin: getIsPoolAdmin,
nTasks: createGetObjectsOfType('task').count([
task => task.status === 'pending',
]),
@@ -80,11 +82,6 @@ export default class Menu extends Component {
isEmpty
)
_getNoOperatableSrs = createSelector(
createFilter(() => this.props.srs, this._checkPermissions),
isEmpty
)
_getNoResourceSets = createSelector(() => this.props.resourceSets, isEmpty)
get height () {
@@ -108,9 +105,17 @@ export default class Menu extends Component {
}
render () {
const { isAdmin, nTasks, status, user, pools, nHosts } = this.props
const {
isAdmin,
isPoolAdmin,
nTasks,
status,
user,
pools,
nHosts,
srs,
} = this.props
const noOperatablePools = this._getNoOperatablePools()
const noOperatableSrs = this._getNoOperatableSrs()
const noResourceSets = this._getNoResourceSets()
/* eslint-disable object-property-newline */
@@ -136,7 +141,7 @@ export default class Menu extends Component {
icon: 'template',
label: 'homeTemplatePage',
},
!noOperatableSrs && {
!isEmpty(srs) && {
to: '/home?t=SR',
icon: 'sr',
label: 'homeSrPage',
@@ -313,7 +318,9 @@ export default class Menu extends Component {
icon: 'menu-new',
label: 'newMenu',
subMenu: [
(isAdmin || !noResourceSets) && {
(isAdmin ||
(isPoolAdmin && process.env.XOA_PLAN > 3) ||
!noResourceSets) && {
to: '/vms/new',
icon: 'menu-new-vm',
label: 'newVmPage',

View File

@@ -63,7 +63,7 @@
margin: auto;
}
.refreshNames {
.refreshNames, .availableTemplateVars {
cursor: pointer;
}

View File

@@ -13,6 +13,7 @@ import store from 'store'
import Tags from 'tags'
import Tooltip from 'tooltip'
import Wizard, { Section } from 'wizard'
import { alert } from 'modal'
import { Container, Row, Col } from 'grid'
import { injectIntl } from 'react-intl'
import {
@@ -30,8 +31,8 @@ import {
isEmpty,
join,
map,
slice,
size,
slice,
sum,
sumBy,
} from 'lodash'
@@ -73,12 +74,14 @@ import {
getCoresPerSocketPossibilities,
generateReadableRandomString,
noop,
resolveIds,
resolveResourceSet,
} from 'utils'
import {
createSelector,
createGetObject,
createGetObjectsOfType,
getIsPoolAdmin,
getUser,
} from 'selectors'
@@ -87,6 +90,32 @@ import styles from './index.css'
const NB_VMS_MIN = 2
const NB_VMS_MAX = 100
const AVAILABLE_TEMPLATE_VARS = {
'{name}': 'templateNameInfo',
'%': 'templateIndexInfo',
}
const showAvailableTemplateVars = () =>
alert(
_('availableTemplateVarsTitle'),
<ul>
{map(AVAILABLE_TEMPLATE_VARS, (value, key) => (
<li key={key}>{_.keyValue(key, _(value))}</li>
))}
</ul>
)
const AvailableTemplateVarsInfo = () => (
<Tooltip content={_('availableTemplateVarsInfo')}>
<a
className={classNames('text-info', styles.availableTemplateVars)}
onClick={showAvailableTemplateVars}
>
<Icon icon='info' />
</a>
</Tooltip>
)
/* eslint-disable camelcase */
const getObject = createGetObject((_, id) => id)
@@ -163,6 +192,7 @@ class Vif extends BaseComponent {
) : (
<SelectResourceSetsNetwork
onChange={onChangeNetwork}
predicate={networkPredicate}
resourceSet={resourceSet}
value={vif.network}
/>
@@ -210,6 +240,7 @@ class Vif extends BaseComponent {
})
@connectStore(() => ({
isAdmin: createSelector(getUser, user => user && user.permission === 'admin'),
isPoolAdmin: getIsPoolAdmin,
networks: createGetObjectsOfType('network').sort(),
pool: createGetObject((_, props) => props.location.query.pool),
pools: createGetObjectsOfType('pool'),
@@ -241,7 +272,9 @@ export default class NewVm extends BaseComponent {
_getResourceSet = () => {
const {
location: { query: { resourceSet: resourceSetId } },
location: {
query: { resourceSet: resourceSetId },
},
resourceSets,
} = this.props
return resourceSets && find(resourceSets, ({ id }) => id === resourceSetId)
@@ -292,7 +325,7 @@ export default class NewVm extends BaseComponent {
name_label: '',
name_description: '',
nameLabels: map(Array(NB_VMS_MIN), (_, index) => `VM_${index + 1}`),
namePattern: '{name}_%',
namePattern: '{name}%',
nbVms: NB_VMS_MIN,
VDIs: [],
VIFs: [],
@@ -330,6 +363,7 @@ export default class NewVm extends BaseComponent {
}
let cloudConfig
let cloudConfigs
if (state.configDrive) {
const hostname = state.name_label
.replace(/^\s+|\s+$/g, '')
@@ -344,7 +378,14 @@ export default class NewVm extends BaseComponent {
''
)}`
} else {
cloudConfig = state.customConfig
const replacer = this._buildTemplate(state.customConfig)
cloudConfig = replacer(this.state.state, 0)
if (state.multipleVms) {
const seqStart = state.seqStart
cloudConfigs = map(state.nameLabels, (_, i) =>
replacer(state, i + +seqStart)
)
}
}
} else if (state.template.name_label === 'CoreOS') {
cloudConfig = state.cloudConfig
@@ -403,7 +444,7 @@ export default class NewVm extends BaseComponent {
}
return state.multipleVms
? createVms(data, state.nameLabels)
? createVms(data, state.nameLabels, cloudConfigs)
: createVm(data)
}
@@ -435,7 +476,7 @@ export default class NewVm extends BaseComponent {
$SR:
pool || isInResourceSet(vdi.$SR)
? vdi.$SR
: resourceSet.objectsByType['SR'][0].id,
: this._getDefaultSr(template),
}
}
})
@@ -447,13 +488,12 @@ export default class NewVm extends BaseComponent {
network:
pool || isInResourceSet(vif.$network)
? vif.$network
: resourceSet.objectsByType['network'][0].id,
: this._getDefaultNetworkId(template),
})
})
if (VIFs.length === 0) {
const networkId = this._getDefaultNetworkId()
VIFs.push({
network: networkId,
network: this._getDefaultNetworkId(template),
})
}
const name_label =
@@ -464,7 +504,7 @@ export default class NewVm extends BaseComponent {
state.name_description === '' || !state.name_descriptionHasChanged
? template.name_description || ''
: state.name_description
const replacer = this._buildTemplate()
const replacer = this._buildVmsNameTemplate()
this._setState({
// infos
name_label,
@@ -484,7 +524,7 @@ export default class NewVm extends BaseComponent {
'SSH',
sshKeys: this.props.userSshKeys && this.props.userSshKeys.length && [0],
customConfig:
'#cloud-config\n#hostname: myhostname\n#ssh_authorized_keys:\n# - ssh-rsa <myKey>\n#packages:\n# - htop\n',
'#cloud-config\n#hostname: {name}%\n#ssh_authorized_keys:\n# - ssh-rsa <myKey>\n#packages:\n# - htop\n',
// interfaces
VIFs,
// disks
@@ -495,7 +535,7 @@ export default class NewVm extends BaseComponent {
name_description: disk.name_description || 'Created by XO',
name_label:
(name_label || 'disk') + '_' + generateReadableRandomString(5),
SR: pool ? pool.default_SR : resourceSet.objectsByType['SR'][0].id,
SR: this._getDefaultSr(template),
}
}),
})
@@ -546,8 +586,12 @@ export default class NewVm extends BaseComponent {
_getNetworkPredicate = createSelector(
this._getIsInPool,
this._getIsInResourceSet,
(isInPool, isInResourceSet) => network =>
isInResourceSet(network.id) || isInPool(network)
() => this.props.pool === undefined,
() => this.state.state.template,
(isInPool, isInResourceSet, self, template) => network =>
(self ? isInResourceSet(network.id) : isInPool(network)) &&
template !== undefined &&
template.$pool === network.$pool
)
_getPoolNetworks = createSelector(
() => this.props.networks,
@@ -580,27 +624,34 @@ export default class NewVm extends BaseComponent {
)
}
)
_getDefaultNetworkId = () => {
const resourceSet = this._getResolvedResourceSet()
if (resourceSet) {
const { network } = resourceSet.objectsByType
return !isEmpty(network) && network[0].id
_getDefaultNetworkId = template => {
if (template === undefined) {
return
}
const network = find(this._getPoolNetworks(), network => {
const pif = getObject(store.getState(), network.PIFs[0])
return pif && pif.management
})
const network =
this.props.pool === undefined
? find(this._getResolvedResourceSet().objectsByType.network, {
$pool: template.$pool,
})
: find(this._getPoolNetworks(), network => {
const pif = getObject(store.getState(), network.PIFs[0])
return pif && pif.management
})
return network && network.id
}
_buildTemplate = createSelector(
_buildVmsNameTemplate = createSelector(
() => this.state.state.namePattern,
namePattern =>
buildTemplate(namePattern, {
'{name}': state => state.name_label || '',
'%': (_, i) => i,
})
namePattern => this._buildTemplate(namePattern)
)
_buildTemplate = pattern =>
buildTemplate(pattern, {
'{name}': state => state.name_label || '',
'%': (_, i) => i,
})
_getVgpuTypePredicate = createSelector(
() => this.props.pool,
pool => vgpuType => pool !== undefined && pool.id === vgpuType.$pool
@@ -630,7 +681,7 @@ export default class NewVm extends BaseComponent {
if (nbVmsClamped < nameLabels.length) {
this._setState({ nameLabels: slice(newNameLabels, 0, nbVmsClamped) })
} else {
const replacer = this._buildTemplate()
const replacer = this._buildVmsNameTemplate()
for (
let i = +seqStart + nameLabels.length;
i <= +seqStart + nbVmsClamped - 1;
@@ -645,7 +696,7 @@ export default class NewVm extends BaseComponent {
const { nameLabels, seqStart } = this.state.state
const nbVms = nameLabels.length
const newNameLabels = []
const replacer = this._buildTemplate()
const replacer = this._buildVmsNameTemplate()
for (let i = +seqStart; i <= +seqStart + nbVms - 1; i++) {
newNameLabels.push(replacer(this.state.state, i))
@@ -670,9 +721,34 @@ export default class NewVm extends BaseComponent {
})
this._reset()
}
_getDefaultSr = template => {
const { pool } = this.props
if (pool !== undefined) {
return pool.default_SR
}
if (template === undefined) {
return
}
const defaultSr = getObject(store.getState(), template.$pool, true)
.default_SR
return includes(
resolveIds(
filter(
this._getResolvedResourceSet().objectsByType.SR,
this._getSrPredicate()
)
),
defaultSr
)
? defaultSr
: undefined
}
_addVdi = () => {
const { state } = this.state
const { pool } = this.props
this._setState({
VDIs: [
@@ -683,7 +759,7 @@ export default class NewVm extends BaseComponent {
(state.name_label || 'disk') +
'_' +
generateReadableRandomString(5),
SR: pool && pool.default_SR,
SR: this._getDefaultSr(state.template),
type: 'system',
},
],
@@ -697,13 +773,13 @@ export default class NewVm extends BaseComponent {
})
}
_addInterface = () => {
const networkId = this._getDefaultNetworkId()
const { state } = this.state
this._setState({
VIFs: [
...this.state.state.VIFs,
...state.VIFs,
{
network: networkId,
network: this._getDefaultNetworkId(state.template),
},
],
})
@@ -744,7 +820,7 @@ export default class NewVm extends BaseComponent {
// MAIN ------------------------------------------------------------------------
_renderHeader = () => {
const { isAdmin, pool, resourceSets } = this.props
const { isAdmin, isPoolAdmin, pool, resourceSets } = this.props
const selectPool = (
<span className={styles.inlineSelect}>
<SelectPool onChange={this._selectPool} value={pool} />
@@ -763,9 +839,12 @@ export default class NewVm extends BaseComponent {
<Row>
<Col mediumSize={12}>
<h2>
{isAdmin || !isEmpty(resourceSets)
{isAdmin ||
(isPoolAdmin && process.env.XOA_PLAN > 3) ||
!isEmpty(resourceSets)
? _('newVmCreateNewVmOn', {
select: isAdmin ? selectPool : selectResourceSet,
select:
isAdmin || isPoolAdmin ? selectPool : selectResourceSet,
})
: _('newVmCreateNewVmNoPermission')}
</h2>
@@ -1027,7 +1106,11 @@ export default class NewVm extends BaseComponent {
value='customConfig'
/>
&nbsp;
<span>{_('newVmCustomConfig')}</span>
<span>
{_('newVmCustomConfig')}
&nbsp;
<AvailableTemplateVarsInfo />
</span>
&nbsp;
<DebounceTextarea
className={classNames('form-control', styles.customConfig)}
@@ -1158,7 +1241,9 @@ export default class NewVm extends BaseComponent {
// INTERFACES ------------------------------------------------------------------
_renderInterfaces = () => {
const { state: { VIFs } } = this.state
const {
state: { VIFs },
} = this.state
return (
<Section
@@ -1199,7 +1284,9 @@ export default class NewVm extends BaseComponent {
// DISKS -----------------------------------------------------------------------
_renderDisks = () => {
const { state: { configDrive, existingDisks, VDIs } } = this.state
const {
state: { configDrive, existingDisks, VDIs },
} = this.state
const { pool } = this.props
let i = 0
const resourceSet = this._getResolvedResourceSet()
@@ -1481,6 +1568,8 @@ export default class NewVm extends BaseComponent {
)}
value={namePattern}
/>
&nbsp;
<AvailableTemplateVarsInfo />
</Item>
<Item label={_('newVmFirstIndex')}>
<DebounceInput
@@ -1491,6 +1580,16 @@ export default class NewVm extends BaseComponent {
value={seqStart}
/>
</Item>
<Item>
<Tooltip content={_('newVmNameRefresh')}>
<a
className={styles.refreshNames}
onClick={this._updateNameLabels}
>
<Icon icon='refresh' />
</a>
</Tooltip>
</Item>
<Item className='input-group'>
<DebounceInput
className='form-control'
@@ -1509,16 +1608,6 @@ export default class NewVm extends BaseComponent {
</Tooltip>
</span>
</Item>
<Item>
<Tooltip content={_('newVmNameRefresh')}>
<a
className={styles.refreshNames}
onClick={this._updateNameLabels}
>
<Icon icon='refresh' />
</a>
</Tooltip>
</Item>
{multipleVms && (
<LineItem>
{map(nameLabels, (nameLabel, index) => (

View File

@@ -1,17 +1,25 @@
import React from 'react'
import _ from 'intl'
import _, { messages } from 'intl'
import ActionButton from 'action-button'
import ActionRowButton from 'action-row-button'
import Component from 'base-component'
import Copiable from 'copiable'
import renderXoItem from 'render-xo-item'
import SelectFiles from 'select-files'
import Upgrade from 'xoa-upgrade'
import { connectStore } from 'utils'
import { createGetObjectsOfType } from 'selectors'
import { XoSelect } from 'editable'
import { installSupplementalPackOnAllHosts, setPoolMaster } from 'xo'
import { map } from 'lodash'
import { connectStore } from 'utils'
import { injectIntl } from 'react-intl'
import { createGetObjectsOfType } from 'selectors'
import { Text, XoSelect } from 'editable'
import { Container, Row, Col } from 'grid'
import {
installSupplementalPackOnAllHosts,
setPoolMaster,
setRemoteSyslogHost,
setRemoteSyslogHosts,
} from 'xo'
@connectStore(() => ({
master: createGetObjectsOfType('host').find((_, { pool }) => ({
@@ -39,56 +47,128 @@ class PoolMaster extends Component {
}
}
export default connectStore({
@injectIntl
@connectStore({
hosts: createGetObjectsOfType('host')
.filter((_, { pool }) => ({ $pool: pool.id }))
.sort(),
gpuGroups: createGetObjectsOfType('gpuGroup'),
})(({ gpuGroups, pool }) => (
<div>
<h3 className='mb-1'>{_('xenSettingsLabel')}</h3>
<Container>
<Row>
<Col size={3}>
<strong>{_('uuid')}</strong>
</Col>
<Col size={9}>
<Copiable tagName='div'>{pool.uuid}</Copiable>
</Col>
</Row>
<Row>
<Col size={3}>
<strong>{_('poolHaStatus')}</strong>
</Col>
<Col size={9}>
{pool.HA_enabled ? _('poolHaEnabled') : _('poolHaDisabled')}
</Col>
</Row>
<Row>
<Col size={3}>
<strong>{_('setpoolMaster')}</strong>
</Col>
<Col size={9}>
<PoolMaster pool={pool} />
</Col>
</Row>
</Container>
<h3 className='mt-1 mb-1'>{_('poolGpuGroups')}</h3>
<Container>
<Row>
<Col size={9}>
<ul className='list-group'>
{map(gpuGroups, gpuGroup => (
<li key={gpuGroup.id} className='list-group-item'>
{renderXoItem(gpuGroup)}
</li>
))}
</ul>
</Col>
</Row>
</Container>
<h3 className='mt-1 mb-1'>{_('supplementalPackPoolNew')}</h3>
<Upgrade place='poolSupplementalPacks' required={2}>
<SelectFiles
onChange={file => installSupplementalPackOnAllHosts(pool, file)}
/>
</Upgrade>
</div>
))
})
export default class TabAdvanced extends Component {
_setRemoteSyslogHosts = () =>
setRemoteSyslogHosts(this.props.hosts, this.state.syslogDestination).then(
() => this.setState({ editRemoteSyslog: false, syslogDestination: '' })
)
render () {
const { hosts, gpuGroups, pool } = this.props
const { state } = this
const { editRemoteSyslog } = state
return (
<div>
<Container>
<Row>
<Col>
<h3>{_('xenSettingsLabel')}</h3>
<table className='table'>
<tbody>
<tr>
<th>{_('uuid')}</th>
<Copiable tagName='td'>{pool.uuid}</Copiable>
</tr>
<tr>
<th>{_('poolHaStatus')}</th>
<td>
{pool.HA_enabled
? _('poolHaEnabled')
: _('poolHaDisabled')}
</td>
</tr>
<tr>
<th>{_('setpoolMaster')}</th>
<td>
<PoolMaster pool={pool} />
</td>
</tr>
<tr>
<th>{_('syslogRemoteHost')}</th>
<td>
<ul className='pl-0'>
{map(hosts, host => (
<li key={host.id}>
<span>{`${host.name_label}: `}</span>
<Text
value={host.logging.syslog_destination || ''}
onChange={value =>
setRemoteSyslogHost(host, value)
}
/>
</li>
))}
</ul>
<ActionRowButton
btnStyle={editRemoteSyslog ? 'info' : 'primary'}
handler={this.toggleState('editRemoteSyslog')}
icon='edit'
>
{_('poolEditAll')}
</ActionRowButton>
{editRemoteSyslog && (
<form
id='formRemoteSyslog'
className='form-inline mt-1'
>
<div className='form-group'>
<input
className='form-control'
onChange={this.linkState('syslogDestination')}
placeholder={this.props.intl.formatMessage(
messages.poolRemoteSyslogPlaceHolder
)}
type='text'
value={state.syslogDestination}
/>
</div>
<div className='form-group ml-1'>
<ActionButton
btnStyle='primary'
form='formRemoteSyslog'
handler={this._setRemoteSyslogHosts}
icon='save'
>
{_('confirmOk')}
</ActionButton>
</div>
</form>
)}
</td>
</tr>
</tbody>
</table>
</Col>
</Row>
</Container>
<h3 className='mt-1 mb-1'>{_('poolGpuGroups')}</h3>
<Container>
<Row>
<Col size={9}>
<ul className='list-group'>
{map(gpuGroups, gpuGroup => (
<li key={gpuGroup.id} className='list-group-item'>
{renderXoItem(gpuGroup)}
</li>
))}
</ul>
</Col>
</Row>
</Container>
<h3 className='mt-1 mb-1'>{_('supplementalPackPoolNew')}</h3>
<Upgrade place='poolSupplementalPacks' required={2}>
<SelectFiles
onChange={file => installSupplementalPackOnAllHosts(pool, file)}
/>
</Upgrade>
</div>
)
}
}

View File

@@ -3,6 +3,7 @@ import ActionButton from 'action-button'
import ActionRowButton from 'action-row-button'
import ButtonGroup from 'button-group'
import Component from 'base-component'
import copy from 'copy-to-clipboard'
import Icon from 'icon'
import Link from 'link'
import propTypes from 'prop-types-decorator'
@@ -78,13 +79,26 @@ const COLUMNS = [
.sort()
const getVmIds = createSelector(getVbds, vbds => map(vbds, 'VM'))
const getVms = createGetObjectsOfType('VM').pick(getVmIds)
const getVmControllers = createGetObjectsOfType('VM-controller').pick(
getVmIds
)
const getVmSnapshots = createGetObjectsOfType('VM-snapshot').pick(
getVmIds
)
const getVmTemplates = createGetObjectsOfType('VM-template').pick(
getVmIds
)
const getAllVms = createSelector(
getVms,
getVmControllers,
getVmSnapshots,
(vms, vmSnapshots) => ({ ...vms, ...vmSnapshots })
getVmTemplates,
(vms, vmControllers, vmSnapshots, vmTemplates) => ({
...vms,
...vmControllers,
...vmSnapshots,
...vmTemplates,
})
)
return (state, props) => ({
@@ -105,12 +119,18 @@ const COLUMNS = [
return null
}
const link =
vm.type === 'VM'
? `/vms/${vm.id}`
: vm.$snapshot_of === undefined
const type = vm.type
let link
if (type === 'VM') {
link = `/vms/${vm.id}`
} else if (type === 'VM-template') {
link = `/home?s=${vm.id}&t=VM-template`
} else {
link =
vm.$snapshot_of === undefined
? '/dashboard/health'
: `/vms/${vm.$snapshot_of}/snapshots`
}
return (
<Row className={index > 0 && 'mt-1'}>
@@ -165,6 +185,11 @@ const GROUPED_ACTIONS = [
]
const INDIVIDUAL_ACTIONS = [
{
handler: vdi => copy(vdi.uuid),
icon: 'clipboard',
label: vdi => _('copyUuid', { uuid: vdi.uuid }),
},
{
handler: deleteVdi,
icon: 'delete',

View File

@@ -10,7 +10,6 @@ import map from 'lodash/map'
import orderBy from 'lodash/orderBy'
import propTypes from 'prop-types-decorator'
import React from 'react'
import Upgrade from 'xoa-upgrade'
import { Container, Col, Row } from 'grid'
import { importVms, isSrWritable } from 'xo'
import { SizeInput } from 'form'
@@ -319,107 +318,101 @@ export default class Import extends Component {
return (
<Page header={HEADER} title='newImport' formatTitle>
{process.env.XOA_PLAN > 1 ? (
<Container>
<form id='import-form'>
<FormGrid.Row>
<FormGrid.LabelCol>{_('vmImportToPool')}</FormGrid.LabelCol>
<FormGrid.InputCol>
<SelectPool
value={pool}
onChange={this._handleSelectedPool}
required
/>
</FormGrid.InputCol>
</FormGrid.Row>
<FormGrid.Row>
<FormGrid.LabelCol>{_('vmImportToSr')}</FormGrid.LabelCol>
<FormGrid.InputCol>
<SelectSr
disabled={!pool}
onChange={this._handleSelectedSr}
predicate={srPredicate}
required
value={sr}
/>
</FormGrid.InputCol>
</FormGrid.Row>
{sr && (
<div>
<Dropzone
onDrop={this._handleDrop}
message={_('importVmsList')}
/>
<hr />
<h5>{_('vmsToImport')}</h5>
{vms.length > 0 ? (
<div>
{map(vms, ({ data, error, file, type }, vmIndex) => (
<div key={file.preview} className={styles.vmContainer}>
<strong>{file.name}</strong>
<span className='pull-right'>
<strong>{`(${formatSize(file.size)})`}</strong>
</span>
{!error ? (
data && (
<div>
<hr />
<div className='alert alert-info' role='alert'>
<strong>
{_('vmImportFileType', { type })}
</strong>{' '}
{_('vmImportConfigAlert')}
</div>
<VmData
{...data}
ref={`vm-data-${vmIndex}`}
pool={pool}
/>
</div>
)
) : (
<Container>
<form id='import-form'>
<FormGrid.Row>
<FormGrid.LabelCol>{_('vmImportToPool')}</FormGrid.LabelCol>
<FormGrid.InputCol>
<SelectPool
value={pool}
onChange={this._handleSelectedPool}
required
/>
</FormGrid.InputCol>
</FormGrid.Row>
<FormGrid.Row>
<FormGrid.LabelCol>{_('vmImportToSr')}</FormGrid.LabelCol>
<FormGrid.InputCol>
<SelectSr
disabled={!pool}
onChange={this._handleSelectedSr}
predicate={srPredicate}
required
value={sr}
/>
</FormGrid.InputCol>
</FormGrid.Row>
{sr && (
<div>
<Dropzone
onDrop={this._handleDrop}
message={_('importVmsList')}
/>
<hr />
<h5>{_('vmsToImport')}</h5>
{vms.length > 0 ? (
<div>
{map(vms, ({ data, error, file, type }, vmIndex) => (
<div key={file.preview} className={styles.vmContainer}>
<strong>{file.name}</strong>
<span className='pull-right'>
<strong>{`(${formatSize(file.size)})`}</strong>
</span>
{!error ? (
data && (
<div>
<hr />
<div className='alert alert-danger' role='alert'>
<strong>{_('vmImportError')}</strong>{' '}
{(error && error.message) ||
_('noVmImportErrorDescription')}
<div className='alert alert-info' role='alert'>
<strong>
{_('vmImportFileType', { type })}
</strong>{' '}
{_('vmImportConfigAlert')}
</div>
<VmData
{...data}
ref={`vm-data-${vmIndex}`}
pool={pool}
/>
</div>
)}
</div>
))}
</div>
) : (
<p>{_('noSelectedVms')}</p>
)}
<hr />
<div className='form-group pull-right'>
<ActionButton
btnStyle='primary'
disabled={!vms.length}
className='mr-1'
form='import-form'
handler={this._import}
icon='import'
redirectOnSuccess={getRedirectionUrl}
type='submit'
>
{_('newImport')}
</ActionButton>
<Button onClick={this._handleCleanSelectedVms}>
{_('importVmsCleanList')}
</Button>
)
) : (
<div>
<hr />
<div className='alert alert-danger' role='alert'>
<strong>{_('vmImportError')}</strong>{' '}
{(error && error.message) ||
_('noVmImportErrorDescription')}
</div>
</div>
)}
</div>
))}
</div>
) : (
<p>{_('noSelectedVms')}</p>
)}
<hr />
<div className='form-group pull-right'>
<ActionButton
btnStyle='primary'
disabled={!vms.length}
className='mr-1'
form='import-form'
handler={this._import}
icon='import'
redirectOnSuccess={getRedirectionUrl}
type='submit'
>
{_('newImport')}
</ActionButton>
<Button onClick={this._handleCleanSelectedVms}>
{_('importVmsCleanList')}
</Button>
</div>
)}
</form>
</Container>
) : (
<Container>
<Upgrade place='vmImport' available={2} />
</Container>
)}
</div>
)}
</form>
</Container>
</Page>
)
}

View File

@@ -585,7 +585,9 @@ export default class TabAdvanced extends Component {
<tr>
<th>{_('xenToolsStatus')}</th>
<td>
{vm.xenTools && `${vm.xenTools.major}.${vm.xenTools.minor}`}
{vm.xenTools
? `${vm.xenTools.major}.${vm.xenTools.minor}`
: _('xenToolsNotInstalled')}
</td>
</tr>
<tr>

View File

@@ -218,7 +218,6 @@ class VifAllowedIps extends BaseComponent {
containerPredicate={this._getIsNetworkAllowed()}
onChange={this._addIp}
predicate={this._getIpPredicate()}
required
resourceSetId={resourceSet}
/>
) : (
@@ -227,7 +226,6 @@ class VifAllowedIps extends BaseComponent {
containerPredicate={this._getIsNetworkAllowed()}
onChange={this._addIp}
predicate={this._getIpPredicate()}
required
/>
)}
</span>

View File

@@ -10,8 +10,13 @@ import { FormattedRelative, FormattedTime } from 'react-intl'
import { Container, Row, Col } from 'grid'
import { Text } from 'editable'
import { includes, isEmpty } from 'lodash'
import { createGetObjectsOfType } from 'selectors'
import {
createSelector,
createGetObjectsOfType,
getCheckPermissions,
} from 'selectors'
import {
cloneVm,
copyVm,
deleteSnapshot,
deleteSnapshots,
@@ -82,6 +87,13 @@ const INDIVIDUAL_ACTIONS = [
icon: 'vm-copy',
label: _('copySnapshot'),
},
{
disabled: (snapshot, { canAdministrate }) => !canAdministrate(snapshot),
handler: snapshot => cloneVm(snapshot, false),
icon: 'vm-fast-clone',
label: _('fastCloneVmLabel'),
redirectOnSuccess: snapshot => `/vms/${snapshot}/general`,
},
{
handler: exportVm,
icon: 'export',
@@ -107,11 +119,17 @@ const INDIVIDUAL_ACTIONS = [
]
@connectStore(() => ({
checkPermissions: getCheckPermissions,
snapshots: createGetObjectsOfType('VM-snapshot')
.pick((_, props) => props.vm.snapshots)
.sort(),
}))
export default class TabSnapshot extends Component {
_getCanAdministrate = createSelector(
() => this.props.checkPermissions,
check => vm => check(vm.id, 'administrate')
)
render () {
const { snapshots, vm } = this.props
return (
@@ -146,6 +164,7 @@ export default class TabSnapshot extends Component {
<SortedTable
collection={snapshots}
columns={COLUMNS}
data-canAdministrate={this._getCanAdministrate()}
groupedActions={GROUPED_ACTIONS}
individualActions={INDIVIDUAL_ACTIONS}
/>

2971
yarn.lock

File diff suppressed because it is too large Load Diff