Compare commits
36 Commits
xo-server-
...
xo-web-v5.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecd460a991 | ||
|
|
b4d7648ffe | ||
|
|
eb3dfb0f30 | ||
|
|
2b9ba69480 | ||
|
|
8f784162ea | ||
|
|
a2ab64b142 | ||
|
|
052817ccbf | ||
|
|
48b2297bc1 | ||
|
|
e76a0ad4bd | ||
|
|
baf6d30348 | ||
|
|
7d250dd90b | ||
|
|
efaabb02e8 | ||
|
|
0c3b98d451 | ||
|
|
28d1539ea6 | ||
|
|
8ad02d2d51 | ||
|
|
1947a066e0 | ||
|
|
d99e643634 | ||
|
|
65e1ac2ef9 | ||
|
|
64a768090f | ||
|
|
488eed046e | ||
|
|
dccddd78a6 | ||
|
|
3c247abcf9 | ||
|
|
db795e91fd | ||
|
|
f060f56c93 | ||
|
|
51be573f5e | ||
|
|
4257cbb618 | ||
|
|
e25d6b712d | ||
|
|
b499d60130 | ||
|
|
68e06303a4 | ||
|
|
60085798f2 | ||
|
|
c62cab39f1 | ||
|
|
30483ab2d9 | ||
|
|
c38c716616 | ||
|
|
ded1127d64 | ||
|
|
38d6130e89 | ||
|
|
ee47e40d1a |
@@ -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: {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
[lints]
|
||||
|
||||
[options]
|
||||
esproposal.decorators=ignore
|
||||
include_warnings=true
|
||||
module.use_strict=true
|
||||
|
||||
|
||||
47
@xen-orchestra/babel-config/index.js
Normal file
47
@xen-orchestra/babel-config/index.js
Normal 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]]),
|
||||
}
|
||||
}
|
||||
11
@xen-orchestra/babel-config/package.json
Normal file
11
@xen-orchestra/babel-config/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/*",
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "complex-matcher",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.0",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
|
||||
@@ -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])
|
||||
),
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
13
packages/xo-server/bin/run-vhd-test
Executable file
13
packages/xo-server/bin/run-vhd-test
Executable 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)
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
155
packages/xo-server/src/api/backup-ng.js
Normal file
155
packages/xo-server/src/api/backup-ng.js
Normal 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',
|
||||
},
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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'
|
||||
@@ -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'],
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
15
packages/xo-server/src/patch.js
Normal file
15
packages/xo-server/src/patch.js
Normal 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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
72
packages/xo-server/src/vhd-test.js
Normal file
72
packages/xo-server/src/vhd-test.js
Normal 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 }
|
||||
@@ -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
|
||||
|
||||
|
||||
772
packages/xo-server/src/xo-mixins/backups-ng/index.js
Normal file
772
packages/xo-server/src/xo-mixins/backups-ng/index.js
Normal 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)
|
||||
}
|
||||
}
|
||||
153
packages/xo-server/src/xo-mixins/backups-ng/migration.js
Normal file
153
packages/xo-server/src/xo-mixins/backups-ng/migration.js
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
124
packages/xo-server/src/xo-mixins/jobs/execute-call.js
Normal file
124
packages/xo-server/src/xo-mixins/jobs/execute-call.js
Normal 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
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
],
|
||||
263
packages/xo-server/src/xo-mixins/jobs/index.js
Normal file
263
packages/xo-server/src/xo-mixins/jobs/index.js
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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() : ''
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
21
packages/xo-web/src/xo-app/backup-ng/edit.js
Normal file
21
packages/xo-web/src/xo-app/backup-ng/edit.js
Normal 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))
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
210
packages/xo-web/src/xo-app/backup-ng/index.js
Normal file
210
packages/xo-web/src/xo-app/backup-ng/index.js
Normal 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>
|
||||
))
|
||||
269
packages/xo-web/src/xo-app/backup-ng/new.js
Normal file
269
packages/xo-web/src/xo-app/backup-ng/new.js
Normal 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))
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
134
packages/xo-web/src/xo-app/backup-ng/restore/index.js
Normal file
134
packages/xo-web/src/xo-app/backup-ng/restore/index.js
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)} </span>
|
||||
<span className={styles.hiddenCollapsed}>
|
||||
{' '}
|
||||
{typeof label === 'string' ? _(label) : label}
|
||||
</span>
|
||||
{pill > 0 && <span className='tag tag-pill tag-primary'>{pill}</span>}
|
||||
{extra}
|
||||
</Link>
|
||||
|
||||
@@ -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)}
|
||||
/> {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)}
|
||||
/> {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>
|
||||
))
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user