Compare commits

...

36 Commits

Author SHA1 Message Date
Julien Fontanet
ecd460a991 feat(xo-web): 5.17.0 2018-03-02 19:57:24 +01:00
Julien Fontanet
b4d7648ffe feat(xo-server): 5.17.0 2018-03-02 19:57:04 +01:00
Julien Fontanet
eb3dfb0f30 feat(Backups NG): first iteration (#2705) 2018-03-02 19:56:08 +01:00
Julien Fontanet
2b9ba69480 fix(xo-server): getJob return the correct job 2018-03-02 19:53:16 +01:00
Julien Fontanet
8f784162ea chore(xo-server): Xapi#exportDeltaVm make streams writable 2018-03-02 19:52:35 +01:00
Julien Fontanet
a2ab64b142 chore(xo-server): Xapi#exportDeltaVm accept a snapshot 2018-03-02 19:52:00 +01:00
Julien Fontanet
052817ccbf chore(xo-server): RemoteHandler#rename handle cheksum 2018-03-02 19:51:03 +01:00
Julien Fontanet
48b2297bc1 chore(xo-server): handle nested job props (#2712) 2018-03-02 19:29:08 +01:00
Nicolas Raynaud
e76a0ad4bd feat(xo-server): improve VHD merge speed (#2643)
Avoid re-opening/closing the files multiple times, introduce a lot of latency in remote FS.
2018-03-02 19:08:01 +01:00
Olivier Lambert
baf6d30348 fix(changelog): remove useless spaces 2018-03-02 18:31:32 +01:00
Olivier Lambert
7d250dd90b feat(changelog): move and update changelog 2018-03-02 18:30:22 +01:00
Rajaa.BARHTAOUI
efaabb02e8 feat(xo-web): confirm modal before host emergency shutdown (#2714)
Fixes #2230
2018-03-02 18:05:58 +01:00
Julien Fontanet
0c3b98d451 fix(xo-server): forward createOutputStream errors with checksum 2018-03-02 15:29:26 +01:00
Julien Fontanet
28d1539ea6 fix(xo-server): fix Xapi#snapshotVm
It was broken by #2701.
2018-03-02 10:53:49 +01:00
Julien Fontanet
8ad02d2d51 feat(xo-web): ActionButton accept data-* props instead of handlerParam (#2713) 2018-03-02 09:57:26 +01:00
Julien Fontanet
1947a066e0 chore: disable flow for test
Still some config issues which I have to fix.
2018-03-01 16:30:02 +01:00
Julien Fontanet
d99e643634 chore(xo-server): inject schedule in jobs (#2710) 2018-03-01 16:27:51 +01:00
Rajaa.BARHTAOUI
65e1ac2ef9 chore(xo-web): consistently use "Username" label (#2709)
Fixes #2651
2018-03-01 15:58:48 +01:00
Julien Fontanet
64a768090f fix(xo-server): typo, executor → executors 2018-03-01 13:37:40 +01:00
Julien Fontanet
488eed046e chore(xo-server): pluggable job executors (#2707) 2018-03-01 12:10:08 +01:00
Julien Fontanet
dccddd78a6 chore(xo-web): rewrite smart-backup-pattern (#2698)
Fix a few issues
2018-02-28 17:07:16 +01:00
Julien Fontanet
3c247abcf9 chore(xo-web): add exact prop to NavLink (#2699) 2018-02-28 17:05:44 +01:00
Julien Fontanet
db795e91fd feat(complex-matcher): 0.3.0 2018-02-28 16:40:18 +01:00
Julien Fontanet
f060f56c93 feat(complex-matcher): number comparison (#2702)
`foo:>=42` matches `{ foo: 42 }` but not `"bar"` nor `{ foo: 37 }`.
2018-02-28 16:36:54 +01:00
Julien Fontanet
51be573f5e chore(xo-web): rewrite smart-backup-pattern 2018-02-28 16:23:29 +01:00
Julien Fontanet
4257cbb618 chore(xo-server): improve jobs code (#2696)
- add type filtering (default to `call`)
- support passing extra params to the call
- Flow typing
2018-02-28 16:22:41 +01:00
Julien Fontanet
e25d6b712d chore(xo-web): addSubscriptions provide initial props (#2697) 2018-02-28 16:09:56 +01:00
Julien Fontanet
b499d60130 chore(xo-server): improve scheduling code (#2695) 2018-02-28 15:59:19 +01:00
Julien Fontanet
68e06303a4 chore(xo-server): more cancelable Xapi methods (#2701) 2018-02-28 15:25:22 +01:00
badrAZ
60085798f2 fix(xo-web/jobs/vm.revert): use the snapshot's id instead of the vm's id (#2685)
Fixes #2498
2018-02-28 14:33:05 +01:00
badrAZ
c62cab39f1 feat(xo-web/VM): change the "share" button position (#2667)
Fixes #2663
2018-02-28 14:10:27 +01:00
Julien Fontanet
30483ab2d9 feat(xo-web): pass userData to SortedTable actions (#2700) 2018-02-28 13:43:41 +01:00
Julien Fontanet
c38c716616 chore(xo-server): use sepecific Babel plugins instead of stage-0 (#2694) 2018-02-28 12:59:23 +01:00
Julien Fontanet
ded1127d64 chore: mutualize Babel 7 config 2018-02-26 22:30:37 +01:00
Julien Fontanet
38d6130e89 chore(xo-cli): remove flow test 2018-02-26 21:58:32 +01:00
Julien Fontanet
ee47e40d1a feat(xo-web/logs): display real job status (#2688) 2018-02-26 18:02:39 +01:00
67 changed files with 3760 additions and 1672 deletions

View File

@@ -2,6 +2,10 @@ module.exports = {
extends: ['standard', 'standard-jsx'],
globals: {
__DEV__: true,
$Diff: true,
$Exact: true,
$Keys: true,
$Shape: true,
},
parser: 'babel-eslint',
rules: {

View File

@@ -8,6 +8,7 @@
[lints]
[options]
esproposal.decorators=ignore
include_warnings=true
module.use_strict=true

View File

@@ -0,0 +1,47 @@
'use strict'
const PLUGINS_RE = /^(?:@babel\/plugin-.+|babel-plugin-lodash)$/
const PRESETS_RE = /^@babel\/preset-.+$/
const NODE_ENV = process.env.NODE_ENV || 'development'
const __PROD__ = NODE_ENV === 'production'
const __TEST__ = NODE_ENV === 'test'
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] = {}
} else if (!(name in presets) && PRESETS_RE.test(name)) {
presets[name] = {}
}
})
return {
comments: !__PROD__,
ignore: __TEST__ ? undefined : [/\.spec\.js$/],
plugins: Object.keys(plugins).map(plugin => [plugin, plugins[plugin]]),
presets: Object.keys(presets).map(preset => [preset, presets[preset]]),
}
}

View File

@@ -0,0 +1,11 @@
{
"private": true,
"name": "@xen-orchestra/babel-config",
"version": "0.0.0",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/@xen-orchestra/babel-config",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
}
}

View File

@@ -1,47 +1,3 @@
'use strict'
const NODE_ENV = process.env.NODE_ENV || 'development'
const __PROD__ = NODE_ENV === 'production'
const __TEST__ = NODE_ENV === 'test'
const pkg = require('./package')
const plugins = {
lodash: {},
}
const 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) && /@babel\/plugin-.+/.test(name)) {
plugins[name] = {}
} else if (!(name in presets) && /@babel\/preset-.+/.test(name)) {
presets[name] = {}
}
})
module.exports = {
comments: !__PROD__,
ignore: __TEST__ ? undefined : [/\.spec\.js$/],
plugins: Object.keys(plugins).map(plugin => [plugin, plugins[plugin]]),
presets: Object.keys(presets).map(preset => [preset, presets[preset]]),
}
module.exports = require('../../@xen-orchestra/babel-config')(
require('./package.json')
)

View File

@@ -1,121 +1,184 @@
# ChangeLog
## **5.17.0** (2018-03-02)
### Enhancements
- Add modal confirmation for host emergency mode [#2230](https://github.com/vatesfr/xen-orchestra/issues/2230)
- Authorize stats fetching in RO mode [#2678](https://github.com/vatesfr/xen-orchestra/issues/2678)
- Limit VM.export concurrency [#2669](https://github.com/vatesfr/xen-orchestra/issues/2669)
- Basic backup: snapshots names [#2668](https://github.com/vatesfr/xen-orchestra/issues/2668)
- Change placement of "share" button for self [#2663](https://github.com/vatesfr/xen-orchestra/issues/2663)
- Username field labeled inconsistently [#2651](https://github.com/vatesfr/xen-orchestra/issues/2651)
- Backup report for VDI chain status [#2639](https://github.com/vatesfr/xen-orchestra/issues/2639)
- [Dashboard/Health] Control domain VDIs should includes snapshots [#2634](https://github.com/vatesfr/xen-orchestra/issues/2634)
- Do not count VM-snapshot in self quota [#2626](https://github.com/vatesfr/xen-orchestra/issues/2626)
- [xo-web] Backup logs [#2618](https://github.com/vatesfr/xen-orchestra/issues/2618)
- [VM/Snapshots] grouped deletion [#2595](https://github.com/vatesfr/xen-orchestra/issues/2595)
- [Backups] add a new state for a VM: skipped [#2591](https://github.com/vatesfr/xen-orchestra/issues/2591)
- Set a self-service VM at "share" after creation [#2589](https://github.com/vatesfr/xen-orchestra/issues/2589)
- [Backup logs] Improve Unhealthy VDI Chain message [#2586](https://github.com/vatesfr/xen-orchestra/issues/2586)
- [SortedTable] Put sort criteria in URL like the filter [#2584](https://github.com/vatesfr/xen-orchestra/issues/2584)
- Cant attach XenTools on User side. [#2503](https://github.com/vatesfr/xen-orchestra/issues/2503)
- Pool filter for health view [#2302](https://github.com/vatesfr/xen-orchestra/issues/2302)
- [Smart Backup] Improve feedback [#2253](https://github.com/vatesfr/xen-orchestra/issues/2253)
### Bugs
- Limit VDI export concurrency [#2672](https://github.com/vatesfr/xen-orchestra/issues/2672)
- Select is broken outside dev mode [#2645](https://github.com/vatesfr/xen-orchestra/issues/2645)
- "New" XOSAN automatically register the user [#2625](https://github.com/vatesfr/xen-orchestra/issues/2625)
- [VM/Advanced] Error on resource set change should not be hidden [#2620](https://github.com/vatesfr/xen-orchestra/issues/2620)
- misspelled word [#2606](https://github.com/vatesfr/xen-orchestra/issues/2606)
- Jobs vm.revert failing all the time [#2498](https://github.com/vatesfr/xen-orchestra/issues/2498)
## **5.16.0** (2018-01-31)
### Enhancements
- Use @xen-orchestra/cron everywhere [#2616](https://github.com/vatesfr/xen-orchestra/issues/2616)
- [SortedTable] Possibility to specify grouped/individual actions together [#2596](https://github.com/vatesfr/xen-orchestra/issues/2596)
- Self-service: allow VIF create [#2593](https://github.com/vatesfr/xen-orchestra/issues/2593)
- Ghost tasks [#2579](https://github.com/vatesfr/xen-orchestra/issues/2579)
- Autopatching: ignore 7.3 update patch for 7.2 [#2564](https://github.com/vatesfr/xen-orchestra/issues/2564)
- Allow deleting VMs for which `destroy` is blocked [#2525](https://github.com/vatesfr/xen-orchestra/issues/2525)
- Better confirmation on mass destructive actions [#2522](https://github.com/vatesfr/xen-orchestra/issues/2522)
- Move VM In to/Out of Self Service Group [#1913](https://github.com/vatesfr/xen-orchestra/issues/1913)
- Two factor auth [#1897](https://github.com/vatesfr/xen-orchestra/issues/1897)
- token.create should accept an expiration [#1769](https://github.com/vatesfr/xen-orchestra/issues/1769)
- Self Service User - User don't have quota in his dashboard [#1538](https://github.com/vatesfr/xen-orchestra/issues/1538)
- Remove CoffeeScript in xo-server [#189](https://github.com/vatesfr/xen-orchestra/issues/189)
- Better Handling of suspending VMs from the Home screen [#2547](https://github.com/vatesfr/xen-orchestra/issues/2547)
- [xen-api] Stronger reconnection policy [#2410](https://github.com/vatesfr/xen-orchestra/issues/2410)
### Bugs
- [cron] toJSDate is not a function [#2661](https://github.com/vatesfr/xen-orchestra/issues/2661)
- [Delta backup] Merge should not fail when delta contains no data [#2635](https://github.com/vatesfr/xen-orchestra/issues/2635)
- Select issues [#2590](https://github.com/vatesfr/xen-orchestra/issues/2590)
- Fix selects display [#2575](https://github.com/vatesfr/xen-orchestra/issues/2575)
- [SortedTable] Stuck when displaying last page [#2569](https://github.com/vatesfr/xen-orchestra/issues/2569)
- [vm/network] Duplicate key error [#2553](https://github.com/vatesfr/xen-orchestra/issues/2553)
- Jobs vm.revert failing all the time [#2498](https://github.com/vatesfr/xen-orchestra/issues/2498)
- TZ selector is not used for backup schedule preview [#2464](https://github.com/vatesfr/xen-orchestra/issues/2464)
- Remove filter in VM/network view [#2548](https://github.com/vatesfr/xen-orchestra/issues/2548)
## **5.15.0** (2017-12-29)
### Enhancements
* VDI resize online method removed in 7.3 [#2542](https://github.com/vatesfr/xen-orchestra/issues/2542)
* Smart replace VDI.pool_migrate removed from XenServer 7.3 Free [#2541](https://github.com/vatesfr/xen-orchestra/issues/2541)
* New memory constraints in XenServer 7.3 [#2540](https://github.com/vatesfr/xen-orchestra/issues/2540)
* Link to Settings/Logs for admins in error notifications [#2516](https://github.com/vatesfr/xen-orchestra/issues/2516)
* [Self Service] Do not use placehodlers to describe inputs [#2509](https://github.com/vatesfr/xen-orchestra/issues/2509)
* Obfuscate password in log in LDAP plugin test [#2506](https://github.com/vatesfr/xen-orchestra/issues/2506)
* Log rotation [#2492](https://github.com/vatesfr/xen-orchestra/issues/2492)
* Continuous Replication TAG [#2473](https://github.com/vatesfr/xen-orchestra/issues/2473)
* Graphs in VM list view [#2469](https://github.com/vatesfr/xen-orchestra/issues/2469)
* [Delta Backups] Do not include merge duration in transfer speed stat [#2426](https://github.com/vatesfr/xen-orchestra/issues/2426)
* Warning for disperse mode [#2537](https://github.com/vatesfr/xen-orchestra/issues/2537)
- VDI resize online method removed in 7.3 [#2542](https://github.com/vatesfr/xen-orchestra/issues/2542)
- Smart replace VDI.pool_migrate removed from XenServer 7.3 Free [#2541](https://github.com/vatesfr/xen-orchestra/issues/2541)
- New memory constraints in XenServer 7.3 [#2540](https://github.com/vatesfr/xen-orchestra/issues/2540)
- Link to Settings/Logs for admins in error notifications [#2516](https://github.com/vatesfr/xen-orchestra/issues/2516)
- [Self Service] Do not use placehodlers to describe inputs [#2509](https://github.com/vatesfr/xen-orchestra/issues/2509)
- Obfuscate password in log in LDAP plugin test [#2506](https://github.com/vatesfr/xen-orchestra/issues/2506)
- Log rotation [#2492](https://github.com/vatesfr/xen-orchestra/issues/2492)
- Continuous Replication TAG [#2473](https://github.com/vatesfr/xen-orchestra/issues/2473)
- Graphs in VM list view [#2469](https://github.com/vatesfr/xen-orchestra/issues/2469)
- [Delta Backups] Do not include merge duration in transfer speed stat [#2426](https://github.com/vatesfr/xen-orchestra/issues/2426)
- Warning for disperse mode [#2537](https://github.com/vatesfr/xen-orchestra/issues/2537)
### Bugs
* VM console doesn't work when using IPv6 in URL [#2530](https://github.com/vatesfr/xen-orchestra/issues/2530)
* Retention issue with failed basic backup [#2524](https://github.com/vatesfr/xen-orchestra/issues/2524)
* [VM/Advanced] Check that the autopower on setting is working [#2489](https://github.com/vatesfr/xen-orchestra/issues/2489)
* Cloud config drive create fail on XenServer < 7 [#2478](https://github.com/vatesfr/xen-orchestra/issues/2478)
* VM create fails due to missing vGPU id [#2466](https://github.com/vatesfr/xen-orchestra/issues/2466)
- VM console doesn't work when using IPv6 in URL [#2530](https://github.com/vatesfr/xen-orchestra/issues/2530)
- Retention issue with failed basic backup [#2524](https://github.com/vatesfr/xen-orchestra/issues/2524)
- [VM/Advanced] Check that the autopower on setting is working [#2489](https://github.com/vatesfr/xen-orchestra/issues/2489)
- Cloud config drive create fail on XenServer < 7 [#2478](https://github.com/vatesfr/xen-orchestra/issues/2478)
- VM create fails due to missing vGPU id [#2466](https://github.com/vatesfr/xen-orchestra/issues/2466)
## **5.14.0** (2017-10-31)
### Enhancements
* VM snapshot description display [#2458](https://github.com/vatesfr/xen-orchestra/issues/2458)
* [Home] Ability to sort VM by number of snapshots [#2450](https://github.com/vatesfr/xen-orchestra/issues/2450)
* Display XS version in host view [#2439](https://github.com/vatesfr/xen-orchestra/issues/2439)
* [File restore]: Clarify the possibility to select multiple files [#2438](https://github.com/vatesfr/xen-orchestra/issues/2438)
* [Continuous Replication] Time in replicated VMs [#2431](https://github.com/vatesfr/xen-orchestra/issues/2431)
* [SortedTable] Active page in URL param [#2405](https://github.com/vatesfr/xen-orchestra/issues/2405)
* replace all '...' with the UTF-8 equivalent [#2391](https://github.com/vatesfr/xen-orchestra/issues/2391)
* [SortedTable] Explicit when no items [#2388](https://github.com/vatesfr/xen-orchestra/issues/2388)
* Handle patching licenses [#2382](https://github.com/vatesfr/xen-orchestra/issues/2382)
* Credential leaking in logs for messages regarding invalid credentials and "too fast authentication" [#2363](https://github.com/vatesfr/xen-orchestra/issues/2363)
* [SortedTable] Keyboard support [#2330](https://github.com/vatesfr/xen-orchestra/issues/2330)
* token.create should accept an expiration [#1769](https://github.com/vatesfr/xen-orchestra/issues/1769)
* On updater error, display link to documentation [#1610](https://github.com/vatesfr/xen-orchestra/issues/1610)
* Add basic vGPU support [#2413](https://github.com/vatesfr/xen-orchestra/issues/2413)
* Storage View - Disk Tab - real disk usage [#2475](https://github.com/vatesfr/xen-orchestra/issues/2475)
- VM snapshot description display [#2458](https://github.com/vatesfr/xen-orchestra/issues/2458)
- [Home] Ability to sort VM by number of snapshots [#2450](https://github.com/vatesfr/xen-orchestra/issues/2450)
- Display XS version in host view [#2439](https://github.com/vatesfr/xen-orchestra/issues/2439)
- [File restore]: Clarify the possibility to select multiple files [#2438](https://github.com/vatesfr/xen-orchestra/issues/2438)
- [Continuous Replication] Time in replicated VMs [#2431](https://github.com/vatesfr/xen-orchestra/issues/2431)
- [SortedTable] Active page in URL param [#2405](https://github.com/vatesfr/xen-orchestra/issues/2405)
- replace all '...' with the UTF-8 equivalent [#2391](https://github.com/vatesfr/xen-orchestra/issues/2391)
- [SortedTable] Explicit when no items [#2388](https://github.com/vatesfr/xen-orchestra/issues/2388)
- Handle patching licenses [#2382](https://github.com/vatesfr/xen-orchestra/issues/2382)
- Credential leaking in logs for messages regarding invalid credentials and "too fast authentication" [#2363](https://github.com/vatesfr/xen-orchestra/issues/2363)
- [SortedTable] Keyboard support [#2330](https://github.com/vatesfr/xen-orchestra/issues/2330)
- token.create should accept an expiration [#1769](https://github.com/vatesfr/xen-orchestra/issues/1769)
- On updater error, display link to documentation [#1610](https://github.com/vatesfr/xen-orchestra/issues/1610)
- Add basic vGPU support [#2413](https://github.com/vatesfr/xen-orchestra/issues/2413)
- Storage View - Disk Tab - real disk usage [#2475](https://github.com/vatesfr/xen-orchestra/issues/2475)
### Bugs
* Config drive - Custom config not working properly [#2449](https://github.com/vatesfr/xen-orchestra/issues/2449)
* Snapshot sorted table breaks copyVm [#2446](https://github.com/vatesfr/xen-orchestra/issues/2446)
* [vm/snapshots] Incorrect default sort order [#2442](https://github.com/vatesfr/xen-orchestra/issues/2442)
* [Backups/Jobs] Incorrect months mapping [#2427](https://github.com/vatesfr/xen-orchestra/issues/2427)
* [Xapi#barrier()] Not compatible with XenServer < 6.1 [#2418](https://github.com/vatesfr/xen-orchestra/issues/2418)
* [SortedTable] Change page when no more items on the page [#2401](https://github.com/vatesfr/xen-orchestra/issues/2401)
* Review and fix creating a VM from a snapshot [#2343](https://github.com/vatesfr/xen-orchestra/issues/2343)
* Unable to edit / save restored backup job [#1922](https://github.com/vatesfr/xen-orchestra/issues/1922)
- Config drive - Custom config not working properly [#2449](https://github.com/vatesfr/xen-orchestra/issues/2449)
- Snapshot sorted table breaks copyVm [#2446](https://github.com/vatesfr/xen-orchestra/issues/2446)
- [vm/snapshots] Incorrect default sort order [#2442](https://github.com/vatesfr/xen-orchestra/issues/2442)
- [Backups/Jobs] Incorrect months mapping [#2427](https://github.com/vatesfr/xen-orchestra/issues/2427)
- [Xapi#barrier()] Not compatible with XenServer < 6.1 [#2418](https://github.com/vatesfr/xen-orchestra/issues/2418)
- [SortedTable] Change page when no more items on the page [#2401](https://github.com/vatesfr/xen-orchestra/issues/2401)
- Review and fix creating a VM from a snapshot [#2343](https://github.com/vatesfr/xen-orchestra/issues/2343)
- Unable to edit / save restored backup job [#1922](https://github.com/vatesfr/xen-orchestra/issues/1922)
## **5.13.0** (2017-09-29)
### Enhancements
* replace all '...' with the UTF-8 equivalent [#2391](https://github.com/vatesfr/xen-orchestra/issues/2391)
* [SortedTable] Explicit when no items [#2388](https://github.com/vatesfr/xen-orchestra/issues/2388)
* Auto select iqn or lun if there is only one [#2379](https://github.com/vatesfr/xen-orchestra/issues/2379)
* [Sparklines] Hide points [#2370](https://github.com/vatesfr/xen-orchestra/issues/2370)
* Allow xo-server-recover-account to generate a random password [#2360](https://github.com/vatesfr/xen-orchestra/issues/2360)
* Add disk in existing VM as self user [#2348](https://github.com/vatesfr/xen-orchestra/issues/2348)
* Sorted table for Settings/server [#2340](https://github.com/vatesfr/xen-orchestra/issues/2340)
* Sign in should be case insensitive [#2337](https://github.com/vatesfr/xen-orchestra/issues/2337)
* [SortedTable] Extend checkbox click to whole column [#2329](https://github.com/vatesfr/xen-orchestra/issues/2329)
* [SortedTable] Ability to select all items (across pages) [#2324](https://github.com/vatesfr/xen-orchestra/issues/2324)
* [SortedTable] Range selection [#2323](https://github.com/vatesfr/xen-orchestra/issues/2323)
* Warning on SMB remote creation [#2316](https://github.com/vatesfr/xen-orchestra/issues/2316)
* [Home | SortedTable] Add link to syntax doc in the filter input [#2305](https://github.com/vatesfr/xen-orchestra/issues/2305)
* [SortedTable] Add optional binding of filter to an URL query [#2301](https://github.com/vatesfr/xen-orchestra/issues/2301)
* [Home][Keyboard navigation] Allow selecting the objects [#2214](https://github.com/vatesfr/xen-orchestra/issues/2214)
* SR view / Disks: option to display non managed VDIs [#1724](https://github.com/vatesfr/xen-orchestra/issues/1724)
* Continuous Replication Retention [#1692](https://github.com/vatesfr/xen-orchestra/issues/1692)
- replace all '...' with the UTF-8 equivalent [#2391](https://github.com/vatesfr/xen-orchestra/issues/2391)
- [SortedTable] Explicit when no items [#2388](https://github.com/vatesfr/xen-orchestra/issues/2388)
- Auto select iqn or lun if there is only one [#2379](https://github.com/vatesfr/xen-orchestra/issues/2379)
- [Sparklines] Hide points [#2370](https://github.com/vatesfr/xen-orchestra/issues/2370)
- Allow xo-server-recover-account to generate a random password [#2360](https://github.com/vatesfr/xen-orchestra/issues/2360)
- Add disk in existing VM as self user [#2348](https://github.com/vatesfr/xen-orchestra/issues/2348)
- Sorted table for Settings/server [#2340](https://github.com/vatesfr/xen-orchestra/issues/2340)
- Sign in should be case insensitive [#2337](https://github.com/vatesfr/xen-orchestra/issues/2337)
- [SortedTable] Extend checkbox click to whole column [#2329](https://github.com/vatesfr/xen-orchestra/issues/2329)
- [SortedTable] Ability to select all items (across pages) [#2324](https://github.com/vatesfr/xen-orchestra/issues/2324)
- [SortedTable] Range selection [#2323](https://github.com/vatesfr/xen-orchestra/issues/2323)
- Warning on SMB remote creation [#2316](https://github.com/vatesfr/xen-orchestra/issues/2316)
- [Home | SortedTable] Add link to syntax doc in the filter input [#2305](https://github.com/vatesfr/xen-orchestra/issues/2305)
- [SortedTable] Add optional binding of filter to an URL query [#2301](https://github.com/vatesfr/xen-orchestra/issues/2301)
- [Home][Keyboard navigation] Allow selecting the objects [#2214](https://github.com/vatesfr/xen-orchestra/issues/2214)
- SR view / Disks: option to display non managed VDIs [#1724](https://github.com/vatesfr/xen-orchestra/issues/1724)
- Continuous Replication Retention [#1692](https://github.com/vatesfr/xen-orchestra/issues/1692)
### Bugs
* iSCSI issue on LUN selector [#2374](https://github.com/vatesfr/xen-orchestra/issues/2374)
* Errors in VM copy are not properly reported [#2347](https://github.com/vatesfr/xen-orchestra/issues/2347)
* Removing a PIF IP fails [#2346](https://github.com/vatesfr/xen-orchestra/issues/2346)
* Review and fix creating a VM from a snapshot [#2343](https://github.com/vatesfr/xen-orchestra/issues/2343)
* iSCSI LUN Detection fails with authentification [#2339](https://github.com/vatesfr/xen-orchestra/issues/2339)
* Fix PoolActionBar to add a new SR [#2307](https://github.com/vatesfr/xen-orchestra/issues/2307)
* [VM migration] Error if default SR not accessible to target host [#2180](https://github.com/vatesfr/xen-orchestra/issues/2180)
* A job shouldn't executable more than once at the same time [#2053](https://github.com/vatesfr/xen-orchestra/issues/2053)
- iSCSI issue on LUN selector [#2374](https://github.com/vatesfr/xen-orchestra/issues/2374)
- Errors in VM copy are not properly reported [#2347](https://github.com/vatesfr/xen-orchestra/issues/2347)
- Removing a PIF IP fails [#2346](https://github.com/vatesfr/xen-orchestra/issues/2346)
- Review and fix creating a VM from a snapshot [#2343](https://github.com/vatesfr/xen-orchestra/issues/2343)
- iSCSI LUN Detection fails with authentification [#2339](https://github.com/vatesfr/xen-orchestra/issues/2339)
- Fix PoolActionBar to add a new SR [#2307](https://github.com/vatesfr/xen-orchestra/issues/2307)
- [VM migration] Error if default SR not accessible to target host [#2180](https://github.com/vatesfr/xen-orchestra/issues/2180)
- A job shouldn't executable more than once at the same time [#2053](https://github.com/vatesfr/xen-orchestra/issues/2053)
## **5.12.0** (2017-08-31)
### Enhancements
* PIF selector with physical status [#2326](https://github.com/vatesfr/xen-orchestra/issues/2326)
* [SortedTable] Range selection [#2323](https://github.com/vatesfr/xen-orchestra/issues/2323)
* Self service filter for home/VM view [#2303](https://github.com/vatesfr/xen-orchestra/issues/2303)
* SR/Disks Display total of VDIs to coalesce [#2300](https://github.com/vatesfr/xen-orchestra/issues/2300)
* Pool filter in the task view [#2293](https://github.com/vatesfr/xen-orchestra/issues/2293)
* "Loading" while fetching objects [#2285](https://github.com/vatesfr/xen-orchestra/issues/2285)
* [SortedTable] Add grouped actions feature [#2276](https://github.com/vatesfr/xen-orchestra/issues/2276)
* Add a filter to the backups' log [#2246](https://github.com/vatesfr/xen-orchestra/issues/2246)
* It should not be possible to migrate a halted VM. [#2233](https://github.com/vatesfr/xen-orchestra/issues/2233)
* [Home][Keyboard navigation] Allow selecting the objects [#2214](https://github.com/vatesfr/xen-orchestra/issues/2214)
* Allow to set pool master [#2213](https://github.com/vatesfr/xen-orchestra/issues/2213)
* Continuous Replication Retention [#1692](https://github.com/vatesfr/xen-orchestra/issues/1692)
- PIF selector with physical status [#2326](https://github.com/vatesfr/xen-orchestra/issues/2326)
- [SortedTable] Range selection [#2323](https://github.com/vatesfr/xen-orchestra/issues/2323)
- Self service filter for home/VM view [#2303](https://github.com/vatesfr/xen-orchestra/issues/2303)
- SR/Disks Display total of VDIs to coalesce [#2300](https://github.com/vatesfr/xen-orchestra/issues/2300)
- Pool filter in the task view [#2293](https://github.com/vatesfr/xen-orchestra/issues/2293)
- "Loading" while fetching objects [#2285](https://github.com/vatesfr/xen-orchestra/issues/2285)
- [SortedTable] Add grouped actions feature [#2276](https://github.com/vatesfr/xen-orchestra/issues/2276)
- Add a filter to the backups' log [#2246](https://github.com/vatesfr/xen-orchestra/issues/2246)
- It should not be possible to migrate a halted VM. [#2233](https://github.com/vatesfr/xen-orchestra/issues/2233)
- [Home][Keyboard navigation] Allow selecting the objects [#2214](https://github.com/vatesfr/xen-orchestra/issues/2214)
- Allow to set pool master [#2213](https://github.com/vatesfr/xen-orchestra/issues/2213)
- Continuous Replication Retention [#1692](https://github.com/vatesfr/xen-orchestra/issues/1692)
### Bugs
* Home pagination bug [#2310](https://github.com/vatesfr/xen-orchestra/issues/2310)
* Fix PoolActionBar to add a new SR [#2307](https://github.com/vatesfr/xen-orchestra/issues/2307)
* VM snapshots are not correctly deleted [#2304](https://github.com/vatesfr/xen-orchestra/issues/2304)
* Parallel deletion of VMs fails [#2297](https://github.com/vatesfr/xen-orchestra/issues/2297)
* Continous replication create multiple zombie disks [#2292](https://github.com/vatesfr/xen-orchestra/issues/2292)
* Add user to Group issue [#2196](https://github.com/vatesfr/xen-orchestra/issues/2196)
* [VM migration] Error if default SR not accessible to target host [#2180](https://github.com/vatesfr/xen-orchestra/issues/2180)
- Home pagination bug [#2310](https://github.com/vatesfr/xen-orchestra/issues/2310)
- Fix PoolActionBar to add a new SR [#2307](https://github.com/vatesfr/xen-orchestra/issues/2307)
- VM snapshots are not correctly deleted [#2304](https://github.com/vatesfr/xen-orchestra/issues/2304)
- Parallel deletion of VMs fails [#2297](https://github.com/vatesfr/xen-orchestra/issues/2297)
- Continous replication create multiple zombie disks [#2292](https://github.com/vatesfr/xen-orchestra/issues/2292)
- Add user to Group issue [#2196](https://github.com/vatesfr/xen-orchestra/issues/2196)
- [VM migration] Error if default SR not accessible to target host [#2180](https://github.com/vatesfr/xen-orchestra/issues/2180)
## **5.11.0** (2017-07-31)

View File

@@ -56,7 +56,7 @@
"precommit": "scripts/lint-staged",
"prepare": "scripts/run-script prepare",
"pretest": "eslint --ignore-path .gitignore .",
"test": "jest && flow status"
"test": "jest"
},
"workspaces": [
"@xen-orchestra/*",

View File

@@ -1,47 +1,3 @@
'use strict'
const NODE_ENV = process.env.NODE_ENV || 'development'
const __PROD__ = NODE_ENV === 'production'
const __TEST__ = NODE_ENV === 'test'
const pkg = require('./package')
const plugins = {
lodash: {},
}
const 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) && /@babel\/plugin-.+/.test(name)) {
plugins[name] = {}
} else if (!(name in presets) && /@babel\/preset-.+/.test(name)) {
presets[name] = {}
}
})
module.exports = {
comments: !__PROD__,
ignore: __TEST__ ? undefined : [/\.spec\.js$/],
plugins: Object.keys(plugins).map(plugin => [plugin, plugins[plugin]]),
presets: Object.keys(presets).map(preset => [preset, presets[preset]]),
}
module.exports = require('../../@xen-orchestra/babel-config')(
require('./package.json')
)

View File

@@ -1,6 +1,6 @@
{
"name": "complex-matcher",
"version": "0.2.1",
"version": "0.3.0",
"license": "ISC",
"description": "",
"keywords": [],

View File

@@ -70,6 +70,29 @@ export class And extends Node {
}
}
export class Comparison extends Node {
constructor (operator, value) {
super()
this._comparator = Comparison.comparators[operator]
this._operator = operator
this._value = value
}
match (value) {
return typeof value === 'number' && this._comparator(value, this._value)
}
toString () {
return this._operator + String(this._value)
}
}
Comparison.comparators = {
'>': (a, b) => a > b,
'>=': (a, b) => a >= b,
'<': (a, b) => a < b,
'<=': (a, b) => a <= b,
}
export class Or extends Node {
constructor (children) {
super()
@@ -408,6 +431,13 @@ const parser = P.grammar({
P.text(')')
).map(_ => new Or(_[4])),
P.seq(P.text('!'), r.ws, r.term).map(_ => new Not(_[2])),
P.seq(P.regex(/[<>]=?/), r.rawString).map(([op, val]) => {
val = +val
if (Number.isNaN(val)) {
throw new TypeError('value must be a number')
}
return new Comparison(op, val)
}),
P.seq(r.string, r.ws, P.text(':'), r.ws, r.term).map(
_ => new Property(_[0], _[4])
),

View File

@@ -1,47 +1,3 @@
'use strict'
const NODE_ENV = process.env.NODE_ENV || 'development'
const __PROD__ = NODE_ENV === 'production'
const __TEST__ = NODE_ENV === 'test'
const pkg = require('./package')
const plugins = {
lodash: {},
}
const 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) && /@babel\/plugin-.+/.test(name)) {
plugins[name] = {}
} else if (!(name in presets) && /@babel\/preset-.+/.test(name)) {
presets[name] = {}
}
})
module.exports = {
comments: !__PROD__,
ignore: __TEST__ ? undefined : [/\.spec\.js$/],
plugins: Object.keys(plugins).map(plugin => [plugin, plugins[plugin]]),
presets: Object.keys(presets).map(preset => [preset, presets[preset]]),
}
module.exports = require('../../@xen-orchestra/babel-config')(
require('./package.json')
)

View File

@@ -1,47 +1,3 @@
'use strict'
const NODE_ENV = process.env.NODE_ENV || 'development'
const __PROD__ = NODE_ENV === 'production'
const __TEST__ = NODE_ENV === 'test'
const pkg = require('./package')
const plugins = {
lodash: {},
}
const 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) && /@babel\/plugin-.+/.test(name)) {
plugins[name] = {}
} else if (!(name in presets) && /@babel\/preset-.+/.test(name)) {
presets[name] = {}
}
})
module.exports = {
comments: !__PROD__,
ignore: __TEST__ ? undefined : [/\.spec\.js$/],
plugins: Object.keys(plugins).map(plugin => [plugin, plugins[plugin]]),
presets: Object.keys(presets).map(preset => [preset, presets[preset]]),
}
module.exports = require('../../@xen-orchestra/babel-config')(
require('./package.json')
)

View File

@@ -62,7 +62,6 @@
"dev": "cross-env NODE_ENV=development babel --watch --source-maps --out-dir=dist/ src/",
"prebuild": "rimraf dist/",
"predev": "yarn run prebuild",
"prepublishOnly": "yarn run build",
"pretest": "flow status"
"prepublishOnly": "yarn run build"
}
}

View File

@@ -1,47 +1,3 @@
'use strict'
const NODE_ENV = process.env.NODE_ENV || 'development'
const __PROD__ = NODE_ENV === 'production'
const __TEST__ = NODE_ENV === 'test'
const pkg = require('./package')
const plugins = {
lodash: {},
}
const 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) && /@babel\/plugin-.+/.test(name)) {
plugins[name] = {}
} else if (!(name in presets) && /@babel\/preset-.+/.test(name)) {
presets[name] = {}
}
})
module.exports = {
comments: !__PROD__,
ignore: __TEST__ ? undefined : [/\.spec\.js$/],
plugins: Object.keys(plugins).map(plugin => [plugin, plugins[plugin]]),
presets: Object.keys(presets).map(preset => [preset, presets[preset]]),
}
module.exports = require('../../@xen-orchestra/babel-config')(
require('./package.json')
)

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env node
'use strict'
global.Promise = require('bluebird')
process.on('unhandledRejection', function (reason) {
console.warn('[Warn] Possibly unhandled rejection:', reason && reason.stack || reason)
})
require("exec-promise")(require("../dist/vhd-test").default)

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server",
"version": "5.16.2",
"version": "5.17.0",
"license": "AGPL-3.0",
"description": "Server part of Xen-Orchestra",
"keywords": [
@@ -121,9 +121,12 @@
"babel-core": "^6.26.0",
"babel-plugin-lodash": "^3.3.2",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-export-extensions": "^6.22.0",
"babel-plugin-transform-function-bind": "^6.22.0",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-env": "^1.6.1",
"babel-preset-stage-0": "^6.24.1",
"babel-preset-flow": "^6.23.0",
"cross-env": "^5.1.3",
"index-modules": "^0.3.0",
"rimraf": "^2.6.2"
@@ -141,6 +144,9 @@
"plugins": [
"lodash",
"transform-decorators-legacy",
"transform-export-extensions",
"transform-function-bind",
"transform-object-rest-spread",
"transform-runtime"
],
"presets": [
@@ -152,7 +158,7 @@
}
}
],
"stage-0"
"flow"
]
}
}

View File

@@ -0,0 +1,155 @@
export function createJob ({ schedules, ...job }) {
job.userId = this.user.id
return this.createBackupNgJob(job, schedules)
}
createJob.permission = 'admin'
createJob.params = {
compression: {
enum: ['', 'native'],
optional: true,
},
mode: {
enum: ['full', 'delta'],
},
name: {
type: 'string',
optional: true,
},
remotes: {
type: 'object',
optional: true,
},
schedules: {
type: 'object',
optional: true,
},
settings: {
type: 'object',
},
vms: {
type: 'object',
},
}
export function deleteJob ({ id }) {
return this.deleteBackupNgJob(id)
}
deleteJob.permission = 'admin'
deleteJob.params = {
id: {
type: 'string',
},
}
export function editJob (props) {
return this.updateJob(props)
}
editJob.permission = 'admin'
editJob.params = {
compression: {
enum: ['', 'native'],
optional: true,
},
id: {
type: 'string',
},
mode: {
enum: ['full', 'delta'],
optional: true,
},
name: {
type: 'string',
optional: true,
},
remotes: {
type: 'object',
optional: true,
},
settings: {
type: 'object',
optional: true,
},
vms: {
type: 'object',
optional: true,
},
}
export function getAllJobs () {
return this.getAllBackupNgJobs()
}
getAllJobs.permission = 'admin'
export function getJob ({ id }) {
return this.getBackupNgJob(id)
}
getJob.permission = 'admin'
getJob.params = {
id: {
type: 'string',
},
}
export async function runJob ({ id, scheduleId }) {
return this.runJobSequence([id], await this.getSchedule(scheduleId))
}
runJob.permission = 'admin'
runJob.params = {
id: {
type: 'string',
},
scheduleId: {
type: 'string',
},
}
// -----------------------------------------------------------------------------
export function deleteVmBackup ({ id }) {
return this.deleteVmBackupNg(id)
}
deleteVmBackup.permission = 'admin'
deleteVmBackup.params = {
id: {
type: 'string',
},
}
export function listVmBackups ({ remotes }) {
return this.listVmBackupsNg(remotes)
}
listVmBackups.permission = 'admin'
listVmBackups.params = {
remotes: {
type: 'array',
items: {
type: 'string',
},
},
}
export function importVmBackupNg ({ id, sr }) {
return this.importVmBackupNg(id, sr)
}
importVmBackupNg.permission = 'admin'
importVmBackupNg.params = {
id: {
type: 'string',
},
sr: {
type: 'string',
},
}

View File

@@ -8,7 +8,7 @@ getAll.permission = 'admin'
getAll.description = 'Gets all available jobs'
export async function get (id) {
return /* await */ this.getJob(id)
return /* await */ this.getJob(id, 'call')
}
get.permission = 'admin'

View File

@@ -99,11 +99,14 @@ set.params = {
// -------------------------------------------------------------------
export function get ({ id }) {
const { user } = this
if (!user) {
throw unauthorized()
}
return this.getResourceSet(id)
}
get.permission = 'admin'
get.params = {
id: {
type: 'string',

View File

@@ -17,37 +17,40 @@ get.params = {
id: { type: 'string' },
}
export async function create ({ jobId, cron, enabled, name, timezone }) {
return /* await */ this.createSchedule(this.session.get('user_id'), {
job: jobId,
export function create ({ cron, enabled, jobId, name, timezone }) {
return this.createSchedule({
cron,
enabled,
jobId,
name,
timezone,
userId: this.session.get('user_id'),
})
}
create.permission = 'admin'
create.description = 'Creates a new schedule'
create.params = {
jobId: { type: 'string' },
cron: { type: 'string' },
enabled: { type: 'boolean', optional: true },
jobId: { type: 'string' },
name: { type: 'string', optional: true },
timezone: { type: 'string', optional: true },
}
export async function set ({ id, jobId, cron, enabled, name, timezone }) {
await this.updateSchedule(id, { job: jobId, cron, enabled, name, timezone })
export async function set ({ cron, enabled, id, jobId, name, timezone }) {
await this.updateSchedule({ cron, enabled, id, jobId, name, timezone })
}
set.permission = 'admin'
set.description = 'Modifies an existing schedule'
set.params = {
id: { type: 'string' },
jobId: { type: 'string', optional: true },
cron: { type: 'string', optional: true },
enabled: { type: 'boolean', optional: true },
id: { type: 'string' },
jobId: { type: 'string', optional: true },
name: { type: 'string', optional: true },
timezone: { type: 'string', optional: true },
}
async function delete_ ({ id }) {

View File

@@ -1,30 +0,0 @@
export async function enable ({ id }) {
const schedule = await this.getSchedule(id)
schedule.enabled = true
await this.updateSchedule(id, schedule)
}
enable.permission = 'admin'
enable.description = "Enables a schedule to run it's job as scheduled"
enable.params = {
id: { type: 'string' },
}
export async function disable ({ id }) {
const schedule = await this.getSchedule(id)
schedule.enabled = false
await this.updateSchedule(id, schedule)
}
disable.permission = 'admin'
disable.description = 'Disables a schedule'
disable.params = {
id: { type: 'string' },
}
export function getScheduleTable () {
return this.scheduleTable
}
disable.permission = 'admin'
disable.description = 'Get a map of existing schedules enabled/disabled state'

View File

@@ -1058,12 +1058,12 @@ export function revert ({ snapshot, snapshotBefore }) {
}
revert.params = {
id: { type: 'string' },
snapshot: { type: 'string' },
snapshotBefore: { type: 'boolean', optional: true },
}
revert.resolve = {
snapshot: ['id', 'VM-snapshot', 'administrate'],
snapshot: ['snapshot', 'VM-snapshot', 'administrate'],
}
// -------------------------------------------------------------------

View File

@@ -1,186 +0,0 @@
import { BaseError } from 'make-error'
import { createPredicate } from 'value-matcher'
import { timeout } from 'promise-toolbox'
import { assign, filter, find, isEmpty, map, mapValues } from 'lodash'
import { crossProduct } from './math'
import { asyncMap, serializeError, thunkToArray } from './utils'
export class JobExecutorError extends BaseError {}
export class UnsupportedJobType extends JobExecutorError {
constructor (job) {
super('Unknown job type: ' + job.type)
}
}
export class UnsupportedVectorType extends JobExecutorError {
constructor (vector) {
super('Unknown vector type: ' + vector.type)
}
}
// ===================================================================
const paramsVectorActionsMap = {
extractProperties ({ mapping, value }) {
return mapValues(mapping, key => value[key])
},
crossProduct ({ items }) {
return thunkToArray(
crossProduct(map(items, value => resolveParamsVector.call(this, value)))
)
},
fetchObjects ({ pattern }) {
const objects = filter(this.xo.getObjects(), createPredicate(pattern))
if (isEmpty(objects)) {
throw new Error('no objects match this pattern')
}
return objects
},
map ({ collection, iteratee, paramName = 'value' }) {
return map(resolveParamsVector.call(this, collection), value => {
return resolveParamsVector.call(this, {
...iteratee,
[paramName]: value,
})
})
},
set: ({ values }) => values,
}
export function resolveParamsVector (paramsVector) {
const visitor = paramsVectorActionsMap[paramsVector.type]
if (!visitor) {
throw new Error(`Unsupported function '${paramsVector.type}'.`)
}
return visitor.call(this, paramsVector)
}
// ===================================================================
export default class JobExecutor {
constructor (xo) {
this.xo = xo
// The logger is not available until Xo has started.
xo.on('start', () =>
xo.getLogger('jobs').then(logger => {
this._logger = logger
})
)
}
async exec (job) {
const runJobId = this._logger.notice(`Starting execution of ${job.id}.`, {
event: 'job.start',
userId: job.userId,
jobId: job.id,
key: job.key,
})
try {
if (job.type === 'call') {
const execStatus = await this._execCall(job, runJobId)
this.xo.emit('job:terminated', execStatus)
} else {
throw new UnsupportedJobType(job)
}
this._logger.notice(`Execution terminated for ${job.id}.`, {
event: 'job.end',
runJobId,
})
} catch (error) {
this._logger.error(`The execution of ${job.id} has failed.`, {
event: 'job.end',
runJobId,
error: serializeError(error),
})
throw error
}
}
async _execCall (job, runJobId) {
const { paramsVector } = job
const paramsFlatVector = paramsVector
? resolveParamsVector.call(this, paramsVector)
: [{}] // One call with no parameters
const connection = this.xo.createUserConnection()
connection.set('user_id', job.userId)
const schedule = find(await this.xo.getAllSchedules(), { job: job.id })
const execStatus = {
calls: {},
runJobId,
start: Date.now(),
timezone: schedule !== undefined ? schedule.timezone : undefined,
}
await asyncMap(paramsFlatVector, params => {
const runCallId = this._logger.notice(
`Starting ${job.method} call. (${job.id})`,
{
event: 'jobCall.start',
runJobId,
method: job.method,
params,
}
)
const call = (execStatus.calls[runCallId] = {
method: job.method,
params,
start: Date.now(),
})
let promise = this.xo.callApiMethod(
connection,
job.method,
assign({}, params)
)
if (job.timeout) {
promise = promise::timeout(job.timeout)
}
return promise.then(
value => {
this._logger.notice(
`Call ${job.method} (${runCallId}) is a success. (${job.id})`,
{
event: 'jobCall.end',
runJobId,
runCallId,
returnedValue: value,
}
)
call.returnedValue = value
call.end = Date.now()
},
reason => {
this._logger.notice(
`Call ${job.method} (${runCallId}) has failed. (${job.id})`,
{
event: 'jobCall.end',
runJobId,
runCallId,
error: serializeError(reason),
}
)
call.error = reason
call.end = Date.now()
}
)
})
connection.close()
execStatus.end = Date.now()
return execStatus
}
}

View File

@@ -1,43 +0,0 @@
import Collection from '../collection/redis'
import Model from '../model'
import { forEach } from '../utils'
import { parseProp } from './utils'
// ===================================================================
export default class Job extends Model {}
export class Jobs extends Collection {
get Model () {
return Job
}
async create (job) {
// Serializes.
job.paramsVector = JSON.stringify(job.paramsVector)
return /* await */ this.add(new Job(job))
}
async save (job) {
// Serializes.
job.paramsVector = JSON.stringify(job.paramsVector)
return /* await */ this.update(job)
}
async get (properties) {
const jobs = await super.get(properties)
// Deserializes.
forEach(jobs, job => {
job.paramsVector = parseProp('job', job, 'paramsVector', {})
const { timeout } = job
if (timeout !== undefined) {
job.timeout = +timeout
}
})
return jobs
}
}

View File

@@ -1,38 +0,0 @@
import Collection from '../collection/redis'
import Model from '../model'
import { forEach } from '../utils'
// ===================================================================
export default class Schedule extends Model {}
export class Schedules extends Collection {
get Model () {
return Schedule
}
create (userId, job, cron, enabled, name = undefined, timezone = undefined) {
return this.add(
new Schedule({
userId,
job,
cron,
enabled,
name,
timezone,
})
)
}
async save (schedule) {
return /* await */ this.update(schedule)
}
async get (properties) {
const schedules = await super.get(properties)
forEach(schedules, schedule => {
schedule.enabled = schedule.enabled === 'true'
})
return schedules
}
}

View File

@@ -0,0 +1,15 @@
// @flow
// patch o: assign properties from p
// if the value of a p property is null, delete it from o
const patch = <T: {}>(o: T, p: $Shape<T>) => {
Object.keys(p).forEach(k => {
const v: any = p[k]
if (v === null) {
delete o[k]
} else if (v !== undefined) {
o[k] = v
}
})
}
export { patch as default }

View File

@@ -10,6 +10,8 @@ import {
validChecksumOfReadStream,
} from '../utils'
const checksumFile = file => file + '.checksum'
export default class RemoteHandlerAbstract {
constructor (remote) {
this._remote = { ...remote, ...parse(remote.url) }
@@ -92,8 +94,15 @@ export default class RemoteHandlerAbstract {
return this.createReadStream(file, options).then(streamToBuffer)
}
async rename (oldPath, newPath) {
return this._rename(oldPath, newPath)
async rename (oldPath, newPath, { checksum = false } = {}) {
let p = this._rename(oldPath, newPath)
if (checksum) {
p = Promise.all([
p,
this._rename(checksumFile(oldPath), checksumFile(newPath)),
])
}
return p
}
async _rename (oldPath, newPath) {
@@ -112,6 +121,7 @@ export default class RemoteHandlerAbstract {
file,
{ checksum = false, ignoreMissingChecksum = false, ...options } = {}
) {
const path = typeof file === 'string' ? file : file.path
const streamP = this._createReadStream(file, options).then(stream => {
// detect early errors
let promise = eventToPromise(stream, 'readable')
@@ -142,7 +152,7 @@ export default class RemoteHandlerAbstract {
// avoid a unhandled rejection warning
;streamP::ignoreErrors()
return this.readFile(`${file}.checksum`).then(
return this.readFile(checksumFile(path)).then(
checksum =>
streamP.then(stream => {
const { length } = stream
@@ -164,14 +174,31 @@ export default class RemoteHandlerAbstract {
throw new Error('Not implemented')
}
async openFile (path, flags) {
return { fd: await this._openFile(path, flags), path }
}
async _openFile (path, flags) {
throw new Error('Not implemented')
}
async closeFile (fd) {
return this._closeFile(fd.fd)
}
async _closeFile (fd) {
throw new Error('Not implemented')
}
async refreshChecksum (path) {
const stream = addChecksumToReadStream(await this.createReadStream(path))
stream.resume() // start reading the whole file
const checksum = await stream.checksum
await this.outputFile(`${path}.checksum`, checksum)
await this.outputFile(checksumFile(path), checksum)
}
async createOutputStream (file, { checksum = false, ...options } = {}) {
const path = typeof file === 'string' ? file : file.path
const streamP = this._createOutputStream(file, {
flags: 'wx',
...options,
@@ -187,10 +214,12 @@ export default class RemoteHandlerAbstract {
}
const streamWithChecksum = addChecksumToReadStream(connectorStream)
streamWithChecksum.pipe(await streamP)
const stream = await streamP
stream.on('error', forwardError)
streamWithChecksum.pipe(stream)
streamWithChecksum.checksum
.then(value => this.outputFile(`${file}.checksum`, value))
.then(value => this.outputFile(checksumFile(path), value))
.catch(forwardError)
return connectorStream

View File

@@ -63,13 +63,29 @@ export default class LocalHandler extends RemoteHandlerAbstract {
}
async _createReadStream (file, options) {
return fs.createReadStream(this._getFilePath(file), options)
if (typeof file === 'string') {
return fs.createReadStream(this._getFilePath(file), options)
} else {
return fs.createReadStream('', {
autoClose: false,
...options,
fd: file.fd,
})
}
}
async _createOutputStream (file, options) {
const path = this._getFilePath(file)
await fs.ensureDir(dirname(path))
return fs.createWriteStream(path, options)
if (typeof file === 'string') {
const path = this._getFilePath(file)
await fs.ensureDir(dirname(path))
return fs.createWriteStream(path, options)
} else {
return fs.createWriteStream('', {
autoClose: false,
...options,
fd: file.fd,
})
}
}
async _unlink (file) {
@@ -82,7 +98,17 @@ export default class LocalHandler extends RemoteHandlerAbstract {
}
async _getSize (file) {
const stats = await fs.stat(this._getFilePath(file))
const stats = await fs.stat(
this._getFilePath(typeof file === 'string' ? file : file.path)
)
return stats.size
}
async _openFile (path, flags) {
return fs.open(this._getFilePath(path), flags)
}
async _closeFile (fd) {
return fs.close(fd)
}
}

View File

@@ -139,6 +139,9 @@ export default class SmbHandler extends RemoteHandlerAbstract {
}
async _createReadStream (file, options = {}) {
if (typeof file !== 'string') {
file = file.path
}
const client = this._getClient(this._remote)
let stream
@@ -154,6 +157,9 @@ export default class SmbHandler extends RemoteHandlerAbstract {
}
async _createOutputStream (file, options = {}) {
if (typeof file !== 'string') {
file = file.path
}
const client = this._getClient(this._remote)
const path = this._getFilePath(file)
const dir = this._dirname(path)
@@ -188,13 +194,22 @@ export default class SmbHandler extends RemoteHandlerAbstract {
let size
try {
size = await client.getSize(this._getFilePath(file))::pFinally(() => {
client.close()
})
size = await client
.getSize(this._getFilePath(typeof file === 'string' ? file : file.path))
::pFinally(() => {
client.close()
})
} catch (error) {
throw normalizeError(error)
}
return size
}
// this is a fake
async _openFile (path) {
return this._getFilePath(path)
}
async _closeFile (fd) {}
}

View File

@@ -182,7 +182,7 @@ function checksumStruct (rawStruct, struct) {
// ===================================================================
class Vhd {
export class Vhd {
constructor (handler, path) {
this._handler = handler
this._path = path
@@ -193,7 +193,7 @@ class Vhd {
// =================================================================
_readStream (start, n) {
return this._handler.createReadStream(this._path, {
return this._handler.createReadStream(this._fd ? this._fd : this._path, {
start,
end: start + n - 1, // end is inclusive
})
@@ -328,10 +328,12 @@ class Vhd {
).then(
buf =>
onlyBitmap
? { bitmap: buf }
? { id: blockId, bitmap: buf }
: {
id: blockId,
bitmap: buf.slice(0, this.bitmapSize),
data: buf.slice(this.bitmapSize),
buffer: buf,
}
)
}
@@ -339,7 +341,6 @@ class Vhd {
// get the identifiers and first sectors of the first and last block
// in the file
//
// return undefined if none
_getFirstAndLastBlocks () {
const n = this.header.maxTableEntries
const bat = this.blockTable
@@ -353,7 +354,9 @@ class Vhd {
j += VHD_ENTRY_SIZE
if (i === n) {
throw new Error('no allocated block found')
const error = new Error('no allocated block found')
error.noBlock = true
throw error
}
}
lastSector = firstSector
@@ -383,27 +386,26 @@ class Vhd {
// =================================================================
// Write a buffer/stream at a given position in a vhd file.
_write (data, offset) {
async _write (data, offset) {
debug(
`_write offset=${offset} size=${
Buffer.isBuffer(data) ? data.length : '???'
}`
)
// TODO: could probably be merged in remote handlers.
return this._handler
.createOutputStream(this._path, {
const stream = await this._handler.createOutputStream(
this._fd ? this._fd : this._path,
{
flags: 'r+',
start: offset,
}
)
return Buffer.isBuffer(data)
? new Promise((resolve, reject) => {
stream.on('error', reject)
stream.end(data, resolve)
})
.then(
Buffer.isBuffer(data)
? stream =>
new Promise((resolve, reject) => {
stream.on('error', reject)
stream.end(data, resolve)
})
: stream => eventToPromise(data.pipe(stream), 'finish')
)
: eventToPromise(data.pipe(stream), 'finish')
}
async ensureBatSize (size) {
@@ -415,11 +417,11 @@ class Vhd {
}
const tableOffset = uint32ToUint64(header.tableOffset)
const { first, firstSector, lastSector } = this._getFirstAndLastBlocks()
// extend BAT
const maxTableEntries = (header.maxTableEntries = size)
const batSize = maxTableEntries * VHD_ENTRY_SIZE
const batSize = sectorsToBytes(
sectorsRoundUpNoZero(maxTableEntries * VHD_ENTRY_SIZE)
)
const prevBat = this.blockTable
const bat = (this.blockTable = Buffer.allocUnsafe(batSize))
prevBat.copy(bat)
@@ -428,7 +430,7 @@ class Vhd {
`ensureBatSize: extend in memory BAT ${prevMaxTableEntries} -> ${maxTableEntries}`
)
const extendBat = () => {
const extendBat = async () => {
debug(
`ensureBatSize: extend in file BAT ${prevMaxTableEntries} -> ${maxTableEntries}`
)
@@ -438,25 +440,37 @@ class Vhd {
tableOffset + prevBat.length
)
}
try {
const { first, firstSector, lastSector } = this._getFirstAndLastBlocks()
if (tableOffset + batSize < sectorsToBytes(firstSector)) {
return Promise.all([extendBat(), this.writeHeader()])
}
if (tableOffset + batSize < sectorsToBytes(firstSector)) {
return Promise.all([extendBat(), this.writeHeader()])
}
const { fullBlockSize } = this
const newFirstSector = lastSector + fullBlockSize / VHD_SECTOR_SIZE
debug(
`ensureBatSize: move first block ${firstSector} -> ${newFirstSector}`
)
const { fullBlockSize } = this
const newFirstSector = lastSector + fullBlockSize / VHD_SECTOR_SIZE
debug(`ensureBatSize: move first block ${firstSector} -> ${newFirstSector}`)
return Promise.all([
// copy the first block at the end
this._readStream(sectorsToBytes(firstSector), fullBlockSize)
.then(stream => this._write(stream, sectorsToBytes(newFirstSector)))
.then(extendBat),
this._setBatEntry(first, newFirstSector),
this.writeHeader(),
this.writeFooter(),
])
const stream = await this._readStream(
sectorsToBytes(firstSector),
fullBlockSize
)
await this._write(stream, sectorsToBytes(newFirstSector))
await extendBat()
await this._setBatEntry(first, newFirstSector)
await this.writeHeader()
await this.writeFooter()
} catch (e) {
if (e.noBlock) {
await extendBat()
await this.writeHeader()
await this.writeFooter()
} else {
throw e
}
}
}
// set the first sector (bitmap) of a block
@@ -510,7 +524,16 @@ class Vhd {
await this._write(bitmap, sectorsToBytes(blockAddr))
}
async writeBlockSectors (block, beginSectorId, endSectorId) {
async writeEntireBlock (block) {
let blockAddr = this._getBatEntry(block.id)
if (blockAddr === BLOCK_UNUSED) {
blockAddr = await this.createBlock(block.id)
}
await this._write(block.buffer, sectorsToBytes(blockAddr))
}
async writeBlockSectors (block, beginSectorId, endSectorId, parentBitmap) {
let blockAddr = this._getBatEntry(block.id)
if (blockAddr === BLOCK_UNUSED) {
@@ -525,6 +548,11 @@ class Vhd {
}, sectors=${beginSectorId}...${endSectorId}`
)
for (let i = beginSectorId; i < endSectorId; ++i) {
mapSetBit(parentBitmap, i)
}
await this.writeBlockBitmap(blockAddr, parentBitmap)
await this._write(
block.data.slice(
sectorsToBytes(beginSectorId),
@@ -532,20 +560,11 @@ class Vhd {
),
sectorsToBytes(offset)
)
const { bitmap } = await this._readBlock(block.id, true)
for (let i = beginSectorId; i < endSectorId; ++i) {
mapSetBit(bitmap, i)
}
await this.writeBlockBitmap(blockAddr, bitmap)
}
// Merge block id (of vhd child) into vhd parent.
async coalesceBlock (child, blockId) {
// Get block data and bitmap of block id.
const { bitmap, data } = await child._readBlock(blockId)
const block = await child._readBlock(blockId)
const { bitmap, data } = block
debug(`coalesceBlock block=${blockId}`)
@@ -556,7 +575,7 @@ class Vhd {
if (!mapTestBit(bitmap, i)) {
continue
}
let parentBitmap = null
let endSector = i + 1
// Count changed sectors.
@@ -566,7 +585,16 @@ class Vhd {
// Write n sectors into parent.
debug(`coalesceBlock: write sectors=${i}...${endSector}`)
await this.writeBlockSectors({ id: blockId, data }, i, endSector)
const isFullBlock = i === 0 && endSector === sectorsPerBlock
if (isFullBlock) {
await this.writeEntireBlock(block)
} else {
if (parentBitmap === null) {
parentBitmap = (await this._readBlock(blockId, true)).bitmap
}
await this.writeBlockSectors(block, i, endSector, parentBitmap)
}
i = endSector
}
@@ -620,60 +648,71 @@ export default concurrency(2)(async function vhdMerge (
childPath
) {
const parentVhd = new Vhd(parentHandler, parentPath)
const childVhd = new Vhd(childHandler, childPath)
parentVhd._fd = await parentHandler.openFile(parentPath, 'r+')
try {
const childVhd = new Vhd(childHandler, childPath)
childVhd._fd = await childHandler.openFile(childPath, 'r')
try {
// Reading footer and header.
await Promise.all([
parentVhd.readHeaderAndFooter(),
childVhd.readHeaderAndFooter(),
])
// Reading footer and header.
await Promise.all([
parentVhd.readHeaderAndFooter(),
childVhd.readHeaderAndFooter(),
])
assert(childVhd.header.blockSize === parentVhd.header.blockSize)
assert(childVhd.header.blockSize === parentVhd.header.blockSize)
// Child must be a delta.
if (childVhd.footer.diskType !== HARD_DISK_TYPE_DIFFERENCING) {
throw new Error('Unable to merge, child is not a delta backup.')
}
// Child must be a delta.
if (childVhd.footer.diskType !== HARD_DISK_TYPE_DIFFERENCING) {
throw new Error('Unable to merge, child is not a delta backup.')
}
// Merging in differencing disk is prohibited in our case.
if (parentVhd.footer.diskType !== HARD_DISK_TYPE_DYNAMIC) {
throw new Error('Unable to merge, parent is not a full backup.')
}
// Merging in differencing disk is prohibited in our case.
if (parentVhd.footer.diskType !== HARD_DISK_TYPE_DYNAMIC) {
throw new Error('Unable to merge, parent is not a full backup.')
}
// Allocation table map is not yet implemented.
if (
parentVhd.hasBlockAllocationTableMap() ||
childVhd.hasBlockAllocationTableMap()
) {
throw new Error('Unsupported allocation table map.')
}
// Allocation table map is not yet implemented.
if (
parentVhd.hasBlockAllocationTableMap() ||
childVhd.hasBlockAllocationTableMap()
) {
throw new Error('Unsupported allocation table map.')
}
// Read allocation table of child/parent.
await Promise.all([parentVhd.readBlockTable(), childVhd.readBlockTable()])
// Read allocation table of child/parent.
await Promise.all([parentVhd.readBlockTable(), childVhd.readBlockTable()])
await parentVhd.ensureBatSize(childVhd.header.maxTableEntries)
await parentVhd.ensureBatSize(childVhd.header.maxTableEntries)
let mergedDataSize = 0
for (
let blockId = 0;
blockId < childVhd.header.maxTableEntries;
blockId++
) {
if (childVhd._getBatEntry(blockId) !== BLOCK_UNUSED) {
mergedDataSize += await parentVhd.coalesceBlock(childVhd, blockId)
}
}
const cFooter = childVhd.footer
const pFooter = parentVhd.footer
let mergedDataSize = 0
pFooter.currentSize = { ...cFooter.currentSize }
pFooter.diskGeometry = { ...cFooter.diskGeometry }
pFooter.originalSize = { ...cFooter.originalSize }
pFooter.timestamp = cFooter.timestamp
for (let blockId = 0; blockId < childVhd.header.maxTableEntries; blockId++) {
if (childVhd._getBatEntry(blockId) !== BLOCK_UNUSED) {
mergedDataSize += await parentVhd.coalesceBlock(childVhd, blockId)
// necessary to update values and to recreate the footer after block
// creation
await parentVhd.writeFooter()
return mergedDataSize
} finally {
await childHandler.closeFile(childVhd._fd)
}
} finally {
await parentHandler.closeFile(parentVhd._fd)
}
const cFooter = childVhd.footer
const pFooter = parentVhd.footer
pFooter.currentSize = { ...cFooter.currentSize }
pFooter.diskGeometry = { ...cFooter.diskGeometry }
pFooter.originalSize = { ...cFooter.originalSize }
pFooter.timestamp = cFooter.timestamp
// necessary to update values and to recreate the footer after block
// creation
await parentVhd.writeFooter()
return mergedDataSize
})
// returns true if the child was actually modified

View File

@@ -0,0 +1,72 @@
import execa from 'execa'
import vhdMerge, { chainVhd, Vhd } from './vhd-merge'
import LocalHandler from './remote-handlers/local.js'
async function testVhdMerge () {
console.log('before merge')
const moOfRandom = 4
await execa('bash', [
'-c',
`head -c ${moOfRandom}M < /dev/urandom >randomfile`,
])
await execa('bash', [
'-c',
`head -c ${moOfRandom / 2}M < /dev/urandom >small_randomfile`,
])
await execa('qemu-img', [
'convert',
'-f',
'raw',
'-Ovpc',
'randomfile',
'randomfile.vhd',
])
await execa('vhd-util', ['check', '-t', '-n', 'randomfile.vhd'])
await execa('vhd-util', ['create', '-s', moOfRandom, '-n', 'empty.vhd'])
// await execa('vhd-util', ['snapshot', '-n', 'randomfile_delta.vhd', '-p', 'randomfile.vhd'])
const handler = new LocalHandler({ url: 'file://' + process.cwd() })
const originalSize = await handler._getSize('randomfile')
await chainVhd(handler, 'empty.vhd', handler, 'randomfile.vhd')
const childVhd = new Vhd(handler, 'randomfile.vhd')
console.log('changing type')
await childVhd.readHeaderAndFooter()
console.log('child vhd', childVhd.footer.currentSize, originalSize)
await childVhd.readBlockTable()
childVhd.footer.diskType = 4 // Delta backup.
await childVhd.writeFooter()
console.log('chained')
await vhdMerge(handler, 'empty.vhd', handler, 'randomfile.vhd')
console.log('merged')
const parentVhd = new Vhd(handler, 'empty.vhd')
await parentVhd.readHeaderAndFooter()
console.log('parent vhd', parentVhd.footer.currentSize)
await execa('qemu-img', [
'convert',
'-f',
'vpc',
'-Oraw',
'empty.vhd',
'recovered',
])
await execa('truncate', ['-s', originalSize, 'recovered'])
console.log('ls', (await execa('ls', ['-lt'])).stdout)
console.log(
'diff',
(await execa('diff', ['-q', 'randomfile', 'recovered'])).stdout
)
/* const vhd = new Vhd(handler, 'randomfile_delta.vhd')
await vhd.readHeaderAndFooter()
await vhd.readBlockTable()
console.log('vhd.header.maxTableEntries', vhd.header.maxTableEntries)
await vhd.ensureBatSize(300)
console.log('vhd.header.maxTableEntries', vhd.header.maxTableEntries)
*/
console.log(await handler.list())
console.log('lol')
}
export { testVhdMerge as default }

View File

@@ -6,7 +6,7 @@ import synchronized from 'decorator-synchronized'
import tarStream from 'tar-stream'
import vmdkToVhd from 'xo-vmdk-to-vhd'
import {
cancellable,
cancelable,
catchPlus as pCatch,
defer,
fromEvent,
@@ -710,18 +710,22 @@ export default class Xapi extends XapiBase {
// Returns a stream to the exported VM.
@concurrency(2, stream => stream.then(stream => fromEvent(stream, 'end')))
async exportVm (vmId, { compress = true } = {}) {
@cancelable
async exportVm ($cancelToken, vmId, { compress = true } = {}) {
const vm = this.getObject(vmId)
let host
let snapshotRef
if (isVmRunning(vm)) {
host = vm.$resident_on
snapshotRef = (await this._snapshotVm(vm, `[XO Export] ${vm.name_label}`))
.$ref
snapshotRef = (await this._snapshotVm(
$cancelToken,
vm,
`[XO Export] ${vm.name_label}`
)).$ref
}
const promise = this.getResource('/export/', {
const promise = this.getResource($cancelToken, '/export/', {
host,
query: {
ref: snapshotRef || vm.$ref,
@@ -778,8 +782,9 @@ export default class Xapi extends XapiBase {
})
}
// Create a snapshot of the VM and returns a delta export object.
@cancellable
// Create a snapshot (if necessary) of the VM and returns a delta export
// object.
@cancelable
@deferrable
async exportDeltaVm (
$defer,
@@ -796,16 +801,18 @@ export default class Xapi extends XapiBase {
snapshotNameLabel = undefined,
} = {}
) {
let vm = this.getObject(vmId)
if (!bypassVdiChainsCheck) {
this._assertHealthyVdiChains(this.getObject(vmId))
this._assertHealthyVdiChains(this.getObject(vm))
}
const vm = await this.snapshotVm(vmId)
$defer.onFailure(() => this._deleteVm(vm))
if (snapshotNameLabel) {
;this._setObjectProperties(vm, {
nameLabel: snapshotNameLabel,
})::ignoreErrors()
if (!vm.is_a_snapshot) {
vm = await this.snapshotVm(vmId)
$defer.onFailure(() => this._deleteVm(vm))
if (snapshotNameLabel) {
;this._setObjectProperties(vm, {
nameLabel: snapshotNameLabel,
})::ignoreErrors()
}
}
const baseVm = baseVmId && this.getObject(baseVmId)
@@ -901,7 +908,9 @@ export default class Xapi extends XapiBase {
},
'streams',
{
configurable: true,
value: streams,
writable: true,
}
)
}
@@ -1232,7 +1241,8 @@ export default class Xapi extends XapiBase {
)
}
async _importVm (stream, sr, onVmCreation = undefined) {
@cancelable
async _importVm ($cancelToken, stream, sr, onVmCreation = undefined) {
const taskRef = await this.createTask('VM import')
const query = {}
@@ -1242,16 +1252,18 @@ export default class Xapi extends XapiBase {
query.sr_id = sr.$ref
}
if (onVmCreation) {
if (onVmCreation != null) {
;this._waitObject(
obj =>
obj && obj.current_operations && taskRef in obj.current_operations
obj != null &&
obj.current_operations != null &&
taskRef in obj.current_operations
)
.then(onVmCreation)
::ignoreErrors()
}
const vmRef = await this.putResource(stream, '/import/', {
const vmRef = await this.putResource($cancelToken, stream, '/import/', {
host,
query,
task: taskRef,
@@ -1412,7 +1424,8 @@ export default class Xapi extends XapiBase {
}
@synchronized() // like @concurrency(1) but more efficient
async _snapshotVm (vm, nameLabel = vm.name_label) {
@cancelable
async _snapshotVm ($cancelToken, vm, nameLabel = vm.name_label) {
debug(
`Snapshotting VM ${vm.name_label}${
nameLabel !== vm.name_label ? ` as ${nameLabel}` : ''
@@ -1421,7 +1434,12 @@ export default class Xapi extends XapiBase {
let ref
try {
ref = await this.call('VM.snapshot_with_quiesce', vm.$ref, nameLabel)
ref = await this.callAsync(
$cancelToken,
'VM.snapshot_with_quiesce',
vm.$ref,
nameLabel
).then(extractOpaqueRef)
this.addTag(ref, 'quiesce')::ignoreErrors()
await this._waitObjectState(ref, vm => includes(vm.tags, 'quiesce'))
@@ -1437,7 +1455,12 @@ export default class Xapi extends XapiBase {
) {
throw error
}
ref = await this.call('VM.snapshot', vm.$ref, nameLabel)
ref = await this.callAsync(
$cancelToken,
'VM.snapshot',
vm.$ref,
nameLabel
).then(extractOpaqueRef)
}
// Convert the template to a VM and wait to have receive the up-
// to-date object.
@@ -1854,7 +1877,7 @@ export default class Xapi extends XapiBase {
}
@concurrency(12, stream => stream.then(stream => fromEvent(stream, 'end')))
@cancellable
@cancelable
_exportVdi ($cancelToken, vdi, base, format = VDI_FORMAT_VHD) {
const host = vdi.$SR.$PBDs[0].$host

View File

@@ -0,0 +1,772 @@
// @flow
// $FlowFixMe
import defer from 'golike-defer'
import { dirname, resolve } from 'path'
// $FlowFixMe
import { fromEvent, timeout as pTimeout } from 'promise-toolbox'
// $FlowFixMe
import { isEmpty, last, mapValues, values } from 'lodash'
import { type Pattern, createPredicate } from 'value-matcher'
import { PassThrough } from 'stream'
import { type Executor, type Job } from '../jobs'
import { type Schedule } from '../scheduling'
import createSizeStream from '../../size-stream'
import { asyncMap, safeDateFormat, serializeError } from '../../utils'
// import { parseDateTime } from '../../xapi/utils'
import { type RemoteHandlerAbstract } from '../../remote-handlers/abstract'
import { type Xapi } from '../../xapi'
type Dict<T, K = string> = { [K]: T }
type Mode = 'full' | 'delta'
type Settings = {|
deleteFirst?: boolean,
exportRetention?: number,
snapshotRetention?: number,
vmTimeout?: number
|}
type SimpleIdPattern = {|
id: string | {| __or: string[] |}
|}
export type BackupJob = {|
...$Exact<Job>,
compression?: 'native',
mode: Mode,
remotes?: SimpleIdPattern,
settings: Dict<Settings>,
srs?: SimpleIdPattern,
type: 'backup',
vms: Pattern
|}
type BackupResult = {|
mergeDuration: number,
mergeSize: number,
transferDuration: number,
transferSize: number
|}
type MetadataBase = {|
jobId: string,
mode: Mode,
scheduleId: string,
timestamp: number,
version: '2.0.0',
vm: Object,
vmSnapshot: Object
|}
type MetadataDelta = {| ...MetadataBase, mode: 'delta' |}
type MetadataFull = {|
...MetadataBase,
data: string, // relative path to the XVA
mode: 'full'
|}
type Metadata = MetadataDelta | MetadataFull
const compareSnapshotTime = (
{ snapshot_time: time1 },
{ snapshot_time: time2 }
) => (time1 < time2 ? -1 : 1)
const compareTimestamp = ({ timestamp: time1 }, { timestamp: time2 }) =>
time1 - time2
// returns all entries but the last (retention - 1)-th
//
// the “-1” is because this code is usually run with entries computed before the
// new entry is created
//
// FIXME: check whether it take the new one into account
const getOldEntries = <T>(retention: number, entries?: T[]): T[] =>
entries === undefined
? []
: --retention > 0 ? entries.slice(0, -retention) : entries
const defaultSettings: Settings = {
deleteFirst: false,
exportRetention: 0,
snapshotRetention: 0,
vmTimeout: 0,
}
const getSetting = (
settings: Dict<Settings>,
name: $Keys<Settings>,
...keys: string[]
): any => {
for (let i = 0, n = keys.length; i < n; ++i) {
const objectSettings = settings[keys[i]]
if (objectSettings !== undefined) {
const setting = objectSettings[name]
if (setting !== undefined) {
return setting
}
}
}
return defaultSettings[name]
}
const BACKUP_DIR = 'xo-vm-backups'
const getVmBackupDir = (uuid: string) => `${BACKUP_DIR}/${uuid}`
const isMetadataFile = (filename: string) => filename.endsWith('.json')
const listReplicatedVms = (xapi: Xapi, scheduleId: string, srId) => {
const { all } = xapi.objects
const vms = {}
for (const key in all) {
const object = all[key]
if (
object.$type === 'vm' &&
object.other_config['xo:backup:schedule'] === scheduleId &&
object.other_config['xo:backup:sr'] === srId
) {
vms[object.$id] = object
}
}
// the replicated VMs have been created from a snapshot, therefore we can use
// `snapshot_time` as the creation time
return values(vms).sort(compareSnapshotTime)
}
const parseVmBackupId = id => {
const i = id.indexOf('/')
return {
metadataFilename: id.slice(i + 1),
remoteId: id.slice(0, i),
}
}
// used to resolve the data field from the metadata
const resolveRelativeFromFile = (file, path) =>
resolve('/', dirname(file), path).slice(1)
const unboxIds = (pattern?: SimpleIdPattern): string[] => {
if (pattern === undefined) {
return []
}
const { id } = pattern
return typeof id === 'string' ? [id] : id.__or
}
// File structure on remotes:
//
// <remote>
// └─ xo-vm-backups
// ├─ index.json // TODO
// └─ <VM UUID>
// ├─ index.json // TODO
// ├─ vdis
// │ └─ <VDI UUID>
// │ ├─ index.json // TODO
// │ └─ <YYYYMMDD>T<HHmmss>.vhd
// ├─ <YYYYMMDD>T<HHmmss>.json // backup metadata
// └─ <YYYYMMDD>T<HHmmss>.xva
export default class BackupNg {
_app: any
constructor (app: any) {
this._app = app
app.on('start', () => {
const executor: Executor = async ({
cancelToken,
job: job_,
logger,
runJobId,
schedule = {},
}) => {
const job: BackupJob = (job_: any)
const 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
const status: Object = {
calls: {},
runJobId,
start: Date.now(),
timezone: schedule.timezone,
}
const { calls } = status
await asyncMap(vms, async vm => {
const { uuid } = vm
const method = 'backup-ng'
const params = {
id: uuid,
tag: job.name,
}
const name = vm.name_label
const runCallId = logger.notice(
`Starting backup of ${name}. (${jobId})`,
{
event: 'jobCall.start',
method,
params,
runJobId,
}
)
const call: Object = (calls[runCallId] = {
method,
params,
start: Date.now(),
})
const vmCancel = cancelToken.fork()
try {
// $FlowFixMe injected $defer param
let p = this._backupVm(vmCancel.token, uuid, job, schedule)
const vmTimeout: number = getSetting(
job.settings,
'vmTimeout',
uuid,
scheduleId
)
if (vmTimeout !== 0) {
p = pTimeout.call(p, vmTimeout)
}
const returnedValue = await p
logger.notice(
`Backuping ${name} (${runCallId}) is a success. (${jobId})`,
{
event: 'jobCall.end',
runJobId,
runCallId,
returnedValue,
}
)
call.returnedValue = returnedValue
call.end = Date.now()
} catch (error) {
vmCancel.cancel()
logger.notice(
`Backuping ${name} (${runCallId}) has failed. (${jobId})`,
{
event: 'jobCall.end',
runJobId,
runCallId,
error: serializeError(error),
}
)
call.error = error
call.end = Date.now()
console.warn(error.stack) // TODO: remove
}
})
status.end = Date.now()
return status
}
app.registerJobExecutor('backup', executor)
})
}
async createBackupNgJob (
props: $Diff<BackupJob, {| id: string |}>,
schedules?: Dict<$Diff<Schedule, {| id: string |}>>
): Promise<BackupJob> {
const app = this._app
props.type = 'backup'
const job: BackupJob = await app.createJob(props)
if (schedules !== undefined) {
const { id, settings } = job
const tmpIds = Object.keys(schedules)
await asyncMap(tmpIds, async (tmpId: string) => {
// $FlowFixMe don't know what is the problem (JFT)
const schedule = schedules[tmpId]
schedule.jobId = id
settings[(await app.createSchedule(schedule)).id] = settings[tmpId]
delete settings[tmpId]
})
await app.updateJob({ id, settings })
}
return job
}
async deleteBackupNgJob (id: string): Promise<void> {
const app = this._app
const [schedules] = await Promise.all([
app.getAllSchedules(),
app.getJob(id, 'backup'),
])
await Promise.all([
app.removeJob(id),
asyncMap(schedules, schedule => {
if (schedule.id === id) {
app.removeSchedule(schedule.id)
}
}),
])
}
async deleteVmBackupNg (id: string): Promise<void> {
const app = this._app
const { metadataFilename, remoteId } = parseVmBackupId(id)
const handler = await app.getRemoteHandler(remoteId)
const metadata: Metadata = JSON.parse(
await handler.readFile(metadataFilename)
)
if (metadata.mode === 'delta') {
throw new Error('not implemented')
}
metadata._filename = metadataFilename
await this._deleteFullVmBackups(handler, [metadata])
}
getAllBackupNgJobs (): Promise<BackupJob[]> {
return this._app.getAllJobs('backup')
}
getBackupNgJob (id: string): Promise<BackupJob> {
return this._app.getJob(id, 'backup')
}
async importVmBackupNg (id: string, srId: string): Promise<void> {
const app = this._app
const { metadataFilename, remoteId } = parseVmBackupId(id)
const handler = await app.getRemoteHandler(remoteId)
const metadata: Metadata = JSON.parse(
await handler.readFile(metadataFilename)
)
if (metadata.mode === 'delta') {
throw new Error('not implemented')
}
const xapi = app.getXapi(srId)
const sr = xapi.getObject(srId)
const xva = await handler.createReadStream(
resolveRelativeFromFile(metadataFilename, metadata.data)
)
const vm = await xapi.importVm(xva, { srId: sr.$id })
await Promise.all([
xapi.addTag(vm.$id, 'restored from backup'),
xapi.editVm(vm.$id, {
name_label: `${metadata.vm.name_label} (${safeDateFormat(
metadata.timestamp
)})`,
}),
])
return vm.$id
}
async listVmBackupsNg (remotes: string[]) {
const backupsByVmByRemote: Dict<Dict<Metadata[]>> = {}
const app = this._app
await Promise.all(
remotes.map(async remoteId => {
const handler = await app.getRemoteHandler(remoteId)
const entries = (await handler.list(BACKUP_DIR).catch(error => {
if (error == null || error.code !== 'ENOENT') {
throw error
}
return []
})).filter(name => name !== 'index.json')
const backupsByVm = (backupsByVmByRemote[remoteId] = {})
await Promise.all(
entries.map(async vmId => {
// $FlowFixMe don't know what is the problem (JFT)
const backups = await this._listVmBackups(handler, vmId)
if (backups.length === 0) {
return
}
// inject an id usable by importVmBackupNg()
backups.forEach(backup => {
backup.id = `${remoteId}/${backup._filename}`
})
backupsByVm[vmId] = backups
})
)
})
)
return backupsByVmByRemote
}
// - [x] files (.tmp) should be renamed at the end of job
// - [ ] validate VHDs after exports and before imports
// - [ ] protect against concurrent backup against a single VM (JFT: why?)
// - [x] detect full remote
// - [x] can the snapshot and export retention be different? → Yes
// - [ ] snapshots and files of an old job should be detected and removed
// - [ ] adding and removing VDIs should behave
// - [ ] key export?
// - [x] deleteFirst per target
// - [ ] possibility to (re-)run a single VM in a backup?
// - [x] timeout per VM
// - [ ] display queued VMs
// - [ ] jobs should be cancelable
// - [ ] logs
// - [x] backups should be deletable from the API
// - [ ] check merge/transfert duration/size are what we want for delta
@defer
async _backupVm (
$defer: any,
$cancelToken: any,
vmId: string,
job: BackupJob,
schedule: Schedule
): Promise<BackupResult> {
const app = this._app
const xapi = app.getXapi(vmId)
const vm = xapi.getObject(vmId)
const { id: jobId, settings } = job
const { id: scheduleId } = schedule
const exportRetention: number = getSetting(
settings,
'exportRetention',
scheduleId
)
const snapshotRetention: number = getSetting(
settings,
'snapshotRetention',
scheduleId
)
let remotes, srs
if (exportRetention === 0) {
if (snapshotRetention === 0) {
throw new Error('export and snapshots retentions cannot both be 0')
}
} else {
remotes = unboxIds(job.remotes)
srs = unboxIds(job.srs)
if (remotes.length === 0 && srs.length === 0) {
throw new Error('export retention must be 0 without remotes and SRs')
}
}
const snapshots = vm.$snapshots
.filter(_ => _.other_config['xo:backup:schedule'] === scheduleId)
.sort(compareSnapshotTime)
$defer(() =>
asyncMap(getOldEntries(snapshotRetention, snapshots), _ =>
xapi.deleteVm(_)
)
)
let snapshot = await xapi._snapshotVm(
$cancelToken,
vm,
`[XO Backup] ${vm.name_label}`
)
$defer.onFailure.call(xapi, '_deleteVm', snapshot)
await xapi._updateObjectMapProperty(snapshot, 'other_config', {
'xo:backup:job': jobId,
'xo:backup:schedule': scheduleId,
})
snapshot = await xapi.barrier(snapshot.$ref)
if (exportRetention === 0) {
return {
mergeDuration: 0,
mergeSize: 0,
transferDuration: 0,
transferSize: 0,
}
}
const now = Date.now()
const { mode } = job
const metadata: Metadata = {
jobId,
mode,
scheduleId,
timestamp: now,
version: '2.0.0',
vm,
vmSnapshot: snapshot,
}
if (mode === 'full') {
// TODO: do not create the snapshot if there are no snapshotRetention and
// the VM is not running
if (snapshotRetention === 0) {
$defer.call(xapi, 'deleteVm', snapshot)
}
let xva = await xapi.exportVm($cancelToken, snapshot)
const exportTask = xva.task
xva = xva.pipe(createSizeStream())
const dirname = getVmBackupDir(vm.uuid)
const basename = safeDateFormat(now)
const dataBasename = `${basename}.xva`
const metadataFilename = `${dirname}/${basename}.json`
metadata.data = `./${dataBasename}`
const dataFilename = `${dirname}/${dataBasename}`
const tmpFilename = `${dirname}/.${dataBasename}`
const jsonMetadata = JSON.stringify(metadata)
await Promise.all([
asyncMap(
remotes,
defer(async ($defer, remoteId) => {
const fork = xva.pipe(new PassThrough())
const handler = await app.getRemoteHandler(remoteId)
const oldBackups = getOldEntries(
exportRetention,
await this._listVmBackups(
handler,
vm,
_ => _.mode === 'full' && _.scheduleId === scheduleId
)
)
const deleteFirst = getSetting(settings, 'deleteFirst', remoteId)
if (deleteFirst) {
await this._deleteFullVmBackups(handler, oldBackups)
}
const output = await handler.createOutputStream(tmpFilename, {
checksum: true,
})
$defer.onFailure.call(handler, 'unlink', tmpFilename)
$defer.onSuccess.call(
handler,
'rename',
tmpFilename,
dataFilename,
{ checksum: true }
)
const promise = fromEvent(output, 'finish')
fork.pipe(output)
await Promise.all([exportTask, promise])
await handler.outputFile(metadataFilename, jsonMetadata)
if (!deleteFirst) {
await this._deleteFullVmBackups(handler, oldBackups)
}
})
),
asyncMap(
srs,
defer(async ($defer, srId) => {
const fork = xva.pipe(new PassThrough())
fork.task = exportTask
const xapi = app.getXapi(srId)
const sr = xapi.getObject(srId)
const oldVms = getOldEntries(
exportRetention,
listReplicatedVms(xapi, scheduleId, srId)
)
const deleteFirst = getSetting(settings, 'deleteFirst', srId)
if (deleteFirst) {
await this._deleteVms(xapi, oldVms)
}
const vm = await xapi.barrier(
await xapi._importVm($cancelToken, fork, sr, vm =>
xapi._setObjectProperties(vm, {
nameLabel: `${metadata.vm.name_label} (${safeDateFormat(
metadata.timestamp
)})`,
})
)
)
await Promise.all([
xapi.addTag(vm.$ref, 'Disaster Recovery'),
xapi._updateObjectMapProperty(vm, 'blocked_operations', {
start:
'Start operation for this vm is blocked, clone it if you want to use it.',
}),
xapi._updateObjectMapProperty(vm, 'other_config', {
'xo:backup:sr': srId,
}),
])
if (!deleteFirst) {
await this._deleteVms(xapi, oldVms)
}
})
),
])
return {
mergeDuration: 0,
mergeSize: 0,
transferDuration: Date.now() - now,
transferSize: xva.size,
}
}
const baseSnapshot = last(snapshots)
if (baseSnapshot !== undefined) {
console.log(baseSnapshot.$id) // TODO: remove
// check current state
// await Promise.all([asyncMap(remotes, remoteId => {})])
}
const deltaExport = await xapi.exportDeltaVm(
$cancelToken,
snapshot,
baseSnapshot
)
// forks of the lazy streams
deltaExport.streams = mapValues(deltaExport.streams, lazyStream => {
let stream
return () => {
if (stream === undefined) {
stream = lazyStream()
}
return Promise.resolve(stream).then(stream => {
const fork = stream.pipe(new PassThrough())
fork.task = stream.task
return fork
})
}
})
const mergeStart = 0
const mergeEnd = 0
let transferStart = 0
let transferEnd = 0
await Promise.all([
asyncMap(remotes, defer(async ($defer, remote) => {})),
asyncMap(
srs,
defer(async ($defer, srId) => {
const xapi = app.getXapi(srId)
const sr = xapi.getObject(srId)
const oldVms = getOldEntries(
exportRetention,
listReplicatedVms(xapi, scheduleId, srId)
)
const deleteFirst = getSetting(settings, 'deleteFirst', srId)
if (deleteFirst) {
await this._deleteVms(xapi, oldVms)
}
transferStart =
transferStart === 0
? Date.now()
: Math.min(transferStart, Date.now())
const { vm } = await xapi.importDeltaVm(deltaExport, {
disableStartAfterImport: false, // we'll take care of that
name_label: `${metadata.vm.name_label} (${safeDateFormat(
metadata.timestamp
)})`,
srId: sr.$id,
})
transferEnd = Math.max(transferEnd, Date.now())
await Promise.all([
xapi.addTag(vm.$ref, 'Continuous Replication'),
xapi._updateObjectMapProperty(vm, 'blocked_operations', {
start:
'Start operation for this vm is blocked, clone it if you want to use it.',
}),
xapi._updateObjectMapProperty(vm, 'other_config', {
'xo:backup:sr': srId,
}),
])
if (!deleteFirst) {
await this._deleteVms(xapi, oldVms)
}
})
),
])
return {
mergeDuration: mergeEnd - mergeStart,
mergeSize: 0,
transferDuration: transferEnd - transferStart,
transferSize: 0,
}
}
async _deleteFullVmBackups (
handler: RemoteHandlerAbstract,
backups: Metadata[]
): Promise<void> {
await asyncMap(backups, ({ _filename, data }) =>
Promise.all([
handler.unlink(_filename),
handler.unlink(resolveRelativeFromFile(_filename, data)),
])
)
}
async _deleteVms (xapi: Xapi, vms: Object[]): Promise<void> {
return asyncMap(vms, vm => xapi.deleteVm(vm))
}
async _listVmBackups (
handler: RemoteHandlerAbstract,
vm: Object | string,
predicate?: Metadata => boolean
): Promise<Metadata[]> {
const backups = []
const dir = getVmBackupDir(typeof vm === 'string' ? vm : vm.uuid)
try {
const files = await handler.list(dir)
await Promise.all(
files.filter(isMetadataFile).map(async file => {
const path = `${dir}/${file}`
try {
const metadata = JSON.parse(await handler.readFile(path))
if (predicate === undefined || predicate(metadata)) {
Object.defineProperty(metadata, '_filename', {
value: path,
})
backups.push(metadata)
}
} catch (error) {
console.warn('_listVmBackups', path, error)
}
})
)
} catch (error) {
if (error == null || error.code !== 'ENOENT') {
throw error
}
}
return backups.sort(compareTimestamp)
}
}

View File

@@ -0,0 +1,153 @@
// @flow
import assert from 'assert'
import { type BackupJob } from '../backups-ng'
import { type CallJob } from '../jobs'
import { type Schedule } from '../scheduling'
const createOr = (children: Array<any>): any =>
children.length === 1 ? children[0] : { __or: children }
const methods = {
'vm.deltaCopy': (
job: CallJob,
{ retention = 1, sr, vms },
schedule: Schedule
) => ({
mode: 'delta',
settings: {
[schedule.id]: {
exportRetention: retention,
vmTimeout: job.timeout,
},
},
srs: { id: sr },
userId: job.userId,
vms,
}),
'vm.rollingDeltaBackup': (
job: CallJob,
{ depth = 1, retention = depth, remote, vms },
schedule: Schedule
) => ({
mode: 'delta',
remotes: { id: remote },
settings: {
[schedule.id]: {
exportRetention: retention,
vmTimeout: job.timeout,
},
},
vms,
}),
'vm.rollingDrCopy': (
job: CallJob,
{ deleteOldBackupsFirst, depth = 1, retention = depth, sr, vms },
schedule: Schedule
) => ({
mode: 'full',
settings: {
[schedule.id]: {
deleteFirst: deleteOldBackupsFirst,
exportRetention: retention,
vmTimeout: job.timeout,
},
},
srs: { id: sr },
vms,
}),
'vm.rollingBackup': (
job: CallJob,
{ compress, depth = 1, retention = depth, remoteId, vms },
schedule: Schedule
) => ({
compression: compress ? 'native' : undefined,
mode: 'full',
remotes: { id: remoteId },
settings: {
[schedule.id]: {
exportRetention: retention,
vmTimeout: job.timeout,
},
},
vms,
}),
'vm.rollingSnapshot': (
job: CallJob,
{ depth = 1, retention = depth, vms },
schedule: Schedule
) => ({
mode: 'full',
settings: {
[schedule.id]: {
snapshotRetention: retention,
vmTimeout: job.timeout,
},
},
vms,
}),
}
const parseParamsVector = vector => {
assert.strictEqual(vector.type, 'crossProduct')
const { items } = vector
assert.strictEqual(items.length, 2)
let vms, params
if (items[1].type === 'map') {
;[params, vms] = items
vms = vms.collection
assert.strictEqual(vms.type, 'fetchObjects')
vms = vms.pattern
} else {
;[vms, params] = items
assert.strictEqual(vms.type, 'set')
vms = vms.values
if (vms.length !== 0) {
assert.deepStrictEqual(Object.keys(vms[0]), ['id'])
vms = { id: createOr(vms.map(_ => _.id)) }
}
}
assert.strictEqual(params.type, 'set')
params = params.values
assert.strictEqual(params.length, 1)
params = params[0]
return { ...params, vms }
}
export const translateOldJobs = async (app: any): Promise<Array<BackupJob>> => {
const backupJobs: Array<BackupJob> = []
const [jobs, schedules] = await Promise.all([
app.getAllJobs('call'),
app.getAllSchedules(),
])
jobs.forEach(job => {
try {
const { id } = job
let method, schedule
if (
job.type === 'call' &&
(method = methods[job.method]) !== undefined &&
(schedule = schedules.find(_ => _.jobId === id)) !== undefined
) {
const params = parseParamsVector(job.paramsVector)
backupJobs.push({
id,
name: params.tag || job.name,
type: 'backup',
userId: job.userId,
// $FlowFixMe `method` is initialized but Flow fails to see this
...method(job, params, schedule),
})
}
} catch (error) {
console.warn('translateOldJobs', job, error)
}
})
return backupJobs
}

View File

@@ -1,87 +0,0 @@
import { assign } from 'lodash'
import { lastly } from 'promise-toolbox'
import { noSuchObject } from 'xo-common/api-errors'
import JobExecutor from '../job-executor'
import { Jobs as JobsDb } from '../models/job'
import { mapToArray } from '../utils'
// ===================================================================
export default class Jobs {
constructor (xo) {
this._executor = new JobExecutor(xo)
const jobsDb = (this._jobs = new JobsDb({
connection: xo._redis,
prefix: 'xo:job',
indexes: ['user_id', 'key'],
}))
this._runningJobs = Object.create(null)
xo.on('clean', () => jobsDb.rebuildIndexes())
xo.on('start', () => {
xo.addConfigManager(
'jobs',
() => jobsDb.get(),
jobs => Promise.all(mapToArray(jobs, job => jobsDb.save(job))),
['users']
)
})
}
async getAllJobs () {
return /* await */ this._jobs.get()
}
async getJob (id) {
const job = await this._jobs.first(id)
if (!job) {
throw noSuchObject(id, 'job')
}
return job.properties
}
async createJob (job) {
// TODO: use plain objects
const job_ = await this._jobs.create(job)
return job_.properties
}
async updateJob ({ id, ...props }) {
const job = await this.getJob(id)
assign(job, props)
if (job.timeout === null) {
delete job.timeout
}
return /* await */ this._jobs.save(job)
}
async removeJob (id) {
return /* await */ this._jobs.remove(id)
}
_runJob (job) {
const { id } = job
const runningJobs = this._runningJobs
if (runningJobs[id]) {
throw new Error(`job ${id} is already running`)
}
runningJobs[id] = true
return this._executor.exec(job)::lastly(() => {
delete runningJobs[id]
})
}
async runJobSequence (idSequence) {
const jobs = await Promise.all(
mapToArray(idSequence, id => this.getJob(id))
)
for (const job of jobs) {
await this._runJob(job)
}
}
}

View File

@@ -0,0 +1,124 @@
import { createPredicate } from 'value-matcher'
import { timeout } from 'promise-toolbox'
import { assign, filter, isEmpty, map, mapValues } from 'lodash'
import { crossProduct } from '../../math'
import { asyncMap, serializeError, thunkToArray } from '../../utils'
// ===================================================================
const paramsVectorActionsMap = {
extractProperties ({ mapping, value }) {
return mapValues(mapping, key => value[key])
},
crossProduct ({ items }) {
return thunkToArray(
crossProduct(map(items, value => resolveParamsVector.call(this, value)))
)
},
fetchObjects ({ pattern }) {
const objects = filter(this.getObjects(), createPredicate(pattern))
if (isEmpty(objects)) {
throw new Error('no objects match this pattern')
}
return objects
},
map ({ collection, iteratee, paramName = 'value' }) {
return map(resolveParamsVector.call(this, collection), value => {
return resolveParamsVector.call(this, {
...iteratee,
[paramName]: value,
})
})
},
set: ({ values }) => values,
}
export function resolveParamsVector (paramsVector) {
const visitor = paramsVectorActionsMap[paramsVector.type]
if (!visitor) {
throw new Error(`Unsupported function '${paramsVector.type}'.`)
}
return visitor.call(this, paramsVector)
}
// ===================================================================
export default async function executeJobCall ({
app,
job,
logger,
runJobId,
schedule,
session,
}) {
const { paramsVector } = job
const paramsFlatVector = paramsVector
? resolveParamsVector.call(app, paramsVector)
: [{}] // One call with no parameters
const execStatus = {
calls: {},
runJobId,
start: Date.now(),
timezone: schedule !== undefined ? schedule.timezone : undefined,
}
await asyncMap(paramsFlatVector, params => {
const runCallId = logger.notice(
`Starting ${job.method} call. (${job.id})`,
{
event: 'jobCall.start',
runJobId,
method: job.method,
params,
}
)
const call = (execStatus.calls[runCallId] = {
method: job.method,
params,
start: Date.now(),
})
let promise = app.callApiMethod(session, job.method, assign({}, params))
if (job.timeout) {
promise = promise::timeout(job.timeout)
}
return promise.then(
value => {
logger.notice(
`Call ${job.method} (${runCallId}) is a success. (${job.id})`,
{
event: 'jobCall.end',
runJobId,
runCallId,
returnedValue: value,
}
)
call.returnedValue = value
call.end = Date.now()
},
reason => {
logger.notice(
`Call ${job.method} (${runCallId}) has failed. (${job.id})`,
{
event: 'jobCall.end',
runJobId,
runCallId,
error: serializeError(reason),
}
)
call.error = reason
call.end = Date.now()
}
)
})
execStatus.end = Date.now()
return execStatus
}

View File

@@ -1,7 +1,7 @@
/* eslint-env jest */
import { forEach } from 'lodash'
import { resolveParamsVector } from './job-executor'
import { resolveParamsVector } from './execute-call'
describe('resolveParamsVector', function () {
forEach(
@@ -68,37 +68,35 @@ describe('resolveParamsVector', function () {
// Context.
{
xo: {
getObjects: function () {
return [
{
id: 'vm:1',
$pool: 'pool:1',
tags: [],
type: 'VM',
power_state: 'Halted',
},
{
id: 'vm:2',
$pool: 'pool:1',
tags: ['foo'],
type: 'VM',
power_state: 'Running',
},
{
id: 'host:1',
type: 'host',
power_state: 'Running',
},
{
id: 'vm:3',
$pool: 'pool:8',
tags: ['foo'],
type: 'VM',
power_state: 'Halted',
},
]
},
getObjects: function () {
return [
{
id: 'vm:1',
$pool: 'pool:1',
tags: [],
type: 'VM',
power_state: 'Halted',
},
{
id: 'vm:2',
$pool: 'pool:1',
tags: ['foo'],
type: 'VM',
power_state: 'Running',
},
{
id: 'host:1',
type: 'host',
power_state: 'Running',
},
{
id: 'vm:3',
$pool: 'pool:8',
tags: ['foo'],
type: 'VM',
power_state: 'Halted',
},
]
},
},
],

View File

@@ -0,0 +1,263 @@
// @flow
import type { Pattern } from 'value-matcher'
// $FlowFixMe
import { cancelable } from 'promise-toolbox'
import { noSuchObject } from 'xo-common/api-errors'
import Collection from '../../collection/redis'
import patch from '../../patch'
import { mapToArray, serializeError } from '../../utils'
import type Logger from '../logs/loggers/abstract'
import { type Schedule } from '../scheduling'
import executeCall from './execute-call'
// ===================================================================
export type Job = {
id: string,
name: string,
type: string,
userId: string
}
type ParamsVector =
| {|
items: Array<Object>,
type: 'crossProduct'
|}
| {|
mapping: Object,
type: 'extractProperties',
value: Object
|}
| {|
pattern: Pattern,
type: 'fetchObjects'
|}
| {|
collection: Object,
iteratee: Function,
paramName?: string,
type: 'map'
|}
| {|
type: 'set',
values: any
|}
export type CallJob = {|
...$Exact<Job>,
method: string,
paramsVector: ParamsVector,
timeout?: number,
type: 'call'
|}
export type Executor = ({|
app: Object,
cancelToken: any,
job: Job,
logger: Logger,
runJobId: string,
schedule?: Schedule,
session: Object
|}) => Promise<any>
// -----------------------------------------------------------------------------
const normalize = job => {
Object.keys(job).forEach(key => {
try {
job[key] = JSON.parse(job[key])
} catch (_) {}
})
return job
}
const serialize = (job: {| [string]: any |}) => {
Object.keys(job).forEach(key => {
const value = job[key]
if (typeof value !== 'string') {
job[key] = JSON.stringify(job[key])
}
})
return job
}
class JobsDb extends Collection {
async create (job): Promise<Job> {
return normalize((await this.add(serialize((job: any)))).properties)
}
async save (job): Promise<void> {
await this.update(serialize((job: any)))
}
async get (properties): Promise<Array<Job>> {
const jobs = await super.get(properties)
jobs.forEach(normalize)
return jobs
}
}
// -----------------------------------------------------------------------------
export default class Jobs {
_app: any
_executors: { __proto__: null, [string]: Executor }
_jobs: JobsDb
_logger: Logger
_runningJobs: { __proto__: null, [string]: boolean }
constructor (xo: any) {
this._app = xo
const executors = (this._executors = Object.create(null))
const jobsDb = (this._jobs = new JobsDb({
connection: xo._redis,
prefix: 'xo:job',
indexes: ['user_id', 'key'],
}))
this._logger = undefined
this._runningJobs = Object.create(null)
executors.call = executeCall
xo.on('clean', () => jobsDb.rebuildIndexes())
xo.on('start', () => {
xo.addConfigManager(
'jobs',
() => jobsDb.get(),
jobs => Promise.all(mapToArray(jobs, job => jobsDb.save(job))),
['users']
)
xo.getLogger('jobs').then(logger => {
this._logger = logger
})
})
}
async getAllJobs (type: string = 'call'): Promise<Array<Job>> {
// $FlowFixMe don't know what is the problem (JFT)
const jobs = await this._jobs.get()
const runningJobs = this._runningJobs
const result = []
jobs.forEach(job => {
if (job.type === type) {
job.runId = runningJobs[job.id]
result.push(job)
}
})
return result
}
async getJob (id: string, type?: string): Promise<Job> {
const job = await this._jobs.first(id)
if (job === null || (type !== undefined && job.properties.type !== type)) {
throw noSuchObject(id, 'job')
}
return job.properties
}
createJob (job: $Diff<Job, {| id: string |}>): Promise<Job> {
return this._jobs.create(job)
}
async updateJob ({ id, ...props }: $Shape<Job>) {
const job = await this.getJob(id)
patch(job, props)
return /* await */ this._jobs.save(job)
}
registerJobExecutor (type: string, executor: Executor): void {
const executors = this._executors
if (type in executors) {
throw new Error(`there is already a job executor for type ${type}`)
}
executors[type] = executor
}
async removeJob (id: string) {
return /* await */ this._jobs.remove(id)
}
async _runJob (cancelToken: any, job: Job, schedule?: Schedule) {
const { id } = job
const runningJobs = this._runningJobs
if (id in runningJobs) {
throw new Error(`job ${id} is already running`)
}
const executor = this._executors[job.type]
if (executor === undefined) {
throw new Error(`cannot run job ${id}: no executor for type ${job.type}`)
}
const logger = this._logger
const runJobId = logger.notice(`Starting execution of ${id}.`, {
event: 'job.start',
userId: job.userId,
jobId: id,
// $FlowFixMe only defined for CallJob
key: job.key,
})
runningJobs[id] = runJobId
try {
const app = this._app
const session = app.createUserConnection()
session.set('user_id', job.userId)
const status = await executor({
app,
cancelToken,
job,
logger,
runJobId,
schedule,
session,
})
logger.notice(`Execution terminated for ${job.id}.`, {
event: 'job.end',
runJobId,
})
session.close()
app.emit('job:terminated', status)
} catch (error) {
logger.error(`The execution of ${id} has failed.`, {
event: 'job.end',
runJobId,
error: serializeError(error),
})
throw error
} finally {
delete runningJobs[id]
}
}
@cancelable
async runJobSequence (
$cancelToken: any,
idSequence: Array<string>,
schedule?: Schedule
) {
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)
}
}
}

View File

@@ -1,204 +1,157 @@
import { BaseError } from 'make-error'
// @flow
import { createSchedule } from '@xen-orchestra/cron'
import { noSuchObject } from 'xo-common/api-errors.js'
import { noSuchObject } from 'xo-common/api-errors'
import { Schedules } from '../models/schedule'
import { forEach, mapToArray } from '../utils'
import Collection from '../collection/redis'
import patch from '../patch'
import { asyncMap } from '../utils'
// ===================================================================
export type Schedule = {|
cron: string,
enabled: boolean,
id: string,
jobId: string,
name: string,
timezone?: string,
userId: string
|}
const _resolveId = scheduleOrId => scheduleOrId.id || scheduleOrId
const normalize = schedule => {
const { enabled } = schedule
if (typeof enabled !== 'boolean') {
schedule.enabled = enabled === 'true'
}
if ('job' in schedule) {
schedule.jobId = schedule.job
delete schedule.job
}
return schedule
}
export class SchedulerError extends BaseError {}
export class ScheduleOverride extends SchedulerError {
constructor (scheduleOrId) {
super('Schedule ID ' + _resolveId(scheduleOrId) + ' is already added')
class Schedules extends Collection {
async get (properties) {
const schedules = await super.get(properties)
schedules.forEach(normalize)
return schedules
}
}
export class ScheduleNotEnabled extends SchedulerError {
constructor (scheduleOrId) {
super('Schedule ' + _resolveId(scheduleOrId) + ' is not enabled')
}
}
export default class Scheduling {
_app: any
_db: {|
add: Function,
first: Function,
get: Function,
remove: Function,
update: Function
|}
_runs: { __proto__: null, [string]: () => void }
export class ScheduleAlreadyEnabled extends SchedulerError {
constructor (scheduleOrId) {
super('Schedule ' + _resolveId(scheduleOrId) + ' is already enabled')
}
}
constructor (app: any) {
this._app = app
// ===================================================================
export default class {
constructor (xo) {
this.xo = xo
const schedules = (this._redisSchedules = new Schedules({
connection: xo._redis,
const db = (this._db = new Schedules({
connection: app._redis,
prefix: 'xo:schedule',
indexes: ['user_id', 'job'],
}))
this._scheduleTable = undefined
xo.on('clean', () => schedules.rebuildIndexes())
xo.on('start', () => {
xo.addConfigManager(
this._runs = Object.create(null)
app.on('clean', () => db.rebuildIndexes())
app.on('start', async () => {
app.addConfigManager(
'schedules',
() => schedules.get(),
schedules_ =>
Promise.all(
mapToArray(schedules_, schedule => schedules.save(schedule))
),
() => db.get(),
schedules =>
asyncMap(schedules, schedule => db.update(normalize(schedule))),
['jobs']
)
return this._loadSchedules()
const schedules = await this.getAllSchedules()
schedules.forEach(schedule => this._start(schedule))
})
xo.on('stop', () => this._disableAll())
}
_add (schedule) {
const { id } = schedule
this._schedules[id] = schedule
this._scheduleTable[id] = false
try {
if (schedule.enabled) {
this._enable(schedule)
}
} catch (error) {
console.warn('Scheduling#_add(%s)', id, error)
}
}
_exists (scheduleOrId) {
const id_ = _resolveId(scheduleOrId)
return id_ in this._schedules
}
_isEnabled (scheduleOrId) {
return this._scheduleTable[_resolveId(scheduleOrId)]
}
_enable ({ cron, id, job, timezone = 'local' }) {
this._cronJobs[id] = createSchedule(cron, timezone).startJob(() =>
this.xo.runJobSequence([job])
)
this._scheduleTable[id] = true
}
_disable (scheduleOrId) {
if (!this._exists(scheduleOrId)) {
throw noSuchObject(scheduleOrId, 'schedule')
}
if (!this._isEnabled(scheduleOrId)) {
throw new ScheduleNotEnabled(scheduleOrId)
}
const id = _resolveId(scheduleOrId)
this._cronJobs[id]() // Stop cron job.
delete this._cronJobs[id]
this._scheduleTable[id] = false
}
_disableAll () {
forEach(this._scheduleTable, (enabled, id) => {
if (enabled) {
this._disable(id)
}
app.on('stop', () => {
const runs = this._runs
Object.keys(runs).forEach(id => {
runs[id]()
delete runs[id]
})
})
}
get scheduleTable () {
return this._scheduleTable
}
async _loadSchedules () {
this._schedules = {}
this._scheduleTable = {}
this._cronJobs = {}
const schedules = await this.xo.getAllSchedules()
forEach(schedules, schedule => {
this._add(schedule)
})
}
async _getSchedule (id) {
const schedule = await this._redisSchedules.first(id)
if (!schedule) {
throw noSuchObject(id, 'schedule')
}
return schedule
}
async getSchedule (id) {
return (await this._getSchedule(id)).properties
}
async getAllSchedules () {
return /* await */ this._redisSchedules.get()
}
async createSchedule (userId, { job, cron, enabled, name, timezone }) {
const schedule_ = await this._redisSchedules.create(
userId,
job,
async createSchedule ({
cron,
enabled,
jobId,
name,
timezone,
userId,
}: $Diff<Schedule, {| id: string |}>) {
const schedule = (await this._db.add({
cron,
enabled,
jobId,
name,
timezone
)
const schedule = schedule_.properties
this._add(schedule)
timezone,
userId,
})).properties
this._start(schedule)
return schedule
}
async updateSchedule (id, { job, cron, enabled, name, timezone }) {
const schedule = await this._getSchedule(id)
if (job !== undefined) schedule.set('job', job)
if (cron !== undefined) schedule.set('cron', cron)
if (enabled !== undefined) schedule.set('enabled', enabled)
if (name !== undefined) schedule.set('name', name)
if (timezone === null) {
schedule.set('timezone', undefined) // Remove current timezone
} else if (timezone !== undefined) {
schedule.set('timezone', timezone)
}
await this._redisSchedules.save(schedule)
const { properties } = schedule
if (!this._exists(id)) {
async getSchedule (id: string): Promise<Schedule> {
const schedule = await this._db.first(id)
if (schedule === null) {
throw noSuchObject(id, 'schedule')
}
// disable the schedule, _add() will enable it if necessary
if (this._isEnabled(id)) {
this._disable(id)
}
this._add(properties)
return schedule.properties
}
async removeSchedule (id) {
await this._redisSchedules.remove(id)
async getAllSchedules (): Promise<Array<Schedule>> {
return this._db.get()
}
try {
this._disable(id)
} catch (exc) {
if (!(exc instanceof SchedulerError)) {
throw exc
}
} finally {
delete this._schedules[id]
delete this._scheduleTable[id]
async removeSchedule (id: string) {
this._stop(id)
await this._db.remove(id)
}
async updateSchedule ({
cron,
enabled,
id,
jobId,
name,
timezone,
userId,
}: $Shape<Schedule>) {
const schedule = await this.getSchedule(id)
patch(schedule, { cron, enabled, jobId, name, timezone, userId })
this._start(schedule)
await this._db.update(schedule)
}
_start (schedule: Schedule) {
const { id } = schedule
this._stop(id)
if (schedule.enabled) {
this._runs[id] = createSchedule(
schedule.cron,
schedule.timezone
).startJob(() => this._app.runJobSequence([schedule.jobId], schedule))
}
}
_stop (id: string) {
const runs = this._runs
if (id in runs) {
runs[id]()
delete runs[id]
}
}
}

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "xo-web",
"version": "5.16.2",
"version": "5.17.0",
"license": "AGPL-3.0",
"description": "Web interface client for Xen-Orchestra",
"keywords": [
@@ -30,6 +30,7 @@
"node": ">=6"
},
"devDependencies": {
"@julien-f/freactal": "0.1.0",
"@nraynaud/novnc": "0.6.1",
"@xen-orchestra/cron": "^1.0.2",
"ansi_up": "^2.0.2",
@@ -56,7 +57,7 @@
"chartist-plugin-legend": "^0.6.1",
"chartist-plugin-tooltip": "0.0.11",
"classnames": "^2.2.3",
"complex-matcher": "^0.2.1",
"complex-matcher": "^0.3.0",
"cookies-js": "^1.2.2",
"d3": "^4.12.2",
"debounce-input-decorator": "^0.1.0",

View File

@@ -1,5 +1,5 @@
import isFunction from 'lodash/isFunction'
import React from 'react'
import { isFunction, startsWith } from 'lodash'
import Button from './button'
import Component from './base-component'
@@ -27,6 +27,9 @@ import { error as _error } from './notification'
handler: propTypes.func.isRequired,
// optional value which will be passed as first param to the handler
//
// if you need multiple values, you can provide `data-*` props instead of
// `handlerParam`
handlerParam: propTypes.any,
// XO icon to use for this button
@@ -50,11 +53,30 @@ export default class ActionButton extends Component {
}
async _execute () {
if (this.props.pending || this.state.working) {
const { props } = this
if (props.pending || this.state.working) {
return
}
const { children, handler, handlerParam, tooltip } = this.props
const { children, handler, tooltip } = props
let handlerParam
if ('handlerParam' in props) {
handlerParam = props.handlerParam
} else {
let empty = true
handlerParam = {}
Object.keys(props).forEach(key => {
if (startsWith(key, 'data-')) {
empty = false
handlerParam[key.slice(5)] = props[key]
}
})
if (empty) {
handlerParam = undefined
}
}
try {
this.setState({
@@ -64,7 +86,7 @@ export default class ActionButton extends Component {
const result = await handler(handlerParam)
const { redirectOnSuccess } = this.props
const { redirectOnSuccess } = props
if (redirectOnSuccess) {
return this.context.router.push(
isFunction(redirectOnSuccess)

View File

@@ -7,6 +7,16 @@ 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 () {

View File

@@ -60,6 +60,8 @@ const messages = {
selfServicePage: 'Self service',
backupPage: 'Backup',
jobsPage: 'Jobs',
backupNG: 'Backups NG',
backupNGName: 'Name',
xoaPage: 'XOA',
updatePage: 'Updates',
licensesPage: 'Licenses',
@@ -207,6 +209,7 @@ const messages = {
selectSshKey: 'Select SSH key(s)…',
selectSrs: 'Select SR(s)…',
selectVms: 'Select VM(s)…',
selectVmSnapshots: 'Select snapshot(s)…',
selectVmTemplates: 'Select VM template(s)…',
selectTags: 'Select tag(s)…',
selectVdis: 'Select disk(s)…',
@@ -274,8 +277,9 @@ const messages = {
jobServerTimezone: 'Server',
runJob: 'Run job',
runJobVerbose: 'One shot running started. See overview for logs.',
jobStarted: 'Started',
jobFinished: 'Finished',
jobInterrupted: 'Interrupted',
jobStarted: 'Started',
saveBackupJob: 'Save',
deleteBackupSchedule: 'Remove backup job',
deleteBackupScheduleQuestion:
@@ -352,6 +356,13 @@ const messages = {
remoteTestSuccessMessage: 'The remote appears to work correctly',
remoteConnectionFailed: 'Connection failed',
// ------ Backup job -----
confirmDeleteBackupJobsTitle:
'Delete backup job{nJobs, plural, one {} other {s}}',
confirmDeleteBackupJobsBody:
'Are you sure you want to delete {nJobs, number} backup job{nJobs, plural, one {} other {s}}?',
// ------ Remote -----
remoteName: 'Name',
remotePath: 'Path',
@@ -431,10 +442,10 @@ const messages = {
groupNameColumn: 'Name',
groupUsersColumn: 'Users',
addUserToGroupColumn: 'Add User',
userNameColumn: 'Email',
userNameColumn: 'Username',
userPermissionColumn: 'Permissions',
userPasswordColumn: 'Password',
userName: 'Email',
userName: 'Username',
userPassword: 'Password',
createUserButton: 'Create',
noUserFound: 'No user found',
@@ -1161,6 +1172,9 @@ const messages = {
restoreFilesUnselectAll: 'Unselect all files',
// ----- Modals -----
emergencyShutdownHostModalTitle: 'Emergency shutdown Host',
emergencyShutdownHostModalMessage:
'Are you sure you want to shutdown {host}?',
emergencyShutdownHostsModalTitle:
'Emergency shutdown Host{nHosts, plural, one {} other {s}}',
emergencyShutdownHostsModalMessage:

View File

@@ -3,9 +3,14 @@ import React from 'react'
import Link from './link'
export const NavLink = ({ children, to }) => (
export const NavLink = ({ children, exact, to }) => (
<li className='nav-item' role='tab'>
<Link className='nav-link' activeClassName='active' to={to}>
<Link
activeClassName='active'
className='nav-link'
onlyActiveOnIndex={exact}
to={to}
>
{children}
</Link>
</li>

View File

@@ -420,6 +420,27 @@ export const SelectVm = makeStoreSelect(
// ===================================================================
export const SelectVmSnapshot = makeStoreSelect(
() => {
const getSnapshotsByVms = createGetObjectsOfType('VM-snapshot')
.filter(getPredicate)
.sort()
.groupBy('$snapshot_of')
const getVms = createGetObjectsOfType('VM')
.pick(createSelector(getSnapshotsByVms, keys))
.sort()
return {
xoObjects: getSnapshotsByVms,
xoContainers: getVms,
}
},
{ placeholder: _('selectVmSnapshots') }
)
// ===================================================================
export const SelectHostVm = makeStoreSelect(
() => {
const getHosts = createGetObjectsOfType('host')

View File

@@ -1,5 +1,5 @@
import * as CM from 'complex-matcher'
import { flatten, identity, map } from 'lodash'
import { identity } from 'lodash'
import { EMPTY_OBJECT } from './utils'
@@ -21,33 +21,52 @@ export const constructPattern = (
return not ? { __not: pattern } : pattern
}
const parsePattern = pattern => {
const patternValues = flatten(
pattern.__not !== undefined ? pattern.__not.__or : pattern.__or
)
const valueToComplexMatcher = pattern => {
if (typeof pattern === 'string') {
return new CM.String(pattern)
}
const queryString = new CM.Or(
map(patternValues, array => new CM.String(array))
)
return pattern.__not !== undefined ? CM.Not(queryString) : queryString
if (Array.isArray(pattern)) {
return new CM.And(pattern.map(valueToComplexMatcher))
}
if (pattern !== null && typeof pattern === 'object') {
const keys = Object.keys(pattern)
const { length } = keys
if (length === 1) {
const [key] = keys
if (key === '__and') {
return new CM.And(pattern.__and.map(valueToComplexMatcher))
}
if (key === '__or') {
return new CM.Or(pattern.__or.map(valueToComplexMatcher))
}
if (key === '__not') {
return new CM.Not(valueToComplexMatcher(pattern.__not))
}
}
const children = []
Object.keys(pattern).forEach(property => {
const subpattern = pattern[property]
if (subpattern !== undefined) {
children.push(
new CM.Property(property, valueToComplexMatcher(subpattern))
)
}
})
return children.length === 0 ? new CM.Null() : new CM.And(children)
}
throw new Error('could not transform this pattern')
}
export const constructQueryString = pattern => {
const powerState = pattern.power_state
const pool = pattern.$pool
const tags = pattern.tags
const filter = []
if (powerState !== undefined) {
filter.push(new CM.Property('power_state', new CM.String(powerState)))
try {
return valueToComplexMatcher(pattern).toString()
} catch (error) {
console.warn('constructQueryString', pattern, error)
return ''
}
if (pool !== undefined) {
filter.push(new CM.Property('$pool', parsePattern(pool)))
}
if (tags !== undefined) {
filter.push(new CM.Property('tags', parsePattern(tags)))
}
return filter.length !== 0 ? new CM.And(filter).toString() : ''
}

View File

@@ -209,15 +209,19 @@ class IndividualAction extends Component {
isFunction(disabled) ? disabled(item, userData) : disabled
)
_executeAction = () => {
const p = this.props
return p.handler(p.item, p.userData)
}
render () {
const { icon, label, level, handler, item } = this.props
const { icon, label, level } = this.props
return (
<ActionRowButton
btnStyle={level}
disabled={this._getIsDisabled()}
handler={handler}
handlerParam={item}
handler={this._executeAction}
icon={icon}
tooltip={label}
/>
@@ -234,15 +238,19 @@ class GroupedAction extends Component {
isFunction(disabled) ? disabled(selectedItems, userData) : disabled
)
_executeAction = () => {
const p = this.props
return p.handler(p.selectedItems, p.userData)
}
render () {
const { icon, label, level, handler, selectedItems } = this.props
const { icon, label, level } = this.props
return (
<ActionRowButton
btnStyle={level}
disabled={this._getIsDisabled()}
handler={handler}
handlerParam={selectedItems}
handler={this._executeAction}
icon={icon}
tooltip={label}
/>

View File

@@ -5,9 +5,11 @@ import XoHostInput from './xo-host-input'
import XoPoolInput from './xo-pool-input'
import XoRemoteInput from './xo-remote-input'
import XoRoleInput from './xo-role-input'
import xoSnapshotInput from './xo-snapshot-input'
import XoSrInput from './xo-sr-input'
import XoSubjectInput from './xo-subject-input'
import XoTagInput from './xo-tag-input'
import XoVdiInput from './xo-vdi-input'
import XoVmInput from './xo-vm-input'
import { getType, getXoType } from '../json-schema-input/helpers'
@@ -18,9 +20,11 @@ const XO_TYPE_TO_COMPONENT = {
pool: XoPoolInput,
remote: XoRemoteInput,
role: XoRoleInput,
snapshot: xoSnapshotInput,
sr: XoSrInput,
subject: XoSubjectInput,
tag: XoTagInput,
vdi: XoVdiInput,
vm: XoVmInput,
xoobject: XoHighLevelObjectInput,
}

View File

@@ -0,0 +1,27 @@
import React from 'react'
import XoAbstractInput from './xo-abstract-input'
import { PrimitiveInputWrapper } from '../json-schema-input/helpers'
import { SelectVmSnapshot } from '../select-objects'
// ===================================================================
export default class snapshotInput extends XoAbstractInput {
render () {
const { props } = this
return (
<PrimitiveInputWrapper {...props}>
<SelectVmSnapshot
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={this._onChange}
ref='input'
required={props.required}
value={props.value}
/>
</PrimitiveInputWrapper>
)
}
}

View File

@@ -0,0 +1,27 @@
import React from 'react'
import { SelectVdi } from 'select-objects'
import XoAbstractInput from './xo-abstract-input'
import { PrimitiveInputWrapper } from '../json-schema-input/helpers'
// ===================================================================
export default class VdiInput extends XoAbstractInput {
render () {
const { props } = this
return (
<PrimitiveInputWrapper {...props}>
<SelectVdi
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={this._onChange}
ref='input'
required={props.required}
value={props.value}
/>
</PrimitiveInputWrapper>
)
}
}

View File

@@ -300,10 +300,6 @@ export const subscribeResourceSets = createSubscription(() =>
_call('resourceSet.getAll')
)
export const subscribeScheduleTable = createSubscription(() =>
_call('scheduler.getScheduleTable')
)
export const subscribeSchedules = createSubscription(() =>
_call('schedule.getAll')
)
@@ -660,7 +656,12 @@ export const getHostMissingPatches = host =>
)
export const emergencyShutdownHost = host =>
_call('host.emergencyShutdownHost', { host: resolveId(host) })
confirm({
title: _('emergencyShutdownHostModalTitle'),
body: _('emergencyShutdownHostModalMessage', {
host: <strong>{host.name_label}</strong>,
}),
}).then(() => _call('host.emergencyShutdownHost', { host: resolveId(host) }))
export const emergencyShutdownHosts = hosts => {
const nHosts = size(hosts)
@@ -1118,12 +1119,13 @@ export const importDeltaBackup = ({ remote, file, sr, mapVdisSrs }) =>
)
import RevertSnapshotModalBody from './revert-snapshot-modal' // eslint-disable-line import/first
export const revertSnapshot = vm =>
export const revertSnapshot = snapshot =>
confirm({
title: _('revertVmModalTitle'),
body: <RevertSnapshotModalBody />,
}).then(
snapshotBefore => _call('vm.revert', { id: resolveId(vm), snapshotBefore }),
snapshotBefore =>
_call('vm.revert', { snapshot: resolveId(snapshot), snapshotBefore }),
noop
)
@@ -1201,11 +1203,14 @@ export const createVgpu = (vm, { gpuGroup, vgpuType }) =>
export const deleteVgpu = vgpu => _call('vm.deleteVgpu', resolveIds({ vgpu }))
export const shareVm = (vm, resourceSet) =>
export const shareVm = async (vm, resourceSet) =>
confirm({
title: _('shareVmInResourceSetModalTitle'),
body: _('shareVmInResourceSetModalMessage', {
self: renderXoItem(resourceSet),
self: renderXoItem({
...(await getResourceSet(resourceSet)),
type: 'resourceSet',
}),
}),
}).then(() => editVm(vm, { share: true }), noop)
@@ -1563,7 +1568,7 @@ export const deleteBackupSchedule = async schedule => {
body: _('deleteBackupScheduleQuestion'),
})
await _call('schedule.delete', { id: schedule.id })
await _call('job.delete', { id: schedule.job })
await _call('job.delete', { id: schedule.jobId })
subscribeSchedules.forceRefresh()
subscribeJobs.forceRefresh()
@@ -1586,26 +1591,63 @@ export const deleteSchedules = schedules =>
)
)
export const disableSchedule = id =>
_call('scheduler.disable', { id })::tap(subscribeScheduleTable.forceRefresh)
export const disableSchedule = id => editSchedule({ id, enabled: false })
export const editSchedule = ({
id,
job: jobId,
cron,
enabled,
name,
timezone,
}) =>
export const editSchedule = ({ id, jobId, cron, enabled, name, timezone }) =>
_call('schedule.set', { id, jobId, cron, enabled, name, timezone })::tap(
subscribeSchedules.forceRefresh
)
export const enableSchedule = id =>
_call('scheduler.enable', { id })::tap(subscribeScheduleTable.forceRefresh)
export const enableSchedule = id => editSchedule({ id, enabled: true })
export const getSchedule = id => _call('schedule.get', { id })
// Backup NG ---------------------------------------------------------
export const subscribeBackupNgJobs = createSubscription(() =>
_call('backupNg.getAllJobs')
)
export const createBackupNgJob = props =>
_call('backupNg.createJob', props)::tap(subscribeBackupNgJobs.forceRefresh)
export const deleteBackupNgJobs = async ids => {
const { length } = ids
if (length === 0) {
return
}
const vars = { nJobs: length }
try {
await confirm({
title: _('confirmDeleteBackupJobsTitle', vars),
body: <p>{_('confirmDeleteBackupJobsBody', vars)}</p>,
})
} catch (_) {
return
}
return Promise.all(
ids.map(id => _call('backupNg.deleteJob', { id: resolveId(id) }))
)::tap(subscribeBackupNgJobs.forceRefresh)
}
export const editBackupNgJob = props =>
_call('backupNg.editJob', props)::tap(subscribeBackupNgJobs.forceRefresh)
export const getBackupNgJob = id => _call('backupNg.getJob', { id })
export const runBackupNgJob = ({ id, scheduleId }) =>
_call('backupNg.runJob', { id, scheduleId })
export const listVmBackups = remotes =>
_call('backupNg.listVmBackups', { remotes: resolveIds(remotes) })
export const restoreBackup = (backup, sr) =>
_call('backupNg.importVmBackupNg', {
id: resolveId(backup),
sr: resolveId(sr),
})
// Plugins -----------------------------------------------------------
export const loadPlugin = async id =>
@@ -1682,6 +1724,9 @@ export const deleteResourceSet = async id => {
export const recomputeResourceSetsLimits = () =>
_call('resourceSet.recomputeAllLimits')
export const getResourceSet = id =>
_call('resourceSet.get', { id: resolveId(id) })
// Remote ------------------------------------------------------------
export const getRemote = remote =>
@@ -1866,10 +1911,28 @@ export const createSrLvm = (host, nameLabel, nameDescription, device) =>
// Job logs ----------------------------------------------------------
export const deleteJobsLog = id =>
_call('log.delete', { namespace: 'jobs', id })::tap(
subscribeJobsLogs.forceRefresh
)
export const deleteJobsLogs = async ids => {
const { length } = ids
if (length === 0) {
return
}
if (length !== 1) {
const vars = { nLogs: length }
try {
await confirm({
title: _('logDeleteMultiple', vars),
body: <p>{_('logDeleteMultipleMessage', vars)}</p>,
})
} catch (_) {
return
}
}
return _call('log.delete', {
namespace: 'jobs',
id: ids.map(resolveId),
})::tap(subscribeJobsLogs.forceRefresh)
}
// Logs

View File

@@ -0,0 +1,21 @@
import addSubscriptions from 'add-subscriptions'
import React from 'react'
import { injectState, provideState } from '@julien-f/freactal'
import { Debug } from 'utils'
import { subscribeBackupNgJobs, subscribeSchedules } from 'xo'
import New from './new'
export default [
addSubscriptions({
jobs: subscribeBackupNgJobs,
schedules: subscribeSchedules,
}),
provideState({
computed: {
value: ({ jobs, schedules }) => {},
},
}),
injectState,
props => ({ state }) => <New value={state.value} />,
].reduceRight((value, decorator) => decorator(value))

View File

@@ -0,0 +1,8 @@
import Component from 'base-component'
import React from 'react'
export default class FileRestore extends Component {
render () {
return <p className='text-danger'>Available soon</p>
}
}

View File

@@ -0,0 +1,210 @@
import _ from 'intl'
import ActionButton from 'action-button'
import addSubscriptions from 'add-subscriptions'
import Icon from 'icon'
import React from 'react'
import SortedTable from 'sorted-table'
import { map, groupBy } from 'lodash'
import { Card, CardHeader, CardBlock } from 'card'
import { constructQueryString } from 'smart-backup-pattern'
import { Container, Row, Col } from 'grid'
import { NavLink, NavTabs } from 'nav'
import { routes } from 'utils'
import {
deleteBackupNgJobs,
subscribeBackupNgJobs,
subscribeSchedules,
runBackupNgJob,
} from 'xo'
import LogsTable from '../logs'
import Page from '../page'
import Edit from './edit'
import New from './new'
import FileRestore from './file-restore'
import Restore from './restore'
const Ul = ({ children, ...props }) => (
<ul {...props} style={{ display: 'inline', padding: '0 0.5em' }}>
{children}
</ul>
)
const Li = ({ children, ...props }) => (
<li {...props} style={{ listStyleType: 'none', display: 'inline-block' }}>
{children}
</li>
)
const Td = ({ children, ...props }) => (
<td {...props} style={{ borderRight: '1px solid black' }}>
{children}
</td>
)
const SchedulePreviewBody = ({ job, schedules }) => (
<table>
{map(schedules, schedule => (
<tr key={schedule.id}>
<Td>{schedule.cron}</Td>
<Td>{schedule.timezone}</Td>
<Td>{job.settings[schedule.id].exportRetention}</Td>
<Td>{job.settings[schedule.id].snapshotRetention}</Td>
<td>
<ActionButton
handler={runBackupNgJob}
icon='run-schedule'
size='small'
data-id={job.id}
data-scheduleId={schedule.id}
btnStyle='warning'
/>
</td>
</tr>
))}
</table>
)
const SchedulePreviewHeader = ({ _ }) => (
<Ul>
<Li>Schedule ID</Li> | <Li>Cron</Li> | <Li>Timezone</Li> |{' '}
<Li>Export retention</Li> | <Li>Snapshot retention</Li> |{' '}
</Ul>
)
@addSubscriptions({
jobs: subscribeBackupNgJobs,
schedulesByJob: cb =>
subscribeSchedules(schedules => {
cb(groupBy(schedules, 'jobId'))
}),
})
class JobsTable extends React.Component {
static contextTypes = {
router: React.PropTypes.object,
}
static tableProps = {
actions: [
{
handler: deleteBackupNgJobs,
label: _('deleteBackupSchedule'),
icon: 'delete',
level: 'danger',
},
],
columns: [
{
itemRenderer: _ => _.id.slice(0, 5),
sortCriteria: _ => _.id,
name: _('jobId'),
},
{
itemRenderer: _ => _.name,
sortCriteria: _ => _.name,
name: _('jobName'),
},
{
itemRenderer: _ => _.mode,
sortCriteria: _ => _.mode,
name: 'mode',
},
{
component: _ => (
<SchedulePreviewBody
job={_.item}
schedules={_.userData.schedulesByJob[_.item.id]}
/>
),
name: <SchedulePreviewHeader />,
},
],
individualActions: [
{
handler: (job, { goTo }) =>
goTo({
pathname: '/home',
query: { t: 'VM', s: constructQueryString(job.vms) },
}),
label: _('redirectToMatchingVms'),
icon: 'preview',
},
{
handler: (job, { goTo }) => goTo(`/backup-ng/${job.id}/edit`),
label: '',
icon: 'edit',
level: 'primary',
},
],
}
_goTo = path => {
this.context.router.push(path)
}
render () {
return (
<SortedTable
{...JobsTable.tableProps}
collection={this.props.jobs}
data-goTo={this._goTo}
data-schedulesByJob={this.props.schedulesByJob}
/>
)
}
}
const Overview = () => (
<div>
<Card>
<CardHeader>
<Icon icon='schedule' /> {_('backupSchedules')}
</CardHeader>
<CardBlock>
<JobsTable />
</CardBlock>
</Card>
<LogsTable />
</div>
)
const HEADER = (
<Container>
<Row>
<Col mediumSize={3}>
<h2>
<Icon icon='backup' /> {_('backupPage')}
</h2>
</Col>
<Col mediumSize={9}>
<NavTabs className='pull-right'>
<NavLink exact to='/backup-ng'>
<Icon icon='menu-backup-overview' /> {_('backupOverviewPage')}
</NavLink>
<NavLink to='/backup-ng/new'>
<Icon icon='menu-backup-new' /> {_('backupNewPage')}
</NavLink>
<NavLink to='/backup-ng/restore'>
<Icon icon='menu-backup-restore' /> {_('backupRestorePage')}
</NavLink>
<NavLink to='/backup-ng/file-restore'>
<Icon icon='menu-backup-file-restore' />{' '}
{_('backupFileRestorePage')}
</NavLink>
</NavTabs>
</Col>
</Row>
</Container>
)
export default routes(Overview, {
':id/edit': Edit,
new: New,
restore: Restore,
'file-restore': FileRestore,
})(({ children }) => (
<Page header={HEADER} title='backupPage' formatTitle>
{children}
</Page>
))

View File

@@ -0,0 +1,269 @@
import _, { messages } from 'intl'
import ActionButton from 'action-button'
import moment from 'moment-timezone'
import React from 'react'
import Scheduler, { SchedulePreview } from 'scheduling'
import Upgrade from 'xoa-upgrade'
import { injectState, provideState } from '@julien-f/freactal'
import { cloneDeep, orderBy, size, isEmpty, map } from 'lodash'
import { SelectRemote, SelectSr, SelectVm } from 'select-objects'
import { resolveIds } from 'utils'
import { createBackupNgJob, editBackupNgJob, editSchedule } from 'xo'
const FormGroup = props => <div {...props} className='form-group' />
const Input = props => <input {...props} className='form-control' />
const DEFAULT_CRON_PATTERN = '0 0 * * *'
const DEFAULT_TIMEZONE = moment.tz.guess()
const constructPattern = values => ({
id: {
__or: resolveIds(values),
},
})
const removeScheduleFromSettings = tmpSchedules => {
const newTmpSchedules = cloneDeep(tmpSchedules)
for (let schedule in newTmpSchedules) {
delete newTmpSchedules[schedule].cron
delete newTmpSchedules[schedule].timezone
}
return newTmpSchedules
}
const getRandomId = () =>
Math.random()
.toString(36)
.slice(2)
export default [
New => props => (
<Upgrade place='newBackup' required={2}>
<New {...props} />
</Upgrade>
),
provideState({
initialState: () => ({
delta: false,
formId: getRandomId(),
tmpSchedule: {
cron: DEFAULT_CRON_PATTERN,
timezone: DEFAULT_TIMEZONE,
},
exportRetention: 0,
snapshotRetention: 0,
name: '',
remotes: [],
schedules: {},
srs: [],
vms: [],
tmpSchedules: {},
}),
effects: {
addSchedule: () => state => {
const id = getRandomId()
return {
...state,
tmpSchedules: {
...state.tmpSchedules,
[id]: {
...state.tmpSchedule,
exportRetention: state.exportRetention,
snapshotRetention: state.snapshotRetention,
},
},
}
},
createJob: () => async state => {
await createBackupNgJob({
name: state.name,
mode: state.delta ? 'delta' : 'full',
remotes: constructPattern(state.remotes),
schedules: state.tmpSchedules,
settings: {
...removeScheduleFromSettings(state.tmpSchedules),
...state.schedules,
},
srs: constructPattern(state.srs),
vms: constructPattern(state.vms),
})
},
setTmpSchedule: (_, schedule) => state => ({
...state,
tmpSchedule: {
cron: schedule.cronPattern,
timezone: schedule.timezone,
},
}),
setExportRetention: (_, { target: { value } }) => state => ({
...state,
exportRetention: value,
}),
setSnapshotRetention: (_, { target: { value } }) => state => ({
...state,
snapshotRetention: value,
}),
editSchedule: (
_,
{ target: { dataset: { scheduleId } } }
) => state => ({}),
editTmpSchedule: (_, { scheduleId }) => state => ({
...state,
tmpSchedules: {
...state.tmpSchedules,
[scheduleId]: {
...state.tmpSchedule,
exportRetention: state.exportRetention,
snapshotRetention: state.snapshotRetention,
},
},
}),
setDelta: (_, { target: { value } }) => state => ({
...state,
delta: value,
}),
setName: (_, { target: { value } }) => state => ({
...state,
name: value,
}),
setRemotes: (_, remotes) => state => ({ ...state, remotes }),
setSrs: (_, srs) => state => ({ ...state, srs }),
setVms: (_, vms) => state => ({ ...state, vms }),
},
computed: {
isInvalid: state =>
state.name.trim() === '' ||
(isEmpty(state.schedules) && isEmpty(state.tmpSchedules)),
sortedSchedules: ({ schedules }) => orderBy(schedules, 'name'),
// TO DO: use sortedTmpSchedules
sortedTmpSchedules: ({ tmpSchedules }) => orderBy(tmpSchedules, 'id'),
},
}),
injectState,
({ effects, state }) => (
<form id={state.formId}>
<FormGroup>
<h1>BackupNG</h1>
<label>
<strong>Name</strong>
</label>
<Input onChange={effects.setName} value={state.name} />
</FormGroup>
<FormGroup>
<label>Target remotes (for Export)</label>
<SelectRemote
multi
onChange={effects.setRemotes}
value={state.remotes}
/>
</FormGroup>
<FormGroup>
<label>
Target SRs (for Replication)
<SelectSr multi onChange={effects.setSrs} value={state.srs} />
</label>
</FormGroup>
<FormGroup>
<label>
Vms to Backup
<SelectVm multi onChange={effects.setVms} value={state.vms} />
</label>
</FormGroup>
{false /* TODO: remove when implemented */ && (!isEmpty(state.srs) || !isEmpty(state.remotes)) && (
<Upgrade place='newBackup' required={4}>
<FormGroup>
<label>
<input
type='checkbox'
onChange={effects.setDelta}
value={state.delta}
/>{' '}
Use delta
</label>
</FormGroup>
</Upgrade>
)}
{!isEmpty(state.schedules) && (
<FormGroup>
<h3>Saved schedules</h3>
<ul>
{state.sortedSchedules.map(schedule => (
<li key={schedule.id}>
{schedule.name} {schedule.cron} {schedule.timezone}
<ActionButton
data-scheduleId={schedule.id}
handler={effects.editSchedule}
icon='edit'
size='small'
/>
</li>
))}
</ul>
</FormGroup>
)}
{!isEmpty(state.tmpSchedules) && (
<FormGroup>
<h3>New schedules</h3>
<ul>
{map(state.tmpSchedules, (schedule, key) => (
<li key={key}>
{schedule.cron} {schedule.timezone} {schedule.exportRetention}{' '}
{schedule.snapshotRetention}
<ActionButton
data-scheduleId={key}
handler={effects.editTmpSchedule}
icon='edit'
size='small'
/>
</li>
))}
</ul>
</FormGroup>
)}
<FormGroup>
<h1>BackupNG</h1>
<label>
<strong>Export retention</strong>
</label>
<Input
type='number'
onChange={effects.setExportRetention}
value={state.exportRetention}
/>
<label>
<strong>Snapshot retention</strong>
</label>
<Input
type='number'
onChange={effects.setSnapshotRetention}
value={state.snapshotRetention}
/>
<Scheduler
cronPattern={state.tmpSchedule.cron}
onChange={effects.setTmpSchedule}
timezone={state.tmpSchedule.timezone}
/>
<SchedulePreview
cronPattern={state.tmpSchedule.cron}
timezone={state.tmpSchedule.timezone}
/>
<br />
<ActionButton handler={effects.addSchedule} icon='add'>
Add a schedule
</ActionButton>
</FormGroup>
<ActionButton
disabled={state.isInvalid}
form={state.formId}
handler={effects.createJob}
redirectOnSuccess='/backup-ng'
icon='save'
>
Create
</ActionButton>
</form>
),
].reduceRight((value, decorator) => decorator(value))

View File

@@ -0,0 +1,42 @@
import _ from 'intl'
import React from 'react'
import Component from 'base-component'
import { Select } from 'form'
import { SelectSr } from 'select-objects'
import { FormattedDate } from 'react-intl'
export default class ImportModalBody extends Component {
get value () {
return this.state
}
render () {
return (
<div>
<div className='mb-1'>
<Select
optionRenderer={backup => (
<span>
{`[${backup.mode}] `}
<FormattedDate
value={new Date(backup.timestamp)}
month='long'
day='numeric'
year='numeric'
hour='2-digit'
minute='2-digit'
second='2-digit'
/>
</span>
)}
options={this.props.data.backups}
onChange={this.linkState('backup')}
placeholder={_('importBackupModalSelectBackup')}
/>
</div>
<div>
<SelectSr onChange={this.linkState('sr')} />
</div>
</div>
)
}
}

View File

@@ -0,0 +1,134 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import Icon from 'icon'
import React from 'react'
import SortedTable from 'sorted-table'
import Upgrade from 'xoa-upgrade'
import { addSubscriptions, noop } from 'utils'
import { subscribeRemotes, listVmBackups, restoreBackup } from 'xo'
import { assign, filter, forEach, map } from 'lodash'
import { confirm } from 'modal'
import { error } from 'notification'
import { FormattedDate } from 'react-intl'
import ImportModalBody from './import-modal-body'
const BACKUPS_COLUMNS = [
{
name: 'VM',
itemRenderer: ({ last }) => last.vm.name_label,
},
{
name: 'VM description',
itemRenderer: ({ last }) => last.vm.name_description,
},
{
name: 'Last backup',
itemRenderer: ({ last }) => (
<FormattedDate
value={new Date(last.timestamp)}
month='long'
day='numeric'
year='numeric'
hour='2-digit'
minute='2-digit'
second='2-digit'
/>
),
},
{
name: 'Available backups',
itemRenderer: ({ count }) =>
map(count, (n, mode) => `${mode}: ${n}`).join(', '),
},
]
@addSubscriptions({
remotes: subscribeRemotes,
})
export default class Restore extends Component {
state = {
backupsByVm: {},
}
componentWillReceiveProps (props) {
if (props.remotes !== this.props.remotes) {
this._refreshBackupList(props.remotes)
}
}
_refreshBackupList = async remotes => {
const backupsByRemote = await listVmBackups(
filter(remotes, { enabled: true })
)
const backupsByVm = {}
forEach(backupsByRemote, backups => {
forEach(backups, (vmBackups, vmId) => {
if (backupsByVm[vmId] === undefined) {
backupsByVm[vmId] = { backups: [] }
}
backupsByVm[vmId].backups.push(...vmBackups)
})
})
// TODO: perf
let last
forEach(backupsByVm, (vmBackups, vmId) => {
last = { timestamp: 0 }
const count = {}
forEach(vmBackups.backups, backup => {
if (backup.timestamp > last.timestamp) {
last = backup
}
count[backup.mode] = (count[backup.mode] || 0) + 1
})
assign(vmBackups, { last, count })
})
this.setState({ backupsByVm })
}
_restore = data =>
confirm({
title: `Restore ${data.last.vm.name_label}`,
body: <ImportModalBody data={data} />,
}).then(({ backup, sr }) => {
if (backup == null || sr == null) {
error(_('backupRestoreErrorTitle'), _('backupRestoreErrorMessage'))
return
}
return restoreBackup(backup, sr)
}, noop)
render () {
return (
<Upgrade place='restoreBackup' available={2}>
<div>
<h2>{_('restoreBackups')}</h2>
<div className='mb-1'>
<em>
<Icon icon='info' /> {_('restoreBackupsInfo')}
</em>
</div>
<div className='mb-1'>
<ActionButton
btnStyle='primary'
handler={this._refreshBackupList}
handlerParam={this.props.remotes}
icon='refresh'
>
Refresh backup list
</ActionButton>
</div>
<SortedTable
collection={this.state.backupsByVm}
columns={BACKUPS_COLUMNS}
rowAction={this._restore}
/>
</div>
</Upgrade>
)
}
}

View File

@@ -14,7 +14,7 @@ export default class Edit extends Component {
}
getSchedule(id).then(schedule => {
getJob(schedule.job).then(job => {
getJob(schedule.jobId).then(job => {
this.setState({ job, schedule })
})
})

View File

@@ -22,7 +22,6 @@ import {
runJob,
subscribeJobs,
subscribeSchedules,
subscribeScheduleTable,
subscribeUsers,
} from 'xo'
@@ -65,7 +64,7 @@ const JOB_COLUMNS = [
},
{
name: _('jobState'),
itemRenderer: ({ schedule, scheduleToggleValue }) => (
itemRenderer: ({ schedule }) => (
<StateButton
disabledLabel={_('jobStateDisabled')}
disabledHandler={enableSchedule}
@@ -74,10 +73,10 @@ const JOB_COLUMNS = [
enabledHandler={disableSchedule}
enabledTooltip={_('logIndicationToDisable')}
handlerParam={schedule.id}
state={scheduleToggleValue}
state={schedule.enabled}
/>
),
sortCriteria: 'scheduleToggleValue',
sortCriteria: 'schedule.enabled',
},
{
name: _('jobAction'),
@@ -114,7 +113,7 @@ const JOB_COLUMNS = [
icon='run-schedule'
btnStyle='warning'
handler={runJob}
handlerParam={schedule.job}
handlerParam={schedule.jobId}
/>
</ButtonGroup>
</fieldset>
@@ -133,13 +132,6 @@ export default class Overview extends Component {
router: React.PropTypes.object,
}
constructor (props) {
super(props)
this.state = {
scheduleTable: {},
}
}
componentWillMount () {
const unsubscribeJobs = subscribeJobs(jobs => {
const obj = {}
@@ -155,7 +147,7 @@ export default class Overview extends Component {
const unsubscribeSchedules = subscribeSchedules(schedules => {
// Get only backup jobs.
schedules = filter(schedules, schedule => {
const job = this.state.jobs && this.state.jobs[schedule.job]
const job = this.state.jobs && this.state.jobs[schedule.jobId]
return job && jobKeyToLabel[job.key]
})
@@ -166,16 +158,9 @@ export default class Overview extends Component {
})
})
const unsubscribeScheduleTable = subscribeScheduleTable(scheduleTable => {
this.setState({
scheduleTable,
})
})
this.componentWillUnmount = () => {
unsubscribeJobs()
unsubscribeSchedules()
unsubscribeScheduleTable()
}
}
@@ -188,15 +173,14 @@ export default class Overview extends Component {
_getScheduleCollection = createSelector(
() => this.state.schedules,
() => this.state.scheduleTable,
() => this.state.jobs,
(schedules, scheduleTable, jobs) => {
(schedules, jobs) => {
if (!schedules || !jobs) {
return []
}
return map(schedules, schedule => {
const job = jobs[schedule.job]
const job = jobs[schedule.jobId]
const { items } = job.paramsVector
const pattern = get(items, '[1].collection.pattern')
@@ -212,7 +196,6 @@ export default class Overview extends Component {
get(items, '[1].values[0].tag') ||
schedule.id,
schedule,
scheduleToggleValue: scheduleTable && scheduleTable[schedule.id],
}
})
}
@@ -226,7 +209,7 @@ export default class Overview extends Component {
const isScheduleUserMissing = {}
forEach(schedules, schedule => {
isScheduleUserMissing[schedule.id] = !!(
jobs && find(users, user => user.id === jobs[schedule.job].userId)
jobs && find(users, user => user.id === jobs[schedule.jobId].userId)
)
})

View File

@@ -22,6 +22,7 @@ import { Container, Row, Col } from 'grid'
import About from './about'
import Backup from './backup'
import BackupNg from './backup-ng'
import Dashboard from './dashboard'
import Home from './home'
import Host from './host'
@@ -74,6 +75,7 @@ const BODY_STYLE = {
@routes('home', {
about: About,
backup: Backup,
'backup-ng': BackupNg,
dashboard: Dashboard,
home: Home,
'hosts/:id': Host,

View File

@@ -186,9 +186,12 @@ export default class Jobs extends Component {
const titles = {
Host: 'Host(s)',
Pool: 'Pool(s)',
Remote: 'Remote(s)',
Role: 'Role(s)',
Snapshot: 'Snapshot(s)',
Sr: 'Storage(s)',
Subject: 'Subject(s)',
Vdi: 'Vdi(s)',
Vm: 'VM(s)',
XoObject: 'Object(s)',
}
@@ -220,13 +223,25 @@ export default class Jobs extends Component {
modifyProperty(property, 'Sr')
} else if (
includes(
['host', 'host_id', 'target_host_id', 'targetHost'],
[
'affinityHost',
'host',
'host_id',
'target_host_id',
'targetHost',
],
key
)
) {
modifyProperty(property, 'Host')
} else if (includes(['vm'], key)) {
modifyProperty(property, 'Vm')
} else if (includes(['snapshot'], key)) {
modifyProperty(property, 'Snapshot')
} else if (includes(['remote', 'remoteId'], key)) {
modifyProperty(property, 'Remote')
} else if (includes(['vdi'], key)) {
modifyProperty(property, 'Vdi')
}
}
}

View File

@@ -23,7 +23,6 @@ import {
runJob,
subscribeJobs,
subscribeSchedules,
subscribeScheduleTable,
subscribeUsers,
} from 'xo'
@@ -43,7 +42,6 @@ export default class Overview extends Component {
super(props)
this.state = {
schedules: [],
scheduleTable: {},
}
}
@@ -73,22 +71,15 @@ export default class Overview extends Component {
})
})
const unsubscribeScheduleTable = subscribeScheduleTable(scheduleTable => {
this.setState({
scheduleTable,
})
})
this.componentWillUnmount = () => {
unsubscribeJobs()
unsubscribeSchedules()
unsubscribeScheduleTable()
}
}
_getScheduleJob (schedule) {
const { jobs } = this.state || {}
return jobs[schedule.job]
return jobs[schedule.jobId]
}
_getJobLabel (job = {}) {
@@ -111,7 +102,7 @@ export default class Overview extends Component {
enabledHandler={disableSchedule}
enabledTooltip={_('logIndicationToDisable')}
handlerParam={id}
state={this.state.scheduleTable[id]}
state={schedule.enabled}
/>
)
}
@@ -200,7 +191,7 @@ export default class Overview extends Component {
icon='run-schedule'
btnStyle='warning'
handler={runJob}
handlerParam={schedule.job}
handlerParam={schedule.jobId}
/>
</fieldset>
</td>

View File

@@ -39,18 +39,18 @@ const COLUMNS = [
},
{
itemRenderer: (schedule, userData) => {
const job = userData.jobs[schedule.job]
const job = userData.jobs[schedule.jobId]
if (job !== undefined) {
return (
<span>
{job.name} - {job.method}{' '}
<span className='text-muted'>({schedule.job.slice(4, 8)})</span>
<span className='text-muted'>({schedule.jobId.slice(4, 8)})</span>
</span>
)
}
},
name: _('job'),
sortCriteria: (schedule, userData) => userData.jobs[schedule.job].name,
sortCriteria: (schedule, userData) => userData.jobs[schedule.jobId].name,
},
{
itemRenderer: schedule => schedule.cron,
@@ -115,7 +115,7 @@ export default class Schedules extends Component {
}
for (const id in schedules) {
const schedule = schedules[id]
const scheduleJob = find(jobs, job => job.id === schedule.job)
const scheduleJob = find(jobs, job => job.id === schedule.jobId)
if (scheduleJob && scheduleJob.key === JOB_KEY) {
s[id] = schedule
}
@@ -134,7 +134,7 @@ export default class Schedules extends Component {
const { cronPattern, schedule, timezone } = this.state
let save
if (schedule) {
schedule.job = job.value.id
schedule.jobId = job.value.id
schedule.cron = cronPattern
schedule.name = name.value
schedule.timezone = timezone
@@ -165,7 +165,7 @@ export default class Schedules extends Component {
const { name, job } = this.refs
name.value = schedule.name
job.value = jobs[schedule.job]
job.value = jobs[schedule.jobId]
this.setState({
cronPattern: schedule.cron,
schedule,

View File

@@ -1,8 +1,6 @@
import _, { FormattedDuration } from 'intl'
import ActionButton from 'action-button'
import ActionRowButton from 'action-row-button'
import addSubscriptions from 'add-subscriptions'
import BaseComponent from 'base-component'
import ButtonGroup from 'button-group'
import classnames from 'classnames'
import Icon from 'icon'
import NoObjects from 'no-objects'
@@ -12,13 +10,19 @@ import renderXoItem from 'render-xo-item'
import Select from 'form/select'
import SortedTable from 'sorted-table'
import Tooltip from 'tooltip'
import { alert, confirm } from 'modal'
import { alert } from 'modal'
import { Card, CardHeader, CardBlock } from 'card'
import { connectStore, formatSize, formatSpeed } from 'utils'
import { createFilter, createGetObject, createSelector } from 'selectors'
import { deleteJobsLog, subscribeJobsLogs } from 'xo'
import { forEach, get, includes, isEmpty, map, orderBy } from 'lodash'
import { forEach, includes, keyBy, map, orderBy } from 'lodash'
import { FormattedDate } from 'react-intl'
import { get } from 'xo-defined'
import {
deleteJobsLogs,
subscribeJobs,
subscribeJobsLogs,
subscribeBackupNgJobs,
} from 'xo'
// ===================================================================
@@ -84,7 +88,7 @@ const JobDataInfos = ({
mergeSize,
}) => (
<div>
{transferSize !== undefined && (
{transferSize && transferDuration ? (
<div>
<strong>{_('jobTransferredDataSize')}</strong>{' '}
{formatSize(transferSize)}
@@ -92,15 +96,15 @@ const JobDataInfos = ({
<strong>{_('jobTransferredDataSpeed')}</strong>{' '}
{formatSpeed(transferSize, transferDuration)}
</div>
)}
{mergeSize !== undefined && (
) : null}
{mergeSize && mergeDuration ? (
<div>
<strong>{_('jobMergedDataSize')}</strong> {formatSize(mergeSize)}
<br />
<strong>{_('jobMergedDataSpeed')}</strong>{' '}
{formatSpeed(mergeSize, mergeDuration)}
</div>
)}
) : null}
</div>
)
@@ -270,10 +274,28 @@ class Log extends BaseComponent {
const showCalls = log =>
alert(_('jobModalTitle', { job: log.jobId }), <Log log={log} />)
const LOG_ACTIONS = [
{
handler: deleteJobsLogs,
icon: 'delete',
label: _('remove'),
},
]
const LOG_ACTIONS_INDIVIDUAL = [
{
handler: showCalls,
icon: 'preview',
label: _('logDisplayDetails'),
},
]
const getCallTag = log => log.calls[0].params.tag
const LOG_COLUMNS = [
{
name: _('jobId'),
itemRenderer: log => log.jobId,
itemRenderer: log => log.jobId.slice(4, 8),
sortCriteria: log => log.jobId,
},
{
@@ -283,8 +305,8 @@ const LOG_COLUMNS = [
},
{
name: _('jobTag'),
itemRenderer: log => get(log, 'calls[0].params.tag'),
sortCriteria: log => get(log, 'calls[0].params.tag'),
itemRenderer: log => get(getCallTag, log),
sortCriteria: log => get(getCallTag, log),
},
{
name: _('jobStart'),
@@ -329,9 +351,9 @@ const LOG_COLUMNS = [
},
{
name: _('jobStatus'),
itemRenderer: log => (
itemRenderer: (log, { jobs }) => (
<span>
{log.status === 'finished' && (
{log.status === 'finished' ? (
<span
className={classnames(
'tag',
@@ -342,161 +364,119 @@ const LOG_COLUMNS = [
>
{_('jobFinished')}
</span>
) : log.status === 'started' ? (
log.id === get(() => jobs[log.jobId].runId) ? (
<span className='tag tag-warning'>{_('jobStarted')}</span>
) : (
<span className='tag tag-danger'>{_('jobInterrupted')}</span>
)
) : (
<span className='tag tag-default'>{_('jobUnknown')}</span>
)}
{log.status === 'started' && (
<span className='tag tag-warning'>{_('jobStarted')}</span>
)}
{log.status !== 'started' &&
log.status !== 'finished' && (
<span className='tag tag-default'>{_('jobUnknown')}</span>
)}{' '}
<span className='pull-right'>
<ButtonGroup>
<Tooltip content={_('logDisplayDetails')}>
<ActionRowButton
icon='preview'
handler={showCalls}
handlerParam={log}
/>
</Tooltip>
<Tooltip content={_('remove')}>
<ActionRowButton
handler={deleteJobsLog}
handlerParam={log.logKey}
icon='delete'
/>
</Tooltip>
</ButtonGroup>
</span>
</span>
),
sortCriteria: log => (log.hasErrors ? ' ' : log.status),
},
]
@propTypes({
jobKeys: propTypes.array.isRequired,
})
export default class LogList extends Component {
constructor (props) {
super(props)
this.state = {
logsToClear: [],
}
this.filters = {
onError: 'hasErrors?',
successful: 'status:finished !hasErrors?',
jobCallSkipped: '!hasErrors? callSkipped?',
}
}
const LOG_FILTERS = {
onError: 'hasErrors?',
successful: 'status:finished !hasErrors?',
jobCallSkipped: '!hasErrors? callSkipped?',
}
componentWillMount () {
this.componentWillUnmount = subscribeJobsLogs(rawLogs => {
const logs = {}
const logsToClear = []
forEach(rawLogs, (log, logKey) => {
const data = log.data
const { time } = log
if (
data.event === 'job.start' &&
includes(this.props.jobKeys, data.key)
) {
logsToClear.push(logKey)
logs[logKey] = {
logKey,
jobId: data.jobId.slice(4, 8),
key: data.key,
userId: data.userId,
start: time,
calls: {},
time,
}
} else {
const runJobId = data.runJobId
const entry = logs[runJobId]
if (!entry) {
return
}
logsToClear.push(logKey)
if (data.event === 'job.end') {
entry.end = time
entry.duration = time - entry.start
entry.status = 'finished'
} else if (data.event === 'jobCall.start') {
entry.calls[logKey] = {
callKey: logKey,
params: data.params,
method: data.method,
export default [
propTypes({
jobKeys: propTypes.array,
}),
addSubscriptions(({ jobKeys }) => ({
logs: cb =>
subscribeJobsLogs(rawLogs => {
const logs = {}
forEach(rawLogs, (log, id) => {
const data = log.data
const { time } = log
if (
data.event === 'job.start' &&
(jobKeys === undefined || includes(jobKeys, data.key))
) {
logs[id] = {
id,
jobId: data.jobId,
key: data.key,
userId: data.userId,
start: time,
calls: {},
time,
}
} else if (data.event === 'jobCall.end') {
const call = entry.calls[data.runCallId]
if (data.error) {
call.error = data.error
if (isSkippedError(data.error)) {
entry.callSkipped = true
} else {
entry.hasErrors = true
} else {
const runJobId = data.runJobId
const entry = logs[runJobId]
if (!entry) {
return
}
if (data.event === 'job.end') {
entry.end = time
entry.duration = time - entry.start
entry.status = 'finished'
} else if (data.event === 'jobCall.start') {
entry.calls[id] = {
callKey: id,
params: data.params,
method: data.method,
start: time,
time,
}
} else if (data.event === 'jobCall.end') {
const call = entry.calls[data.runCallId]
if (data.error) {
call.error = data.error
if (isSkippedError(data.error)) {
entry.callSkipped = true
} else {
entry.hasErrors = true
}
entry.meta = 'error'
} else {
call.returnedValue = data.returnedValue
call.end = time
}
entry.meta = 'error'
} else {
call.returnedValue = data.returnedValue
call.end = time
}
}
}
})
})
forEach(logs, log => {
if (log.end === undefined) {
log.status = 'started'
} else if (!log.meta) {
log.meta = 'success'
}
log.calls = orderBy(log.calls, ['time'], ['desc'])
})
forEach(logs, log => {
if (log.end === undefined) {
log.status = 'started'
} else if (!log.meta) {
log.meta = 'success'
}
log.calls = orderBy(log.calls, ['time'], ['desc'])
})
this.setState({
logs: orderBy(logs, ['time'], ['desc']),
logsToClear,
})
})
}
_deleteAllLogs = () => {
return confirm({
title: _('removeAllLogsModalTitle'),
body: <p>{_('removeAllLogsModalWarning')}</p>,
}).then(() => deleteJobsLog(this.state.logsToClear))
}
render () {
const { logs } = this.state
return (
<Card>
<CardHeader>
<Icon icon='log' /> Logs<span className='pull-right'>
<ActionButton
disabled={isEmpty(logs)}
btnStyle='danger'
handler={this._deleteAllLogs}
icon='delete'
/>
</span>
</CardHeader>
<CardBlock>
<NoObjects
collection={logs}
columns={LOG_COLUMNS}
component={SortedTable}
emptyMessage={_('noLogs')}
filters={this.filters}
/>
</CardBlock>
</Card>
)
}
}
cb(orderBy(logs, ['time'], ['desc']))
}),
jobs: cb => subscribeJobs(jobs => cb(keyBy(jobs, 'id'))),
ngJobs: cb => subscribeBackupNgJobs(jobs => cb(keyBy(jobs, 'id'))),
})),
({ logs, jobs, ngJobs }) => (
<Card>
<CardHeader>
<Icon icon='log' /> Logs
</CardHeader>
<CardBlock>
<NoObjects
actions={LOG_ACTIONS}
collection={logs}
columns={LOG_COLUMNS}
component={SortedTable}
data-jobs={{ ...jobs, ...ngJobs }}
emptyMessage={_('noLogs')}
filters={LOG_FILTERS}
individualActions={LOG_ACTIONS_INDIVIDUAL}
/>
</CardBlock>
</Card>
),
].reduceRight((value, decorator) => decorator(value))

View File

@@ -202,6 +202,11 @@ export default class Menu extends Component {
},
],
},
isAdmin && {
to: '/backup-ng',
icon: 'menu-backup',
label: ['Backup NG'],
},
isAdmin && {
to: 'xoa/update',
icon: 'menu-xoa',
@@ -460,7 +465,10 @@ const MenuLinkItem = props => {
size='lg'
fixedWidth
/>
<span className={styles.hiddenCollapsed}> {_(label)}&nbsp;</span>
<span className={styles.hiddenCollapsed}>
{' '}
{typeof label === 'string' ? _(label) : label}&nbsp;
</span>
{pill > 0 && <span className='tag tag-pill tag-primary'>{pill}</span>}
{extra}
</Link>

View File

@@ -42,16 +42,16 @@ import {
XEN_DEFAULT_CPU_WEIGHT,
XEN_VIDEORAM_VALUES,
} from 'xo'
import {
createGetObjectsOfType,
createSelector,
getCheckPermissions,
isAdmin,
} from 'selectors'
import { createGetObjectsOfType, createSelector, isAdmin } from 'selectors'
// Button's height = react-select's height(36 px) + react-select's border-width(1 px) * 2
// https://github.com/JedWatson/react-select/blob/916ab0e62fc7394be8e24f22251c399a68de8b1c/less/select.less#L21, L22
const SHARE_BUTTON_STYLE = { height: '38px' }
const forceReboot = vm => restartVm(vm, true)
const forceShutdown = vm => stopVm(vm, true)
const fullCopy = vm => cloneVm(vm, true)
const shareVmProxy = vm => shareVm(vm, vm.resourceSet)
@connectStore(() => {
const getAffinityHost = createGetObjectsOfType('host').find((_, { vm }) => ({
@@ -128,31 +128,6 @@ class ResourceSetItem extends Component {
}
}
@addSubscriptions({
resourceSets: subscribeResourceSets,
})
class ShareVmButton extends Component {
_shareVm = () => {
const { resourceSets, vm } = this.props
return shareVm(vm, {
...find(resourceSets, { id: vm.resourceSet }),
type: 'resourceSet',
})
}
render () {
return (
<TabButton
btnStyle='primary'
handler={this._shareVm}
icon='vm-share'
labelId='vmShareButton'
/>
)
}
}
class NewVgpu extends Component {
get value () {
return this.state
@@ -315,332 +290,322 @@ export default connectStore(() => {
createSelector(getVgpus, vgpus => map(vgpus, 'gpuGroup'))
)
const getCanAdministrate = createSelector(
getCheckPermissions,
(_, props) => props.vm.id,
(check, id) => check(id, 'administrate')
)
return {
canAdministrate: getCanAdministrate,
gpuGroup: getGpuGroup,
isAdmin,
vgpus: getVgpus,
vgpuTypes: getVgpuTypes,
}
})(
({ canAdministrate, container, gpuGroup, isAdmin, vgpus, vgpuTypes, vm }) => (
<Container>
<Row>
<Col className='text-xs-right'>
{(isAdmin || canAdministrate) &&
vm.resourceSet != null && <ShareVmButton vm={vm} />}
{vm.power_state === 'Running' && (
<span>
<TabButton
btnStyle='primary'
handler={suspendVm}
handlerParam={vm}
icon='vm-suspend'
labelId='suspendVmLabel'
/>
<TabButton
btnStyle='warning'
handler={forceReboot}
handlerParam={vm}
icon='vm-force-reboot'
labelId='forceRebootVmLabel'
/>
<TabButton
btnStyle='warning'
handler={forceShutdown}
handlerParam={vm}
icon='vm-force-shutdown'
labelId='forceShutdownVmLabel'
/>
</span>
)}
{vm.power_state === 'Halted' && (
<span>
<TabButton
btnStyle='primary'
handler={recoveryStartVm}
handlerParam={vm}
icon='vm-recovery-mode'
labelId='recoveryModeLabel'
/>
<TabButton
btnStyle='primary'
handler={fullCopy}
handlerParam={vm}
icon='vm-clone'
labelId='cloneVmLabel'
/>
<TabButton
btnStyle='danger'
handler={convertVmToTemplate}
handlerParam={vm}
icon='vm-create-template'
labelId='vmConvertButton'
redirectOnSuccess='/'
/>
</span>
)}
{vm.power_state === 'Suspended' && (
<span>
<TabButton
btnStyle='primary'
handler={resumeVm}
handlerParam={vm}
icon='vm-start'
labelId='resumeVmLabel'
/>
<TabButton
btnStyle='warning'
handler={forceShutdown}
handlerParam={vm}
icon='vm-force-shutdown'
labelId='forceShutdownVmLabel'
/>
</span>
)}
<TabButton
btnStyle='danger'
handler={deleteVm}
handlerParam={vm}
icon='vm-delete'
labelId='vmRemoveButton'
/>
</Col>
</Row>
<Row>
<Col>
<h3>{_('xenSettingsLabel')}</h3>
<table className='table'>
<tbody>
})(({ container, gpuGroup, isAdmin, vgpus, vgpuTypes, vm }) => (
<Container>
<Row>
<Col className='text-xs-right'>
{vm.power_state === 'Running' && (
<span>
<TabButton
btnStyle='primary'
handler={suspendVm}
handlerParam={vm}
icon='vm-suspend'
labelId='suspendVmLabel'
/>
<TabButton
btnStyle='warning'
handler={forceReboot}
handlerParam={vm}
icon='vm-force-reboot'
labelId='forceRebootVmLabel'
/>
<TabButton
btnStyle='warning'
handler={forceShutdown}
handlerParam={vm}
icon='vm-force-shutdown'
labelId='forceShutdownVmLabel'
/>
</span>
)}
{vm.power_state === 'Halted' && (
<span>
<TabButton
btnStyle='primary'
handler={recoveryStartVm}
handlerParam={vm}
icon='vm-recovery-mode'
labelId='recoveryModeLabel'
/>
<TabButton
btnStyle='primary'
handler={fullCopy}
handlerParam={vm}
icon='vm-clone'
labelId='cloneVmLabel'
/>
<TabButton
btnStyle='danger'
handler={convertVmToTemplate}
handlerParam={vm}
icon='vm-create-template'
labelId='vmConvertButton'
redirectOnSuccess='/'
/>
</span>
)}
{vm.power_state === 'Suspended' && (
<span>
<TabButton
btnStyle='primary'
handler={resumeVm}
handlerParam={vm}
icon='vm-start'
labelId='resumeVmLabel'
/>
<TabButton
btnStyle='warning'
handler={forceShutdown}
handlerParam={vm}
icon='vm-force-shutdown'
labelId='forceShutdownVmLabel'
/>
</span>
)}
<TabButton
btnStyle='danger'
handler={deleteVm}
handlerParam={vm}
icon='vm-delete'
labelId='vmRemoveButton'
/>
</Col>
</Row>
<Row>
<Col>
<h3>{_('xenSettingsLabel')}</h3>
<table className='table'>
<tbody>
<tr>
<th>{_('uuid')}</th>
<Copiable tagName='td'>{vm.uuid}</Copiable>
</tr>
<tr>
<th>{_('virtualizationMode')}</th>
<td>
{vm.virtualizationMode === 'pv'
? _('paraVirtualizedMode')
: _('hardwareVirtualizedMode')}
</td>
</tr>
{vm.virtualizationMode === 'pv' && (
<tr>
<th>{_('uuid')}</th>
<Copiable tagName='td'>{vm.uuid}</Copiable>
</tr>
<tr>
<th>{_('virtualizationMode')}</th>
<th>{_('pvArgsLabel')}</th>
<td>
{vm.virtualizationMode === 'pv'
? _('paraVirtualizedMode')
: _('hardwareVirtualizedMode')}
<Text
value={vm.PV_args}
onChange={value => editVm(vm, { PV_args: value })}
/>
</td>
</tr>
{vm.virtualizationMode === 'pv' && (
<tr>
<th>{_('pvArgsLabel')}</th>
<td>
<Text
value={vm.PV_args}
onChange={value => editVm(vm, { PV_args: value })}
/>
</td>
</tr>
)}
)}
<tr>
<th>{_('cpuWeightLabel')}</th>
<td>
<Number
value={vm.cpuWeight == null ? null : vm.cpuWeight}
onChange={value => editVm(vm, { cpuWeight: value })}
nullable
>
{vm.cpuWeight == null
? _('defaultCpuWeight', { value: XEN_DEFAULT_CPU_WEIGHT })
: vm.cpuWeight}
</Number>
</td>
</tr>
<tr>
<th>{_('cpuCapLabel')}</th>
<td>
<Number
value={vm.cpuCap == null ? null : vm.cpuCap}
onChange={value => editVm(vm, { cpuCap: value })}
nullable
>
{vm.cpuCap == null
? _('defaultCpuCap', { value: XEN_DEFAULT_CPU_CAP })
: vm.cpuCap}
</Number>
</td>
</tr>
<tr>
<th>{_('autoPowerOn')}</th>
<td>
<Toggle
value={Boolean(vm.auto_poweron)}
onChange={value => editVm(vm, { auto_poweron: value })}
/>
</td>
</tr>
<tr>
<th>{_('ha')}</th>
<td>
<Toggle
value={vm.high_availability}
onChange={value => editVm(vm, { high_availability: value })}
/>
</td>
</tr>
<tr>
<th>{_('vmAffinityHost')}</th>
<td>
<AffinityHost vm={vm} />
</td>
</tr>
{vm.virtualizationMode === 'hvm' && (
<tr>
<th>{_('cpuWeightLabel')}</th>
<th>{_('vmVgpus')}</th>
<td>
<Number
value={vm.cpuWeight == null ? null : vm.cpuWeight}
onChange={value => editVm(vm, { cpuWeight: value })}
nullable
>
{vm.cpuWeight == null
? _('defaultCpuWeight', { value: XEN_DEFAULT_CPU_WEIGHT })
: vm.cpuWeight}
</Number>
<Vgpus vgpus={vgpus} vm={vm} />
</td>
</tr>
)}
{vm.virtualizationMode === 'hvm' && (
<tr>
<th>{_('cpuCapLabel')}</th>
<td>
<Number
value={vm.cpuCap == null ? null : vm.cpuCap}
onChange={value => editVm(vm, { cpuCap: value })}
nullable
>
{vm.cpuCap == null
? _('defaultCpuCap', { value: XEN_DEFAULT_CPU_CAP })
: vm.cpuCap}
</Number>
</td>
</tr>
<tr>
<th>{_('autoPowerOn')}</th>
<th>{_('vmVga')}</th>
<td>
<Toggle
value={Boolean(vm.auto_poweron)}
onChange={value => editVm(vm, { auto_poweron: value })}
value={vm.vga === 'std'}
onChange={value =>
editVm(vm, { vga: value ? 'std' : 'cirrus' })
}
/>
</td>
</tr>
)}
{vm.vga === 'std' && (
<tr>
<th>{_('ha')}</th>
<th>{_('vmVideoram')}</th>
<td>
<Toggle
value={vm.high_availability}
onChange={value => editVm(vm, { high_availability: value })}
/>
<select
className='form-control'
onChange={event =>
editVm(vm, { videoram: +getEventValue(event) })
}
value={vm.videoram}
>
{map(XEN_VIDEORAM_VALUES, val => (
<option key={val} value={val}>
{formatSize(val * 1048576)}
</option>
))}
</select>
</td>
</tr>
<tr>
<th>{_('vmAffinityHost')}</th>
<td>
<AffinityHost vm={vm} />
</td>
</tr>
{vm.virtualizationMode === 'hvm' && (
<tr>
<th>{_('vmVgpus')}</th>
<td>
<Vgpus vgpus={vgpus} vm={vm} />
</td>
</tr>
)}
{vm.virtualizationMode === 'hvm' && (
<tr>
<th>{_('vmVga')}</th>
<td>
<Toggle
value={vm.vga === 'std'}
onChange={value =>
editVm(vm, { vga: value ? 'std' : 'cirrus' })
}
/>
</td>
</tr>
)}
{vm.vga === 'std' && (
<tr>
<th>{_('vmVideoram')}</th>
<td>
<select
className='form-control'
onChange={event =>
editVm(vm, { videoram: +getEventValue(event) })
}
value={vm.videoram}
>
{map(XEN_VIDEORAM_VALUES, val => (
<option key={val} value={val}>
{formatSize(val * 1048576)}
</option>
))}
</select>
</td>
</tr>
)}
</tbody>
</table>
<br />
<h3>{_('vmLimitsLabel')}</h3>
<table className='table table-hover'>
<tbody>
<tr>
<th>{_('vmCpuLimitsLabel')}</th>
<td>
)}
</tbody>
</table>
<br />
<h3>{_('vmLimitsLabel')}</h3>
<table className='table table-hover'>
<tbody>
<tr>
<th>{_('vmCpuLimitsLabel')}</th>
<td>
<Number
value={vm.CPUs.number}
onChange={cpus => editVm(vm, { cpus })}
/>
/
{vm.power_state === 'Running' ? (
vm.CPUs.max
) : (
<Number
value={vm.CPUs.number}
onChange={cpus => editVm(vm, { cpus })}
value={vm.CPUs.max}
onChange={cpusStaticMax => editVm(vm, { cpusStaticMax })}
/>
/
{vm.power_state === 'Running' ? (
vm.CPUs.max
) : (
<Number
value={vm.CPUs.max}
onChange={cpusStaticMax => editVm(vm, { cpusStaticMax })}
/>
)}
</td>
</tr>
<tr>
<th>{_('vmCpuTopology')}</th>
<td>
<CoresPerSocket container={container} vm={vm} />
</td>
</tr>
<tr>
<th>{_('vmMemoryLimitsLabel')}</th>
<td>
<p>
Static: {formatSize(vm.memory.static[0])}/<Size
value={defined(vm.memory.static[1], null)}
onChange={memoryStaticMax =>
editVm(vm, { memoryStaticMax })
}
/>
</p>
<p>
Dynamic:{' '}
<Size
value={defined(vm.memory.dynamic[0], null)}
onChange={memoryMin => editVm(vm, { memoryMin })}
/>/<Size
value={defined(vm.memory.dynamic[1], null)}
onChange={memoryMax => editVm(vm, { memoryMax })}
/>
</p>
</td>
</tr>
</tbody>
</table>
<br />
<h3>{_('guestOsLabel')}</h3>
<table className='table table-hover'>
<tbody>
<tr>
<th>{_('xenToolsStatus')}</th>
<td>
{_('xenToolsStatusValue', {
status: normalizeXenToolsStatus(vm.xenTools),
})}
</td>
</tr>
<tr>
<th>{_('osName')}</th>
<td>
{isEmpty(vm.os_version) ? (
_('unknownOsName')
) : (
<span>
<Icon
className='text-info'
icon={osFamily(vm.os_version.distro)}
/>&nbsp;{vm.os_version.name}
</span>
)}
</td>
</tr>
<tr>
<th>{_('osKernel')}</th>
<td>
{(vm.os_version && vm.os_version.uname) ||
_('unknownOsKernel')}
</td>
</tr>
</tbody>
</table>
<br />
<h3>{_('miscLabel')}</h3>
<table className='table table-hover'>
<tbody>
<tr>
<th>{_('originalTemplate')}</th>
<td>
{vm.other.base_template_name
? vm.other.base_template_name
: _('unknownOriginalTemplate')}
</td>
</tr>
<tr>
<th>{_('resourceSet')}</th>
<td>
{isAdmin ? (
)}
</td>
</tr>
<tr>
<th>{_('vmCpuTopology')}</th>
<td>
<CoresPerSocket container={container} vm={vm} />
</td>
</tr>
<tr>
<th>{_('vmMemoryLimitsLabel')}</th>
<td>
<p>
Static: {formatSize(vm.memory.static[0])}/<Size
value={defined(vm.memory.static[1], null)}
onChange={memoryStaticMax =>
editVm(vm, { memoryStaticMax })
}
/>
</p>
<p>
Dynamic:{' '}
<Size
value={defined(vm.memory.dynamic[0], null)}
onChange={memoryMin => editVm(vm, { memoryMin })}
/>/<Size
value={defined(vm.memory.dynamic[1], null)}
onChange={memoryMax => editVm(vm, { memoryMax })}
/>
</p>
</td>
</tr>
</tbody>
</table>
<br />
<h3>{_('guestOsLabel')}</h3>
<table className='table table-hover'>
<tbody>
<tr>
<th>{_('xenToolsStatus')}</th>
<td>
{_('xenToolsStatusValue', {
status: normalizeXenToolsStatus(vm.xenTools),
})}
</td>
</tr>
<tr>
<th>{_('osName')}</th>
<td>
{isEmpty(vm.os_version) ? (
_('unknownOsName')
) : (
<span>
<Icon
className='text-info'
icon={osFamily(vm.os_version.distro)}
/>&nbsp;{vm.os_version.name}
</span>
)}
</td>
</tr>
<tr>
<th>{_('osKernel')}</th>
<td>
{(vm.os_version && vm.os_version.uname) || _('unknownOsKernel')}
</td>
</tr>
</tbody>
</table>
<br />
<h3>{_('miscLabel')}</h3>
<table className='table table-hover'>
<tbody>
<tr>
<th>{_('originalTemplate')}</th>
<td>
{vm.other.base_template_name
? vm.other.base_template_name
: _('unknownOriginalTemplate')}
</td>
</tr>
<tr>
<th>{_('resourceSet')}</th>
<td>
{isAdmin ? (
<div className='input-group'>
<SelectResourceSet
onChange={resourceSet =>
editVm(vm, {
@@ -650,17 +615,39 @@ export default connectStore(() => {
}
value={vm.resourceSet}
/>
) : vm.resourceSet !== undefined ? (
<ResourceSetItem id={vm.resourceSet} />
) : (
_('resourceSetNone')
)}
</td>
</tr>
</tbody>
</table>
</Col>
</Row>
</Container>
)
)
{vm.resourceSet !== undefined && (
<span className='input-group-btn'>
<ActionButton
btnStyle='primary'
handler={shareVmProxy}
handlerParam={vm}
icon='vm-share'
style={SHARE_BUTTON_STYLE}
tooltip={_('vmShareButton')}
/>
</span>
)}
</div>
) : vm.resourceSet !== undefined ? (
<span>
<ResourceSetItem id={vm.resourceSet} />{' '}
<ActionButton
btnStyle='primary'
handler={shareVmProxy}
handlerParam={vm}
icon='vm-share'
size='small'
tooltip={_('vmShareButton')}
/>
</span>
) : (
_('resourceSetNone')
)}
</td>
</tr>
</tbody>
</table>
</Col>
</Row>
</Container>
))

View File

@@ -567,6 +567,10 @@
normalize-path "^2.0.1"
through2 "^2.0.3"
"@julien-f/freactal@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@julien-f/freactal/-/freactal-0.1.0.tgz#c3c97c1574ed82de6989f7f3c6110f34b0da3866"
"@marsaud/smb2-promise@^0.2.0", "@marsaud/smb2-promise@^0.2.1":
version "0.2.1"
resolved "https://registry.yarnpkg.com/@marsaud/smb2-promise/-/smb2-promise-0.2.1.tgz#fee95f4baba6e4d930e8460d3377aa12560e0f0e"
@@ -1786,7 +1790,7 @@ babel-plugin-transform-function-bind@^6.22.0:
babel-plugin-syntax-function-bind "^6.8.0"
babel-runtime "^6.22.0"
babel-plugin-transform-object-rest-spread@^6.22.0:
babel-plugin-transform-object-rest-spread@^6.22.0, babel-plugin-transform-object-rest-spread@^6.26.0:
version "6.26.0"
resolved "https://registry.yarnpkg.com/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.26.0.tgz#0f36692d50fef6b7e2d4b3ac1478137a963b7b06"
dependencies: