Compare commits
2 Commits
xo-web-v5.
...
pierre-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1efe1d82cf | ||
|
|
c4b4ee6476 |
@@ -11,7 +11,7 @@
|
||||
"vhd-lib": "^0.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.10.1"
|
||||
"node": ">=8.16.1"
|
||||
},
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/backups-cli",
|
||||
"name": "@xen-orchestra/backups-cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/cron",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.4",
|
||||
"license": "ISC",
|
||||
"description": "Focused, well maintained, cron parser/scheduler",
|
||||
"keywords": [
|
||||
|
||||
@@ -5,21 +5,14 @@ import parse from './parse'
|
||||
|
||||
const MAX_DELAY = 2 ** 31 - 1
|
||||
|
||||
function nextDelay(schedule) {
|
||||
const now = schedule._createDate()
|
||||
return next(schedule._schedule, now) - now
|
||||
}
|
||||
|
||||
class Job {
|
||||
constructor(schedule, fn) {
|
||||
let scheduledDate
|
||||
const wrapper = () => {
|
||||
const now = Date.now()
|
||||
if (scheduledDate > now) {
|
||||
// we're early, delay
|
||||
//
|
||||
// no need to check _isEnabled, we're just delaying the existing timeout
|
||||
//
|
||||
// see https://github.com/vatesfr/xen-orchestra/issues/4625
|
||||
this._timeout = setTimeout(wrapper, scheduledDate - now)
|
||||
return
|
||||
}
|
||||
|
||||
this._isRunning = true
|
||||
|
||||
let result
|
||||
@@ -39,9 +32,7 @@ class Job {
|
||||
this._isRunning = false
|
||||
|
||||
if (this._isEnabled) {
|
||||
const now = Date.now()
|
||||
scheduledDate = +schedule._createDate()
|
||||
const delay = scheduledDate - now
|
||||
const delay = nextDelay(schedule)
|
||||
this._timeout =
|
||||
delay < MAX_DELAY
|
||||
? setTimeout(wrapper, delay)
|
||||
|
||||
@@ -11,21 +11,13 @@
|
||||
|
||||
> Users must be able to say: “Nice enhancement, I'm eager to test it”
|
||||
|
||||
- [Hub] Ability to select SR in hub VM installation (PR [#4571](https://github.com/vatesfr/xen-orchestra/pull/4571))
|
||||
- [Hub] Display more info about downloadable templates (PR [#4593](https://github.com/vatesfr/xen-orchestra/pull/4593))
|
||||
- [Support] Ability to open and close support tunnel from the user interface [#4513](https://github.com/vatesfr/xen-orchestra/issues/4513) (PR [#4616](https://github.com/vatesfr/xen-orchestra/pull/4616))
|
||||
- [xo-server-transport-icinga2] Add support of [icinga2](https://icinga.com/docs/icinga2/latest/doc/12-icinga2-api/) for reporting services status [#4563](https://github.com/vatesfr/xen-orchestra/issues/4563) (PR [#4573](https://github.com/vatesfr/xen-orchestra/pull/4573))
|
||||
- [Hub] Ability to update existing template (PR [#4613](https://github.com/vatesfr/xen-orchestra/pull/4613))
|
||||
- [Menu] Remove legacy backup entry [#4467](https://github.com/vatesfr/xen-orchestra/issues/4467) (PR [#4476](https://github.com/vatesfr/xen-orchestra/pull/4476))
|
||||
- [Backup NG] Offline backup feature [#3449](https://github.com/vatesfr/xen-orchestra/issues/3449) (PR [#4470](https://github.com/vatesfr/xen-orchestra/pull/4470))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
> Users must be able to say: “I had this issue, happy to know it's fixed”
|
||||
|
||||
- [SR] Fix `[object HTMLInputElement]` name after re-attaching a SR [#4546](https://github.com/vatesfr/xen-orchestra/issues/4546) (PR [#4550](https://github.com/vatesfr/xen-orchestra/pull/4550))
|
||||
- [Schedules] Prevent double runs [#4625](https://github.com/vatesfr/xen-orchestra/issues/4625) (PR [#4626](https://github.com/vatesfr/xen-orchestra/pull/4626))
|
||||
- [Schedules] Properly enable/disable on config import (PR [#4624](https://github.com/vatesfr/xen-orchestra/pull/4624))
|
||||
- [Patches] Better error handling when fetching missing patches (PR [#4519](https://github.com/vatesfr/xen-orchestra/pull/4519))
|
||||
|
||||
### Released packages
|
||||
|
||||
@@ -34,12 +26,5 @@
|
||||
>
|
||||
> Rule of thumb: add packages on top.
|
||||
|
||||
- @xen-orchestra/cron v1.0.5
|
||||
- xo-server-transport-icinga2 v0.1.0
|
||||
- xo-server-sdn-controller v0.3.1
|
||||
- xo-server v5.51.0
|
||||
- xo-web v5.51.0
|
||||
|
||||
### Dropped packages
|
||||
|
||||
- xo-server-cloud : this package was useless for OpenSource installations because it required a complete XOA environment
|
||||
|
||||
@@ -20,7 +20,7 @@ We'll consider at this point that you've got a working node on your box. E.g:
|
||||
|
||||
```
|
||||
$ node -v
|
||||
v8.16.2
|
||||
v8.12.0
|
||||
```
|
||||
|
||||
If not, see [this page](https://nodejs.org/en/download/package-manager/) for instructions on how to install Node.
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
"node": ">=6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/log": "^0.2.0",
|
||||
"async-iterator-to-stream": "^1.0.2",
|
||||
"core-js": "^3.0.0",
|
||||
"from2": "^2.3.0",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncIteratorToStream from 'async-iterator-to-stream'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
|
||||
import resolveRelativeFromFile from './_resolveRelativeFromFile'
|
||||
|
||||
@@ -14,17 +13,12 @@ import {
|
||||
import { fuFooter, fuHeader, checksumStruct } from './_structs'
|
||||
import { test as mapTestBit } from './_bitmap'
|
||||
|
||||
const { warn } = createLogger('vhd-lib:createSyntheticStream')
|
||||
|
||||
export default async function createSyntheticStream(handler, paths) {
|
||||
const fds = []
|
||||
const cleanup = () => {
|
||||
for (let i = 0, n = fds.length; i < n; ++i) {
|
||||
handler.closeFile(fds[i]).catch(error => {
|
||||
warn('error while closing file', {
|
||||
error,
|
||||
fd: fds[i],
|
||||
})
|
||||
console.warn('createReadStream, closeFd', i, error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from 'assert'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
|
||||
import checkFooter from './_checkFooter'
|
||||
import checkHeader from './_checkHeader'
|
||||
@@ -16,7 +15,10 @@ import {
|
||||
SECTOR_SIZE,
|
||||
} from './_constants'
|
||||
|
||||
const { debug } = createLogger('vhd-lib:Vhd')
|
||||
const VHD_UTIL_DEBUG = 0
|
||||
const debug = VHD_UTIL_DEBUG
|
||||
? str => console.log(`[vhd-merge]${str}`)
|
||||
: () => null
|
||||
|
||||
// ===================================================================
|
||||
//
|
||||
|
||||
@@ -172,3 +172,11 @@ export const patchPrecheckFailed = create(20, ({ errorType, patch }) => ({
|
||||
},
|
||||
message: `patch precheck failed: ${errorType}`,
|
||||
}))
|
||||
|
||||
export const listMissingPatchesFailed = create(21, ({ host, reason }) => ({
|
||||
data: {
|
||||
host,
|
||||
reason,
|
||||
},
|
||||
message: 'could not fetch missing patches',
|
||||
}))
|
||||
|
||||
@@ -354,7 +354,7 @@ class BackupReportsXoPlugin {
|
||||
log.jobName
|
||||
} ${STATUS_ICON[log.status]}`,
|
||||
markdown: toMarkdown(markdown),
|
||||
success: log.status === 'success',
|
||||
nagiosStatus: log.status === 'success' ? 0 : 2,
|
||||
nagiosMarkdown:
|
||||
log.status === 'success'
|
||||
? `[Xen Orchestra] [Success] Metadata backup report for ${log.jobName}`
|
||||
@@ -390,7 +390,7 @@ class BackupReportsXoPlugin {
|
||||
log.status
|
||||
} − Backup report for ${jobName} ${STATUS_ICON[log.status]}`,
|
||||
markdown: toMarkdown(markdown),
|
||||
success: false,
|
||||
nagiosStatus: 2,
|
||||
nagiosMarkdown: `[Xen Orchestra] [${log.status}] Backup report for ${jobName} - Error : ${log.result.message}`,
|
||||
})
|
||||
}
|
||||
@@ -646,7 +646,7 @@ class BackupReportsXoPlugin {
|
||||
subject: `[Xen Orchestra] ${log.status} − Backup report for ${jobName} ${
|
||||
STATUS_ICON[log.status]
|
||||
}`,
|
||||
success: log.status === 'success',
|
||||
nagiosStatus: log.status === 'success' ? 0 : 2,
|
||||
nagiosMarkdown:
|
||||
log.status === 'success'
|
||||
? `[Xen Orchestra] [Success] Backup report for ${jobName}`
|
||||
@@ -656,7 +656,7 @@ class BackupReportsXoPlugin {
|
||||
})
|
||||
}
|
||||
|
||||
_sendReport({ markdown, subject, success, nagiosMarkdown }) {
|
||||
_sendReport({ markdown, subject, nagiosStatus, nagiosMarkdown }) {
|
||||
const xo = this._xo
|
||||
return Promise.all([
|
||||
xo.sendEmail !== undefined &&
|
||||
@@ -676,14 +676,9 @@ class BackupReportsXoPlugin {
|
||||
}),
|
||||
xo.sendPassiveCheck !== undefined &&
|
||||
xo.sendPassiveCheck({
|
||||
status: success ? 0 : 2,
|
||||
status: nagiosStatus,
|
||||
message: nagiosMarkdown,
|
||||
}),
|
||||
xo.sendIcinga2Status !== undefined &&
|
||||
xo.sendIcinga2Status({
|
||||
status: success ? 'OK' : 'CRITICAL',
|
||||
message: markdown,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -713,7 +708,7 @@ class BackupReportsXoPlugin {
|
||||
return this._sendReport({
|
||||
subject: `[Xen Orchestra] ${globalStatus} ${icon}`,
|
||||
markdown,
|
||||
success: false,
|
||||
nagiosStatus: 2,
|
||||
nagiosMarkdown: `[Xen Orchestra] [${globalStatus}] Error : ${error.message}`,
|
||||
})
|
||||
}
|
||||
@@ -909,7 +904,7 @@ class BackupReportsXoPlugin {
|
||||
? ICON_FAILURE
|
||||
: ICON_SKIPPED
|
||||
}`,
|
||||
success: globalSuccess,
|
||||
nagiosStatus: globalSuccess ? 0 : 2,
|
||||
nagiosMarkdown: globalSuccess
|
||||
? `[Xen Orchestra] [Success] Backup report for ${tag}`
|
||||
: `[Xen Orchestra] [${
|
||||
|
||||
10
packages/xo-server-cloud/.npmignore
Normal file
10
packages/xo-server-cloud/.npmignore
Normal file
@@ -0,0 +1,10 @@
|
||||
/examples/
|
||||
example.js
|
||||
example.js.map
|
||||
*.example.js
|
||||
*.example.js.map
|
||||
|
||||
/test/
|
||||
/tests/
|
||||
*.spec.js
|
||||
*.spec.js.map
|
||||
@@ -1,6 +1,4 @@
|
||||
# xo-server-transport-icinga2 [](https://travis-ci.org/vatesfr/xen-orchestra)
|
||||
|
||||
> xo-server plugin to send status to icinga2 server
|
||||
# xo-server-cloud [](https://travis-ci.org/vatesfr/xen-orchestra)
|
||||
|
||||
## Install
|
||||
|
||||
@@ -13,13 +11,6 @@ the web interface, see [the plugin documentation](https://xen-orchestra.com/docs
|
||||
|
||||
## Development
|
||||
|
||||
### `Xo#sendIcinga2Status({ status, message })`
|
||||
|
||||
This xo method is called to send a passive check to icinga2 and change the status of a service.
|
||||
It has two parameters:
|
||||
- status: it's the service status in icinga2 (0: OK | 1: WARNING | 2: CRITICAL | 3: UNKNOWN).
|
||||
- message: it's the status information in icinga2.
|
||||
|
||||
```
|
||||
# Install dependencies
|
||||
> npm install
|
||||
54
packages/xo-server-cloud/package.json
Normal file
54
packages/xo-server-cloud/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "xo-server-cloud",
|
||||
"version": "0.3.0",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"keywords": [
|
||||
"cloud",
|
||||
"orchestra",
|
||||
"plugin",
|
||||
"xen",
|
||||
"xen-orchestra",
|
||||
"xo-server"
|
||||
],
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-cloud",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-cloud",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Pierre Donias",
|
||||
"email": "pierre.donias@gmail.com"
|
||||
},
|
||||
"preferGlobal": false,
|
||||
"main": "dist/",
|
||||
"bin": {},
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"dependencies": {
|
||||
"http-request-plus": "^0.8.0",
|
||||
"jsonrpc-websocket-client": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.0.0",
|
||||
"@babel/core": "^7.0.0",
|
||||
"@babel/preset-env": "^7.0.0",
|
||||
"cross-env": "^6.0.3",
|
||||
"rimraf": "^3.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",
|
||||
"clean": "rimraf dist/",
|
||||
"dev": "cross-env NODE_ENV=development babel --watch --source-maps --out-dir=dist/ src/",
|
||||
"prebuild": "yarn run clean",
|
||||
"predev": "yarn run prebuild",
|
||||
"prepublishOnly": "yarn run build"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
208
packages/xo-server-cloud/src/index.js
Normal file
208
packages/xo-server-cloud/src/index.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import Client, { createBackoff } from 'jsonrpc-websocket-client'
|
||||
import hrp from 'http-request-plus'
|
||||
|
||||
const WS_URL = 'ws://localhost:9001'
|
||||
const HTTP_URL = 'http://localhost:9002'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
class XoServerCloud {
|
||||
constructor({ xo }) {
|
||||
this._xo = xo
|
||||
|
||||
// Defined in configure().
|
||||
this._conf = null
|
||||
this._key = null
|
||||
}
|
||||
|
||||
configure(configuration) {
|
||||
this._conf = configuration
|
||||
}
|
||||
|
||||
async load() {
|
||||
const getResourceCatalog = this._getCatalog.bind(this)
|
||||
getResourceCatalog.description =
|
||||
"Get the list of user's available resources"
|
||||
getResourceCatalog.permission = 'admin'
|
||||
getResourceCatalog.params = {
|
||||
filters: { type: 'object', optional: true },
|
||||
}
|
||||
|
||||
const registerResource = ({ namespace }) =>
|
||||
this._registerResource(namespace)
|
||||
registerResource.description = 'Register a resource via cloud plugin'
|
||||
registerResource.params = {
|
||||
namespace: {
|
||||
type: 'string',
|
||||
},
|
||||
}
|
||||
registerResource.permission = 'admin'
|
||||
|
||||
const downloadAndInstallResource = this._downloadAndInstallResource.bind(
|
||||
this
|
||||
)
|
||||
|
||||
downloadAndInstallResource.description =
|
||||
'Download and install a resource via cloud plugin'
|
||||
|
||||
downloadAndInstallResource.params = {
|
||||
id: { type: 'string' },
|
||||
namespace: { type: 'string' },
|
||||
version: { type: 'string' },
|
||||
sr: { type: 'string' },
|
||||
}
|
||||
|
||||
downloadAndInstallResource.resolve = {
|
||||
sr: ['sr', 'SR', 'administrate'],
|
||||
}
|
||||
|
||||
downloadAndInstallResource.permission = 'admin'
|
||||
|
||||
this._unsetApiMethods = this._xo.addApiMethods({
|
||||
cloud: {
|
||||
downloadAndInstallResource,
|
||||
getResourceCatalog,
|
||||
registerResource,
|
||||
},
|
||||
})
|
||||
this._unsetRequestResource = this._xo.defineProperty(
|
||||
'requestResource',
|
||||
this._requestResource,
|
||||
this
|
||||
)
|
||||
|
||||
const updater = (this._updater = new Client(WS_URL))
|
||||
const connect = () =>
|
||||
updater.open(createBackoff()).catch(error => {
|
||||
console.error('xo-server-cloud: fail to connect to updater', error)
|
||||
|
||||
return connect()
|
||||
})
|
||||
updater.on('closed', connect).on('scheduledAttempt', ({ delay }) => {
|
||||
console.warn('xo-server-cloud: next attempt in %s ms', delay)
|
||||
})
|
||||
connect()
|
||||
}
|
||||
|
||||
unload() {
|
||||
this._unsetApiMethods()
|
||||
this._unsetRequestResource()
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
async _getCatalog({ filters } = {}) {
|
||||
const catalog = await this._updater.call('getResourceCatalog', { filters })
|
||||
|
||||
if (!catalog) {
|
||||
throw new Error('cannot get catalog')
|
||||
}
|
||||
|
||||
return catalog
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
async _getNamespaces() {
|
||||
const catalog = await this._getCatalog()
|
||||
|
||||
if (!catalog._namespaces) {
|
||||
throw new Error('cannot get namespaces')
|
||||
}
|
||||
|
||||
return catalog._namespaces
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
async _downloadAndInstallResource({ id, namespace, sr, version }) {
|
||||
const stream = await this._requestResource({
|
||||
hub: true,
|
||||
id,
|
||||
namespace,
|
||||
version,
|
||||
})
|
||||
const vm = await this._xo.getXapi(sr.$poolId).importVm(stream, {
|
||||
srId: sr.id,
|
||||
type: 'xva',
|
||||
})
|
||||
await vm.update_other_config({
|
||||
'xo:resource:namespace': namespace,
|
||||
'xo:resource:xva:version': version,
|
||||
'xo:resource:xva:id': id,
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
async _registerResource(namespace) {
|
||||
const _namespace = (await this._getNamespaces())[namespace]
|
||||
|
||||
if (_namespace === undefined) {
|
||||
throw new Error(`${namespace} is not available`)
|
||||
}
|
||||
|
||||
if (_namespace.registered || _namespace.pending) {
|
||||
throw new Error(`already registered for ${namespace}`)
|
||||
}
|
||||
|
||||
return this._updater.call('registerResource', { namespace })
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
async _getNamespaceCatalog({ hub, namespace }) {
|
||||
const namespaceCatalog = (await this._getCatalog({ filters: { hub } }))[
|
||||
namespace
|
||||
]
|
||||
|
||||
if (!namespaceCatalog) {
|
||||
throw new Error(`cannot get catalog: ${namespace} not registered`)
|
||||
}
|
||||
|
||||
return namespaceCatalog
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
async _requestResource({ hub = false, id, namespace, version }) {
|
||||
const _namespace = (await this._getNamespaces())[namespace]
|
||||
|
||||
if (!hub && (!_namespace || !_namespace.registered)) {
|
||||
throw new Error(`cannot get resource: ${namespace} not registered`)
|
||||
}
|
||||
|
||||
const { _token: token } = await this._getNamespaceCatalog({
|
||||
hub,
|
||||
namespace,
|
||||
})
|
||||
|
||||
// 2018-03-20 Extra check: getResourceDownloadToken seems to be called without a token in some cases
|
||||
if (token === undefined) {
|
||||
throw new Error(`${namespace} namespace token is undefined`)
|
||||
}
|
||||
|
||||
const downloadToken = await this._updater.call('getResourceDownloadToken', {
|
||||
token,
|
||||
id,
|
||||
version,
|
||||
})
|
||||
|
||||
if (!downloadToken) {
|
||||
throw new Error('cannot get download token')
|
||||
}
|
||||
|
||||
const response = await hrp(HTTP_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${downloadToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
// currently needed for XenApi#putResource()
|
||||
response.length = response.headers['content-length']
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
export default opts => new XoServerCloud(opts)
|
||||
@@ -31,7 +31,7 @@
|
||||
"node": ">=6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/cron": "^1.0.5",
|
||||
"@xen-orchestra/cron": "^1.0.4",
|
||||
"lodash": "^4.16.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"node": ">=6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/cron": "^1.0.5",
|
||||
"@xen-orchestra/cron": "^1.0.4",
|
||||
"d3-time-format": "^2.1.1",
|
||||
"json5": "^2.0.1",
|
||||
"lodash": "^4.17.4"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"predev": "yarn run prebuild",
|
||||
"prepublishOnly": "yarn run build"
|
||||
},
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.0",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,8 @@ export class OvsdbClient {
|
||||
|
||||
Attributes on created OVS ports (corresponds to a XAPI `PIF` or `VIF`):
|
||||
- `other_config`:
|
||||
- `xo:sdn-controller:private-network-uuid`: UUID of the private network
|
||||
- `xo:sdn-controller:cross-pool` : UUID of the remote network connected by the tunnel
|
||||
- `xo:sdn-controller:private-pool-wide`: `true` if created (and managed) by a SDN Controller
|
||||
|
||||
Attributes on created OVS interfaces:
|
||||
- `options`:
|
||||
@@ -66,49 +67,55 @@ export class OvsdbClient {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async addInterfaceAndPort(
|
||||
network,
|
||||
networkUuid,
|
||||
networkName,
|
||||
remoteAddress,
|
||||
encapsulation,
|
||||
key,
|
||||
password,
|
||||
privateNetworkUuid
|
||||
remoteNetwork
|
||||
) {
|
||||
if (
|
||||
this._adding.find(
|
||||
elem => elem.id === network.uuid && elem.addr === remoteAddress
|
||||
elem => elem.id === networkUuid && elem.addr === remoteAddress
|
||||
) !== undefined
|
||||
) {
|
||||
return
|
||||
}
|
||||
const adding = { id: network.uuid, addr: remoteAddress }
|
||||
const adding = { id: networkUuid, addr: remoteAddress }
|
||||
this._adding.push(adding)
|
||||
|
||||
const socket = await this._connect()
|
||||
const bridge = await this._getBridgeForNetwork(network, socket)
|
||||
if (bridge.uuid === undefined) {
|
||||
const [bridgeUuid, bridgeName] = await this._getBridgeUuidForNetwork(
|
||||
networkUuid,
|
||||
networkName,
|
||||
socket
|
||||
)
|
||||
if (bridgeUuid === undefined) {
|
||||
socket.destroy()
|
||||
this._adding = this._adding.filter(
|
||||
elem => elem.id !== network.uuid || elem.addr !== remoteAddress
|
||||
elem => elem.id !== networkUuid || elem.addr !== remoteAddress
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const alreadyExist = await this._interfaceAndPortAlreadyExist(
|
||||
bridge,
|
||||
bridgeUuid,
|
||||
bridgeName,
|
||||
remoteAddress,
|
||||
socket
|
||||
)
|
||||
if (alreadyExist) {
|
||||
socket.destroy()
|
||||
this._adding = this._adding.filter(
|
||||
elem => elem.id !== network.uuid || elem.addr !== remoteAddress
|
||||
elem => elem.id !== networkUuid || elem.addr !== remoteAddress
|
||||
)
|
||||
return bridge.name
|
||||
return bridgeName
|
||||
}
|
||||
|
||||
const index = ++this._numberOfPortAndInterface
|
||||
const interfaceName = bridge.name + '_iface' + index
|
||||
const portName = bridge.name + '_port' + index
|
||||
const interfaceName = bridgeName + '_iface' + index
|
||||
const portName = bridgeName + '_port' + index
|
||||
|
||||
// Add interface and port to the bridge
|
||||
const options = { remote_ip: remoteAddress, key: key }
|
||||
@@ -132,9 +139,11 @@ export class OvsdbClient {
|
||||
row: {
|
||||
name: portName,
|
||||
interfaces: ['set', [['named-uuid', 'new_iface']]],
|
||||
other_config: toMap({
|
||||
'xo:sdn-controller:private-network-uuid': privateNetworkUuid,
|
||||
}),
|
||||
other_config: toMap(
|
||||
remoteNetwork !== undefined
|
||||
? { 'xo:sdn-controller:cross-pool': remoteNetwork }
|
||||
: { 'xo:sdn-controller:private-pool-wide': 'true' }
|
||||
),
|
||||
},
|
||||
'uuid-name': 'new_port',
|
||||
}
|
||||
@@ -142,7 +151,7 @@ export class OvsdbClient {
|
||||
const mutateBridgeOperation = {
|
||||
op: 'mutate',
|
||||
table: 'Bridge',
|
||||
where: [['_uuid', '==', ['uuid', bridge.uuid]]],
|
||||
where: [['_uuid', '==', ['uuid', bridgeUuid]]],
|
||||
mutations: [['ports', 'insert', ['set', [['named-uuid', 'new_port']]]]],
|
||||
}
|
||||
const params = [
|
||||
@@ -154,7 +163,7 @@ export class OvsdbClient {
|
||||
const jsonObjects = await this._sendOvsdbTransaction(params, socket)
|
||||
|
||||
this._adding = this._adding.filter(
|
||||
elem => elem.id !== network.uuid || elem.addr !== remoteAddress
|
||||
elem => elem.id !== networkUuid || elem.addr !== remoteAddress
|
||||
)
|
||||
if (jsonObjects === undefined) {
|
||||
socket.destroy()
|
||||
@@ -180,8 +189,8 @@ export class OvsdbClient {
|
||||
details,
|
||||
port: portName,
|
||||
interface: interfaceName,
|
||||
bridge: bridge.name,
|
||||
network: network.name_label,
|
||||
bridge: bridgeName,
|
||||
network: networkName,
|
||||
host: this.host.name_label,
|
||||
})
|
||||
socket.destroy()
|
||||
@@ -191,24 +200,33 @@ export class OvsdbClient {
|
||||
log.debug('Port and interface added to bridge', {
|
||||
port: portName,
|
||||
interface: interfaceName,
|
||||
bridge: bridge.name,
|
||||
network: network.name_label,
|
||||
bridge: bridgeName,
|
||||
network: networkName,
|
||||
host: this.host.name_label,
|
||||
})
|
||||
socket.destroy()
|
||||
return bridge.name
|
||||
return bridgeName
|
||||
}
|
||||
|
||||
async resetForNetwork(network, privateNetworkUuid) {
|
||||
async resetForNetwork(
|
||||
networkUuid,
|
||||
networkName,
|
||||
crossPoolOnly,
|
||||
remoteNetwork
|
||||
) {
|
||||
const socket = await this._connect()
|
||||
const bridge = await this._getBridgeForNetwork(network, socket)
|
||||
if (bridge.uuid === undefined) {
|
||||
const [bridgeUuid, bridgeName] = await this._getBridgeUuidForNetwork(
|
||||
networkUuid,
|
||||
networkName,
|
||||
socket
|
||||
)
|
||||
if (bridgeUuid === undefined) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
// Delete old ports created by a SDN controller
|
||||
const ports = await this._getBridgePorts(bridge, socket)
|
||||
const ports = await this._getBridgePorts(bridgeUuid, bridgeName, socket)
|
||||
if (ports === undefined) {
|
||||
socket.destroy()
|
||||
return
|
||||
@@ -232,14 +250,15 @@ export class OvsdbClient {
|
||||
// 2019-09-03
|
||||
// Compatibility code, to be removed in 1 year.
|
||||
const oldShouldDelete =
|
||||
config[0] === 'private_pool_wide' ||
|
||||
config[0] === 'cross_pool' ||
|
||||
config[0] === 'xo:sdn-controller:private-pool-wide' ||
|
||||
config[0] === 'xo:sdn-controller:cross-pool'
|
||||
(config[0] === 'private_pool_wide' && !crossPoolOnly) ||
|
||||
(config[0] === 'cross_pool' &&
|
||||
(remoteNetwork === undefined || remoteNetwork === config[1]))
|
||||
|
||||
const shouldDelete =
|
||||
config[0] === 'xo:sdn-controller:private-network-uuid' &&
|
||||
config[1] === privateNetworkUuid
|
||||
(config[0] === 'xo:sdn-controller:private-pool-wide' &&
|
||||
!crossPoolOnly) ||
|
||||
(config[0] === 'xo:sdn-controller:cross-pool' &&
|
||||
(remoteNetwork === undefined || remoteNetwork === config[1]))
|
||||
|
||||
if (shouldDelete || oldShouldDelete) {
|
||||
portsToDelete.push(['uuid', portUuid])
|
||||
@@ -256,7 +275,7 @@ export class OvsdbClient {
|
||||
const mutateBridgeOperation = {
|
||||
op: 'mutate',
|
||||
table: 'Bridge',
|
||||
where: [['_uuid', '==', ['uuid', bridge.uuid]]],
|
||||
where: [['_uuid', '==', ['uuid', bridgeUuid]]],
|
||||
mutations: [['ports', 'delete', ['set', portsToDelete]]],
|
||||
}
|
||||
|
||||
@@ -269,7 +288,7 @@ export class OvsdbClient {
|
||||
if (jsonObjects[0].error != null) {
|
||||
log.error('Error while deleting ports from bridge', {
|
||||
error: jsonObjects[0].error,
|
||||
bridge: bridge.name,
|
||||
bridge: bridgeName,
|
||||
host: this.host.name_label,
|
||||
})
|
||||
socket.destroy()
|
||||
@@ -278,7 +297,7 @@ export class OvsdbClient {
|
||||
|
||||
log.debug('Ports deleted from bridge', {
|
||||
nPorts: jsonObjects[0].result[0].count,
|
||||
bridge: bridge.name,
|
||||
bridge: bridgeName,
|
||||
host: this.host.name_label,
|
||||
})
|
||||
socket.destroy()
|
||||
@@ -316,9 +335,9 @@ export class OvsdbClient {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async _getBridgeForNetwork(network, socket) {
|
||||
async _getBridgeUuidForNetwork(networkUuid, networkName, socket) {
|
||||
const where = [
|
||||
['external_ids', 'includes', toMap({ 'xs-network-uuids': network.uuid })],
|
||||
['external_ids', 'includes', toMap({ 'xs-network-uuids': networkUuid })],
|
||||
]
|
||||
const selectResult = await this._select(
|
||||
'Bridge',
|
||||
@@ -328,17 +347,25 @@ export class OvsdbClient {
|
||||
)
|
||||
if (selectResult === undefined) {
|
||||
log.error('No bridge found for network', {
|
||||
network: network.name_label,
|
||||
network: networkName,
|
||||
host: this.host.name_label,
|
||||
})
|
||||
return {}
|
||||
return []
|
||||
}
|
||||
|
||||
return { uuid: selectResult._uuid[1], name: selectResult.name }
|
||||
const bridgeUuid = selectResult._uuid[1]
|
||||
const bridgeName = selectResult.name
|
||||
|
||||
return [bridgeUuid, bridgeName]
|
||||
}
|
||||
|
||||
async _interfaceAndPortAlreadyExist(bridge, remoteAddress, socket) {
|
||||
const ports = await this._getBridgePorts(bridge, socket)
|
||||
async _interfaceAndPortAlreadyExist(
|
||||
bridgeUuid,
|
||||
bridgeName,
|
||||
remoteAddress,
|
||||
socket
|
||||
) {
|
||||
const ports = await this._getBridgePorts(bridgeUuid, bridgeName, socket)
|
||||
if (ports === undefined) {
|
||||
return false
|
||||
}
|
||||
@@ -366,8 +393,8 @@ export class OvsdbClient {
|
||||
return false
|
||||
}
|
||||
|
||||
async _getBridgePorts(bridge, socket) {
|
||||
const where = [['_uuid', '==', ['uuid', bridge.uuid]]]
|
||||
async _getBridgePorts(bridgeUuid, bridgeName, socket) {
|
||||
const where = [['_uuid', '==', ['uuid', bridgeUuid]]]
|
||||
const selectResult = await this._select('Bridge', ['ports'], where, socket)
|
||||
if (selectResult === undefined) {
|
||||
return
|
||||
@@ -1,202 +0,0 @@
|
||||
import createLogger from '@xen-orchestra/log'
|
||||
import { filter, find, forOwn, map, sample } from 'lodash'
|
||||
|
||||
// =============================================================================
|
||||
|
||||
const log = createLogger('xo:xo-server:sdn-controller:private-network')
|
||||
|
||||
// =============================================================================
|
||||
|
||||
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789?!'
|
||||
const createPassword = () =>
|
||||
Array.from({ length: 16 }, _ => sample(CHARS)).join('')
|
||||
|
||||
// =============================================================================
|
||||
|
||||
export class PrivateNetwork {
|
||||
constructor(controller, uuid) {
|
||||
this.controller = controller
|
||||
this.uuid = uuid
|
||||
this.networks = {}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async addHost(host) {
|
||||
if (host.$ref === this.center?.$ref) {
|
||||
// Nothing to do
|
||||
return
|
||||
}
|
||||
|
||||
const hostClient = this.controller.ovsdbClients[host.$ref]
|
||||
if (hostClient === undefined) {
|
||||
log.error('No OVSDB client found', {
|
||||
host: host.name_label,
|
||||
pool: host.$pool.name_label,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const centerClient = this.controller.ovsdbClients[this.center.$ref]
|
||||
if (centerClient === undefined) {
|
||||
log.error('No OVSDB client found for star-center', {
|
||||
privateNetwork: this.uuid,
|
||||
host: this.center.name_label,
|
||||
pool: this.center.$pool.name_label,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const network = this.networks[host.$pool.uuid]
|
||||
const centerNetwork = this.networks[this.center.$pool.uuid]
|
||||
const otherConfig = network.other_config
|
||||
const encapsulation =
|
||||
otherConfig['xo:sdn-controller:encapsulation'] ?? 'gre'
|
||||
const vni = otherConfig['xo:sdn-controller:vni'] ?? '0'
|
||||
const password =
|
||||
otherConfig['xo:sdn-controller:encrypted'] === 'true'
|
||||
? createPassword()
|
||||
: undefined
|
||||
|
||||
let bridgeName
|
||||
try {
|
||||
;[bridgeName] = await Promise.all([
|
||||
hostClient.addInterfaceAndPort(
|
||||
network,
|
||||
centerClient.host.address,
|
||||
encapsulation,
|
||||
vni,
|
||||
password,
|
||||
this.uuid
|
||||
),
|
||||
centerClient.addInterfaceAndPort(
|
||||
centerNetwork,
|
||||
hostClient.host.address,
|
||||
encapsulation,
|
||||
vni,
|
||||
password,
|
||||
this.uuid
|
||||
),
|
||||
])
|
||||
} catch (error) {
|
||||
log.error('Error while connecting host to private network', {
|
||||
error,
|
||||
privateNetwork: this.uuid,
|
||||
network: network.name_label,
|
||||
host: host.name_label,
|
||||
pool: host.$pool.name_label,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info('Host added', {
|
||||
privateNetwork: this.uuid,
|
||||
network: network.name_label,
|
||||
host: host.name_label,
|
||||
pool: host.$pool.name_label,
|
||||
})
|
||||
|
||||
return bridgeName
|
||||
}
|
||||
|
||||
addNetwork(network) {
|
||||
this.networks[network.$pool.uuid] = network
|
||||
log.info('Adding network', {
|
||||
privateNetwork: this.uuid,
|
||||
network: network.name_label,
|
||||
pool: network.$pool.name_label,
|
||||
})
|
||||
if (this.center === undefined) {
|
||||
return this.electNewCenter()
|
||||
}
|
||||
|
||||
const hosts = filter(network.$pool.$xapi.objects.all, { $type: 'host' })
|
||||
return Promise.all(
|
||||
map(hosts, async host => {
|
||||
const hostClient = this.controller.ovsdbClients[host.$ref]
|
||||
const network = this.networks[host.$pool.uuid]
|
||||
await hostClient.resetForNetwork(network, this.uuid)
|
||||
await this.addHost(host)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async electNewCenter() {
|
||||
delete this.center
|
||||
|
||||
// TODO: make it random
|
||||
const hosts = this._getHosts()
|
||||
for (const host of hosts) {
|
||||
const pif = find(host.$PIFs, {
|
||||
network: this.networks[host.$pool.uuid].$ref,
|
||||
})
|
||||
if (pif?.currently_attached && host.$metrics.live) {
|
||||
this.center = host
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (this.center === undefined) {
|
||||
log.error('No available host to elect new star-center', {
|
||||
privateNetwork: this.uuid,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await this._reset()
|
||||
|
||||
// Recreate star topology
|
||||
await Promise.all(map(hosts, host => this.addHost(host)))
|
||||
|
||||
log.info('New star-center elected', {
|
||||
center: this.center.name_label,
|
||||
privateNetwork: this.uuid,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getPools() {
|
||||
const pools = []
|
||||
forOwn(this.networks, network => {
|
||||
pools.push(network.$pool)
|
||||
})
|
||||
return pools
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
_reset() {
|
||||
return Promise.all(
|
||||
map(this._getHosts(), async host => {
|
||||
// Clean old ports and interfaces
|
||||
const hostClient = this.controller.ovsdbClients[host.$ref]
|
||||
if (hostClient === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const network = this.networks[host.$pool.uuid]
|
||||
try {
|
||||
await hostClient.resetForNetwork(network, this.uuid)
|
||||
} catch (error) {
|
||||
log.error('Error while resetting private network', {
|
||||
error,
|
||||
privateNetwork: this.uuid,
|
||||
network: network.name_label,
|
||||
host: host.name_label,
|
||||
pool: network.$pool.name_label,
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
_getHosts() {
|
||||
const hosts = []
|
||||
forOwn(this.networks, network => {
|
||||
hosts.push(...filter(network.$pool.$xapi.objects.all, { $type: 'host' }))
|
||||
})
|
||||
return hosts
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
[vms]
|
||||
default = ''
|
||||
withOsAndXenTools = ''
|
||||
# vmToBackup = ''
|
||||
|
||||
[templates]
|
||||
|
||||
@@ -154,19 +154,6 @@ class XoConnection extends Xo {
|
||||
})
|
||||
}
|
||||
|
||||
async startTempVm(id, params, withXenTools = false) {
|
||||
await this.call('vm.start', { id, ...params })
|
||||
this._tempResourceDisposers.push('vm.stop', { id, force: true })
|
||||
return this.waitObjectState(id, vm => {
|
||||
if (
|
||||
vm.power_state !== 'Running' ||
|
||||
(withXenTools && vm.xenTools === false)
|
||||
) {
|
||||
throw new Error('retry')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async createTempRemote(params) {
|
||||
const remote = await this.call('remote.create', params)
|
||||
this._tempResourceDisposers.push('remote.delete', { id: remote.id })
|
||||
|
||||
@@ -55,68 +55,6 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`backupNg create and execute backup with enabled offline backup 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
"id": Any<String>,
|
||||
"type": "VM",
|
||||
},
|
||||
"end": Any<Number>,
|
||||
"id": Any<String>,
|
||||
"message": Any<String>,
|
||||
"start": Any<Number>,
|
||||
"status": "success",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`backupNg create and execute backup with enabled offline backup 2`] = `
|
||||
Object {
|
||||
"data": Any<Object>,
|
||||
"end": Any<Number>,
|
||||
"id": Any<String>,
|
||||
"message": Any<String>,
|
||||
"start": Any<Number>,
|
||||
"status": "success",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`backupNg create and execute backup with enabled offline backup 3`] = `
|
||||
Object {
|
||||
"end": Any<Number>,
|
||||
"id": Any<String>,
|
||||
"message": Any<String>,
|
||||
"result": Object {
|
||||
"size": Any<Number>,
|
||||
},
|
||||
"start": Any<Number>,
|
||||
"status": "success",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`backupNg create and execute backup with enabled offline backup 4`] = `
|
||||
Object {
|
||||
"data": Any<Object>,
|
||||
"end": Any<Number>,
|
||||
"id": Any<String>,
|
||||
"message": Any<String>,
|
||||
"start": Any<Number>,
|
||||
"status": "success",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`backupNg create and execute backup with enabled offline backup 5`] = `
|
||||
Object {
|
||||
"end": Any<Number>,
|
||||
"id": Any<String>,
|
||||
"message": Any<String>,
|
||||
"result": Object {
|
||||
"size": Any<Number>,
|
||||
},
|
||||
"start": Any<Number>,
|
||||
"status": "success",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`backupNg execute three times a delta backup with 2 remotes, 2 as retention and 2 as fullInterval 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
|
||||
@@ -584,110 +584,4 @@ describe('backupNg', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('create and execute backup with enabled offline backup', async () => {
|
||||
const vm = xo.objects.all[config.vms.withOsAndXenTools]
|
||||
if (vm.power_state !== 'Running') {
|
||||
await xo.startTempVm(vm.id, { force: true }, true)
|
||||
}
|
||||
|
||||
const scheduleTempId = randomId()
|
||||
const srId = config.srs.default
|
||||
const { id: remoteId } = await xo.createTempRemote(config.remotes.default)
|
||||
const backupInput = {
|
||||
mode: 'full',
|
||||
remotes: {
|
||||
id: remoteId,
|
||||
},
|
||||
schedules: {
|
||||
[scheduleTempId]: getDefaultSchedule(),
|
||||
},
|
||||
settings: {
|
||||
'': {
|
||||
offlineBackup: true,
|
||||
},
|
||||
[scheduleTempId]: {
|
||||
copyRetention: 1,
|
||||
exportRetention: 1,
|
||||
},
|
||||
},
|
||||
srs: {
|
||||
id: srId,
|
||||
},
|
||||
vms: {
|
||||
id: vm.id,
|
||||
},
|
||||
}
|
||||
const backup = await xo.createTempBackupNgJob(backupInput)
|
||||
expect(backup.settings[''].offlineBackup).toBe(true)
|
||||
|
||||
const schedule = await xo.getSchedule({ jobId: backup.id })
|
||||
|
||||
await Promise.all([
|
||||
xo.runBackupJob(backup.id, schedule.id, { remotes: [remoteId] }),
|
||||
xo.waitObjectState(vm.id, vm => {
|
||||
if (vm.power_state !== 'Halted') {
|
||||
throw new Error('retry')
|
||||
}
|
||||
}),
|
||||
])
|
||||
|
||||
await xo.waitObjectState(vm.id, vm => {
|
||||
if (vm.power_state !== 'Running') {
|
||||
throw new Error('retry')
|
||||
}
|
||||
})
|
||||
|
||||
const backupLogs = await xo.getBackupLogs({
|
||||
jobId: backup.id,
|
||||
scheduleId: schedule.id,
|
||||
})
|
||||
expect(backupLogs.length).toBe(1)
|
||||
|
||||
const { tasks, ...log } = backupLogs[0]
|
||||
validateRootTask(log, {
|
||||
data: {
|
||||
mode: backupInput.mode,
|
||||
reportWhen: backupInput.settings[''].reportWhen,
|
||||
},
|
||||
jobId: backup.id,
|
||||
jobName: backupInput.name,
|
||||
scheduleId: schedule.id,
|
||||
status: 'success',
|
||||
})
|
||||
|
||||
expect(Array.isArray(tasks)).toBe(true)
|
||||
tasks.forEach(({ tasks, ...vmTask }) => {
|
||||
validateVmTask(vmTask, vm.id, { status: 'success' })
|
||||
|
||||
expect(Array.isArray(tasks)).toBe(true)
|
||||
tasks.forEach(({ tasks, ...subTask }) => {
|
||||
expect(subTask.message).not.toBe('snapshot')
|
||||
|
||||
if (subTask.message === 'export') {
|
||||
validateExportTask(
|
||||
subTask,
|
||||
subTask.data.type === 'remote' ? remoteId : srId,
|
||||
{
|
||||
data: expect.any(Object),
|
||||
status: 'success',
|
||||
}
|
||||
)
|
||||
|
||||
expect(Array.isArray(tasks)).toBe(true)
|
||||
tasks.forEach(operationTask => {
|
||||
if (
|
||||
operationTask.message === 'transfer' ||
|
||||
operationTask.message === 'merge'
|
||||
) {
|
||||
validateOperationTask(operationTask, {
|
||||
result: { size: expect.any(Number) },
|
||||
status: 'success',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}, 200e3)
|
||||
})
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "xo-server-transport-icinga2",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-transport-icinga2",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-transport-icinga2",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"main": "./dist",
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",
|
||||
"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"
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"engines": {
|
||||
"node": ">=8.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.4.4",
|
||||
"@babel/core": "^7.4.4",
|
||||
"@babel/preset-env": "^7.4.4",
|
||||
"cross-env": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/log": "^0.2.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import assert from 'assert'
|
||||
import { URL } from 'url'
|
||||
|
||||
// =============================================================================
|
||||
|
||||
export const configurationSchema = {
|
||||
type: 'object',
|
||||
|
||||
properties: {
|
||||
server: {
|
||||
type: 'string',
|
||||
description: `
|
||||
The icinga2 server http/https address.
|
||||
|
||||
*If no port is provided in the URL, 5665 will be used.*
|
||||
|
||||
Examples:
|
||||
- https://icinga2.example.com
|
||||
- http://192.168.0.1:1234
|
||||
`.trim(),
|
||||
},
|
||||
user: {
|
||||
type: 'string',
|
||||
description: 'The icinga2 server username',
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
description: 'The icinga2 server password',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
description: `
|
||||
The filter to use
|
||||
|
||||
See: https://icinga.com/docs/icinga2/latest/doc/12-icinga2-api/#filters
|
||||
|
||||
Example:
|
||||
- Monitor the backup jobs of the VMs of a specific host:
|
||||
|
||||
\`host.name=="xoa.example.com" && service.name=="xo-backup"\`
|
||||
`.trim(),
|
||||
},
|
||||
acceptUnauthorized: {
|
||||
type: 'boolean',
|
||||
description: 'Accept unauthorized certificates',
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
required: ['server'],
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
const STATUS_MAP = {
|
||||
OK: 0,
|
||||
WARNING: 1,
|
||||
CRITICAL: 2,
|
||||
UNKNOWN: 3,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
class XoServerIcinga2 {
|
||||
constructor({ xo }) {
|
||||
this._xo = xo
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
configure(configuration) {
|
||||
const serverUrl = new URL(configuration.server)
|
||||
if (configuration.user !== '') {
|
||||
serverUrl.username = configuration.user
|
||||
}
|
||||
if (configuration.password !== '') {
|
||||
serverUrl.password = configuration.password
|
||||
}
|
||||
if (serverUrl.port === '') {
|
||||
serverUrl.port = '5665' // Default icinga2 access port
|
||||
}
|
||||
serverUrl.pathname = '/v1/actions/process-check-result'
|
||||
this._url = serverUrl.href
|
||||
|
||||
this._filter =
|
||||
configuration.filter !== undefined ? configuration.filter : ''
|
||||
this._acceptUnauthorized = configuration.acceptUnauthorized
|
||||
}
|
||||
|
||||
load() {
|
||||
this._unset = this._xo.defineProperty(
|
||||
'sendIcinga2Status',
|
||||
this._sendIcinga2Status,
|
||||
this
|
||||
)
|
||||
}
|
||||
|
||||
unload() {
|
||||
this._unset()
|
||||
}
|
||||
|
||||
test() {
|
||||
return this._sendIcinga2Status({
|
||||
message:
|
||||
'The server-icinga2 plugin for Xen Orchestra server seems to be working fine, nicely done :)',
|
||||
status: 'OK',
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
_sendIcinga2Status({ message, status }) {
|
||||
const icinga2Status = STATUS_MAP[status]
|
||||
assert(icinga2Status !== undefined, `Invalid icinga2 status: ${status}`)
|
||||
return this._xo
|
||||
.httpRequest(this._url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
rejectUnauthorized: !this._acceptUnauthorized,
|
||||
body: JSON.stringify({
|
||||
type: 'Service',
|
||||
filter: this._filter,
|
||||
plugin_output: message,
|
||||
exit_status: icinga2Status,
|
||||
}),
|
||||
})
|
||||
.readAll()
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
export default opts => new XoServerIcinga2(opts)
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.0.0",
|
||||
"@xen-orchestra/cron": "^1.0.5",
|
||||
"@xen-orchestra/cron": "^1.0.4",
|
||||
"@xen-orchestra/log": "^0.2.0",
|
||||
"handlebars": "^4.0.6",
|
||||
"html-minifier": "^4.0.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "xo-server",
|
||||
"version": "5.51.0",
|
||||
"version": "5.50.1",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Server part of Xen-Orchestra",
|
||||
"keywords": [
|
||||
@@ -35,7 +35,7 @@
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.1",
|
||||
"@xen-orchestra/async-map": "^0.0.0",
|
||||
"@xen-orchestra/cron": "^1.0.5",
|
||||
"@xen-orchestra/cron": "^1.0.4",
|
||||
"@xen-orchestra/defined": "^0.0.0",
|
||||
"@xen-orchestra/emit-async": "^0.0.0",
|
||||
"@xen-orchestra/fs": "^0.10.1",
|
||||
|
||||
@@ -2,6 +2,7 @@ import createLogger from '@xen-orchestra/log'
|
||||
import deferrable from 'golike-defer'
|
||||
import unzip from 'julien-f-unzip'
|
||||
import { filter, find, pickBy, some } from 'lodash'
|
||||
import { listMissingPatchesFailed } from 'xo-common/api-errors'
|
||||
|
||||
import ensureArray from '../../_ensureArray'
|
||||
import { debounce } from '../../decorators'
|
||||
@@ -40,7 +41,7 @@ const XCP_NG_DEBOUNCE_TIME_MS = 60000
|
||||
// list all yum updates available for a XCP-ng host
|
||||
// (hostObject) → { uuid: patchObject }
|
||||
async function _listXcpUpdates(host) {
|
||||
return JSON.parse(
|
||||
const patches = JSON.parse(
|
||||
await this.call(
|
||||
'host.call_plugin',
|
||||
host.$ref,
|
||||
@@ -49,6 +50,15 @@ async function _listXcpUpdates(host) {
|
||||
{}
|
||||
)
|
||||
)
|
||||
|
||||
if (patches.error !== undefined) {
|
||||
throw listMissingPatchesFailed({
|
||||
host: host.$id,
|
||||
reason: patches.error,
|
||||
})
|
||||
}
|
||||
|
||||
return patches
|
||||
}
|
||||
|
||||
const _listXcpUpdateDebounced = debounceWithKey(
|
||||
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
type Xapi,
|
||||
TAG_COPY_SRC,
|
||||
} from '../../xapi'
|
||||
import { formatDateTime, getVmDisks } from '../../xapi/utils'
|
||||
import { getVmDisks } from '../../xapi/utils'
|
||||
import {
|
||||
resolveRelativeFromFile,
|
||||
safeDateFormat,
|
||||
@@ -75,7 +75,6 @@ type Settings = {|
|
||||
deleteFirst?: boolean,
|
||||
copyRetention?: number,
|
||||
exportRetention?: number,
|
||||
offlineBackup?: boolean,
|
||||
offlineSnapshot?: boolean,
|
||||
reportWhen?: ReportWhen,
|
||||
snapshotRetention?: number,
|
||||
@@ -148,7 +147,6 @@ const defaultSettings: Settings = {
|
||||
deleteFirst: false,
|
||||
exportRetention: 0,
|
||||
fullInterval: 0,
|
||||
offlineBackup: false,
|
||||
offlineSnapshot: false,
|
||||
reportWhen: 'failure',
|
||||
snapshotRetention: 0,
|
||||
@@ -190,7 +188,7 @@ const getJobCompression = ({ compression: c }) =>
|
||||
const listReplicatedVms = (
|
||||
xapi: Xapi,
|
||||
scheduleOrJobId: string,
|
||||
srUuid?: string,
|
||||
srId?: string,
|
||||
vmUuid?: string
|
||||
): Vm[] => {
|
||||
const { all } = xapi.objects
|
||||
@@ -205,7 +203,7 @@ const listReplicatedVms = (
|
||||
'start' in object.blocked_operations &&
|
||||
(oc['xo:backup:job'] === scheduleOrJobId ||
|
||||
oc['xo:backup:schedule'] === scheduleOrJobId) &&
|
||||
oc['xo:backup:sr'] === srUuid &&
|
||||
oc['xo:backup:sr'] === srId &&
|
||||
(oc['xo:backup:vm'] === vmUuid ||
|
||||
// 2018-03-28, JFT: to catch VMs replicated before this fix
|
||||
oc['xo:backup:vm'] === undefined)
|
||||
@@ -481,21 +479,16 @@ const disableVmHighAvailability = async (xapi: Xapi, vm: Vm) => {
|
||||
// Attributes on created VM snapshots:
|
||||
//
|
||||
// - `other_config`:
|
||||
// - `xo:backup:datetime` = snapshot.snapshot_time (allow sorting replicated VMs)
|
||||
// - `xo:backup:deltaChainLength` = n (number of delta copies/replicated since a full)
|
||||
// - `xo:backup:exported` = 'true' (added at the end of the backup)
|
||||
//
|
||||
// Attributes on created VMs and created snapshots:
|
||||
//
|
||||
// - `other_config`:
|
||||
// - `xo:backup:datetime`: format is UTC %Y%m%dT%H:%M:%SZ
|
||||
// - from snapshots: snapshot.snapshot_time
|
||||
// - with offline backup: formatDateTime(Date.now())
|
||||
// - `xo:backup:job` = job.id
|
||||
// - `xo:backup:schedule` = schedule.id
|
||||
// - `xo:backup:vm` = vm.uuid
|
||||
//
|
||||
// Attributes of created VMs:
|
||||
//
|
||||
// - all snapshots attributes (see above)
|
||||
// - `name_label`: `${original name} - ${job name} - (${safeDateFormat(backup timestamp)})`
|
||||
// - tag:
|
||||
// - copy in delta mode: `Continuous Replication`
|
||||
@@ -1030,12 +1023,6 @@ export default class BackupNg {
|
||||
throw new Error('copy, export and snapshot retentions cannot both be 0')
|
||||
}
|
||||
|
||||
const isOfflineBackup =
|
||||
mode === 'full' && getSetting(settings, 'offlineBackup', [vmUuid, ''])
|
||||
if (isOfflineBackup && snapshotRetention > 0) {
|
||||
throw new Error('offline backup is not compatible with rolling snapshot')
|
||||
}
|
||||
|
||||
if (
|
||||
!some(
|
||||
vm.$VBDs,
|
||||
@@ -1045,139 +1032,110 @@ export default class BackupNg {
|
||||
throw new Error('no disks found')
|
||||
}
|
||||
|
||||
let baseSnapshot, exported: Vm, exportDateTime
|
||||
if (isOfflineBackup) {
|
||||
exported = vm
|
||||
exportDateTime = formatDateTime(Date.now())
|
||||
if (vm.power_state === 'Running') {
|
||||
await wrapTask(
|
||||
{
|
||||
logger,
|
||||
message: 'shutdown VM',
|
||||
parentId: taskId,
|
||||
},
|
||||
xapi.shutdownVm(vm)
|
||||
)
|
||||
$defer(() => xapi.startVm(vm))
|
||||
}
|
||||
} else {
|
||||
const snapshots = vm.$snapshots
|
||||
.filter(_ => _.other_config['xo:backup:job'] === jobId)
|
||||
.sort(compareSnapshotTime)
|
||||
const snapshots = vm.$snapshots
|
||||
.filter(_ => _.other_config['xo:backup:job'] === jobId)
|
||||
.sort(compareSnapshotTime)
|
||||
|
||||
const bypassVdiChainsCheck: boolean = getSetting(
|
||||
settings,
|
||||
'bypassVdiChainsCheck',
|
||||
[vmUuid, '']
|
||||
)
|
||||
if (!bypassVdiChainsCheck) {
|
||||
xapi._assertHealthyVdiChains(vm)
|
||||
}
|
||||
|
||||
const offlineSnapshot: boolean = getSetting(settings, 'offlineSnapshot', [
|
||||
vmUuid,
|
||||
'',
|
||||
])
|
||||
const startAfterSnapshot = offlineSnapshot && vm.power_state === 'Running'
|
||||
if (startAfterSnapshot) {
|
||||
await wrapTask(
|
||||
{
|
||||
logger,
|
||||
message: 'shutdown VM',
|
||||
parentId: taskId,
|
||||
},
|
||||
xapi.shutdownVm(vm)
|
||||
)
|
||||
}
|
||||
|
||||
exported = (await wrapTask(
|
||||
{
|
||||
logger,
|
||||
message: 'snapshot',
|
||||
parentId: taskId,
|
||||
result: _ => _.uuid,
|
||||
},
|
||||
xapi._snapshotVm(
|
||||
$cancelToken,
|
||||
vm,
|
||||
`[XO Backup ${job.name}] ${vm.name_label}`
|
||||
)
|
||||
): any)
|
||||
|
||||
if (startAfterSnapshot) {
|
||||
ignoreErrors.call(xapi.startVm(vm))
|
||||
}
|
||||
const bypassVdiChainsCheck: boolean = getSetting(
|
||||
settings,
|
||||
'bypassVdiChainsCheck',
|
||||
[vmUuid, '']
|
||||
)
|
||||
if (!bypassVdiChainsCheck) {
|
||||
xapi._assertHealthyVdiChains(vm)
|
||||
}
|
||||
|
||||
const offlineSnapshot: boolean = getSetting(settings, 'offlineSnapshot', [
|
||||
vmUuid,
|
||||
'',
|
||||
])
|
||||
const startAfterSnapshot = offlineSnapshot && vm.power_state === 'Running'
|
||||
if (startAfterSnapshot) {
|
||||
await wrapTask(
|
||||
{
|
||||
logger,
|
||||
message: 'add metadata to snapshot',
|
||||
message: 'shutdown VM',
|
||||
parentId: taskId,
|
||||
},
|
||||
exported.update_other_config({
|
||||
'xo:backup:datetime': exported.snapshot_time,
|
||||
'xo:backup:job': jobId,
|
||||
'xo:backup:schedule': scheduleId,
|
||||
'xo:backup:vm': vmUuid,
|
||||
})
|
||||
xapi.shutdownVm(vm)
|
||||
)
|
||||
}
|
||||
|
||||
exported = await xapi.barrier(exported.$ref)
|
||||
|
||||
if (mode === 'delta') {
|
||||
baseSnapshot = findLast(
|
||||
snapshots,
|
||||
_ => 'xo:backup:exported' in _.other_config
|
||||
)
|
||||
|
||||
// JFT 2018-10-02: support previous snapshots which did not have this
|
||||
// entry, can be removed after 2018-12.
|
||||
if (baseSnapshot === undefined) {
|
||||
baseSnapshot = last(snapshots)
|
||||
}
|
||||
}
|
||||
snapshots.push(exported)
|
||||
|
||||
// snapshots to delete due to the snapshot retention settings
|
||||
const snapshotsToDelete = flatMap(
|
||||
groupBy(snapshots, _ => _.other_config['xo:backup:schedule']),
|
||||
(snapshots, scheduleId) =>
|
||||
getOldEntries(
|
||||
getSetting(settings, 'snapshotRetention', [scheduleId]),
|
||||
snapshots
|
||||
)
|
||||
let snapshot: Vm = (await wrapTask(
|
||||
{
|
||||
logger,
|
||||
message: 'snapshot',
|
||||
parentId: taskId,
|
||||
result: _ => _.uuid,
|
||||
},
|
||||
xapi._snapshotVm(
|
||||
$cancelToken,
|
||||
vm,
|
||||
`[XO Backup ${job.name}] ${vm.name_label}`
|
||||
)
|
||||
): any)
|
||||
|
||||
// delete unused snapshots
|
||||
await asyncMap(snapshotsToDelete, vm => {
|
||||
// snapshot and baseSnapshot should not be deleted right now
|
||||
if (vm !== exported && vm !== baseSnapshot) {
|
||||
return xapi.deleteVm(vm)
|
||||
}
|
||||
if (startAfterSnapshot) {
|
||||
ignoreErrors.call(xapi.startVm(vm))
|
||||
}
|
||||
|
||||
await wrapTask(
|
||||
{
|
||||
logger,
|
||||
message: 'add metadata to snapshot',
|
||||
parentId: taskId,
|
||||
},
|
||||
snapshot.update_other_config({
|
||||
'xo:backup:datetime': snapshot.snapshot_time,
|
||||
'xo:backup:job': jobId,
|
||||
'xo:backup:schedule': scheduleId,
|
||||
'xo:backup:vm': vmUuid,
|
||||
})
|
||||
)
|
||||
|
||||
exported = ((await wrapTask(
|
||||
{
|
||||
logger,
|
||||
message: 'waiting for uptodate snapshot record',
|
||||
parentId: taskId,
|
||||
},
|
||||
xapi.barrier(exported.$ref)
|
||||
): any): Vm)
|
||||
snapshot = await xapi.barrier(snapshot.$ref)
|
||||
|
||||
if (mode === 'full' && snapshotsToDelete.includes(exported)) {
|
||||
// TODO: do not create the snapshot if there are no snapshotRetention and
|
||||
// the VM is not running
|
||||
$defer.call(xapi, 'deleteVm', exported)
|
||||
} else if (mode === 'delta') {
|
||||
if (snapshotsToDelete.includes(exported)) {
|
||||
$defer.onFailure.call(xapi, 'deleteVm', exported)
|
||||
}
|
||||
if (snapshotsToDelete.includes(baseSnapshot)) {
|
||||
$defer.onSuccess.call(xapi, 'deleteVm', baseSnapshot)
|
||||
}
|
||||
let baseSnapshot
|
||||
if (mode === 'delta') {
|
||||
baseSnapshot = findLast(
|
||||
snapshots,
|
||||
_ => 'xo:backup:exported' in _.other_config
|
||||
)
|
||||
|
||||
// JFT 2018-10-02: support previous snapshots which did not have this
|
||||
// entry, can be removed after 2018-12.
|
||||
if (baseSnapshot === undefined) {
|
||||
baseSnapshot = last(snapshots)
|
||||
}
|
||||
}
|
||||
snapshots.push(snapshot)
|
||||
|
||||
// snapshots to delete due to the snapshot retention settings
|
||||
const snapshotsToDelete = flatMap(
|
||||
groupBy(snapshots, _ => _.other_config['xo:backup:schedule']),
|
||||
(snapshots, scheduleId) =>
|
||||
getOldEntries(
|
||||
getSetting(settings, 'snapshotRetention', [scheduleId]),
|
||||
snapshots
|
||||
)
|
||||
)
|
||||
|
||||
// delete unused snapshots
|
||||
await asyncMap(snapshotsToDelete, vm => {
|
||||
// snapshot and baseSnapshot should not be deleted right now
|
||||
if (vm !== snapshot && vm !== baseSnapshot) {
|
||||
return xapi.deleteVm(vm)
|
||||
}
|
||||
})
|
||||
|
||||
snapshot = ((await wrapTask(
|
||||
{
|
||||
logger,
|
||||
message: 'waiting for uptodate snapshot record',
|
||||
parentId: taskId,
|
||||
},
|
||||
xapi.barrier(snapshot.$ref)
|
||||
): any): Vm)
|
||||
|
||||
if (copyRetention === 0 && exportRetention === 0) {
|
||||
return
|
||||
@@ -1193,8 +1151,14 @@ export default class BackupNg {
|
||||
const metadataFilename = `${vmDir}/${basename}.json`
|
||||
|
||||
if (mode === 'full') {
|
||||
// TODO: do not create the snapshot if there are no snapshotRetention and
|
||||
// the VM is not running
|
||||
if (snapshotsToDelete.includes(snapshot)) {
|
||||
$defer.call(xapi, 'deleteVm', snapshot)
|
||||
}
|
||||
|
||||
let compress = getJobCompression(job)
|
||||
const pool = exported.$pool
|
||||
const pool = snapshot.$pool
|
||||
if (
|
||||
compress === 'zstd' &&
|
||||
pool.restrictions.restrict_zstd_export !== 'false'
|
||||
@@ -1211,10 +1175,10 @@ export default class BackupNg {
|
||||
let xva: any = await wrapTask(
|
||||
{
|
||||
logger,
|
||||
message: 'start VM export',
|
||||
message: 'start snapshot export',
|
||||
parentId: taskId,
|
||||
},
|
||||
xapi.exportVm($cancelToken, exported, {
|
||||
xapi.exportVm($cancelToken, snapshot, {
|
||||
compress,
|
||||
})
|
||||
)
|
||||
@@ -1239,7 +1203,7 @@ export default class BackupNg {
|
||||
timestamp: now,
|
||||
version: '2.0.0',
|
||||
vm,
|
||||
vmSnapshot: exported.id !== vm.id ? exported : undefined,
|
||||
vmSnapshot: snapshot,
|
||||
xva: `./${dataBasename}`,
|
||||
}
|
||||
const dataFilename = `${vmDir}/${dataBasename}`
|
||||
@@ -1323,7 +1287,7 @@ export default class BackupNg {
|
||||
async (taskId, sr) => {
|
||||
const fork = forkExport()
|
||||
|
||||
const { uuid: srUuid, xapi } = sr
|
||||
const { $id: srId, xapi } = sr
|
||||
|
||||
// delete previous interrupted copies
|
||||
ignoreErrors.call(
|
||||
@@ -1335,7 +1299,7 @@ export default class BackupNg {
|
||||
|
||||
const oldVms = getOldEntries(
|
||||
copyRetention - 1,
|
||||
listReplicatedVms(xapi, scheduleId, srUuid, vmUuid)
|
||||
listReplicatedVms(xapi, scheduleId, srId, vmUuid)
|
||||
)
|
||||
|
||||
const deleteOldBackups = () =>
|
||||
@@ -1347,9 +1311,7 @@ export default class BackupNg {
|
||||
},
|
||||
this._deleteVms(xapi, oldVms)
|
||||
)
|
||||
const deleteFirst = getSetting(settings, 'deleteFirst', [
|
||||
srUuid,
|
||||
])
|
||||
const deleteFirst = getSetting(settings, 'deleteFirst', [srId])
|
||||
if (deleteFirst) {
|
||||
await deleteOldBackups()
|
||||
}
|
||||
@@ -1379,15 +1341,7 @@ export default class BackupNg {
|
||||
'start',
|
||||
'Start operation for this vm is blocked, clone it if you want to use it.'
|
||||
),
|
||||
!isOfflineBackup
|
||||
? vm.update_other_config('xo:backup:sr', srUuid)
|
||||
: vm.update_other_config({
|
||||
'xo:backup:datetime': exportDateTime,
|
||||
'xo:backup:job': jobId,
|
||||
'xo:backup:schedule': scheduleId,
|
||||
'xo:backup:sr': srUuid,
|
||||
'xo:backup:vm': exported.uuid,
|
||||
}),
|
||||
vm.update_other_config('xo:backup:sr', srId),
|
||||
])
|
||||
|
||||
if (!deleteFirst) {
|
||||
@@ -1400,6 +1354,13 @@ export default class BackupNg {
|
||||
noop // errors are handled in logs
|
||||
)
|
||||
} else if (mode === 'delta') {
|
||||
if (snapshotsToDelete.includes(snapshot)) {
|
||||
$defer.onFailure.call(xapi, 'deleteVm', snapshot)
|
||||
}
|
||||
if (snapshotsToDelete.includes(baseSnapshot)) {
|
||||
$defer.onSuccess.call(xapi, 'deleteVm', baseSnapshot)
|
||||
}
|
||||
|
||||
let deltaChainLength = 0
|
||||
let fullVdisRequired
|
||||
await (async () => {
|
||||
@@ -1437,11 +1398,11 @@ export default class BackupNg {
|
||||
}
|
||||
})
|
||||
|
||||
for (const { uuid: srUuid, xapi } of srs) {
|
||||
for (const { $id: srId, xapi } of srs) {
|
||||
const replicatedVm = listReplicatedVms(
|
||||
xapi,
|
||||
jobId,
|
||||
srUuid,
|
||||
srId,
|
||||
vmUuid
|
||||
).find(vm => vm.other_config[TAG_COPY_SRC] === baseSnapshot.uuid)
|
||||
if (replicatedVm === undefined) {
|
||||
@@ -1507,7 +1468,7 @@ export default class BackupNg {
|
||||
message: 'start snapshot export',
|
||||
parentId: taskId,
|
||||
},
|
||||
xapi.exportDeltaVm($cancelToken, exported, baseSnapshot, {
|
||||
xapi.exportDeltaVm($cancelToken, snapshot, baseSnapshot, {
|
||||
fullVdisRequired,
|
||||
})
|
||||
)
|
||||
@@ -1529,7 +1490,7 @@ export default class BackupNg {
|
||||
}/${basename}.vhd`
|
||||
),
|
||||
vm,
|
||||
vmSnapshot: exported,
|
||||
vmSnapshot: snapshot,
|
||||
}
|
||||
|
||||
const jsonMetadata = JSON.stringify(metadata)
|
||||
@@ -1695,7 +1656,7 @@ export default class BackupNg {
|
||||
async (taskId, sr) => {
|
||||
const fork = forkExport()
|
||||
|
||||
const { uuid: srUuid, xapi } = sr
|
||||
const { $id: srId, xapi } = sr
|
||||
|
||||
// delete previous interrupted copies
|
||||
ignoreErrors.call(
|
||||
@@ -1707,7 +1668,7 @@ export default class BackupNg {
|
||||
|
||||
const oldVms = getOldEntries(
|
||||
copyRetention - 1,
|
||||
listReplicatedVms(xapi, scheduleId, srUuid, vmUuid)
|
||||
listReplicatedVms(xapi, scheduleId, srId, vmUuid)
|
||||
)
|
||||
|
||||
const deleteOldBackups = () =>
|
||||
@@ -1720,9 +1681,7 @@ export default class BackupNg {
|
||||
this._deleteVms(xapi, oldVms)
|
||||
)
|
||||
|
||||
const deleteFirst = getSetting(settings, 'deleteFirst', [
|
||||
srUuid,
|
||||
])
|
||||
const deleteFirst = getSetting(settings, 'deleteFirst', [srId])
|
||||
if (deleteFirst) {
|
||||
await deleteOldBackups()
|
||||
}
|
||||
@@ -1739,7 +1698,7 @@ export default class BackupNg {
|
||||
name_label: `${metadata.vm.name_label} - ${
|
||||
job.name
|
||||
} - (${safeDateFormat(metadata.timestamp)})`,
|
||||
srId: sr.$id,
|
||||
srId,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1750,7 +1709,7 @@ export default class BackupNg {
|
||||
'start',
|
||||
'Start operation for this vm is blocked, clone it if you want to use it.'
|
||||
),
|
||||
vm.update_other_config('xo:backup:sr', srUuid),
|
||||
vm.update_other_config('xo:backup:sr', srId),
|
||||
])
|
||||
|
||||
if (!deleteFirst) {
|
||||
@@ -1765,7 +1724,7 @@ export default class BackupNg {
|
||||
|
||||
if (!isFull) {
|
||||
ignoreErrors.call(
|
||||
exported.update_other_config(
|
||||
snapshot.update_other_config(
|
||||
'xo:backup:deltaChainLength',
|
||||
String(deltaChainLength)
|
||||
)
|
||||
@@ -1775,16 +1734,14 @@ export default class BackupNg {
|
||||
throw new Error(`no exporter for backup mode ${mode}`)
|
||||
}
|
||||
|
||||
if (!isOfflineBackup) {
|
||||
await wrapTask(
|
||||
{
|
||||
logger,
|
||||
message: 'set snapshot.other_config[xo:backup:exported]',
|
||||
parentId: taskId,
|
||||
},
|
||||
exported.update_other_config('xo:backup:exported', 'true')
|
||||
)
|
||||
}
|
||||
await wrapTask(
|
||||
{
|
||||
logger,
|
||||
message: 'set snapshot.other_config[xo:backup:exported]',
|
||||
parentId: taskId,
|
||||
},
|
||||
snapshot.update_other_config('xo:backup:exported', 'true')
|
||||
)
|
||||
}
|
||||
|
||||
async _deleteDeltaVmBackups(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
once,
|
||||
range,
|
||||
sortBy,
|
||||
trim,
|
||||
} from 'lodash'
|
||||
import {
|
||||
chainVhd,
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
|
||||
import createSizeStream from '../size-stream'
|
||||
import xapiObjectToXo from '../xapi-object-to-xo'
|
||||
import { debounceWithKey } from '../_pDebounceWithKey'
|
||||
import { lvs, pvs } from '../lvm'
|
||||
import {
|
||||
forEach,
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const DEBOUNCE_DELAY = 10e3
|
||||
const DELTA_BACKUP_EXT = '.json'
|
||||
const DELTA_BACKUP_EXT_LENGTH = DELTA_BACKUP_EXT.length
|
||||
const TAG_SOURCE_VM = 'xo:source_vm'
|
||||
@@ -279,7 +278,7 @@ const mountLvmPv = (device, partition) => {
|
||||
args.push('--show', '-f', device.path)
|
||||
|
||||
return execa('losetup', args).then(({ stdout }) => {
|
||||
const path = stdout.trim()
|
||||
const path = trim(stdout)
|
||||
return {
|
||||
path,
|
||||
unmount: once(() =>
|
||||
@@ -301,9 +300,6 @@ export default class {
|
||||
this._xo = xo
|
||||
}
|
||||
|
||||
@debounceWithKey.decorate(DEBOUNCE_DELAY, function keyFn(remoteId) {
|
||||
return [this, remoteId]
|
||||
})
|
||||
async listRemoteBackups(remoteId) {
|
||||
const handler = await this._xo.getRemoteHandler(remoteId)
|
||||
|
||||
@@ -330,9 +326,6 @@ export default class {
|
||||
return backups
|
||||
}
|
||||
|
||||
@debounceWithKey.decorate(DEBOUNCE_DELAY, function keyFn(remoteId) {
|
||||
return [this, remoteId]
|
||||
})
|
||||
async listVmBackups(remoteId) {
|
||||
const handler = await this._xo.getRemoteHandler(remoteId)
|
||||
|
||||
|
||||
@@ -77,10 +77,7 @@ export default class Scheduling {
|
||||
'schedules',
|
||||
() => db.get(),
|
||||
schedules =>
|
||||
asyncMap(schedules, async schedule => {
|
||||
await db.update(normalize(schedule))
|
||||
this._start(schedule.id)
|
||||
}),
|
||||
asyncMap(schedules, schedule => db.update(normalize(schedule))),
|
||||
['jobs']
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "xo-web",
|
||||
"version": "5.51.0",
|
||||
"version": "5.50.3",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Web interface client for Xen-Orchestra",
|
||||
"keywords": [
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nraynaud/novnc": "0.6.1",
|
||||
"@xen-orchestra/cron": "^1.0.5",
|
||||
"@xen-orchestra/cron": "^1.0.4",
|
||||
"@xen-orchestra/defined": "^0.0.0",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"ansi_up": "^4.0.3",
|
||||
|
||||
@@ -17,11 +17,9 @@ const messages = {
|
||||
notifications: 'Notifications',
|
||||
noNotifications: 'No notifications so far.',
|
||||
notificationNew: 'NEW!',
|
||||
moreDetails: 'More details',
|
||||
messageSubject: 'Subject',
|
||||
messageFrom: 'From',
|
||||
messageReply: 'Reply',
|
||||
sr: 'SR',
|
||||
tryXoa: 'Try XOA for free and deploy it here.',
|
||||
|
||||
editableLongClickPlaceholder: 'Long click to edit',
|
||||
@@ -123,7 +121,11 @@ const messages = {
|
||||
newServerPage: 'Server',
|
||||
newImport: 'Import',
|
||||
xosan: 'XOSAN',
|
||||
backupMigrationLink: 'How to migrate to the new backup system',
|
||||
backupDeprecatedMessage:
|
||||
'Warning: Backup is deprecated, use Backup NG instead.',
|
||||
moveRestoreLegacyMessage: 'Warning: Your legacy backups can be found here',
|
||||
backupMigrationLink: 'How to migrate to Backup NG',
|
||||
backupNgNewPage: 'Create a new backup with Backup NG',
|
||||
backupOverviewPage: 'Overview',
|
||||
backupNewPage: 'New',
|
||||
backupRemotesPage: 'Remotes',
|
||||
@@ -131,6 +133,7 @@ const messages = {
|
||||
backupFileRestorePage: 'File restore',
|
||||
schedule: 'Schedule',
|
||||
newVmBackup: 'New VM backup',
|
||||
editVmBackup: 'Edit VM backup',
|
||||
backup: 'Backup',
|
||||
rollingSnapshot: 'Rolling Snapshot',
|
||||
deltaBackup: 'Delta Backup',
|
||||
@@ -153,12 +156,7 @@ const messages = {
|
||||
freeUpgrade: 'Free upgrade!',
|
||||
checkXoa: 'Check XOA',
|
||||
xoaCheck: 'XOA check',
|
||||
closeTunnel: 'Close tunnel',
|
||||
openTunnel: 'Open tunnel',
|
||||
supportCommunity:
|
||||
'The XOA check and the support tunnel are available in XOA.',
|
||||
supportTunnel: 'Support tunnel',
|
||||
supportTunnelClosed: 'The support tunnel is closed.',
|
||||
checkXoaCommunity: 'XOA check is available in XOA.',
|
||||
|
||||
// ----- Sign out -----
|
||||
signOut: 'Sign out',
|
||||
@@ -426,12 +424,13 @@ const messages = {
|
||||
jobUserNotFound: "This job's creator no longer exists",
|
||||
backupUserNotFound: "This backup's creator no longer exists",
|
||||
redirectToMatchingVms: 'Click here to see the matching VMs',
|
||||
migrateToBackupNg: 'Migrate to Backup NG',
|
||||
noMatchingVms: 'There are no matching VMs!',
|
||||
allMatchingVms: '{icon} See the matching VMs ({nMatchingVms, number})',
|
||||
backupOwner: 'Backup owner',
|
||||
migrateBackupSchedule: 'Migrate to the new backup system',
|
||||
migrateBackupSchedule: 'Migrate to Backup NG',
|
||||
migrateBackupScheduleMessage:
|
||||
'This will convert the legacy backup job to the new backup system. This operation is not reversible. Do you want to continue?',
|
||||
'This will convert the old backup job to a Backup NG job. This operation is not reversible. Do you want to continue?',
|
||||
runBackupNgJobConfirm: 'Are you sure you want to run {name} ({id})?',
|
||||
cancelJobConfirm: 'Are you sure you want to cancel {name} ({id})?',
|
||||
scheduleDstWarning:
|
||||
@@ -453,9 +452,6 @@ const messages = {
|
||||
backupName: 'Name',
|
||||
offlineSnapshot: 'Offline snapshot',
|
||||
offlineSnapshotInfo: 'Shutdown VMs before snapshotting them',
|
||||
offlineBackup: 'Offline backup',
|
||||
offlineBackupInfo:
|
||||
'Export VMs without snapshotting them. The VMs will be shutdown during the export.',
|
||||
timeout: 'Timeout',
|
||||
timeoutInfo: 'Number of hours after which a job is considered failed',
|
||||
fullBackupInterval: 'Full backup interval',
|
||||
@@ -917,8 +913,8 @@ const messages = {
|
||||
installAllPatchesOnHostContent:
|
||||
'Are you sure you want to install all patches on this host?',
|
||||
patchRelease: 'Release',
|
||||
updatePluginNotInstalled:
|
||||
'An error occurred while fetching the patches. Please make sure the updater plugin is installed by running `yum install xcp-ng-updater` on the host.',
|
||||
cannotFetchMissingPatches:
|
||||
'We are unable to fetch the missing patches at the moment…',
|
||||
showChangelog: 'Show changelog',
|
||||
changelog: 'Changelog',
|
||||
changelogPatch: 'Patch',
|
||||
@@ -2167,9 +2163,8 @@ const messages = {
|
||||
size: 'Size',
|
||||
totalDiskSize: 'Total disk size',
|
||||
hideInstalledPool: 'Already installed templates are hidden',
|
||||
hubSrErrorTitle: 'Missing property',
|
||||
hubImportNotificationTitle: 'XVA import',
|
||||
hubTemplateDescriptionNotAvailable:
|
||||
'No description available for this template',
|
||||
|
||||
// Licenses
|
||||
xosanUnregisteredDisclaimer:
|
||||
|
||||
@@ -87,7 +87,7 @@ export default {
|
||||
}
|
||||
),
|
||||
|
||||
// These IDs are used temporarily to be preselected in backup/new/vms
|
||||
// These IDs are used temporarily to be preselected in backup-ng/new/vms
|
||||
homeVmIdsSelection: combineActionHandlers([], {
|
||||
[actions.setHomeVmIdsSelection]: (_, homeVmIdsSelection) =>
|
||||
homeVmIdsSelection,
|
||||
|
||||
@@ -761,15 +761,16 @@ export const disableHost = host =>
|
||||
|
||||
export const getHostMissingPatches = async host => {
|
||||
const hostId = resolveId(host)
|
||||
if (host.productBrand !== 'XCP-ng') {
|
||||
try {
|
||||
const patches = await _call('pool.listMissingPatches', { host: hostId })
|
||||
// Hide paid patches to XS-free users
|
||||
return host.license_params.sku_type !== 'free'
|
||||
? patches
|
||||
: filter(patches, { paid: false })
|
||||
}
|
||||
try {
|
||||
return await _call('pool.listMissingPatches', { host: hostId })
|
||||
if (
|
||||
host.productBrand !== 'XCP-ng' &&
|
||||
host.license_params.sku_type !== 'free'
|
||||
) {
|
||||
return filter(patches, { paid: false })
|
||||
}
|
||||
return patches
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
@@ -1322,21 +1323,21 @@ export const createVms = (args, nameLabels, cloudConfigs) =>
|
||||
export const getCloudInitConfig = template =>
|
||||
_call('vm.getCloudInitConfig', { template })
|
||||
|
||||
export const pureDeleteVm = (vm, props) =>
|
||||
_call('vm.delete', { id: resolveId(vm), ...props })
|
||||
|
||||
export const deleteVm = (vm, retryWithForce = true) =>
|
||||
confirm({
|
||||
title: _('deleteVmModalTitle'),
|
||||
body: _('deleteVmModalMessage'),
|
||||
})
|
||||
.then(() => pureDeleteVm(vm), noop)
|
||||
.then(() => _call('vm.delete', { id: resolveId(vm) }), noop)
|
||||
.catch(error => {
|
||||
if (retryWithForce && forbiddenOperation.is(error)) {
|
||||
return confirm({
|
||||
title: _('deleteVmBlockedModalTitle'),
|
||||
body: _('deleteVmBlockedModalMessage'),
|
||||
}).then(() => pureDeleteVm(vm, { force: true }), noop)
|
||||
}).then(
|
||||
() => _call('vm.delete', { id: resolveId(vm), force: true }),
|
||||
noop
|
||||
)
|
||||
}
|
||||
|
||||
throw error
|
||||
@@ -1673,6 +1674,8 @@ export const createBondedNetwork = params =>
|
||||
_call('network.createBonded', params)
|
||||
export const createPrivateNetwork = params =>
|
||||
_call('sdnController.createPrivateNetwork', params)
|
||||
export const createCrossPoolPrivateNetwork = params =>
|
||||
_call('sdnController.createCrossPoolPrivateNetwork', params)
|
||||
|
||||
export const deleteNetwork = network =>
|
||||
confirm({
|
||||
@@ -2919,18 +2922,3 @@ export const unlockXosan = (licenseId, srId) =>
|
||||
// Support --------------------------------------------------------------------
|
||||
|
||||
export const checkXoa = () => _call('xoa.check')
|
||||
|
||||
export const closeTunnel = () =>
|
||||
_call('xoa.supportTunnel.close')::tap(subscribeTunnelState.forceRefresh)
|
||||
|
||||
export const openTunnel = () =>
|
||||
_call('xoa.supportTunnel.open')::tap(() => {
|
||||
subscribeTunnelState.forceRefresh()
|
||||
// After 1s, we most likely got the tunnel ID
|
||||
// and we don't want to wait another 5s to show it to the user.
|
||||
setTimeout(subscribeTunnelState.forceRefresh, 1000)
|
||||
})
|
||||
|
||||
export const subscribeTunnelState = createSubscription(() =>
|
||||
_call('xoa.supportTunnel.getState')
|
||||
)
|
||||
|
||||
@@ -49,15 +49,6 @@
|
||||
@extend .fa-check;
|
||||
@extend .text-success;
|
||||
}
|
||||
&-true {
|
||||
@extend .fa;
|
||||
@extend .fa-check;
|
||||
@extend .text-success;
|
||||
}
|
||||
&-false {
|
||||
@extend .fa;
|
||||
@extend .fa-times;
|
||||
}
|
||||
&-undo {
|
||||
@extend .fa;
|
||||
@extend .fa-undo;
|
||||
@@ -1111,10 +1102,6 @@
|
||||
@extend .fa;
|
||||
@extend .fa-share;
|
||||
}
|
||||
&-open-tunnel {
|
||||
@extend .fa;
|
||||
@extend .fa-arrows-h;
|
||||
}
|
||||
|
||||
// XOSAN related
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ const DEFAULTS = {
|
||||
compression: '',
|
||||
concurrency: 0,
|
||||
fullInterval: 0,
|
||||
offlineBackup: false,
|
||||
offlineSnapshot: false,
|
||||
reportWhen: 'failure',
|
||||
timeout: 0,
|
||||
@@ -17,7 +16,6 @@ const MODES = {
|
||||
|
||||
compression: 'full',
|
||||
fullInterval: 'delta',
|
||||
offlineBackup: 'full',
|
||||
}
|
||||
|
||||
const getSettingsWithNonDefaultValue = (mode, settings) =>
|
||||
@@ -222,9 +222,7 @@ export default class Restore extends Component {
|
||||
return (
|
||||
<Upgrade place='restoreBackup' available={4}>
|
||||
<div>
|
||||
<RestoreFileLegacy />
|
||||
<div className='mt-1 mb-1'>
|
||||
<h3>{_('backupFileRestorePage')}</h3>
|
||||
<div className='mb-1'>
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
handler={this._refreshBackupList}
|
||||
@@ -242,6 +240,7 @@ export default class Restore extends Component {
|
||||
columns={BACKUPS_COLUMNS}
|
||||
individualActions={this._individualActions}
|
||||
/>
|
||||
<RestoreFileLegacy />
|
||||
</div>
|
||||
</Upgrade>
|
||||
)
|
||||
439
packages/xo-web/src/xo-app/backup-ng/index.js
Normal file
439
packages/xo-web/src/xo-app/backup-ng/index.js
Normal file
@@ -0,0 +1,439 @@
|
||||
import _ from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import addSubscriptions from 'add-subscriptions'
|
||||
import Button from 'button'
|
||||
import ButtonLink from 'button-link'
|
||||
import constructQueryString from 'construct-query-string'
|
||||
import Copiable from 'copiable'
|
||||
import CopyToClipboard from 'react-copy-to-clipboard'
|
||||
import decorate from 'apply-decorators'
|
||||
import Icon from 'icon'
|
||||
import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
import SortedTable from 'sorted-table'
|
||||
import StateButton from 'state-button'
|
||||
import Tooltip from 'tooltip'
|
||||
import { adminOnly, connectStore, routes } from 'utils'
|
||||
import { Card, CardHeader, CardBlock } from 'card'
|
||||
import { confirm } from 'modal'
|
||||
import { Container, Row, Col } from 'grid'
|
||||
import { createGetLoneSnapshots, createSelector } from 'selectors'
|
||||
import { get } from '@xen-orchestra/defined'
|
||||
import { isEmpty, map, groupBy, some } from 'lodash'
|
||||
import { NavLink, NavTabs } from 'nav'
|
||||
import {
|
||||
cancelJob,
|
||||
deleteBackupJobs,
|
||||
disableSchedule,
|
||||
enableSchedule,
|
||||
runBackupNgJob,
|
||||
runMetadataBackupJob,
|
||||
subscribeBackupNgJobs,
|
||||
subscribeBackupNgLogs,
|
||||
subscribeMetadataBackupJobs,
|
||||
subscribeSchedules,
|
||||
} from 'xo'
|
||||
|
||||
import LogsTable, { LogStatus } from '../logs/backup-ng'
|
||||
import Page from '../page'
|
||||
|
||||
import Edit from './edit'
|
||||
import FileRestore from './file-restore'
|
||||
import getSettingsWithNonDefaultValue from './_getSettingsWithNonDefaultValue'
|
||||
import Health from './health'
|
||||
import NewVmBackup, { NewMetadataBackup } from './new'
|
||||
import Restore, { RestoreMetadata } from './restore'
|
||||
import { destructPattern } from './utils'
|
||||
|
||||
const Ul = props => <ul {...props} style={{ listStyleType: 'none' }} />
|
||||
const Li = props => (
|
||||
<li
|
||||
{...props}
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const _runBackupJob = ({ id, name, schedule, type }) =>
|
||||
confirm({
|
||||
title: _('runJob'),
|
||||
body: _('runBackupNgJobConfirm', {
|
||||
id: id.slice(0, 5),
|
||||
name: <strong>{name}</strong>,
|
||||
}),
|
||||
}).then(() =>
|
||||
type === 'backup'
|
||||
? runBackupNgJob({ id, schedule })
|
||||
: runMetadataBackupJob({ id, schedule })
|
||||
)
|
||||
|
||||
const _deleteBackupJobs = items => {
|
||||
const { backup: backupIds, metadataBackup: metadataBackupIds } = groupBy(
|
||||
items,
|
||||
'type'
|
||||
)
|
||||
return deleteBackupJobs({ backupIds, metadataBackupIds })
|
||||
}
|
||||
|
||||
const SchedulePreviewBody = decorate([
|
||||
addSubscriptions(({ schedule }) => ({
|
||||
lastRunLog: cb =>
|
||||
subscribeBackupNgLogs(logs => {
|
||||
let lastRunLog
|
||||
for (const runId in logs) {
|
||||
const log = logs[runId]
|
||||
if (
|
||||
log.scheduleId === schedule.id &&
|
||||
(lastRunLog === undefined || lastRunLog.start < log.start)
|
||||
) {
|
||||
lastRunLog = log
|
||||
}
|
||||
}
|
||||
cb(lastRunLog)
|
||||
}),
|
||||
})),
|
||||
({ job, schedule, lastRunLog }) => (
|
||||
<Ul>
|
||||
<Li>
|
||||
{schedule.name
|
||||
? _.keyValue(_('scheduleName'), schedule.name)
|
||||
: _.keyValue(_('scheduleCron'), schedule.cron)}{' '}
|
||||
<Tooltip content={_('scheduleCopyId', { id: schedule.id.slice(4, 8) })}>
|
||||
<CopyToClipboard text={schedule.id}>
|
||||
<Button size='small'>
|
||||
<Icon icon='clipboard' />
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
</Li>
|
||||
<Li>
|
||||
<StateButton
|
||||
disabledLabel={_('stateDisabled')}
|
||||
disabledHandler={enableSchedule}
|
||||
disabledTooltip={_('logIndicationToEnable')}
|
||||
enabledLabel={_('stateEnabled')}
|
||||
enabledHandler={disableSchedule}
|
||||
enabledTooltip={_('logIndicationToDisable')}
|
||||
handlerParam={schedule.id}
|
||||
state={schedule.enabled}
|
||||
style={{ marginRight: '0.5em' }}
|
||||
/>
|
||||
{job.runId !== undefined ? (
|
||||
<ActionButton
|
||||
btnStyle='danger'
|
||||
handler={cancelJob}
|
||||
handlerParam={job}
|
||||
icon='cancel'
|
||||
key='cancel'
|
||||
size='small'
|
||||
tooltip={_('formCancel')}
|
||||
/>
|
||||
) : (
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
data-id={job.id}
|
||||
data-name={job.name}
|
||||
data-schedule={schedule.id}
|
||||
data-type={job.type}
|
||||
handler={_runBackupJob}
|
||||
icon='run-schedule'
|
||||
key='run'
|
||||
size='small'
|
||||
/>
|
||||
)}{' '}
|
||||
{lastRunLog !== undefined && (
|
||||
<LogStatus log={lastRunLog} tooltip={_('scheduleLastRun')} />
|
||||
)}
|
||||
</Li>
|
||||
</Ul>
|
||||
),
|
||||
])
|
||||
|
||||
const MODES = [
|
||||
{
|
||||
label: 'rollingSnapshot',
|
||||
test: job =>
|
||||
some(job.settings, ({ snapshotRetention }) => snapshotRetention > 0),
|
||||
},
|
||||
{
|
||||
label: 'backup',
|
||||
test: job =>
|
||||
job.mode === 'full' && !isEmpty(get(() => destructPattern(job.remotes))),
|
||||
},
|
||||
{
|
||||
label: 'deltaBackup',
|
||||
test: job =>
|
||||
job.mode === 'delta' && !isEmpty(get(() => destructPattern(job.remotes))),
|
||||
},
|
||||
{
|
||||
label: 'continuousReplication',
|
||||
test: job =>
|
||||
job.mode === 'delta' && !isEmpty(get(() => destructPattern(job.srs))),
|
||||
},
|
||||
{
|
||||
label: 'disasterRecovery',
|
||||
test: job =>
|
||||
job.mode === 'full' && !isEmpty(get(() => destructPattern(job.srs))),
|
||||
},
|
||||
{
|
||||
label: 'poolMetadata',
|
||||
test: job => !isEmpty(destructPattern(job.pools)),
|
||||
},
|
||||
{
|
||||
label: 'xoConfig',
|
||||
test: job => job.xoMetadata,
|
||||
},
|
||||
]
|
||||
|
||||
@addSubscriptions({
|
||||
jobs: subscribeBackupNgJobs,
|
||||
metadataJobs: subscribeMetadataBackupJobs,
|
||||
schedulesByJob: cb =>
|
||||
subscribeSchedules(schedules => {
|
||||
cb(groupBy(schedules, 'jobId'))
|
||||
}),
|
||||
})
|
||||
class JobsTable extends React.Component {
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
}
|
||||
|
||||
static tableProps = {
|
||||
actions: [
|
||||
{
|
||||
handler: _deleteBackupJobs,
|
||||
label: _('deleteBackupSchedule'),
|
||||
icon: 'delete',
|
||||
level: 'danger',
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{
|
||||
itemRenderer: ({ id }) => (
|
||||
<Copiable data={id} tagName='p'>
|
||||
{id.slice(4, 8)}
|
||||
</Copiable>
|
||||
),
|
||||
name: _('jobId'),
|
||||
},
|
||||
{
|
||||
valuePath: 'name',
|
||||
name: _('jobName'),
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
itemRenderer: job => (
|
||||
<Ul>
|
||||
{MODES.filter(({ test }) => test(job)).map(({ label }) => (
|
||||
<Li key={label}>{_(label)}</Li>
|
||||
))}
|
||||
</Ul>
|
||||
),
|
||||
sortCriteria: 'mode',
|
||||
name: _('jobModes'),
|
||||
},
|
||||
{
|
||||
itemRenderer: (job, { schedulesByJob }) =>
|
||||
map(get(() => schedulesByJob[job.id]), schedule => (
|
||||
<SchedulePreviewBody
|
||||
job={job}
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
/>
|
||||
)),
|
||||
name: _('jobSchedules'),
|
||||
},
|
||||
{
|
||||
itemRenderer: job => {
|
||||
const {
|
||||
compression,
|
||||
concurrency,
|
||||
fullInterval,
|
||||
offlineSnapshot,
|
||||
reportWhen,
|
||||
timeout,
|
||||
} = getSettingsWithNonDefaultValue(job.mode, {
|
||||
compression: job.compression,
|
||||
...job.settings[''],
|
||||
})
|
||||
|
||||
return (
|
||||
<Ul>
|
||||
{reportWhen !== undefined && (
|
||||
<Li>{_.keyValue(_('reportWhen'), reportWhen)}</Li>
|
||||
)}
|
||||
{concurrency !== undefined && (
|
||||
<Li>{_.keyValue(_('concurrency'), concurrency)}</Li>
|
||||
)}
|
||||
{timeout !== undefined && (
|
||||
<Li>{_.keyValue(_('timeout'), timeout / 3600e3)} hours</Li>
|
||||
)}
|
||||
{fullInterval !== undefined && (
|
||||
<Li>{_.keyValue(_('fullBackupInterval'), fullInterval)}</Li>
|
||||
)}
|
||||
{offlineSnapshot !== undefined && (
|
||||
<Li>
|
||||
{_.keyValue(
|
||||
_('offlineSnapshot'),
|
||||
_(offlineSnapshot ? 'stateEnabled' : 'stateDisabled')
|
||||
)}
|
||||
</Li>
|
||||
)}
|
||||
{compression !== undefined && (
|
||||
<Li>
|
||||
{_.keyValue(
|
||||
_('compression'),
|
||||
compression === 'native' ? 'GZIP' : compression
|
||||
)}
|
||||
</Li>
|
||||
)}
|
||||
</Ul>
|
||||
)
|
||||
},
|
||||
name: _('formNotes'),
|
||||
},
|
||||
],
|
||||
individualActions: [
|
||||
{
|
||||
handler: (job, { goTo }) =>
|
||||
goTo({
|
||||
pathname: '/home',
|
||||
query: { t: 'VM', s: constructQueryString(job.vms) },
|
||||
}),
|
||||
disabled: job => job.type !== 'backup',
|
||||
label: _('redirectToMatchingVms'),
|
||||
icon: 'preview',
|
||||
},
|
||||
{
|
||||
handler: (job, { goTo }) => goTo(`/backup-ng/${job.id}/edit`),
|
||||
label: _('formEdit'),
|
||||
icon: 'edit',
|
||||
level: 'primary',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
_goTo = path => {
|
||||
this.context.router.push(path)
|
||||
}
|
||||
|
||||
_getCollection = createSelector(
|
||||
() => this.props.jobs,
|
||||
() => this.props.metadataJobs,
|
||||
(jobs = [], metadataJobs = []) => [...jobs, ...metadataJobs]
|
||||
)
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SortedTable
|
||||
{...JobsTable.tableProps}
|
||||
collection={this._getCollection()}
|
||||
data-goTo={this._goTo}
|
||||
data-schedulesByJob={this.props.schedulesByJob}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const Overview = () => (
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Icon icon='backup' /> {_('backupJobs')}
|
||||
</CardHeader>
|
||||
<CardBlock>
|
||||
<JobsTable />
|
||||
</CardBlock>
|
||||
</Card>
|
||||
<LogsTable />
|
||||
</div>
|
||||
)
|
||||
|
||||
const HealthNavTab = decorate([
|
||||
addSubscriptions({
|
||||
// used by createGetLoneSnapshots
|
||||
schedules: subscribeSchedules,
|
||||
}),
|
||||
connectStore({
|
||||
nLoneSnapshots: createGetLoneSnapshots.count(),
|
||||
}),
|
||||
({ nLoneSnapshots }) => (
|
||||
<NavLink to='/backup-ng/health'>
|
||||
<Icon icon='menu-dashboard-health' /> {_('overviewHealthDashboardPage')}{' '}
|
||||
{nLoneSnapshots > 0 && (
|
||||
<Tooltip content={_('loneSnapshotsMessages', { nLoneSnapshots })}>
|
||||
<span className='tag tag-pill tag-warning'>{nLoneSnapshots}</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</NavLink>
|
||||
),
|
||||
])
|
||||
|
||||
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/overview'>
|
||||
<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>
|
||||
<HealthNavTab />
|
||||
</NavTabs>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
|
||||
const ChooseBackupType = () => (
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
<Card>
|
||||
<CardHeader>{_('backupType')}</CardHeader>
|
||||
<CardBlock className='text-md-center'>
|
||||
<ButtonLink to='backup-ng/new/vms'>
|
||||
<Icon icon='backup' /> {_('backupVms')}
|
||||
</ButtonLink>{' '}
|
||||
<ButtonLink to='backup-ng/new/metadata'>
|
||||
<Icon icon='database' /> {_('backupMetadata')}
|
||||
</ButtonLink>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
|
||||
export default routes('overview', {
|
||||
':id/edit': Edit,
|
||||
new: ChooseBackupType,
|
||||
'new/vms': NewVmBackup,
|
||||
'new/metadata': NewMetadataBackup,
|
||||
overview: Overview,
|
||||
restore: Restore,
|
||||
'restore/metadata': RestoreMetadata,
|
||||
'file-restore': FileRestore,
|
||||
health: Health,
|
||||
})(
|
||||
adminOnly(({ children }) => (
|
||||
<Page header={HEADER} title='backupPage' formatTitle>
|
||||
{children}
|
||||
</Page>
|
||||
))
|
||||
)
|
||||
1071
packages/xo-web/src/xo-app/backup-ng/new/index.js
Normal file
1071
packages/xo-web/src/xo-app/backup-ng/new/index.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -437,7 +437,7 @@ export default decorate([
|
||||
handler={submitHandler}
|
||||
icon='save'
|
||||
redirectOnSuccess={
|
||||
state.isJobInvalid ? undefined : '/backup'
|
||||
state.isJobInvalid ? undefined : '/backup-ng'
|
||||
}
|
||||
size='large'
|
||||
>
|
||||
@@ -272,9 +272,7 @@ export default class Restore extends Component {
|
||||
return (
|
||||
<Upgrade place='restoreBackup' available={2}>
|
||||
<div>
|
||||
<RestoreLegacy />
|
||||
<div className='mt-1 mb-1'>
|
||||
<h3>{_('restore')}</h3>
|
||||
<div className='mb-1'>
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
handler={this._refreshBackupList}
|
||||
@@ -282,7 +280,7 @@ export default class Restore extends Component {
|
||||
>
|
||||
{_('restoreResfreshList')}
|
||||
</ActionButton>{' '}
|
||||
<ButtonLink to='backup/restore/metadata'>
|
||||
<ButtonLink to='backup-ng/restore/metadata'>
|
||||
<Icon icon='database' /> {_('metadata')}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
@@ -293,6 +291,7 @@ export default class Restore extends Component {
|
||||
/>
|
||||
<br />
|
||||
<Logs />
|
||||
<RestoreLegacy />
|
||||
</div>
|
||||
</Upgrade>
|
||||
)
|
||||
@@ -261,7 +261,7 @@ export default decorate([
|
||||
<Upgrade place='restoreMetadataBackup' available={3}>
|
||||
<div>
|
||||
<div className='mb-1'>
|
||||
<ButtonLink to='backup/restore'>
|
||||
<ButtonLink to='backup-ng/restore'>
|
||||
<Icon icon='backup' /> {_('vms')}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
32
packages/xo-web/src/xo-app/backup/edit/index.js
Normal file
32
packages/xo-web/src/xo-app/backup/edit/index.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import _ from 'intl'
|
||||
import Component from 'base-component'
|
||||
import React from 'react'
|
||||
import { getJob, getSchedule } from 'xo'
|
||||
|
||||
import New from '../new'
|
||||
|
||||
export default class Edit extends Component {
|
||||
componentWillMount() {
|
||||
const { id } = this.props.routeParams
|
||||
|
||||
if (id == null) {
|
||||
return
|
||||
}
|
||||
|
||||
getSchedule(id).then(schedule => {
|
||||
getJob(schedule.jobId).then(job => {
|
||||
this.setState({ job, schedule })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const { job, schedule } = this.state
|
||||
|
||||
if (!job || !schedule) {
|
||||
return <h1>{_('statusLoading')}</h1>
|
||||
}
|
||||
|
||||
return <New job={job} schedule={schedule} />
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,37 @@
|
||||
import _ from 'intl'
|
||||
import addSubscriptions from 'add-subscriptions'
|
||||
import ButtonLink from 'button-link'
|
||||
import decorate from 'apply-decorators'
|
||||
import Icon from 'icon'
|
||||
import React from 'react'
|
||||
import Tooltip from 'tooltip'
|
||||
import { adminOnly, connectStore, routes } from 'utils'
|
||||
import { Card, CardHeader, CardBlock } from 'card'
|
||||
import { Container, Row, Col } from 'grid'
|
||||
import { createGetLoneSnapshots } from 'selectors'
|
||||
import { NavLink, NavTabs } from 'nav'
|
||||
import { subscribeSchedules } from 'xo'
|
||||
|
||||
import Edit from './edit'
|
||||
import FileRestore from './file-restore'
|
||||
import Health from './health'
|
||||
import NewVmBackup, { NewMetadataBackup } from './new'
|
||||
import Overview from './overview'
|
||||
import Restore, { RestoreMetadata } from './restore'
|
||||
|
||||
import Link from 'link'
|
||||
import Page from '../page'
|
||||
import React from 'react'
|
||||
import { adminOnly, routes } from 'utils'
|
||||
import { Container, Row, Col } from 'grid'
|
||||
import { NavLink, NavTabs } from 'nav'
|
||||
|
||||
const HealthNavTab = decorate([
|
||||
addSubscriptions({
|
||||
// used by createGetLoneSnapshots
|
||||
schedules: subscribeSchedules,
|
||||
}),
|
||||
connectStore({
|
||||
nLoneSnapshots: createGetLoneSnapshots.count(),
|
||||
}),
|
||||
({ nLoneSnapshots }) => (
|
||||
<NavLink to='/backup/health'>
|
||||
<Icon icon='menu-dashboard-health' /> {_('overviewHealthDashboardPage')}{' '}
|
||||
{nLoneSnapshots > 0 && (
|
||||
<Tooltip content={_('loneSnapshotsMessages', { nLoneSnapshots })}>
|
||||
<span className='tag tag-pill tag-warning'>{nLoneSnapshots}</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</NavLink>
|
||||
),
|
||||
])
|
||||
import New from './new'
|
||||
import Edit from './edit'
|
||||
import Overview from './overview'
|
||||
|
||||
const DeprecatedMsg = () => (
|
||||
<div className='alert alert-warning'>
|
||||
{_('backupDeprecatedMessage')}
|
||||
<br />
|
||||
<Link to='/backup-ng/new'>{_('backupNgNewPage')}</Link>
|
||||
</div>
|
||||
)
|
||||
|
||||
const DEVELOPMENT = process.env.NODE_ENV === 'development'
|
||||
|
||||
const MovingRestoreMessage = () => (
|
||||
<div className='alert alert-warning'>
|
||||
<Link to='/backup-ng/restore'>{_('moveRestoreLegacyMessage')}</Link>
|
||||
</div>
|
||||
)
|
||||
|
||||
const MovingFileRestoreMessage = () => (
|
||||
<div className='alert alert-warning'>
|
||||
<Link to='/backup-ng/file-restore'>{_('moveRestoreLegacyMessage')}</Link>
|
||||
</div>
|
||||
)
|
||||
|
||||
const HEADER = (
|
||||
<Container>
|
||||
@@ -64,43 +56,18 @@ const HEADER = (
|
||||
<Icon icon='menu-backup-file-restore' />{' '}
|
||||
{_('backupFileRestorePage')}
|
||||
</NavLink>
|
||||
<HealthNavTab />
|
||||
</NavTabs>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
|
||||
const ChooseBackupType = () => (
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
<Card>
|
||||
<CardHeader>{_('backupType')}</CardHeader>
|
||||
<CardBlock className='text-md-center'>
|
||||
<ButtonLink to='backup/new/vms'>
|
||||
<Icon icon='backup' /> {_('backupVms')}
|
||||
</ButtonLink>{' '}
|
||||
<ButtonLink to='backup/new/metadata'>
|
||||
<Icon icon='database' /> {_('backupMetadata')}
|
||||
</ButtonLink>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
|
||||
export default routes('overview', {
|
||||
const Backup = routes('overview', {
|
||||
':id/edit': Edit,
|
||||
new: ChooseBackupType,
|
||||
'new/vms': NewVmBackup,
|
||||
'new/metadata': NewMetadataBackup,
|
||||
new: DEVELOPMENT ? New : DeprecatedMsg,
|
||||
overview: Overview,
|
||||
restore: Restore,
|
||||
'restore/metadata': RestoreMetadata,
|
||||
'file-restore': FileRestore,
|
||||
health: Health,
|
||||
restore: MovingRestoreMessage,
|
||||
'file-restore': MovingFileRestoreMessage,
|
||||
})(
|
||||
adminOnly(({ children }) => (
|
||||
<Page header={HEADER} title='backupPage' formatTitle>
|
||||
@@ -108,3 +75,5 @@ export default routes('overview', {
|
||||
</Page>
|
||||
))
|
||||
)
|
||||
|
||||
export default Backup
|
||||
|
||||
@@ -1,698 +0,0 @@
|
||||
import _ from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import Button from 'button'
|
||||
import Component from 'base-component'
|
||||
import GenericInput from 'json-schema-input'
|
||||
import getEventValue from 'get-event-value'
|
||||
import Icon from 'icon'
|
||||
import moment from 'moment-timezone'
|
||||
import React from 'react'
|
||||
import Scheduler, { SchedulePreview } from 'scheduling'
|
||||
import SmartBackupPreview, {
|
||||
constructPattern,
|
||||
destructPattern,
|
||||
} from 'smart-backup'
|
||||
import uncontrollableInput from 'uncontrollable-input'
|
||||
import Wizard, { Section } from 'wizard'
|
||||
import { confirm } from 'modal'
|
||||
import { connectStore, EMPTY_OBJECT } from 'utils'
|
||||
import { Container, Row, Col } from 'grid'
|
||||
import { createGetObjectsOfType, getUser } from 'selectors'
|
||||
import { createJob, createSchedule, getRemote } from 'xo'
|
||||
import { createSelector } from 'reselect'
|
||||
import { forEach, isArray, map, mapValues, noop } from 'lodash'
|
||||
import { generateUiSchema } from 'xo-json-schema-input'
|
||||
import { SelectSubject } from 'select-objects'
|
||||
|
||||
// ===================================================================
|
||||
// FIXME: missing most of translation. Can't be done in a dumb way, some of the word are keyword for XO-Server parameters...
|
||||
|
||||
const NO_SMART_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
vms: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
'xo:type': 'vm',
|
||||
},
|
||||
title: _('editBackupVmsTitle'),
|
||||
description: 'Choose VMs to backup.', // FIXME: can't translate
|
||||
},
|
||||
},
|
||||
required: ['vms'],
|
||||
}
|
||||
const NO_SMART_UI_SCHEMA = generateUiSchema(NO_SMART_SCHEMA)
|
||||
|
||||
const SMART_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
power_state: {
|
||||
default: 'All', // FIXME: can't translate
|
||||
enum: ['All', 'Running', 'Halted'], // FIXME: can't translate
|
||||
title: _('editBackupSmartStatusTitle'),
|
||||
description: 'The statuses of VMs to backup.', // FIXME: can't translate
|
||||
},
|
||||
$pool: {
|
||||
type: 'object',
|
||||
title: _('editBackupSmartPools'),
|
||||
properties: {
|
||||
not: {
|
||||
type: 'boolean',
|
||||
title: _('editBackupNot'),
|
||||
description:
|
||||
'Toggle on to backup VMs that are NOT resident on these pools',
|
||||
},
|
||||
values: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
'xo:type': 'pool',
|
||||
},
|
||||
title: _('editBackupSmartResidentOn'),
|
||||
description: 'Not used if empty.', // FIXME: can't translate
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
type: 'object',
|
||||
title: _('editBackupSmartTags'),
|
||||
properties: {
|
||||
not: {
|
||||
type: 'boolean',
|
||||
title: _('editBackupNot'),
|
||||
description: 'Toggle on to backup VMs that do NOT contain these tags',
|
||||
},
|
||||
values: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
'xo:type': 'tag',
|
||||
},
|
||||
title: _('editBackupSmartTagsTitle'),
|
||||
description:
|
||||
'VMs which contain at least one of these tags. Not used if empty.', // FIXME: can't translate
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['power_state', '$pool', 'tags'],
|
||||
}
|
||||
const SMART_UI_SCHEMA = generateUiSchema(SMART_SCHEMA)
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const COMMON_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tag: {
|
||||
type: 'string',
|
||||
title: _('editBackupTagTitle'),
|
||||
description: 'Back-up tag.', // FIXME: can't translate
|
||||
},
|
||||
_reportWhen: {
|
||||
default: 'failure',
|
||||
enum: ['never', 'always', 'failure'],
|
||||
enumNames: ['never', 'always', 'failure or skipped'], // FIXME: can't translate
|
||||
title: _('editBackupReportTitle'),
|
||||
description: [
|
||||
'When to send reports.',
|
||||
'',
|
||||
'Plugins *tranport-email* and *backup-reports* need to be configured.',
|
||||
].join('\n'),
|
||||
},
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
title: _('editBackupScheduleEnabled'),
|
||||
},
|
||||
},
|
||||
required: ['tag', 'vms', '_reportWhen'],
|
||||
}
|
||||
|
||||
const RETENTION_PROPERTY = {
|
||||
type: 'integer',
|
||||
title: _('editBackupRetentionTitle'),
|
||||
description: 'How many backups to rollover.', // FIXME: can't translate
|
||||
min: 1,
|
||||
}
|
||||
|
||||
const REMOTE_PROPERTY = {
|
||||
type: 'string',
|
||||
'xo:type': 'remote',
|
||||
title: _('editBackupRemoteTitle'),
|
||||
}
|
||||
|
||||
const BACKUP_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...COMMON_SCHEMA.properties,
|
||||
retention: RETENTION_PROPERTY,
|
||||
remoteId: REMOTE_PROPERTY,
|
||||
compress: {
|
||||
type: 'boolean',
|
||||
title: 'Enable compression',
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
required: COMMON_SCHEMA.required.concat(['retention', 'remoteId']),
|
||||
}
|
||||
|
||||
const ROLLING_SNAPSHOT_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...COMMON_SCHEMA.properties,
|
||||
retention: RETENTION_PROPERTY,
|
||||
},
|
||||
required: COMMON_SCHEMA.required.concat('retention'),
|
||||
}
|
||||
|
||||
const DELTA_BACKUP_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...COMMON_SCHEMA.properties,
|
||||
retention: RETENTION_PROPERTY,
|
||||
remote: REMOTE_PROPERTY,
|
||||
},
|
||||
required: COMMON_SCHEMA.required.concat(['retention', 'remote']),
|
||||
}
|
||||
|
||||
const DISASTER_RECOVERY_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...COMMON_SCHEMA.properties,
|
||||
retention: RETENTION_PROPERTY,
|
||||
deleteOldBackupsFirst: {
|
||||
type: 'boolean',
|
||||
title: _('deleteOldBackupsFirst'),
|
||||
description: [
|
||||
'Delete the old backups before copy the vms.',
|
||||
'',
|
||||
'If the backup fails, you will lose your old backups.',
|
||||
].join('\n'),
|
||||
},
|
||||
sr: {
|
||||
type: 'string',
|
||||
'xo:type': 'sr',
|
||||
title: 'To SR',
|
||||
},
|
||||
},
|
||||
required: COMMON_SCHEMA.required.concat(['retention', 'sr']),
|
||||
}
|
||||
|
||||
const CONTINUOUS_REPLICATION_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...COMMON_SCHEMA.properties,
|
||||
retention: RETENTION_PROPERTY,
|
||||
sr: {
|
||||
type: 'string',
|
||||
'xo:type': 'sr',
|
||||
title: 'To SR',
|
||||
},
|
||||
},
|
||||
required: COMMON_SCHEMA.required.concat('sr'),
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const BACKUP_METHOD_TO_INFO = {
|
||||
'vm.rollingBackup': {
|
||||
schema: BACKUP_SCHEMA,
|
||||
uiSchema: generateUiSchema(BACKUP_SCHEMA),
|
||||
label: 'backup',
|
||||
icon: 'backup',
|
||||
jobKey: 'rollingBackup',
|
||||
method: 'vm.rollingBackup',
|
||||
},
|
||||
'vm.rollingSnapshot': {
|
||||
schema: ROLLING_SNAPSHOT_SCHEMA,
|
||||
uiSchema: generateUiSchema(ROLLING_SNAPSHOT_SCHEMA),
|
||||
label: 'rollingSnapshot',
|
||||
icon: 'rolling-snapshot',
|
||||
jobKey: 'rollingSnapshot',
|
||||
method: 'vm.rollingSnapshot',
|
||||
},
|
||||
'vm.rollingDeltaBackup': {
|
||||
schema: DELTA_BACKUP_SCHEMA,
|
||||
uiSchema: generateUiSchema(DELTA_BACKUP_SCHEMA),
|
||||
label: 'deltaBackup',
|
||||
icon: 'delta-backup',
|
||||
jobKey: 'deltaBackup',
|
||||
method: 'vm.rollingDeltaBackup',
|
||||
},
|
||||
'vm.rollingDrCopy': {
|
||||
schema: DISASTER_RECOVERY_SCHEMA,
|
||||
uiSchema: generateUiSchema(DISASTER_RECOVERY_SCHEMA),
|
||||
label: 'disasterRecovery',
|
||||
icon: 'disaster-recovery',
|
||||
jobKey: 'disasterRecovery',
|
||||
method: 'vm.rollingDrCopy',
|
||||
},
|
||||
'vm.deltaCopy': {
|
||||
schema: CONTINUOUS_REPLICATION_SCHEMA,
|
||||
uiSchema: generateUiSchema(CONTINUOUS_REPLICATION_SCHEMA),
|
||||
label: 'continuousReplication',
|
||||
icon: 'continuous-replication',
|
||||
jobKey: 'continuousReplication',
|
||||
method: 'vm.deltaCopy',
|
||||
},
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@uncontrollableInput()
|
||||
class TimeoutInput extends Component {
|
||||
_onChange = event => {
|
||||
const value = getEventValue(event).trim()
|
||||
this.props.onChange(value === '' ? null : +value * 1e3)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this
|
||||
const { value } = props
|
||||
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
onChange={this._onChange}
|
||||
min='1'
|
||||
type='number'
|
||||
value={value == null ? '' : String(value / 1e3)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const DEFAULT_CRON_PATTERN = '0 0 * * *'
|
||||
const DEFAULT_TIMEZONE = moment.tz.guess()
|
||||
const DEVELOPMENT = process.env.NODE_ENV === 'development'
|
||||
|
||||
// xo-web v5.7.1 introduced a bug where an extra level
|
||||
// ({ id: { id: <id> } }) was introduced for the VM param.
|
||||
//
|
||||
// This code automatically unbox the ids.
|
||||
const extractId = value => {
|
||||
while (typeof value === 'object') {
|
||||
value = value.id
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const normalizeMainParams = params => {
|
||||
if (!('retention' in params)) {
|
||||
const { depth, ...rest } = params
|
||||
if (depth != null) {
|
||||
params = rest
|
||||
params.retention = depth
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
@connectStore({
|
||||
currentUser: getUser,
|
||||
vms: createGetObjectsOfType('VM'),
|
||||
})
|
||||
export default class NewLegacyBackup extends Component {
|
||||
_getParams = createSelector(
|
||||
() => this.props.job,
|
||||
() => this.props.schedule,
|
||||
(job, schedule) => {
|
||||
if (!job) {
|
||||
return { main: {}, vms: { vms: [] } }
|
||||
}
|
||||
|
||||
const { items } = job.paramsVector
|
||||
const enabled = schedule != null && schedule.enabled
|
||||
|
||||
// legacy backup jobs
|
||||
if (items.length === 1) {
|
||||
return {
|
||||
main: normalizeMainParams({
|
||||
enabled,
|
||||
...items[0].values[0],
|
||||
}),
|
||||
vms: { vms: map(items[0].values.slice(1), extractId) },
|
||||
}
|
||||
}
|
||||
|
||||
// smart backup
|
||||
if (items[1].type === 'map') {
|
||||
const { pattern } = items[1].collection
|
||||
const { $pool, tags } = pattern
|
||||
|
||||
return {
|
||||
main: normalizeMainParams({
|
||||
enabled,
|
||||
...items[0].values[0],
|
||||
}),
|
||||
vms: {
|
||||
$pool: destructPattern($pool),
|
||||
power_state: pattern.power_state,
|
||||
tags: destructPattern(tags, tags =>
|
||||
map(tags, tag => (isArray(tag) ? tag[0] : tag))
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// normal backup
|
||||
return {
|
||||
main: normalizeMainParams({
|
||||
enabled,
|
||||
...items[1].values[0],
|
||||
}),
|
||||
vms: { vms: map(items[0].values, extractId) },
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
_constructPattern = vms => ({
|
||||
$pool: constructPattern(vms.$pool),
|
||||
power_state: vms.power_state === 'All' ? undefined : vms.power_state,
|
||||
tags: constructPattern(vms.tags, tags => map(tags, tag => [tag])),
|
||||
type: 'VM',
|
||||
})
|
||||
|
||||
_getMainParams = () => this.state.mainParams || this._getParams().main
|
||||
_getVmsParam = () => this.state.vmsParam || this._getParams().vms
|
||||
|
||||
_getScheduling = createSelector(
|
||||
() => this.props.schedule,
|
||||
() => this.state.scheduling,
|
||||
(schedule, scheduling) => {
|
||||
if (scheduling !== undefined) {
|
||||
return scheduling
|
||||
}
|
||||
|
||||
const { cron = DEFAULT_CRON_PATTERN, timezone = DEFAULT_TIMEZONE } =
|
||||
schedule || EMPTY_OBJECT
|
||||
|
||||
return {
|
||||
cronPattern: cron,
|
||||
timezone,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
_handleSubmit = async () => {
|
||||
const method = this._getValue('job', 'method')
|
||||
const backupInfo = BACKUP_METHOD_TO_INFO[method]
|
||||
|
||||
const { enabled, ...mainParams } = this._getMainParams()
|
||||
const vms = this._getVmsParam()
|
||||
|
||||
const job = {
|
||||
...this.state.job,
|
||||
|
||||
type: 'call',
|
||||
key: backupInfo.jobKey,
|
||||
paramsVector: {
|
||||
type: 'crossProduct',
|
||||
items: isArray(vms.vms)
|
||||
? [
|
||||
{
|
||||
type: 'set',
|
||||
values: map(vms.vms, vm => ({ id: extractId(vm) })),
|
||||
},
|
||||
{
|
||||
type: 'set',
|
||||
values: [mainParams],
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: 'set',
|
||||
values: [mainParams],
|
||||
},
|
||||
{
|
||||
type: 'map',
|
||||
collection: {
|
||||
type: 'fetchObjects',
|
||||
pattern: this._constructPattern(vms),
|
||||
},
|
||||
iteratee: {
|
||||
type: 'extractProperties',
|
||||
mapping: { id: 'id' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const scheduling = this._getScheduling()
|
||||
|
||||
let remoteId
|
||||
if (job.type === 'call') {
|
||||
const { paramsVector } = job
|
||||
if (paramsVector.type === 'crossProduct') {
|
||||
const { items } = paramsVector
|
||||
forEach(items, item => {
|
||||
if (item.type === 'set') {
|
||||
forEach(item.values, value => {
|
||||
if (value.remoteId) {
|
||||
remoteId = value.remoteId
|
||||
return false
|
||||
}
|
||||
})
|
||||
if (remoteId) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (remoteId) {
|
||||
const remote = await getRemote(remoteId)
|
||||
if (remote.url.startsWith('file:')) {
|
||||
await confirm({
|
||||
title: _('localRemoteWarningTitle'),
|
||||
body: _('localRemoteWarningMessage'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (job.timeout === null) {
|
||||
delete job.timeout // only needed for job edition
|
||||
}
|
||||
|
||||
// Create backup schedule.
|
||||
return createSchedule(await createJob(job), {
|
||||
cron: scheduling.cronPattern,
|
||||
enabled,
|
||||
timezone: scheduling.timezone,
|
||||
})
|
||||
}
|
||||
|
||||
_handleReset = () => {
|
||||
this.setState(mapValues(this.state, noop))
|
||||
}
|
||||
|
||||
_handleSmartBackupMode = event => {
|
||||
this.setState(
|
||||
event.target.value === 'smart'
|
||||
? { vmsParam: {} }
|
||||
: { vmsParam: { vms: [] } }
|
||||
)
|
||||
}
|
||||
|
||||
_subjectPredicate = ({ type, permission }) =>
|
||||
type === 'user' && permission === 'admin'
|
||||
|
||||
_getValue = (ns, key, defaultValue) => {
|
||||
let tmp
|
||||
|
||||
// look in the state
|
||||
if ((tmp = this.state[ns]) != null && (tmp = tmp[key]) !== undefined) {
|
||||
return tmp
|
||||
}
|
||||
|
||||
// look in the props
|
||||
if ((tmp = this.props[ns]) != null && (tmp = tmp[key]) !== undefined) {
|
||||
return tmp
|
||||
}
|
||||
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
render() {
|
||||
const method = this._getValue('job', 'method', '')
|
||||
const scheduling = this._getScheduling()
|
||||
const vms = this._getVmsParam()
|
||||
|
||||
const backupInfo = BACKUP_METHOD_TO_INFO[method]
|
||||
const smartBackupMode = !isArray(vms.vms)
|
||||
|
||||
return (
|
||||
DEVELOPMENT && (
|
||||
<form id='form-new-vm-backup'>
|
||||
<Wizard>
|
||||
<Section icon='backup' title='newVmBackup'>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
<fieldset className='form-group'>
|
||||
<label>{_('backupOwner')}</label>
|
||||
<SelectSubject
|
||||
onChange={this.linkState('job.userId', 'id')}
|
||||
predicate={this._subjectPredicate}
|
||||
required
|
||||
value={this._getValue(
|
||||
'job',
|
||||
'userId',
|
||||
this.props.currentUser.id
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className='form-group'>
|
||||
<label>{_('jobTimeoutPlaceHolder')}</label>
|
||||
<TimeoutInput
|
||||
className='form-control'
|
||||
onChange={this.linkState('job.timeout')}
|
||||
value={this._getValue('job', 'timeout')}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className='form-group'>
|
||||
<label htmlFor='selectBackup'>
|
||||
{_('newBackupSelection')}
|
||||
</label>
|
||||
<select
|
||||
className='form-control'
|
||||
id='selectBackup'
|
||||
onChange={this.linkState('job.method')}
|
||||
required
|
||||
value={method}
|
||||
>
|
||||
{_('noSelectedValue', message => (
|
||||
<option value=''>{message}</option>
|
||||
))}
|
||||
{map(BACKUP_METHOD_TO_INFO, (info, key) =>
|
||||
_({ key }, info.label, message => (
|
||||
<option value={key}>{message}</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</fieldset>
|
||||
{(method === 'vm.rollingDeltaBackup' ||
|
||||
method === 'vm.deltaCopy') && (
|
||||
<div className='alert alert-warning' role='alert'>
|
||||
<Icon icon='error' /> {_('backupVersionWarning')}
|
||||
</div>
|
||||
)}
|
||||
{backupInfo && (
|
||||
<div>
|
||||
<GenericInput
|
||||
label={
|
||||
<span>
|
||||
<Icon icon={backupInfo.icon} />{' '}
|
||||
{_(backupInfo.label)}
|
||||
</span>
|
||||
}
|
||||
required
|
||||
schema={backupInfo.schema}
|
||||
uiSchema={backupInfo.uiSchema}
|
||||
onChange={this.linkState('mainParams')}
|
||||
value={this._getMainParams()}
|
||||
/>
|
||||
<fieldset className='form-group'>
|
||||
<label htmlFor='smartMode'>
|
||||
{_('smartBackupModeSelection')}
|
||||
</label>
|
||||
<select
|
||||
className='form-control'
|
||||
id='smartMode'
|
||||
onChange={this._handleSmartBackupMode}
|
||||
required
|
||||
value={smartBackupMode ? 'smart' : 'normal'}
|
||||
>
|
||||
{_('normalBackup', message => (
|
||||
<option value='normal'>{message}</option>
|
||||
))}
|
||||
{_('smartBackup', message => (
|
||||
<option value='smart'>{message}</option>
|
||||
))}
|
||||
</select>
|
||||
</fieldset>
|
||||
{smartBackupMode ? (
|
||||
<div>
|
||||
<GenericInput
|
||||
label={
|
||||
<span>
|
||||
<Icon icon='vm' /> {_('vmsToBackup')}
|
||||
</span>
|
||||
}
|
||||
onChange={this.linkState('vmsParam')}
|
||||
required
|
||||
schema={SMART_SCHEMA}
|
||||
uiSchema={SMART_UI_SCHEMA}
|
||||
value={vms}
|
||||
/>
|
||||
<SmartBackupPreview
|
||||
pattern={this._constructPattern(vms)}
|
||||
vms={this.props.vms}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<GenericInput
|
||||
label={
|
||||
<span>
|
||||
<Icon icon='vm' /> {_('vmsToBackup')}
|
||||
</span>
|
||||
}
|
||||
onChange={this.linkState('vmsParam')}
|
||||
required
|
||||
schema={NO_SMART_SCHEMA}
|
||||
uiSchema={NO_SMART_UI_SCHEMA}
|
||||
value={vms}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</Section>
|
||||
<Section icon='schedule' title='schedule'>
|
||||
<Scheduler
|
||||
onChange={this.linkState('scheduling')}
|
||||
value={scheduling}
|
||||
/>
|
||||
<SchedulePreview
|
||||
cronPattern={scheduling.cronPattern}
|
||||
timezone={scheduling.timezone}
|
||||
/>
|
||||
</Section>
|
||||
<Section title='action' summary>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
<fieldset className='pull-right pt-1'>
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
className='mr-1'
|
||||
disabled={!backupInfo}
|
||||
form='form-new-vm-backup'
|
||||
handler={this._handleSubmit}
|
||||
icon='save'
|
||||
redirectOnSuccess='/backup/overview'
|
||||
size='large'
|
||||
>
|
||||
{_('saveBackupJob')}
|
||||
</ActionButton>
|
||||
<Button onClick={this._handleReset} size='large'>
|
||||
{_('selectTableReset')}
|
||||
</Button>
|
||||
</fieldset>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</Section>
|
||||
</Wizard>
|
||||
</form>
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,250 +0,0 @@
|
||||
import _ from 'intl'
|
||||
import ActionRowButton from 'action-row-button'
|
||||
import ButtonGroup from 'button-group'
|
||||
import Component from 'base-component'
|
||||
import constructQueryString from 'construct-query-string'
|
||||
import Icon from 'icon'
|
||||
import LogList from '../../logs'
|
||||
import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
import SortedTable from 'sorted-table'
|
||||
import StateButton from 'state-button'
|
||||
import Tooltip from 'tooltip'
|
||||
import { confirm } from 'modal'
|
||||
import { addSubscriptions } from 'utils'
|
||||
import { createSelector } from 'selectors'
|
||||
import { Card, CardHeader, CardBlock } from 'card'
|
||||
import { filter, find, forEach, get, keyBy, map, orderBy } from 'lodash'
|
||||
import {
|
||||
deleteBackupSchedule,
|
||||
disableSchedule,
|
||||
enableSchedule,
|
||||
migrateBackupSchedule,
|
||||
runJob,
|
||||
subscribeJobs,
|
||||
subscribeSchedules,
|
||||
subscribeUsers,
|
||||
} from 'xo'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const jobKeyToLabel = {
|
||||
continuousReplication: _('continuousReplication'),
|
||||
deltaBackup: _('deltaBackup'),
|
||||
disasterRecovery: _('disasterRecovery'),
|
||||
rollingBackup: _('backup'),
|
||||
rollingSnapshot: _('rollingSnapshot'),
|
||||
}
|
||||
|
||||
const _runJob = ({ jobLabel, jobId, scheduleTag }) =>
|
||||
confirm({
|
||||
title: _('runJob'),
|
||||
body: _('runJobConfirm', {
|
||||
backupType: <strong>{jobLabel}</strong>,
|
||||
id: <strong>{jobId.slice(4, 8)}</strong>,
|
||||
tag: scheduleTag,
|
||||
}),
|
||||
}).then(() => runJob(jobId))
|
||||
|
||||
const JOB_COLUMNS = [
|
||||
{
|
||||
name: _('jobId'),
|
||||
itemRenderer: ({ jobId }) => jobId.slice(4, 8),
|
||||
sortCriteria: 'jobId',
|
||||
},
|
||||
{
|
||||
name: _('jobType'),
|
||||
itemRenderer: ({ jobLabel }) => jobLabel,
|
||||
sortCriteria: 'jobLabel',
|
||||
},
|
||||
{
|
||||
name: _('jobTag'),
|
||||
itemRenderer: ({ scheduleTag }) => scheduleTag,
|
||||
default: true,
|
||||
sortCriteria: ({ scheduleTag }) => scheduleTag,
|
||||
},
|
||||
{
|
||||
name: _('jobScheduling'),
|
||||
itemRenderer: ({ schedule }) => schedule.cron,
|
||||
sortCriteria: ({ schedule }) => schedule.cron,
|
||||
},
|
||||
{
|
||||
name: _('jobTimezone'),
|
||||
itemRenderer: ({ schedule }) => schedule.timezone || _('jobServerTimezone'),
|
||||
sortCriteria: ({ schedule }) => schedule.timezone,
|
||||
},
|
||||
{
|
||||
name: _('state'),
|
||||
itemRenderer: ({ schedule }) => (
|
||||
<StateButton
|
||||
disabledLabel={_('stateDisabled')}
|
||||
disabledHandler={enableSchedule}
|
||||
disabledTooltip={_('logIndicationToEnable')}
|
||||
enabledLabel={_('stateEnabled')}
|
||||
enabledHandler={disableSchedule}
|
||||
enabledTooltip={_('logIndicationToDisable')}
|
||||
handlerParam={schedule.id}
|
||||
state={schedule.enabled}
|
||||
/>
|
||||
),
|
||||
sortCriteria: 'schedule.enabled',
|
||||
},
|
||||
{
|
||||
name: _('jobAction'),
|
||||
itemRenderer: (item, { isScheduleUserMissing }) => {
|
||||
const { redirect, schedule } = item
|
||||
const { id } = schedule
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
{isScheduleUserMissing[id] && (
|
||||
<Tooltip content={_('backupUserNotFound')}>
|
||||
<Icon className='mr-1' icon='error' />
|
||||
</Tooltip>
|
||||
)}
|
||||
<ButtonGroup>
|
||||
{redirect && (
|
||||
<ActionRowButton
|
||||
btnStyle='primary'
|
||||
handler={redirect}
|
||||
icon='preview'
|
||||
tooltip={_('redirectToMatchingVms')}
|
||||
/>
|
||||
)}
|
||||
<ActionRowButton
|
||||
btnStyle='warning'
|
||||
disabled={isScheduleUserMissing[id]}
|
||||
handler={_runJob}
|
||||
handlerParam={item}
|
||||
icon='run-schedule'
|
||||
/>
|
||||
<ActionRowButton
|
||||
btnStyle='danger'
|
||||
handler={migrateBackupSchedule}
|
||||
handlerParam={schedule.jobId}
|
||||
icon='migrate-job'
|
||||
tooltip={_('migrateBackupSchedule')}
|
||||
/>
|
||||
<ActionRowButton
|
||||
btnStyle='danger'
|
||||
handler={deleteBackupSchedule}
|
||||
handlerParam={schedule}
|
||||
icon='delete'
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</fieldset>
|
||||
)
|
||||
},
|
||||
textAlign: 'right',
|
||||
},
|
||||
]
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@addSubscriptions({
|
||||
jobs: cb => subscribeJobs(jobs => cb(keyBy(jobs, 'id'))),
|
||||
schedules: cb => subscribeSchedules(schedules => cb(keyBy(schedules, 'id'))),
|
||||
users: subscribeUsers,
|
||||
})
|
||||
export default class LegacyOverview extends Component {
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
}
|
||||
|
||||
_getSchedules = createSelector(
|
||||
() => this.props.jobs,
|
||||
() => this.props.schedules,
|
||||
(jobs, schedules) =>
|
||||
jobs === undefined || schedules === undefined
|
||||
? []
|
||||
: orderBy(
|
||||
filter(schedules, schedule => {
|
||||
const job = jobs[schedule.jobId]
|
||||
return job && jobKeyToLabel[job.key]
|
||||
}),
|
||||
'id'
|
||||
)
|
||||
)
|
||||
|
||||
_redirectToMatchingVms = pattern => {
|
||||
this.context.router.push({
|
||||
pathname: '/home',
|
||||
query: { t: 'VM', s: constructQueryString(pattern) },
|
||||
})
|
||||
}
|
||||
|
||||
_getScheduleCollection = createSelector(
|
||||
this._getSchedules,
|
||||
() => this.props.jobs,
|
||||
(schedules, jobs) => {
|
||||
if (!schedules || !jobs) {
|
||||
return []
|
||||
}
|
||||
return map(schedules, schedule => {
|
||||
const job = jobs[schedule.jobId]
|
||||
const { items } = job.paramsVector
|
||||
const pattern = get(items, '[1].collection.pattern')
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
jobLabel: jobKeyToLabel[job.key] || _('unknownSchedule'),
|
||||
redirect:
|
||||
pattern !== undefined &&
|
||||
(() => this._redirectToMatchingVms(pattern)),
|
||||
// Old versions of XenOrchestra use items[0]
|
||||
scheduleTag:
|
||||
get(items, '[0].values[0].tag') ||
|
||||
get(items, '[1].values[0].tag') ||
|
||||
schedule.id,
|
||||
schedule,
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
_getIsScheduleUserMissing = createSelector(
|
||||
this._getSchedules,
|
||||
() => this.props.jobs,
|
||||
() => this.props.users,
|
||||
(schedules, jobs, users) => {
|
||||
const isScheduleUserMissing = {}
|
||||
forEach(schedules, schedule => {
|
||||
isScheduleUserMissing[schedule.id] = !(
|
||||
jobs && find(users, user => user.id === jobs[schedule.jobId].userId)
|
||||
)
|
||||
})
|
||||
|
||||
return isScheduleUserMissing
|
||||
}
|
||||
)
|
||||
|
||||
render() {
|
||||
const schedules = this._getScheduleCollection()
|
||||
|
||||
return (
|
||||
schedules.length !== 0 && (
|
||||
<div>
|
||||
<h3>Legacy backup</h3>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Icon icon='schedule' /> {_('backupSchedules')}
|
||||
</CardHeader>
|
||||
<CardBlock>
|
||||
<div className='alert alert-warning'>
|
||||
<a href='https://xen-orchestra.com/blog/migrate-backup-to-backup-ng/'>
|
||||
{_('backupMigrationLink')}
|
||||
</a>
|
||||
</div>
|
||||
<SortedTable
|
||||
columns={JOB_COLUMNS}
|
||||
collection={schedules}
|
||||
data-isScheduleUserMissing={this._getIsScheduleUserMissing()}
|
||||
/>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
<LogList jobKeys={Object.keys(jobKeyToLabel)} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,378 +1,273 @@
|
||||
import _ from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import addSubscriptions from 'add-subscriptions'
|
||||
import Button from 'button'
|
||||
import ActionRowButton from 'action-row-button'
|
||||
import ButtonGroup from 'button-group'
|
||||
import Component from 'base-component'
|
||||
import constructQueryString from 'construct-query-string'
|
||||
import Copiable from 'copiable'
|
||||
import CopyToClipboard from 'react-copy-to-clipboard'
|
||||
import decorate from 'apply-decorators'
|
||||
import Icon from 'icon'
|
||||
import Link from 'link'
|
||||
import LogList from '../../logs'
|
||||
import NoObjects from 'no-objects'
|
||||
import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
import SortedTable from 'sorted-table'
|
||||
import StateButton from 'state-button'
|
||||
import Tooltip from 'tooltip'
|
||||
import { Card, CardHeader, CardBlock } from 'card'
|
||||
import { confirm } from 'modal'
|
||||
import { addSubscriptions } from 'utils'
|
||||
import { createSelector } from 'selectors'
|
||||
import { get } from '@xen-orchestra/defined'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { isEmpty, map, groupBy, some } from 'lodash'
|
||||
import { Card, CardHeader, CardBlock } from 'card'
|
||||
import { filter, find, forEach, get, keyBy, map, orderBy } from 'lodash'
|
||||
import {
|
||||
cancelJob,
|
||||
deleteBackupJobs,
|
||||
deleteBackupSchedule,
|
||||
disableSchedule,
|
||||
enableSchedule,
|
||||
runBackupNgJob,
|
||||
runMetadataBackupJob,
|
||||
subscribeBackupNgJobs,
|
||||
subscribeBackupNgLogs,
|
||||
migrateBackupSchedule,
|
||||
runJob,
|
||||
subscribeJobs,
|
||||
subscribeMetadataBackupJobs,
|
||||
subscribeSchedules,
|
||||
subscribeUsers,
|
||||
} from 'xo'
|
||||
|
||||
import getSettingsWithNonDefaultValue from '../_getSettingsWithNonDefaultValue'
|
||||
import { destructPattern } from '../utils'
|
||||
import LogsTable, { LogStatus } from '../../logs/backup-ng'
|
||||
import LegacyOverview from '../overview-legacy'
|
||||
// ===================================================================
|
||||
|
||||
const Ul = props => <ul {...props} style={{ listStyleType: 'none' }} />
|
||||
const Li = props => (
|
||||
<li
|
||||
{...props}
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
const jobKeyToLabel = {
|
||||
continuousReplication: _('continuousReplication'),
|
||||
deltaBackup: _('deltaBackup'),
|
||||
disasterRecovery: _('disasterRecovery'),
|
||||
rollingBackup: _('backup'),
|
||||
rollingSnapshot: _('rollingSnapshot'),
|
||||
}
|
||||
|
||||
const MODES = [
|
||||
const _runJob = ({ jobLabel, jobId, scheduleTag }) =>
|
||||
confirm({
|
||||
title: _('runJob'),
|
||||
body: _('runJobConfirm', {
|
||||
backupType: <strong>{jobLabel}</strong>,
|
||||
id: <strong>{jobId.slice(4, 8)}</strong>,
|
||||
tag: scheduleTag,
|
||||
}),
|
||||
}).then(() => runJob(jobId))
|
||||
|
||||
const JOB_COLUMNS = [
|
||||
{
|
||||
label: 'rollingSnapshot',
|
||||
test: job =>
|
||||
some(job.settings, ({ snapshotRetention }) => snapshotRetention > 0),
|
||||
name: _('jobId'),
|
||||
itemRenderer: ({ jobId }) => jobId.slice(4, 8),
|
||||
sortCriteria: 'jobId',
|
||||
},
|
||||
{
|
||||
label: 'backup',
|
||||
test: job =>
|
||||
job.mode === 'full' && !isEmpty(get(() => destructPattern(job.remotes))),
|
||||
name: _('jobType'),
|
||||
itemRenderer: ({ jobLabel }) => jobLabel,
|
||||
sortCriteria: 'jobLabel',
|
||||
},
|
||||
{
|
||||
label: 'deltaBackup',
|
||||
test: job =>
|
||||
job.mode === 'delta' && !isEmpty(get(() => destructPattern(job.remotes))),
|
||||
name: _('jobTag'),
|
||||
itemRenderer: ({ scheduleTag }) => scheduleTag,
|
||||
default: true,
|
||||
sortCriteria: ({ scheduleTag }) => scheduleTag,
|
||||
},
|
||||
{
|
||||
label: 'continuousReplication',
|
||||
test: job =>
|
||||
job.mode === 'delta' && !isEmpty(get(() => destructPattern(job.srs))),
|
||||
name: _('jobScheduling'),
|
||||
itemRenderer: ({ schedule }) => schedule.cron,
|
||||
sortCriteria: ({ schedule }) => schedule.cron,
|
||||
},
|
||||
{
|
||||
label: 'disasterRecovery',
|
||||
test: job =>
|
||||
job.mode === 'full' && !isEmpty(get(() => destructPattern(job.srs))),
|
||||
name: _('jobTimezone'),
|
||||
itemRenderer: ({ schedule }) => schedule.timezone || _('jobServerTimezone'),
|
||||
sortCriteria: ({ schedule }) => schedule.timezone,
|
||||
},
|
||||
{
|
||||
label: 'poolMetadata',
|
||||
test: job => !isEmpty(destructPattern(job.pools)),
|
||||
name: _('state'),
|
||||
itemRenderer: ({ schedule }) => (
|
||||
<StateButton
|
||||
disabledLabel={_('stateDisabled')}
|
||||
disabledHandler={enableSchedule}
|
||||
disabledTooltip={_('logIndicationToEnable')}
|
||||
enabledLabel={_('stateEnabled')}
|
||||
enabledHandler={disableSchedule}
|
||||
enabledTooltip={_('logIndicationToDisable')}
|
||||
handlerParam={schedule.id}
|
||||
state={schedule.enabled}
|
||||
/>
|
||||
),
|
||||
sortCriteria: 'schedule.enabled',
|
||||
},
|
||||
{
|
||||
label: 'xoConfig',
|
||||
test: job => job.xoMetadata,
|
||||
name: _('jobAction'),
|
||||
itemRenderer: (item, isScheduleUserMissing) => {
|
||||
const { redirect, schedule } = item
|
||||
const { id } = schedule
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
{isScheduleUserMissing[id] && (
|
||||
<Tooltip content={_('backupUserNotFound')}>
|
||||
<Icon className='mr-1' icon='error' />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Link
|
||||
className='btn btn-sm btn-primary mr-1'
|
||||
to={`/backup/${id}/edit`}
|
||||
>
|
||||
<Icon icon='edit' />
|
||||
</Link>
|
||||
<ButtonGroup>
|
||||
{redirect && (
|
||||
<ActionRowButton
|
||||
btnStyle='primary'
|
||||
handler={redirect}
|
||||
icon='preview'
|
||||
tooltip={_('redirectToMatchingVms')}
|
||||
/>
|
||||
)}
|
||||
<ActionRowButton
|
||||
btnStyle='warning'
|
||||
disabled={isScheduleUserMissing[id]}
|
||||
handler={_runJob}
|
||||
handlerParam={item}
|
||||
icon='run-schedule'
|
||||
/>
|
||||
<ActionRowButton
|
||||
btnStyle='danger'
|
||||
handler={migrateBackupSchedule}
|
||||
handlerParam={schedule.jobId}
|
||||
icon='migrate-job'
|
||||
tooltip={_('migrateToBackupNg')}
|
||||
/>
|
||||
<ActionRowButton
|
||||
btnStyle='danger'
|
||||
handler={deleteBackupSchedule}
|
||||
handlerParam={schedule}
|
||||
icon='delete'
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</fieldset>
|
||||
)
|
||||
},
|
||||
textAlign: 'right',
|
||||
},
|
||||
]
|
||||
|
||||
const _deleteBackupJobs = items => {
|
||||
const { backup: backupIds, metadataBackup: metadataBackupIds } = groupBy(
|
||||
items,
|
||||
'type'
|
||||
)
|
||||
return deleteBackupJobs({ backupIds, metadataBackupIds })
|
||||
}
|
||||
|
||||
const _runBackupJob = ({ id, name, schedule, type }) =>
|
||||
confirm({
|
||||
title: _('runJob'),
|
||||
body: _('runBackupNgJobConfirm', {
|
||||
id: id.slice(0, 5),
|
||||
name: <strong>{name}</strong>,
|
||||
}),
|
||||
}).then(() =>
|
||||
type === 'backup'
|
||||
? runBackupNgJob({ id, schedule })
|
||||
: runMetadataBackupJob({ id, schedule })
|
||||
)
|
||||
|
||||
const SchedulePreviewBody = decorate([
|
||||
addSubscriptions(({ schedule }) => ({
|
||||
lastRunLog: cb =>
|
||||
subscribeBackupNgLogs(logs => {
|
||||
let lastRunLog
|
||||
for (const runId in logs) {
|
||||
const log = logs[runId]
|
||||
if (
|
||||
log.scheduleId === schedule.id &&
|
||||
(lastRunLog === undefined || lastRunLog.start < log.start)
|
||||
) {
|
||||
lastRunLog = log
|
||||
}
|
||||
}
|
||||
cb(lastRunLog)
|
||||
}),
|
||||
})),
|
||||
({ job, schedule, lastRunLog }) => (
|
||||
<Ul>
|
||||
<Li>
|
||||
{schedule.name
|
||||
? _.keyValue(_('scheduleName'), schedule.name)
|
||||
: _.keyValue(_('scheduleCron'), schedule.cron)}{' '}
|
||||
<Tooltip content={_('scheduleCopyId', { id: schedule.id.slice(4, 8) })}>
|
||||
<CopyToClipboard text={schedule.id}>
|
||||
<Button size='small'>
|
||||
<Icon icon='clipboard' />
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
</Li>
|
||||
<Li>
|
||||
<StateButton
|
||||
disabledLabel={_('stateDisabled')}
|
||||
disabledHandler={enableSchedule}
|
||||
disabledTooltip={_('logIndicationToEnable')}
|
||||
enabledLabel={_('stateEnabled')}
|
||||
enabledHandler={disableSchedule}
|
||||
enabledTooltip={_('logIndicationToDisable')}
|
||||
handlerParam={schedule.id}
|
||||
state={schedule.enabled}
|
||||
style={{ marginRight: '0.5em' }}
|
||||
/>
|
||||
{job.runId !== undefined ? (
|
||||
<ActionButton
|
||||
btnStyle='danger'
|
||||
handler={cancelJob}
|
||||
handlerParam={job}
|
||||
icon='cancel'
|
||||
key='cancel'
|
||||
size='small'
|
||||
tooltip={_('formCancel')}
|
||||
/>
|
||||
) : (
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
data-id={job.id}
|
||||
data-name={job.name}
|
||||
data-schedule={schedule.id}
|
||||
data-type={job.type}
|
||||
handler={_runBackupJob}
|
||||
icon='run-schedule'
|
||||
key='run'
|
||||
size='small'
|
||||
/>
|
||||
)}{' '}
|
||||
{lastRunLog !== undefined && (
|
||||
<LogStatus log={lastRunLog} tooltip={_('scheduleLastRun')} />
|
||||
)}
|
||||
</Li>
|
||||
</Ul>
|
||||
),
|
||||
])
|
||||
// ===================================================================
|
||||
|
||||
@addSubscriptions({
|
||||
jobs: subscribeBackupNgJobs,
|
||||
metadataJobs: subscribeMetadataBackupJobs,
|
||||
schedulesByJob: cb =>
|
||||
subscribeSchedules(schedules => {
|
||||
cb(groupBy(schedules, 'jobId'))
|
||||
}),
|
||||
jobs: cb => subscribeJobs(jobs => cb(keyBy(jobs, 'id'))),
|
||||
schedules: cb => subscribeSchedules(schedules => cb(keyBy(schedules, 'id'))),
|
||||
users: subscribeUsers,
|
||||
})
|
||||
class JobsTable extends React.Component {
|
||||
export default class Overview extends Component {
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
}
|
||||
|
||||
static tableProps = {
|
||||
actions: [
|
||||
{
|
||||
handler: _deleteBackupJobs,
|
||||
label: _('deleteBackupSchedule'),
|
||||
icon: 'delete',
|
||||
level: 'danger',
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{
|
||||
itemRenderer: ({ id }) => (
|
||||
<Copiable data={id} tagName='p'>
|
||||
{id.slice(4, 8)}
|
||||
</Copiable>
|
||||
),
|
||||
name: _('jobId'),
|
||||
},
|
||||
{
|
||||
valuePath: 'name',
|
||||
name: _('jobName'),
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
itemRenderer: job => (
|
||||
<Ul>
|
||||
{MODES.filter(({ test }) => test(job)).map(({ label }) => (
|
||||
<Li key={label}>{_(label)}</Li>
|
||||
))}
|
||||
</Ul>
|
||||
),
|
||||
sortCriteria: 'mode',
|
||||
name: _('jobModes'),
|
||||
},
|
||||
{
|
||||
itemRenderer: (job, { schedulesByJob }) =>
|
||||
map(get(() => schedulesByJob[job.id]), schedule => (
|
||||
<SchedulePreviewBody
|
||||
job={job}
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
/>
|
||||
)),
|
||||
name: _('jobSchedules'),
|
||||
},
|
||||
{
|
||||
itemRenderer: job => {
|
||||
const {
|
||||
compression,
|
||||
concurrency,
|
||||
fullInterval,
|
||||
offlineBackup,
|
||||
offlineSnapshot,
|
||||
reportWhen,
|
||||
timeout,
|
||||
} = getSettingsWithNonDefaultValue(job.mode, {
|
||||
compression: job.compression,
|
||||
...job.settings[''],
|
||||
})
|
||||
|
||||
return (
|
||||
<Ul>
|
||||
{reportWhen !== undefined && (
|
||||
<Li>{_.keyValue(_('reportWhen'), reportWhen)}</Li>
|
||||
)}
|
||||
{concurrency !== undefined && (
|
||||
<Li>{_.keyValue(_('concurrency'), concurrency)}</Li>
|
||||
)}
|
||||
{timeout !== undefined && (
|
||||
<Li>{_.keyValue(_('timeout'), timeout / 3600e3)} hours</Li>
|
||||
)}
|
||||
{fullInterval !== undefined && (
|
||||
<Li>{_.keyValue(_('fullBackupInterval'), fullInterval)}</Li>
|
||||
)}
|
||||
{offlineBackup !== undefined && (
|
||||
<Li>
|
||||
{_.keyValue(
|
||||
_('offlineBackup'),
|
||||
_(offlineBackup ? 'stateEnabled' : 'stateDisabled')
|
||||
)}
|
||||
</Li>
|
||||
)}
|
||||
{offlineSnapshot !== undefined && (
|
||||
<Li>
|
||||
{_.keyValue(
|
||||
_('offlineSnapshot'),
|
||||
_(offlineSnapshot ? 'stateEnabled' : 'stateDisabled')
|
||||
)}
|
||||
</Li>
|
||||
)}
|
||||
{compression !== undefined && (
|
||||
<Li>
|
||||
{_.keyValue(
|
||||
_('compression'),
|
||||
compression === 'native' ? 'GZIP' : compression
|
||||
)}
|
||||
</Li>
|
||||
)}
|
||||
</Ul>
|
||||
)
|
||||
},
|
||||
name: _('formNotes'),
|
||||
},
|
||||
],
|
||||
individualActions: [
|
||||
{
|
||||
handler: (job, { goTo }) =>
|
||||
goTo({
|
||||
pathname: '/home',
|
||||
query: { t: 'VM', s: constructQueryString(job.vms) },
|
||||
}),
|
||||
disabled: job => job.type !== 'backup',
|
||||
label: _('redirectToMatchingVms'),
|
||||
icon: 'preview',
|
||||
},
|
||||
{
|
||||
handler: (job, { goTo }) => goTo(`/backup/${job.id}/edit`),
|
||||
label: _('formEdit'),
|
||||
icon: 'edit',
|
||||
level: 'primary',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
_goTo = path => {
|
||||
this.context.router.push(path)
|
||||
}
|
||||
|
||||
_getCollection = createSelector(
|
||||
_getSchedules = createSelector(
|
||||
() => this.props.jobs,
|
||||
() => this.props.metadataJobs,
|
||||
(jobs = [], metadataJobs = []) => [...jobs, ...metadataJobs]
|
||||
() => this.props.schedules,
|
||||
(jobs, schedules) =>
|
||||
jobs === undefined || schedules === undefined
|
||||
? []
|
||||
: orderBy(
|
||||
filter(schedules, schedule => {
|
||||
const job = jobs[schedule.jobId]
|
||||
return job && jobKeyToLabel[job.key]
|
||||
}),
|
||||
'id'
|
||||
)
|
||||
)
|
||||
|
||||
_redirectToMatchingVms = pattern => {
|
||||
this.context.router.push({
|
||||
pathname: '/home',
|
||||
query: { t: 'VM', s: constructQueryString(pattern) },
|
||||
})
|
||||
}
|
||||
|
||||
_getScheduleCollection = createSelector(
|
||||
this._getSchedules,
|
||||
() => this.props.jobs,
|
||||
(schedules, jobs) => {
|
||||
if (!schedules || !jobs) {
|
||||
return []
|
||||
}
|
||||
|
||||
return map(schedules, schedule => {
|
||||
const job = jobs[schedule.jobId]
|
||||
const { items } = job.paramsVector
|
||||
const pattern = get(items, '[1].collection.pattern')
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
jobLabel: jobKeyToLabel[job.key] || _('unknownSchedule'),
|
||||
redirect:
|
||||
pattern !== undefined &&
|
||||
(() => this._redirectToMatchingVms(pattern)),
|
||||
// Old versions of XenOrchestra use items[0]
|
||||
scheduleTag:
|
||||
get(items, '[0].values[0].tag') ||
|
||||
get(items, '[1].values[0].tag') ||
|
||||
schedule.id,
|
||||
schedule,
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
_getIsScheduleUserMissing = createSelector(
|
||||
this._getSchedules,
|
||||
() => this.props.jobs,
|
||||
() => this.props.users,
|
||||
(schedules, jobs, users) => {
|
||||
const isScheduleUserMissing = {}
|
||||
forEach(schedules, schedule => {
|
||||
isScheduleUserMissing[schedule.id] = !(
|
||||
jobs && find(users, user => user.id === jobs[schedule.jobId].userId)
|
||||
)
|
||||
})
|
||||
|
||||
return isScheduleUserMissing
|
||||
}
|
||||
)
|
||||
|
||||
render() {
|
||||
const schedules = this._getSchedules()
|
||||
const isScheduleUserMissing = this._getIsScheduleUserMissing()
|
||||
|
||||
return (
|
||||
<SortedTable
|
||||
{...JobsTable.tableProps}
|
||||
collection={this._getCollection()}
|
||||
data-goTo={this._goTo}
|
||||
data-schedulesByJob={this.props.schedulesByJob}
|
||||
/>
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Icon icon='schedule' /> {_('backupSchedules')}
|
||||
</CardHeader>
|
||||
<CardBlock>
|
||||
<NoObjects
|
||||
collection={schedules}
|
||||
emptyMessage={
|
||||
<span>
|
||||
{_('noScheduledJobs')}{' '}
|
||||
<Link to='/backup-ng/health'>{_('legacySnapshotsLink')}</Link>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{() => (
|
||||
<div>
|
||||
<div className='alert alert-warning'>
|
||||
{_('backupDeprecatedMessage')}
|
||||
<br />
|
||||
<a href='https://xen-orchestra.com/blog/migrate-backup-to-backup-ng/'>
|
||||
{_('backupMigrationLink')}
|
||||
</a>
|
||||
</div>
|
||||
<SortedTable
|
||||
columns={JOB_COLUMNS}
|
||||
collection={this._getScheduleCollection()}
|
||||
userData={isScheduleUserMissing}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</NoObjects>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
<LogList jobKeys={Object.keys(jobKeyToLabel)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const legacyJobKey = [
|
||||
'continuousReplication',
|
||||
'deltaBackup',
|
||||
'disasterRecovery',
|
||||
'backup',
|
||||
'rollingSnapshot',
|
||||
]
|
||||
|
||||
const Overview = decorate([
|
||||
addSubscriptions({
|
||||
legacyJobs: subscribeJobs,
|
||||
}),
|
||||
provideState({
|
||||
computed: {
|
||||
haveLegacyBackups: (_, { legacyJobs }) =>
|
||||
some(legacyJobs, job => legacyJobKey.includes(job.key)),
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ state: { haveLegacyBackups } }) => (
|
||||
<div>
|
||||
{haveLegacyBackups && <LegacyOverview />}
|
||||
<div className='mt-2 mb-1'>
|
||||
{haveLegacyBackups && <h3>{_('backup')}</h3>}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Icon icon='backup' /> {_('backupJobs')}
|
||||
</CardHeader>
|
||||
<CardBlock>
|
||||
<JobsTable />
|
||||
</CardBlock>
|
||||
</Card>
|
||||
<LogsTable />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
])
|
||||
|
||||
export default Overview
|
||||
|
||||
@@ -180,7 +180,7 @@ const OPTIONS = {
|
||||
{
|
||||
handler: (vmIds, _, { setHomeVmIdsSelection }, { router }) => {
|
||||
setHomeVmIdsSelection(vmIds)
|
||||
router.push('backup/new/vms')
|
||||
router.push('backup-ng/new/vms')
|
||||
},
|
||||
icon: 'backup',
|
||||
labelId: 'backupLabel',
|
||||
|
||||
@@ -32,7 +32,7 @@ import styles from './index.css'
|
||||
getPoolHosts,
|
||||
hosts => {
|
||||
return Promise.all(map(hosts, host => getHostMissingPatches(host))).then(
|
||||
patches => uniq(map(flatten(patches), 'name'))
|
||||
patches => uniq(map(flatten(patches.filter(Boolean)), 'name'))
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -285,7 +285,7 @@ export default class TabPatches extends Component {
|
||||
)
|
||||
}
|
||||
if (this.props.missingPatches === null) {
|
||||
return <em>{_('updatePluginNotInstalled')}</em>
|
||||
return <em>{_('cannotFetchMissingPatches')}</em>
|
||||
}
|
||||
const Patches =
|
||||
this.props.host.productBrand === 'XCP-ng' ? XcpPatches : XenServerPatches
|
||||
|
||||
@@ -1,45 +1,40 @@
|
||||
import * as FormGrid from 'form-grid'
|
||||
import _ from 'intl'
|
||||
import decorate from 'apply-decorators'
|
||||
import defined from '@xen-orchestra/defined'
|
||||
import Icon from 'icon'
|
||||
import React from 'react'
|
||||
import SingleLineRow from 'single-line-row'
|
||||
import Tooltip from 'tooltip'
|
||||
import { Container, Col } from 'grid'
|
||||
import { isEmpty, sortBy } from 'lodash'
|
||||
import { Container } from 'grid'
|
||||
import { SelectPool } from 'select-objects'
|
||||
import { error } from 'notification'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { isSrWritable } from 'xo'
|
||||
import { Pool } from 'render-xo-item'
|
||||
import { SelectPool, SelectSr } from 'select-objects'
|
||||
|
||||
export default decorate([
|
||||
provideState({
|
||||
initialState: ({ multi }) => ({
|
||||
pools: multi ? [] : undefined,
|
||||
}),
|
||||
effects: {
|
||||
onChangePools(__, pools) {
|
||||
const { multi, onChange, value } = this.props
|
||||
onChange({
|
||||
...value,
|
||||
[multi ? 'pools' : 'pool']: pools,
|
||||
})
|
||||
onChangePool(__, pools) {
|
||||
const noDefaultSr = Array.isArray(pools)
|
||||
? pools.some(pool => pool.default_SR === undefined)
|
||||
: pools.default_SR === undefined
|
||||
if (noDefaultSr) {
|
||||
error(_('hubSrErrorTitle'), _('noDefaultSr'))
|
||||
} else {
|
||||
this.props.onChange({
|
||||
pools,
|
||||
pool: pools,
|
||||
})
|
||||
return {
|
||||
pools,
|
||||
}
|
||||
}
|
||||
},
|
||||
onChangeSr(__, sr) {
|
||||
const { onChange, value } = this.props
|
||||
onChange({
|
||||
...value,
|
||||
mapPoolsSrs: {
|
||||
...value.mapPoolsSrs,
|
||||
[sr.$pool]: sr.id,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
sortedPools: (_, { value }) => sortBy(value.pools, 'name_label'),
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ effects, install, multi, poolPredicate, state, value }) => (
|
||||
({ effects, install, multi, state, poolPredicate }) => (
|
||||
<Container>
|
||||
<FormGrid.Row>
|
||||
<label>
|
||||
@@ -54,40 +49,12 @@ export default decorate([
|
||||
<SelectPool
|
||||
className='mb-1'
|
||||
multi={multi}
|
||||
onChange={effects.onChangePools}
|
||||
onChange={effects.onChangePool}
|
||||
predicate={poolPredicate}
|
||||
required
|
||||
value={multi ? value.pools : value.pool}
|
||||
value={state.pools}
|
||||
/>
|
||||
</FormGrid.Row>
|
||||
{install && multi && !isEmpty(value.pools) && (
|
||||
<div>
|
||||
<SingleLineRow>
|
||||
<Col size={6}>
|
||||
<strong>{_('pool')}</strong>
|
||||
</Col>
|
||||
<Col size={6}>
|
||||
<strong>{_('sr')}</strong>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
<hr />
|
||||
{state.sortedPools.map(pool => (
|
||||
<SingleLineRow key={pool.id} className='mt-1'>
|
||||
<Col size={6}>
|
||||
<Pool id={pool.id} link />
|
||||
</Col>
|
||||
<Col size={6}>
|
||||
<SelectSr
|
||||
onChange={effects.onChangeSr}
|
||||
predicate={sr => sr.$pool === pool.id && isSrWritable(sr)}
|
||||
required
|
||||
value={defined(value.mapPoolsSrs[pool.id], pool.default_SR)}
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
),
|
||||
])
|
||||
|
||||
@@ -1,35 +1,21 @@
|
||||
import _ from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import decorate from 'apply-decorators'
|
||||
import defined from '@xen-orchestra/defined'
|
||||
import Icon from 'icon'
|
||||
import marked from 'marked'
|
||||
import React from 'react'
|
||||
import { alert, form } from 'modal'
|
||||
import { Card, CardBlock, CardHeader } from 'card'
|
||||
import { Col, Row } from 'grid'
|
||||
import { alert, form } from 'modal'
|
||||
import { connectStore, formatSize, getXoaPlan } from 'utils'
|
||||
import { createGetObjectsOfType } from 'selectors'
|
||||
import { deleteTemplates, downloadAndInstallResource, pureDeleteVm } from 'xo'
|
||||
import { downloadAndInstallResource, deleteTemplates } from 'xo'
|
||||
import { error, success } from 'notification'
|
||||
import { find, filter, isEmpty, map, omit, startCase } from 'lodash'
|
||||
import { find, filter } from 'lodash'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { withRouter } from 'react-router'
|
||||
|
||||
import ResourceForm from './resource-form'
|
||||
|
||||
const Li = props => <li {...props} className='list-group-item' />
|
||||
const Ul = props => <ul {...props} className='list-group' />
|
||||
|
||||
// Template <id> : specific to a template version
|
||||
// Template <namespace> : general template identifier (can have multiple versions)
|
||||
// Template <any> : a default hub metadata, please don't remove it from BANNED_FIELDS
|
||||
|
||||
const BANNED_FIELDS = ['any', 'description'] // These fields will not be displayed on description modal
|
||||
const EXCLUSIVE_FIELDS = ['longDescription'] // These fields will not have a label
|
||||
const MARKDOWN_FIELDS = ['longDescription', 'description']
|
||||
const STATIC_FIELDS = [...EXCLUSIVE_FIELDS, ...BANNED_FIELDS] // These fields will not be displayed with dynamic fields
|
||||
|
||||
const subscribeAlert = () =>
|
||||
alert(
|
||||
_('hubResourceAlert'),
|
||||
@@ -65,7 +51,6 @@ export default decorate([
|
||||
namespace,
|
||||
markHubResourceAsInstalled,
|
||||
markHubResourceAsInstalling,
|
||||
templates,
|
||||
version,
|
||||
} = this.props
|
||||
const { isTemplateInstalled } = this.state
|
||||
@@ -74,10 +59,6 @@ export default decorate([
|
||||
return
|
||||
}
|
||||
const resourceParams = await form({
|
||||
defaultValue: {
|
||||
mapPoolsSrs: {},
|
||||
pools: [],
|
||||
},
|
||||
render: props => (
|
||||
<ResourceForm
|
||||
install
|
||||
@@ -97,26 +78,14 @@ export default decorate([
|
||||
markHubResourceAsInstalling(id)
|
||||
try {
|
||||
await Promise.all(
|
||||
resourceParams.pools.map(async pool => {
|
||||
await downloadAndInstallResource({
|
||||
resourceParams.pools.map(pool =>
|
||||
downloadAndInstallResource({
|
||||
namespace,
|
||||
id,
|
||||
version,
|
||||
sr: defined(
|
||||
resourceParams.mapPoolsSrs[pool.id],
|
||||
pool.default_SR
|
||||
),
|
||||
sr: pool.default_SR,
|
||||
})
|
||||
const oldTemplates = filter(
|
||||
templates,
|
||||
template =>
|
||||
pool.$pool === template.$pool &&
|
||||
template.other['xo:resource:namespace'] === namespace
|
||||
)
|
||||
await Promise.all(
|
||||
oldTemplates.map(template => pureDeleteVm(template))
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
success(_('hubImportNotificationTitle'), _('successfulInstall'))
|
||||
} catch (_error) {
|
||||
@@ -132,9 +101,6 @@ export default decorate([
|
||||
return
|
||||
}
|
||||
const resourceParams = await form({
|
||||
defaultValue: {
|
||||
pool: undefined,
|
||||
},
|
||||
render: props => (
|
||||
<ResourceForm poolPredicate={isPoolCreated} {...props} />
|
||||
),
|
||||
@@ -158,9 +124,6 @@ export default decorate([
|
||||
async deleteTemplates(__, { name }) {
|
||||
const { isPoolCreated } = this.state
|
||||
const resourceParams = await form({
|
||||
defaultValue: {
|
||||
pools: [],
|
||||
},
|
||||
render: props => (
|
||||
<ResourceForm
|
||||
delete
|
||||
@@ -194,85 +157,10 @@ export default decorate([
|
||||
redirectToTaskPage() {
|
||||
this.props.router.push('/tasks')
|
||||
},
|
||||
showDescription() {
|
||||
const {
|
||||
data: { public: _public },
|
||||
name,
|
||||
} = this.props
|
||||
alert(
|
||||
name,
|
||||
<div>
|
||||
{isEmpty(omit(_public, BANNED_FIELDS)) ? (
|
||||
<p>{_('hubTemplateDescriptionNotAvailable')}</p>
|
||||
) : (
|
||||
<div>
|
||||
<Ul>
|
||||
{EXCLUSIVE_FIELDS.map(fieldKey => {
|
||||
const field = _public[fieldKey]
|
||||
if (field !== undefined) {
|
||||
return (
|
||||
<Li key={fieldKey}>
|
||||
{MARKDOWN_FIELDS.includes(fieldKey) ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked(field),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
field
|
||||
)}
|
||||
</Li>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</Ul>
|
||||
<br />
|
||||
<Ul>
|
||||
{map(omit(_public, STATIC_FIELDS), (value, key) => (
|
||||
<Li key={key}>
|
||||
{startCase(key)}
|
||||
<span className='pull-right'>
|
||||
{typeof value === 'boolean' ? (
|
||||
<Icon
|
||||
color={value ? 'green' : 'red'}
|
||||
icon={value ? 'true' : 'false'}
|
||||
/>
|
||||
) : key.toLowerCase().endsWith('size') ? (
|
||||
<strong>{formatSize(value)}</strong>
|
||||
) : (
|
||||
<strong>{value}</strong>
|
||||
)}
|
||||
</span>
|
||||
</Li>
|
||||
))}
|
||||
</Ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
description: (
|
||||
_,
|
||||
{
|
||||
data: {
|
||||
public: { description },
|
||||
},
|
||||
description: _description,
|
||||
}
|
||||
) =>
|
||||
(description !== undefined || _description !== undefined) && (
|
||||
<div
|
||||
className='text-muted'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked(defined(description, _description)),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
installedTemplates: (_, { id, templates }) =>
|
||||
filter(templates, ['other.xo:resource:xva:id', id]),
|
||||
installedTemplates: (_, { namespace, templates }) =>
|
||||
filter(templates, ['other.xo:resource:namespace', namespace]),
|
||||
isTemplateInstalledOnAllPools: ({ installedTemplates }, { pools }) =>
|
||||
installedTemplates.length > 0 &&
|
||||
pools.every(
|
||||
@@ -294,9 +182,11 @@ export default decorate([
|
||||
hubInstallingResources,
|
||||
id,
|
||||
name,
|
||||
os,
|
||||
size,
|
||||
state,
|
||||
totalDiskSize,
|
||||
version,
|
||||
}) => (
|
||||
<Card shadow>
|
||||
<CardHeader>
|
||||
@@ -314,17 +204,15 @@ export default decorate([
|
||||
/>
|
||||
<br />
|
||||
</CardHeader>
|
||||
<CardBlock>
|
||||
{state.description}
|
||||
<ActionButton
|
||||
className='pull-right'
|
||||
color='light'
|
||||
handler={effects.showDescription}
|
||||
icon='info'
|
||||
size='small'
|
||||
>
|
||||
{_('moreDetails')}
|
||||
</ActionButton>
|
||||
<CardBlock className='text-center'>
|
||||
<div>
|
||||
<span className='text-muted'>{_('os')}</span> <strong>{os}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className='text-muted'>{_('version')}</span>
|
||||
{' '}
|
||||
<strong>{version}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className='text-muted'>{_('size')}</span>
|
||||
{' '}
|
||||
|
||||
@@ -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'
|
||||
@@ -30,7 +31,6 @@ import Jobs from './jobs'
|
||||
import Menu from './menu'
|
||||
import Modal, { alert, FormModal } from 'modal'
|
||||
import New from './new'
|
||||
import NewLegacyBackup from './backup/new-legacy-backup'
|
||||
import NewVm from './new-vm'
|
||||
import Pool from './pool'
|
||||
import Self from './self'
|
||||
@@ -76,21 +76,11 @@ const BODY_STYLE = {
|
||||
@routes('home', {
|
||||
about: About,
|
||||
backup: Backup,
|
||||
'backup-ng/*': {
|
||||
onEnter: ({ location }, replace) =>
|
||||
replace(location.pathname.replace('/backup-ng', '/backup')),
|
||||
},
|
||||
'backup-ng': BackupNg,
|
||||
dashboard: Dashboard,
|
||||
home: Home,
|
||||
'hosts/:id': Host,
|
||||
jobs: Jobs,
|
||||
// 2019-10-03
|
||||
// For test/development purposes. It can be removed after a while.
|
||||
// To remove it, it's necessary to remove
|
||||
// - all messages only used in 'xo-app/backup/new-legacy-backup/index.js'
|
||||
// from 'common/intl/messages'.
|
||||
// - folder 'xo-app/backup/new-legacy-backup'.
|
||||
'legacy-backup/new': NewLegacyBackup,
|
||||
new: New,
|
||||
'pools/:id': Pool,
|
||||
self: Self,
|
||||
|
||||
@@ -219,8 +219,35 @@ export default class Menu extends Component {
|
||||
icon: 'menu-backup-file-restore',
|
||||
label: 'backupFileRestorePage',
|
||||
},
|
||||
],
|
||||
},
|
||||
isAdmin && {
|
||||
to: '/backup-ng/overview',
|
||||
icon: 'menu-backup',
|
||||
label: <span>Backup NG</span>,
|
||||
subMenu: [
|
||||
{
|
||||
to: '/backup/health',
|
||||
to: '/backup-ng/overview',
|
||||
icon: 'menu-backup-overview',
|
||||
label: 'backupOverviewPage',
|
||||
},
|
||||
{
|
||||
to: '/backup-ng/new',
|
||||
icon: 'menu-backup-new',
|
||||
label: 'backupNewPage',
|
||||
},
|
||||
{
|
||||
to: '/backup-ng/restore',
|
||||
icon: 'menu-backup-restore',
|
||||
label: 'backupRestorePage',
|
||||
},
|
||||
{
|
||||
to: '/backup-ng/file-restore',
|
||||
icon: 'menu-backup-file-restore',
|
||||
label: 'backupFileRestorePage',
|
||||
},
|
||||
{
|
||||
to: '/backup-ng/health',
|
||||
icon: 'menu-dashboard-health',
|
||||
label: 'overviewHealthDashboardPage',
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import Wizard, { Section } from 'wizard'
|
||||
import { addSubscriptions, connectStore } from 'utils'
|
||||
import {
|
||||
createBondedNetwork,
|
||||
createCrossPoolPrivateNetwork,
|
||||
createNetwork,
|
||||
createPrivateNetwork,
|
||||
getBondModes,
|
||||
@@ -202,23 +203,33 @@ const NewNetwork = decorate([
|
||||
pool: pool.id,
|
||||
})
|
||||
: isPrivate
|
||||
? (() => {
|
||||
const poolIds = [pool.id]
|
||||
const pifIds = [pif.id]
|
||||
for (const network of networks) {
|
||||
poolIds.push(network.pool.id)
|
||||
pifIds.push(network.pif.id)
|
||||
}
|
||||
return createPrivateNetwork({
|
||||
poolIds,
|
||||
pifIds,
|
||||
name,
|
||||
description,
|
||||
encapsulation,
|
||||
? networks.length > 0
|
||||
? (() => {
|
||||
const poolIds = [pool.id]
|
||||
const pifIds = [pif.id]
|
||||
for (const network of networks) {
|
||||
poolIds.push(network.pool.id)
|
||||
pifIds.push(network.pif.id)
|
||||
}
|
||||
return createCrossPoolPrivateNetwork({
|
||||
xoPoolIds: poolIds,
|
||||
networkName: name,
|
||||
networkDescription: description,
|
||||
encapsulation: encapsulation,
|
||||
xoPifIds: pifIds,
|
||||
encrypted,
|
||||
mtu: mtu !== '' ? +mtu : undefined,
|
||||
})
|
||||
})()
|
||||
: createPrivateNetwork({
|
||||
poolId: pool.id,
|
||||
networkName: name,
|
||||
networkDescription: description,
|
||||
encapsulation: encapsulation,
|
||||
pifId: pif.id,
|
||||
encrypted,
|
||||
mtu: mtu !== '' ? +mtu : undefined,
|
||||
})
|
||||
})()
|
||||
: createNetwork({
|
||||
description,
|
||||
mtu,
|
||||
|
||||
@@ -12,6 +12,7 @@ import Page from '../../page'
|
||||
import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
import store from 'store'
|
||||
import trim from 'lodash/trim'
|
||||
import Wizard, { Section } from 'wizard'
|
||||
import { confirm } from 'modal'
|
||||
import { adminOnly, connectStore, formatSize } from 'utils'
|
||||
@@ -568,8 +569,8 @@ export default class New extends Component {
|
||||
|
||||
let { name, description } = this.refs
|
||||
|
||||
name = name.value.trim()
|
||||
description = description.value.trim()
|
||||
name = trim(name.value)
|
||||
description = trim(description.value)
|
||||
if (isEmpty(name) || isEmpty(description)) {
|
||||
error('Missing General Parameters', 'Please complete General Information')
|
||||
return
|
||||
|
||||
@@ -198,7 +198,15 @@ export default class TabPatches extends Component {
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
{productBrand === 'XCP-ng' ? (
|
||||
{missingPatches === null ? (
|
||||
<Row>
|
||||
<Col>
|
||||
<p>
|
||||
<em>{_('cannotFetchMissingPatches')}</em>
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
) : productBrand === 'XCP-ng' ? (
|
||||
<Row>
|
||||
<Col>
|
||||
<h3>{_('hostMissingPatches')}</h3>
|
||||
|
||||
@@ -3,20 +3,17 @@ import ActionButton from 'action-button'
|
||||
import AnsiUp from 'ansi_up'
|
||||
import decorate from 'apply-decorators'
|
||||
import React from 'react'
|
||||
import { addSubscriptions, adminOnly, getXoaPlan } from 'utils'
|
||||
import { adminOnly, getXoaPlan } from 'utils'
|
||||
import { Card, CardBlock, CardHeader } from 'card'
|
||||
import { Container, Row, Col } from 'grid'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { checkXoa, closeTunnel, openTunnel, subscribeTunnelState } from 'xo'
|
||||
import { checkXoa } from 'xo'
|
||||
|
||||
const ansiUp = new AnsiUp()
|
||||
const COMMUNITY = getXoaPlan() === 'Community'
|
||||
|
||||
const Support = decorate([
|
||||
adminOnly,
|
||||
addSubscriptions({
|
||||
tunnelState: subscribeTunnelState,
|
||||
}),
|
||||
provideState({
|
||||
initialState: () => ({ stdoutCheckXoa: '' }),
|
||||
effects: {
|
||||
@@ -25,86 +22,35 @@ const Support = decorate([
|
||||
}),
|
||||
checkXoa: async () => ({ stdoutCheckXoa: await checkXoa() }),
|
||||
},
|
||||
computed: {
|
||||
stdoutSupportTunnel: (_, { tunnelState }) =>
|
||||
tunnelState === undefined
|
||||
? undefined
|
||||
: { __html: ansiUp.ansi_to_html(tunnelState.stdout) },
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({
|
||||
effects,
|
||||
state: { stdoutCheckXoa, stdoutSupportTunnel },
|
||||
tunnelState: { open, stdout } = { open: false, stdout: '' },
|
||||
}) => (
|
||||
({ effects, state: { stdoutCheckXoa } }) => (
|
||||
<Container>
|
||||
{COMMUNITY && (
|
||||
<Row className='mb-2'>
|
||||
<Col>
|
||||
<span className='text-info'>{_('supportCommunity')}</span>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
<Row>
|
||||
<Col mediumSize={6}>
|
||||
<Card>
|
||||
<CardHeader>{_('xoaCheck')}</CardHeader>
|
||||
<CardBlock>
|
||||
<ActionButton
|
||||
btnStyle='success'
|
||||
disabled={COMMUNITY}
|
||||
handler={effects.checkXoa}
|
||||
icon='diagnosis'
|
||||
>
|
||||
{_('checkXoa')}
|
||||
</ActionButton>
|
||||
<hr />
|
||||
<pre
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: ansiUp.ansi_to_html(stdoutCheckXoa),
|
||||
}}
|
||||
/>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col mediumSize={6}>
|
||||
<Card>
|
||||
<CardHeader>{_('supportTunnel')}</CardHeader>
|
||||
<CardBlock>
|
||||
<Row>
|
||||
<Col>
|
||||
{open ? (
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
disabled={COMMUNITY}
|
||||
handler={closeTunnel}
|
||||
icon='remove'
|
||||
>
|
||||
{_('closeTunnel')}
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton
|
||||
btnStyle='success'
|
||||
disabled={COMMUNITY}
|
||||
handler={openTunnel}
|
||||
icon='open-tunnel'
|
||||
>
|
||||
{_('openTunnel')}
|
||||
</ActionButton>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<hr />
|
||||
{open || stdout !== '' ? (
|
||||
{COMMUNITY ? (
|
||||
<CardBlock>
|
||||
<span className='text-info'>{_('checkXoaCommunity')}</span>
|
||||
</CardBlock>
|
||||
) : (
|
||||
<CardBlock>
|
||||
<ActionButton
|
||||
btnStyle='success'
|
||||
handler={effects.checkXoa}
|
||||
icon='diagnosis'
|
||||
>
|
||||
{_('checkXoa')}
|
||||
</ActionButton>
|
||||
<hr />
|
||||
<pre
|
||||
className={!open && stdout !== '' && 'text-danger'}
|
||||
dangerouslySetInnerHTML={stdoutSupportTunnel}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: ansiUp.ansi_to_html(stdoutCheckXoa),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span>{_('supportTunnelClosed')}</span>
|
||||
)}
|
||||
</CardBlock>
|
||||
</CardBlock>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
Reference in New Issue
Block a user