Compare commits

..

2 Commits

Author SHA1 Message Date
Pierre Donias
1efe1d82cf listMissingPatchesFailed 2019-10-14 15:59:38 +02:00
Pierre Donias
c4b4ee6476 fix(xo-server,xo-web,xo-common): list missing patches error handling 2019-10-14 15:59:37 +02:00
79 changed files with 4066 additions and 3996 deletions

View File

@@ -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",

View File

@@ -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": [

View File

@@ -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)

View File

@@ -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

View File

@@ -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.

View File

@@ -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",

View File

@@ -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)
})
}
}

View File

@@ -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
// ===================================================================
//

View File

@@ -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',
}))

View File

@@ -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] [${

View File

@@ -0,0 +1,10 @@
/examples/
example.js
example.js.map
*.example.js
*.example.js.map
/test/
/tests/
*.spec.js
*.spec.js.map

View File

@@ -1,6 +1,4 @@
# xo-server-transport-icinga2 [![Build Status](https://travis-ci.org/vatesfr/xen-orchestra.png?branch=master)](https://travis-ci.org/vatesfr/xen-orchestra)
> xo-server plugin to send status to icinga2 server
# xo-server-cloud [![Build Status](https://travis-ci.org/vatesfr/xen-orchestra.png?branch=master)](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

View 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
}

View 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)

View File

@@ -31,7 +31,7 @@
"node": ">=6"
},
"dependencies": {
"@xen-orchestra/cron": "^1.0.5",
"@xen-orchestra/cron": "^1.0.4",
"lodash": "^4.16.2"
},
"devDependencies": {

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -14,7 +14,6 @@
[vms]
default = ''
withOsAndXenTools = ''
# vmToBackup = ''
[templates]

View File

@@ -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 })

View File

@@ -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 {

View File

@@ -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)
})

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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",

View File

@@ -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",

View File

@@ -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(

View File

@@ -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(

View File

@@ -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)

View File

@@ -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']
)

View File

@@ -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",

View File

@@ -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:

View File

@@ -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,

View File

@@ -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')
)

View File

@@ -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

View File

@@ -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) =>

View File

@@ -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>
)

View 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>
))
)

File diff suppressed because it is too large Load Diff

View File

@@ -437,7 +437,7 @@ export default decorate([
handler={submitHandler}
icon='save'
redirectOnSuccess={
state.isJobInvalid ? undefined : '/backup'
state.isJobInvalid ? undefined : '/backup-ng'
}
size='large'
>

View File

@@ -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>
)

View File

@@ -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>

View 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} />
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
)
}
}

View File

@@ -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

View File

@@ -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',

View File

@@ -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'))
)
}
)

View File

@@ -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

View File

@@ -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>
),
])

View File

@@ -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>
{' '}

View File

@@ -22,6 +22,7 @@ import { Container, Row, Col } from 'grid'
import About from './about'
import Backup from './backup'
import BackupNg from './backup-ng'
import Dashboard from './dashboard'
import Home from './home'
import Host from './host'
@@ -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,

View File

@@ -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',
},

View File

@@ -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,

View File

@@ -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

View File

@@ -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>

View File

@@ -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>