Compare commits

..

1 Commits

Author SHA1 Message Date
Julien Fontanet
3facbcda99 feat(xen-api/_watchEvents): detect and fix desynchornizations 2019-04-08 15:46:26 +02:00
33 changed files with 1269 additions and 1646 deletions

View File

@@ -19,7 +19,7 @@ module.exports = {
overrides: [
{
files: ['cli.js', '*-cli.js', 'packages/*cli*/**/*.js'],
files: ['packages/*cli*/**/*.js', '*-cli.js'],
rules: {
'no-console': 'off',
},

View File

@@ -32,7 +32,7 @@
"promise-toolbox": "^0.12.1",
"readable-stream": "^3.0.6",
"through2": "^3.0.0",
"tmp": "^0.1.0",
"tmp": "^0.0.33",
"xo-remote-parser": "^0.5.0"
},
"devDependencies": {

View File

@@ -5,17 +5,10 @@
- [Settings/remotes] Expose mount options field for SMB [#4063](https://github.com/vatesfr/xen-orchestra/issues/4063) (PR [#4067](https://github.com/vatesfr/xen-orchestra/pull/4067))
- [Backup/Schedule] Add warning regarding DST when you add a schedule [#4042](https://github.com/vatesfr/xen-orchestra/issues/4042) (PR [#4056](https://github.com/vatesfr/xen-orchestra/pull/4056))
- [Import] Avoid blocking the UI when dropping a big OVA file on the UI (PR [#4018](https://github.com/vatesfr/xen-orchestra/pull/4018))
- [Backup NG/Overview] Make backup list title clearer [#4111](https://github.com/vatesfr/xen-orchestra/issues/4111) (PR [#4129](https://github.com/vatesfr/xen-orchestra/pull/4129))
- [Dashboard] Hide "Report" section for non-admins [#4123](https://github.com/vatesfr/xen-orchestra/issues/4123) (PR [#4126](https://github.com/vatesfr/xen-orchestra/pull/4126))
- [VM migration] Auto select default SR and collapse optional actions [#3326](https://github.com/vatesfr/xen-orchestra/issues/3326) (PR [#4121](https://github.com/vatesfr/xen-orchestra/pull/4121))
- [Metadata backup] Logs [#4005](https://github.com/vatesfr/xen-orchestra/issues/4005) (PR [#4014](https://github.com/vatesfr/xen-orchestra/pull/4014))
### Bug fixes
- [Continuous Replication] Fix VHD size guess for empty files [#4105](https://github.com/vatesfr/xen-orchestra/issues/4105) (PR [#4107](https://github.com/vatesfr/xen-orchestra/pull/4107))
- [Backup NG] Only display full backup interval in case of a delta backup (PR [#4125](https://github.com/vatesfr/xen-orchestra/pull/4107))
- [Dashboard/Health] fix 'an error has occurred' on the storage state table [#4128](https://github.com/vatesfr/xen-orchestra/issues/4128) (PR [#4132](https://github.com/vatesfr/xen-orchestra/pull/4132))
- [Menu] XOA: Fixed empty slot when menu is collapsed [#4012](https://github.com/vatesfr/xen-orchestra/issues/4012) (PR [#4068](https://github.com/vatesfr/xen-orchestra/pull/4068)
### Released packages

View File

@@ -16,7 +16,7 @@
"eslint-plugin-react": "^7.6.1",
"eslint-plugin-standard": "^4.0.0",
"exec-promise": "^0.7.0",
"flow-bin": "^0.96.0",
"flow-bin": "^0.95.1",
"globby": "^9.0.0",
"husky": "^1.2.1",
"jest": "^24.1.0",

View File

@@ -44,7 +44,7 @@
"index-modules": "^0.3.0",
"promise-toolbox": "^0.12.1",
"rimraf": "^2.6.1",
"tmp": "^0.1.0"
"tmp": "^0.0.33"
},
"scripts": {
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",

View File

@@ -44,7 +44,7 @@
"index-modules": "^0.3.0",
"readable-stream": "^3.0.6",
"rimraf": "^2.6.2",
"tmp": "^0.1.0"
"tmp": "^0.0.33"
},
"scripts": {
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",

View File

@@ -33,7 +33,6 @@
"node": ">=6"
},
"dependencies": {
"bind-property-descriptor": "^1.0.0",
"blocked": "^1.2.1",
"debug": "^4.0.1",
"event-to-promise": "^0.8.0",

View File

@@ -0,0 +1,8 @@
const handler = {
get(target, property) {
const value = target[property]
return value !== undefined ? value : 0
},
}
export const create = () => new Proxy({ __proto__: null }, handler)

View File

@@ -9,7 +9,6 @@ import minimist from 'minimist'
import pw from 'pw'
import { asCallback, fromCallback } from 'promise-toolbox'
import { filter, find, isArray } from 'lodash'
import { getBoundPropertyDescriptor } from 'bind-property-descriptor'
import { start as createRepl } from 'repl'
import { createClient } from './'
@@ -26,20 +25,6 @@ function askPassword(prompt = 'Password: ') {
})
}
const { getPrototypeOf, ownKeys } = Reflect
function getAllBoundDescriptors(object) {
const descriptors = { __proto__: null }
let current = object
do {
ownKeys(current).forEach(key => {
if (!(key in descriptors)) {
descriptors[key] = getBoundPropertyDescriptor(current, key, object)
}
})
} while ((current = getPrototypeOf(current)) !== null)
return descriptors
}
// ===================================================================
const usage = 'Usage: xen-api <url> [<user> [<password>]]'
@@ -93,17 +78,11 @@ const main = async args => {
const repl = createRepl({
prompt: `${xapi._humanId}> `,
})
repl.context.xapi = xapi
{
const ctx = repl.context
ctx.xapi = xapi
ctx.diff = (a, b) => console.log('%s', diff(a, b))
ctx.find = predicate => find(xapi.objects.all, predicate)
ctx.findAll = predicate => filter(xapi.objects.all, predicate)
Object.defineProperties(ctx, getAllBoundDescriptors(xapi))
}
repl.context.diff = (a, b) => console.log('%s', diff(a, b))
repl.context.find = predicate => find(xapi.objects.all, predicate)
repl.context.findAll = predicate => filter(xapi.objects.all, predicate)
// Make the REPL waits for promise completion.
repl.eval = (evaluate => (cmd, context, filename, cb) => {

View File

@@ -15,6 +15,7 @@ import {
pTimeout,
} from 'promise-toolbox'
import * as MultiCounter from './_MultiCounter'
import autoTransport from './transports/auto'
import coalesceCalls from './_coalesceCalls'
import debug from './_debug'
@@ -34,7 +35,7 @@ const EVENT_TIMEOUT = 60
// ===================================================================
const { defineProperties, defineProperty, freeze, keys: getKeys } = Object
const { defineProperties, freeze, keys: getKeys } = Object
// -------------------------------------------------------------------
@@ -99,6 +100,7 @@ export class Xapi extends EventEmitter {
this._sessionId = undefined
this._status = DISCONNECTED
this._counter = MultiCounter.create()
this._debounce = opts.debounce ?? 200
this._objects = new Collection()
this._objectsByRef = { __proto__: null }
@@ -773,6 +775,10 @@ export class Xapi extends EventEmitter {
this._objects.set(object.$id, object)
objectsByRef[ref] = object
if (prev === undefined) {
++this._counter[type]
}
if (type === 'pool') {
this._pool = object
@@ -785,10 +791,6 @@ export class Xapi extends EventEmitter {
}
})
} else if (type === 'task') {
if (prev === undefined) {
++this._nTasks
}
const taskWatchers = this._taskWatchers
const taskWatcher = taskWatchers[ref]
if (taskWatcher !== undefined) {
@@ -820,6 +822,7 @@ export class Xapi extends EventEmitter {
}
async _refreshCachedRecords(types) {
const counter = this._counter
const toRemoveByType = { __proto__: null }
types.forEach(type => {
toRemoveByType[type] = new Set()
@@ -851,8 +854,15 @@ export class Xapi extends EventEmitter {
this._removeRecordFromCache(type, ref)
})
if (type === 'task') {
this._nTasks = refs.length
const count = refs.length
if (counter[type] !== count) {
console.warn(
'_refreshCachedRecords(%s): xapi=%d != local=%d',
type,
count,
counter[type]
)
counter[type] = count
}
} catch (error) {
// there is nothing ideal to do here, do not interrupt event
@@ -873,9 +883,7 @@ export class Xapi extends EventEmitter {
this._objects.unset(object.$id)
delete byRefs[ref]
if (type === 'task') {
--this._nTasks
}
--this._counter[type]
}
const taskWatchers = this._taskWatchers
@@ -927,6 +935,16 @@ export class Xapi extends EventEmitter {
this._resolveObjectsFetched()
this._resolveObjectsFetched = undefined
const IGNORED_TYPES = {
__proto__: null,
message: true,
role: true,
session: true,
user: true,
VBD_metrics: true,
VIF_metrics: true,
}
// event loop
const debounce = this._debounce
while (true) {
@@ -959,10 +977,25 @@ export class Xapi extends EventEmitter {
fromToken = result.token
this._processEvents(result.events)
// detect and fix disappearing tasks (e.g. when toolstack restarts)
if (result.valid_ref_counts.task !== this._nTasks) {
await this._refreshCachedRecords(['task'])
}
// detect and fix desynchronized records
const localCounts = this._counter
const xapiCounts = result.valid_ref_counts
await this._refreshCachedRecords(
types.filter(type => {
if (type in IGNORED_TYPES) {
return false
}
// XAPI uses lowercased types in events, but this may change, so we
// handle both
let xapiCount = xapiCounts[type]
if (xapiCount === undefined) {
xapiCount = xapiCounts[type.toLowerCase()]
}
return localCounts[type] !== xapiCount
})
)
}
}
}
@@ -1023,23 +1056,17 @@ export class Xapi extends EventEmitter {
const getObjectByRef = ref => this._objectsByRef[ref]
Record = defineProperty(
function(ref, data) {
defineProperties(this, {
$id: { value: data.uuid ?? ref },
$ref: { value: ref },
$xapi: { value: xapi },
})
for (let i = 0; i < nFields; ++i) {
const field = fields[i]
this[field] = data[field]
}
},
'name',
{
value: type,
Record = function(ref, data) {
defineProperties(this, {
$id: { value: data.uuid ?? ref },
$ref: { value: ref },
$xapi: { value: xapi },
})
for (let i = 0; i < nFields; ++i) {
const field = fields[i]
this[field] = data[field]
}
)
}
const getters = { $pool: getPool }
const props = { $type: type }

View File

@@ -32,7 +32,7 @@
"node": ">=6"
},
"dependencies": {
"nodemailer": "^6.1.0",
"nodemailer": "^5.0.0",
"nodemailer-markdown": "^1.0.1",
"promise-toolbox": "^0.12.1"
},

View File

@@ -39,7 +39,7 @@
"@xen-orchestra/cron": "^1.0.3",
"@xen-orchestra/log": "^0.1.4",
"handlebars": "^4.0.6",
"html-minifier": "^4.0.0",
"html-minifier": "^3.5.8",
"human-format": "^0.10.0",
"lodash": "^4.17.4",
"promise-toolbox": "^0.12.1"

View File

@@ -117,7 +117,7 @@
"struct-fu": "^1.2.0",
"tar-stream": "^2.0.1",
"through2": "^3.0.0",
"tmp": "^0.1.0",
"tmp": "^0.0.33",
"uuid": "^3.0.1",
"value-matcher": "^0.2.0",
"vhd-lib": "^0.6.0",
@@ -128,7 +128,7 @@
"xo-collection": "^0.4.1",
"xo-common": "^0.2.0",
"xo-remote-parser": "^0.5.0",
"xo-vmdk-to-vhd": "^0.1.7",
"xo-vmdk-to-vhd": "^0.1.6",
"yazl": "^2.4.3"
},
"devDependencies": {

View File

@@ -266,11 +266,6 @@ export default class Jobs {
reportWhen: (settings && settings.reportWhen) || 'failure',
}
}
if (type === 'metadataBackup') {
data = {
reportWhen: job.settings['']?.reportWhen ?? 'failure',
}
}
const logger = this._logger
const runJobId = logger.notice(`Starting execution of ${id}.`, {

View File

@@ -1,6 +1,7 @@
// @flow
import asyncMap from '@xen-orchestra/async-map'
import createLogger from '@xen-orchestra/log'
import defer from 'golike-defer'
import { fromEvent, ignoreErrors } from 'promise-toolbox'
import debounceWithKey from '../_pDebounceWithKey'
@@ -24,14 +25,9 @@ const METADATA_BACKUP_JOB_TYPE = 'metadataBackup'
const compareTimestamp = (a, b) => a.timestamp - b.timestamp
const DEFAULT_RETENTION = 0
type ReportWhen = 'always' | 'failure' | 'never'
type Settings = {|
reportWhen?: ReportWhen,
retentionPoolMetadata?: number,
retentionXoMetadata?: number,
retentionPoolMetadata?: number,
|}
type MetadataBackupJob = {
@@ -51,22 +47,6 @@ const createSafeReaddir = (handler, methodName) => (path, options) =>
return []
})
const deleteOldBackups = (handler, dir, retention, handleError) =>
handler.list(dir).then(list => {
list.sort()
list = list
.filter(timestamp => /^\d{8}T\d{6}Z$/.test(timestamp))
.slice(0, -retention)
return Promise.all(
list.map(timestamp => {
const backupDir = `${dir}/${timestamp}`
return handler
.rmtree(backupDir)
.catch(error => handleError(error, backupDir))
})
)
}, handleError)
// metadata.json
//
// {
@@ -96,14 +76,10 @@ const deleteOldBackups = (handler, dir, retention, handleError) =>
//
// Task logs emitted in a metadata backup execution:
//
// job.start(data: { reportWhen: ReportWhen })
// job.start
// ├─ task.start(data: { type: 'pool', id: string, pool: <Pool />, poolMaster: <Host /> })
// │ ├─ task.start(data: { type: 'remote', id: string })
// │ │ └─ task.end
// │ └─ task.end
// ├─ task.start(data: { type: 'xo' })
// │ ├─ task.start(data: { type: 'remote', id: string })
// │ │ └─ task.end
// │ └─ task.end
// └─ job.end
export default class metadataBackup {
@@ -156,286 +132,6 @@ export default class metadataBackup {
})
}
async _backupXo({ handlers, job, logger, retention, runJobId, schedule }) {
const app = this._app
const timestamp = Date.now()
const taskId = logger.notice(`Starting XO metadata backup. (${job.id})`, {
data: {
type: 'xo',
},
event: 'task.start',
parentId: runJobId,
})
try {
const scheduleDir = `${DIR_XO_CONFIG_BACKUPS}/${schedule.id}`
const dir = `${scheduleDir}/${safeDateFormat(timestamp)}`
const data = JSON.stringify(await app.exportConfig(), null, 2)
const fileName = `${dir}/data.json`
const metadata = JSON.stringify(
{
jobId: job.id,
jobName: job.name,
scheduleId: schedule.id,
scheduleName: schedule.name,
timestamp,
},
null,
2
)
const metaDataFileName = `${dir}/metadata.json`
await asyncMap(handlers, async (handler, remoteId) => {
const subTaskId = logger.notice(
`Starting XO metadata backup for the remote (${remoteId}). (${
job.id
})`,
{
data: {
id: remoteId,
type: 'remote',
},
event: 'task.start',
parentId: taskId,
}
)
try {
await Promise.all([
handler.outputFile(fileName, data),
handler.outputFile(metaDataFileName, metadata),
])
await deleteOldBackups(
handler,
scheduleDir,
retention,
(error, backupDir) => {
logger.warning(
backupDir !== undefined
? `unable to delete the folder ${backupDir}`
: `unable to list backups for the remote (${remoteId})`,
{
event: 'task.warning',
taskId: subTaskId,
data: {
error,
},
}
)
}
)
logger.notice(
`Backuping XO metadata for the remote (${remoteId}) is a success. (${
job.id
})`,
{
event: 'task.end',
status: 'success',
taskId: subTaskId,
}
)
} catch (error) {
await handler.rmtree(dir).catch(error => {
logger.warning(`unable to delete the folder ${dir}`, {
event: 'task.warning',
taskId: subTaskId,
data: {
error,
},
})
})
logger.error(
`Backuping XO metadata for the remote (${remoteId}) has failed. (${
job.id
})`,
{
event: 'task.end',
result: serializeError(error),
status: 'failure',
taskId: subTaskId,
}
)
}
})
logger.notice(`Backuping XO metadata is a success. (${job.id})`, {
event: 'task.end',
status: 'success',
taskId,
})
} catch (error) {
logger.error(`Backuping XO metadata has failed. (${job.id})`, {
event: 'task.end',
result: serializeError(error),
status: 'failure',
taskId,
})
}
}
async _backupPool(
poolId,
{ cancelToken, handlers, job, logger, retention, runJobId, schedule, xapi }
) {
const poolMaster = await xapi
.getRecord('host', xapi.pool.master)
::ignoreErrors()
const timestamp = Date.now()
const taskId = logger.notice(
`Starting metadata backup for the pool (${poolId}). (${job.id})`,
{
data: {
id: poolId,
pool: xapi.pool,
poolMaster,
type: 'pool',
},
event: 'task.start',
parentId: runJobId,
}
)
try {
const poolDir = `${DIR_XO_POOL_METADATA_BACKUPS}/${schedule.id}/${poolId}`
const dir = `${poolDir}/${safeDateFormat(timestamp)}`
// TODO: export the metadata only once then split the stream between remotes
const stream = await xapi.exportPoolMetadata(cancelToken)
const fileName = `${dir}/data`
const metadata = JSON.stringify(
{
jobId: job.id,
jobName: job.name,
pool: xapi.pool,
poolMaster,
scheduleId: schedule.id,
scheduleName: schedule.name,
timestamp,
},
null,
2
)
const metaDataFileName = `${dir}/metadata.json`
await asyncMap(handlers, async (handler, remoteId) => {
const subTaskId = logger.notice(
`Starting metadata backup for the pool (${poolId}) for the remote (${remoteId}). (${
job.id
})`,
{
data: {
id: remoteId,
type: 'remote',
},
event: 'task.start',
parentId: taskId,
}
)
let outputStream
try {
await Promise.all([
(async () => {
outputStream = await handler.createOutputStream(fileName)
// 'readable-stream/pipeline' not call the callback when an error throws
// from the readable stream
stream.pipe(outputStream)
return fromEvent(stream, 'end').catch(error => {
if (error.message !== 'aborted') {
throw error
}
})
})(),
handler.outputFile(metaDataFileName, metadata),
])
await deleteOldBackups(
handler,
poolDir,
retention,
(error, backupDir) => {
logger.warning(
backupDir !== undefined
? `unable to delete the folder ${backupDir}`
: `unable to list backups for the remote (${remoteId})`,
{
event: 'task.warning',
taskId: subTaskId,
data: {
error,
},
}
)
}
)
logger.notice(
`Backuping pool metadata (${poolId}) for the remote (${remoteId}) is a success. (${
job.id
})`,
{
event: 'task.end',
status: 'success',
taskId: subTaskId,
}
)
} catch (error) {
if (outputStream !== undefined) {
outputStream.destroy()
}
await handler.rmtree(dir).catch(error => {
logger.warning(`unable to delete the folder ${dir}`, {
event: 'task.warning',
taskId: subTaskId,
data: {
error,
},
})
})
logger.error(
`Backuping pool metadata (${poolId}) for the remote (${remoteId}) has failed. (${
job.id
})`,
{
event: 'task.end',
result: serializeError(error),
status: 'failure',
taskId: subTaskId,
}
)
}
})
logger.notice(
`Backuping pool metadata (${poolId}) is a success. (${job.id})`,
{
event: 'task.end',
status: 'success',
taskId,
}
)
} catch (error) {
logger.error(
`Backuping pool metadata (${poolId}) has failed. (${job.id})`,
{
event: 'task.end',
result: serializeError(error),
status: 'failure',
taskId,
}
)
}
}
async _executor({
cancelToken,
job: job_,
@@ -459,103 +155,199 @@ export default class metadataBackup {
throw new Error('no metadata mode found')
}
let { retentionXoMetadata, retentionPoolMetadata } =
job.settings[schedule.id] || {}
const app = this._app
const { retentionXoMetadata, retentionPoolMetadata } =
job?.settings[schedule.id] || {}
// it also replaces null retentions introduced by the commit
// https://github.com/vatesfr/xen-orchestra/commit/fea5117ed83b58d3a57715b32d63d46e3004a094#diff-c02703199db2a4c217943cf8e02b91deR40
if (retentionXoMetadata == null) {
retentionXoMetadata = DEFAULT_RETENTION
}
if (retentionPoolMetadata == null) {
retentionPoolMetadata = DEFAULT_RETENTION
const timestamp = Date.now()
const formattedTimestamp = safeDateFormat(timestamp)
const commonMetadata = {
jobId: job.id,
jobName: job.name,
scheduleId: schedule.id,
scheduleName: schedule.name,
timestamp,
}
if (
(retentionPoolMetadata === 0 && retentionXoMetadata === 0) ||
(!job.xoMetadata && retentionPoolMetadata === 0) ||
(isEmptyPools && retentionXoMetadata === 0)
) {
const files = []
if (job.xoMetadata && retentionXoMetadata > 0) {
const taskId = logger.notice(`Starting XO metadata backup. (${job.id})`, {
data: {
type: 'xo',
},
event: 'task.start',
parentId: runJobId,
})
const xoMetadataDir = `${DIR_XO_CONFIG_BACKUPS}/${schedule.id}`
const dir = `${xoMetadataDir}/${formattedTimestamp}`
const data = JSON.stringify(await app.exportConfig(), null, 2)
const fileName = `${dir}/data.json`
const metadata = JSON.stringify(commonMetadata, null, 2)
const metaDataFileName = `${dir}/metadata.json`
files.push({
executeBackup: defer(($defer, handler) => {
$defer.onFailure(() => handler.rmtree(dir))
return Promise.all([
handler.outputFile(fileName, data),
handler.outputFile(metaDataFileName, metadata),
]).then(
result => {
logger.notice(`Backuping XO metadata is a success. (${job.id})`, {
event: 'task.end',
status: 'success',
taskId,
})
return result
},
error => {
logger.notice(`Backuping XO metadata has failed. (${job.id})`, {
event: 'task.end',
result: serializeError(error),
status: 'failure',
taskId,
})
throw error
}
)
}),
dir: xoMetadataDir,
retention: retentionXoMetadata,
})
}
if (!isEmptyPools && retentionPoolMetadata > 0) {
files.push(
...(await Promise.all(
poolIds.map(async id => {
const xapi = this._app.getXapi(id)
const poolMaster = await xapi.getRecord('host', xapi.pool.master)
const taskId = logger.notice(
`Starting metadata backup for the pool (${id}). (${job.id})`,
{
data: {
id,
pool: xapi.pool,
poolMaster,
type: 'pool',
},
event: 'task.start',
parentId: runJobId,
}
)
const poolMetadataDir = `${DIR_XO_POOL_METADATA_BACKUPS}/${
schedule.id
}/${id}`
const dir = `${poolMetadataDir}/${formattedTimestamp}`
// TODO: export the metadata only once then split the stream between remotes
const stream = await app.getXapi(id).exportPoolMetadata(cancelToken)
const fileName = `${dir}/data`
const metadata = JSON.stringify(
{
...commonMetadata,
pool: xapi.pool,
poolMaster,
},
null,
2
)
const metaDataFileName = `${dir}/metadata.json`
return {
executeBackup: defer(($defer, handler) => {
$defer.onFailure(() => handler.rmtree(dir))
return Promise.all([
(async () => {
const outputStream = await handler.createOutputStream(
fileName
)
$defer.onFailure(() => outputStream.destroy())
// 'readable-stream/pipeline' not call the callback when an error throws
// from the readable stream
stream.pipe(outputStream)
return fromEvent(stream, 'end').catch(error => {
if (error.message !== 'aborted') {
throw error
}
})
})(),
handler.outputFile(metaDataFileName, metadata),
]).then(
result => {
logger.notice(
`Backuping pool metadata (${id}) is a success. (${
job.id
})`,
{
event: 'task.end',
status: 'success',
taskId,
}
)
return result
},
error => {
logger.notice(
`Backuping pool metadata (${id}) has failed. (${job.id})`,
{
event: 'task.end',
result: serializeError(error),
status: 'failure',
taskId,
}
)
throw error
}
)
}),
dir: poolMetadataDir,
retention: retentionPoolMetadata,
}
})
))
)
}
if (files.length === 0) {
throw new Error('no retentions corresponding to the metadata modes found')
}
cancelToken.throwIfRequested()
const app = this._app
const timestampReg = /^\d{8}T\d{6}Z$/
return asyncMap(
// TODO: emit a warning task if a remote is broken
asyncMap(remoteIds, id => app.getRemoteHandler(id)::ignoreErrors()),
async handler => {
if (handler === undefined) {
return
}
const handlers = {}
await Promise.all(
remoteIds.map(id =>
app.getRemoteHandler(id).then(
handler => {
handlers[id] = handler
},
error => {
logger.warning(`unable to get the handler for the remote (${id})`, {
event: 'task.warning',
taskId: runJobId,
data: {
error,
},
await Promise.all(
files.map(async ({ executeBackup, dir, retention }) => {
await executeBackup(handler)
// deleting old backups
await handler.list(dir).then(list => {
list.sort()
list = list
.filter(timestampDir => timestampReg.test(timestampDir))
.slice(0, -retention)
return Promise.all(
list.map(timestampDir =>
handler.rmtree(`${dir}/${timestampDir}`)
)
)
})
}
})
)
)
}
)
if (Object.keys(handlers).length === 0) {
return
}
const promises = []
if (job.xoMetadata && retentionXoMetadata !== 0) {
promises.push(
this._backupXo({
handlers,
job,
logger,
retention: retentionXoMetadata,
runJobId,
schedule,
})
)
}
if (!isEmptyPools && retentionPoolMetadata !== 0) {
poolIds.forEach(id => {
let xapi
try {
xapi = this._app.getXapi(id)
} catch (error) {
logger.warning(
`unable to get the xapi associated to the pool (${id})`,
{
event: 'task.warning',
taskId: runJobId,
data: {
error,
},
}
)
}
if (xapi !== undefined) {
promises.push(
this._backupPool(id, {
cancelToken,
handlers,
job,
logger,
retention: retentionPoolMetadata,
runJobId,
schedule,
xapi,
})
)
}
})
}
return Promise.all(promises)
}
async createMetadataBackupJob(

View File

@@ -1,6 +1,6 @@
{
"name": "xo-vmdk-to-vhd",
"version": "0.1.7",
"version": "0.1.6",
"license": "AGPL-3.0",
"description": "JS lib streaming a vmdk file to a vhd",
"keywords": [
@@ -28,7 +28,7 @@
"core-js": "3.0.0",
"pipette": "^0.9.3",
"promise-toolbox": "^0.12.1",
"tmp": "^0.1.0",
"tmp": "^0.0.33",
"vhd-lib": "^0.6.0"
},
"devDependencies": {

View File

@@ -95,7 +95,7 @@
"moment": "^2.20.1",
"moment-timezone": "^0.5.14",
"notifyjs": "^3.0.0",
"otplib": "^11.0.0",
"otplib": "^10.0.1",
"promise-toolbox": "^0.12.1",
"prop-types": "^15.6.0",
"qrcode": "^1.3.2",
@@ -128,7 +128,7 @@
"redux-thunk": "^2.0.1",
"reselect": "^2.5.4",
"rimraf": "^2.6.2",
"semver": "^6.0.0",
"semver": "^5.4.1",
"styled-components": "^3.1.5",
"uglify-es": "^3.3.4",
"uncontrollable-input": "^0.1.1",
@@ -142,7 +142,7 @@
"xo-common": "^0.2.0",
"xo-lib": "^0.8.0",
"xo-remote-parser": "^0.5.0",
"xo-vmdk-to-vhd": "^0.1.7"
"xo-vmdk-to-vhd": "^0.1.6"
},
"scripts": {
"build": "NODE_ENV=production gulp build",

View File

@@ -3016,8 +3016,7 @@ export default {
"Le SR par défaut n'est pas connecté à l'hôte",
// Original text: "For each VDI, select an SR:"
chooseSrForEachVdisModalSelectSr:
'Pour chaque VDI, sélectionner un SR (optionnel)',
chooseSrForEachVdisModalSelectSr: 'Pour chaque VDI, sélectionner un SR :',
// Original text: "Select main SR…"
chooseSrForEachVdisModalMainSr: 'Sélectionner le SR principal…',

View File

@@ -46,7 +46,6 @@ const messages = {
metadata: 'Metadata',
chooseBackup: 'Choose a backup',
clickToShowError: 'Click to show error',
backupJobs: 'Backup jobs',
// ----- Modals -----
alertOk: 'OK',
@@ -1609,7 +1608,7 @@ const messages = {
migrateVmNoTargetHostMessage: 'A target host is required to migrate a VM',
migrateVmNoDefaultSrError: 'No default SR',
migrateVmNotConnectedDefaultSrError: 'Default SR not connected to host',
chooseSrForEachVdisModalSelectSr: 'For each VDI, select an SR (optional)',
chooseSrForEachVdisModalSelectSr: 'For each VDI, select an SR:',
chooseSrForEachVdisModalMainSr: 'Select main SR…',
chooseSrForEachVdisModalVdiLabel: 'VDI',
chooseSrForEachVdisModalSrLabel: 'SR*',

View File

@@ -213,8 +213,6 @@ export const getStatus = state => state.status
export const getUser = state => state.user
export const getXoaState = state => state.xoaUpdaterState
export const getCheckPermissions = invoke(() => {
const getPredicate = create(
state => state.permissions,

View File

@@ -70,8 +70,7 @@ export default class ChooseSrForEachVdisModal extends Component {
{props.vdis != null && mainSr != null && (
<Collapsible
buttonText={_('chooseSrForEachVdisModalSelectSr')}
collapsible
size='small'
collapsible={props.vdis.length >= 3}
>
<br />
<Container>

View File

@@ -152,7 +152,7 @@ export default class MigrateVmModalBody extends BaseComponent {
return
}
const { pools, vbds, vm } = this.props
const { vbds, vm } = this.props
const intraPool = vm.$pool === host.$pool
// Intra-pool
@@ -178,7 +178,7 @@ export default class MigrateVmModalBody extends BaseComponent {
intraPool,
mapVifsNetworks: undefined,
migrationNetwork: undefined,
targetSrs: { mainSr: pools[host.$pool].default_SR },
targetSrs: {},
})
return
}

View File

@@ -14,7 +14,6 @@ const MODES = {
__proto__: null,
compression: 'full',
fullInterval: 'delta',
}
const getSettingsWithNonDefaultValue = (mode, settings) =>

View File

@@ -340,7 +340,7 @@ const Overview = () => (
<div>
<Card>
<CardHeader>
<Icon icon='backup' /> {_('backupJobs')}
<Icon icon='schedule' /> {_('backupSchedules')}
</CardHeader>
<CardBlock>
<JobsTable />

View File

@@ -1,5 +1,6 @@
import _ from 'intl'
import decorate from 'apply-decorators'
import defined from '@xen-orchestra/defined'
import Icon from 'icon'
import React from 'react'
import Scheduler, { SchedulePreview } from 'scheduling'
@@ -34,9 +35,9 @@ export default decorate([
name: value.trim() === '' ? null : value,
})
},
setRetention({ setSchedule }, value, { name }) {
setRetention: ({ setSchedule }, value, { name }) => () => {
setSchedule({
[name]: value,
[name]: defined(value, null),
})
},
},
@@ -65,6 +66,7 @@ export default decorate([
value={schedule.name}
/>
</FormGroup>
{/* retentions effects are defined on initialize() */}
{retentions.map(({ name, valuePath }) => (
<FormGroup key={valuePath}>
<label>
@@ -74,7 +76,6 @@ export default decorate([
data-name={valuePath}
min='0'
onChange={effects.setRetention}
required
value={schedule[valuePath]}
/>
</FormGroup>

View File

@@ -935,18 +935,16 @@ export default decorate([
placeholder={formatMessage(messages.timeoutUnit)}
/>
</FormGroup>
{state.isDelta && (
<FormGroup>
<label htmlFor={state.inputFullIntervalId}>
<strong>{_('fullBackupInterval')}</strong>
</label>{' '}
<Number
id={state.inputFullIntervalId}
onChange={effects.setFullInterval}
value={fullInterval}
/>
</FormGroup>
)}
<FormGroup>
<label htmlFor={state.inputFullIntervalId}>
<strong>{_('fullBackupInterval')}</strong>
</label>{' '}
<Number
id={state.inputFullIntervalId}
onChange={effects.setFullInterval}
value={fullInterval}
/>
</FormGroup>
{state.isFull && (
<FormGroup>
<label htmlFor={state.compressionId}>

View File

@@ -38,6 +38,7 @@ import Schedules from '../_schedules'
// A retention can be:
// - number: set by user
// - undefined: will be replaced by the default value in the display(table + modal) and on submitting the form
// - null: when a user voluntarily deletes its value.
const DEFAULT_RETENTION = 1
const RETENTION_POOL_METADATA = {
@@ -206,20 +207,7 @@ export default decorate([
schedules: ({ _schedules }, { schedules }) =>
defined(_schedules, schedules),
settings: ({ _settings }, { job }) =>
// it replaces null retentions introduced by the commit
// https://github.com/vatesfr/xen-orchestra/commit/fea5117ed83b58d3a57715b32d63d46e3004a094#diff-c02703199db2a4c217943cf8e02b91deR40
defined(_settings, () =>
mapValues(job.settings, setting => {
const newSetting = { ...setting }
if (newSetting.retentionPoolMetadata === null) {
newSetting.retentionPoolMetadata = 0
}
if (newSetting.retentionXoMetadata === null) {
newSetting.retentionXoMetadata = 0
}
return newSetting
})
),
defined(_settings, () => job.settings),
remotes: ({ _remotes }, { job }) =>
defined(_remotes, () => destructPattern(job.remotes), []),
remotesPredicate: ({ remotes }) => ({ id }) => !remotes.includes(id),
@@ -239,13 +227,13 @@ export default decorate([
state.modePoolMetadata &&
every(
state.settings,
({ retentionPoolMetadata }) => retentionPoolMetadata === 0
({ retentionPoolMetadata }) => retentionPoolMetadata === null
),
missingRetentionXoMetadata: state =>
state.modeXoMetadata &&
every(
state.settings,
({ retentionXoMetadata }) => retentionXoMetadata === 0
({ retentionXoMetadata }) => retentionXoMetadata === null
),
missingSchedules: state => isEmpty(state.schedules),
},

View File

@@ -39,14 +39,9 @@ import {
const SrColContainer = connectStore(() => ({
container: createGetObject(),
}))(
({ container }) =>
container !== undefined && (
<Link to={`${container.type}s/${container.id}`}>
{container.name_label}
</Link>
)
)
}))(({ container }) => (
<Link to={`${container.type}s/${container.id}`}>{container.name_label}</Link>
))
const VmColContainer = connectStore(() => ({
container: createGetObject(),

View File

@@ -489,37 +489,35 @@ class DefaultCard extends Component {
</Card>
</Col>
</Row>
{props.isAdmin && (
<Row>
<Col>
<Card>
<CardHeader>
<Icon icon='menu-dashboard-stats' /> {_('dashboardReport')}
</CardHeader>
<CardBlock className='text-xs-center'>
<ActionButton
btnStyle='primary'
disabled={!canSendTheReport}
handler={sendUsageReport}
icon=''
>
{_('dashboardSendReport')}
</ActionButton>
<br />
{!canSendTheReport && (
<span>
<Link to='/settings/plugins' className='text-info'>
<Icon icon='info' /> {_('dashboardSendReportInfo')}
</Link>
<br />
</span>
)}
{_('dashboardSendReportMessage')}
</CardBlock>
</Card>
</Col>
</Row>
)}
<Row>
<Col>
<Card>
<CardHeader>
<Icon icon='menu-dashboard-stats' /> {_('dashboardReport')}
</CardHeader>
<CardBlock className='text-xs-center'>
<ActionButton
btnStyle='primary'
disabled={!canSendTheReport}
handler={sendUsageReport}
icon=''
>
{_('dashboardSendReport')}
</ActionButton>
<br />
{!canSendTheReport && (
<span>
<Link to='/settings/plugins' className='text-info'>
<Icon icon='info' /> {_('dashboardSendReportInfo')}
</Link>
<br />
</span>
)}
{_('dashboardSendReportMessage')}
</CardBlock>
</Card>
</Col>
</Row>
<Row>
<Col>
<PatchesCard hosts={this._getHosts()} />

View File

@@ -38,6 +38,7 @@ export const LogStatus = ({ log, tooltip = _('logDisplayDetails') }) => {
return (
<ActionButton
btnStyle={className}
disabled={log.status !== 'failure' && isEmpty(log.tasks)}
handler={showTasks}
handlerParam={log.id}
icon='preview'

View File

@@ -1,20 +1,16 @@
import _, { FormattedDuration } from 'intl'
import ActionButton from 'action-button'
import decorate from 'apply-decorators'
import defined, { get } from '@xen-orchestra/defined'
import Icon from 'icon'
import React from 'react'
import Select from 'form/select'
import Tooltip from 'tooltip'
import { addSubscriptions, formatSize, formatSpeed } from 'utils'
import { countBy, cloneDeep, filter, keyBy, map } from 'lodash'
import { countBy, filter, get, keyBy, map } from 'lodash'
import { FormattedDate } from 'react-intl'
import { injectState, provideState } from 'reaclette'
import { runBackupNgJob, subscribeBackupNgLogs, subscribeRemotes } from 'xo'
import { Vm, Sr, Remote, Pool } from 'render-xo-item'
const hasTaskFailed = ({ status }) =>
status !== 'success' && status !== 'pending'
import { Vm, Sr, Remote } from 'render-xo-item'
const TASK_STATUS = {
failure: {
@@ -48,75 +44,19 @@ const TaskStateInfos = ({ status }) => {
)
}
const TaskDate = ({ value }) => (
<FormattedDate
value={new Date(value)}
month='short'
day='numeric'
year='numeric'
hour='2-digit'
minute='2-digit'
second='2-digit'
/>
)
const TaskStart = ({ task }) => (
<div>{_.keyValue(_('taskStart'), <TaskDate value={task.start} />)}</div>
)
const TaskEnd = ({ task }) =>
task.end !== undefined ? (
<div>{_.keyValue(_('taskEnd'), <TaskDate value={task.end} />)}</div>
) : null
const TaskDuration = ({ task }) =>
task.end !== undefined ? (
<div>
{_.keyValue(
_('taskDuration'),
<FormattedDuration duration={task.end - task.start} />
)}
</div>
) : null
const UNHEALTHY_VDI_CHAIN_ERROR = 'unhealthy VDI chain'
const UNHEALTHY_VDI_CHAIN_LINK =
'https://xen-orchestra.com/docs/backup_troubleshooting.html#vdi-chain-protection'
const TaskError = ({ task }) => {
let message
if (
!hasTaskFailed(task) ||
(message = defined(() => task.result.message, () => task.result.code)) ===
undefined
) {
return null
}
if (message === UNHEALTHY_VDI_CHAIN_ERROR) {
return (
<div>
<Tooltip content={_('clickForMoreInformation')}>
<a
className='text-info'
href={UNHEALTHY_VDI_CHAIN_LINK}
rel='noopener noreferrer'
target='_blank'
>
<Icon icon='info' /> {_('unhealthyVdiChainError')}
</a>
</Tooltip>
</div>
)
}
const [label, className] =
task.status === 'skipped'
? [_('taskReason'), 'text-info']
: [_('taskError'), 'text-danger']
return (
<div>{_.keyValue(label, <span className={className}>{message}</span>)}</div>
const TaskDate = ({ label, value }) =>
_.keyValue(
_(label),
<FormattedDate
value={new Date(value)}
month='short'
day='numeric'
year='numeric'
hour='2-digit'
minute='2-digit'
second='2-digit'
/>
)
}
const Warnings = ({ warnings }) =>
warnings !== undefined ? (
@@ -132,168 +72,9 @@ const Warnings = ({ warnings }) =>
</div>
) : null
const VmTask = ({ children, restartVmJob, task }) => (
<div>
<Vm id={task.data.id} link newTab /> <TaskStateInfos status={task.status} />{' '}
{restartVmJob !== undefined && hasTaskFailed(task) && (
<ActionButton
handler={restartVmJob}
icon='run'
size='small'
tooltip={_('backupRestartVm')}
data-vm={task.data.id}
/>
)}
<Warnings warnings={task.warnings} />
{children}
<TaskStart task={task} />
<TaskEnd task={task} />
<TaskDuration task={task} />
<TaskError task={task} />
{task.transfer !== undefined && (
<div>
{_.keyValue(
_('taskTransferredDataSize'),
formatSize(task.transfer.size)
)}
<br />
{_.keyValue(
_('taskTransferredDataSpeed'),
formatSpeed(task.transfer.size, task.transfer.duration)
)}
</div>
)}
{task.merge !== undefined && (
<div>
{_.keyValue(_('taskMergedDataSize'), formatSize(task.merge.size))}
<br />
{_.keyValue(
_('taskMergedDataSpeed'),
formatSpeed(task.merge.size, task.merge.duration)
)}
</div>
)}
{task.isFull !== undefined &&
_.keyValue(_('exportType'), task.isFull ? 'full' : 'delta')}
</div>
)
const PoolTask = ({ children, task }) => (
<div>
<Pool id={task.data.id} link newTab />{' '}
<TaskStateInfos status={task.status} />
<Warnings warnings={task.warnings} />
{children}
<TaskStart task={task} />
<TaskEnd task={task} />
<TaskDuration task={task} />
<TaskError task={task} />
</div>
)
const XoTask = ({ children, task }) => (
<div>
<Icon icon='menu-xoa' /> XO <TaskStateInfos status={task.status} />
<Warnings warnings={task.warnings} />
{children}
<TaskStart task={task} />
<TaskEnd task={task} />
<TaskDuration task={task} />
<TaskError task={task} />
</div>
)
const SnapshotTask = ({ task }) => (
<div>
<Icon icon='task' /> {_('snapshotVmLabel')}{' '}
<TaskStateInfos status={task.status} />
<Warnings warnings={task.warnings} />
<TaskStart task={task} />
<TaskEnd task={task} />
<TaskError task={task} />
</div>
)
const RemoteTask = ({ children, task }) => (
<div>
<Remote id={task.data.id} link newTab />{' '}
<TaskStateInfos status={task.status} />
<Warnings warnings={task.warnings} />
{children}
<TaskStart task={task} />
<TaskEnd task={task} />
<TaskDuration task={task} />
<TaskError task={task} />
</div>
)
const SrTask = ({ children, task }) => (
<div>
<Sr id={task.data.id} link newTab /> <TaskStateInfos status={task.status} />
<Warnings warnings={task.warnings} />
{children}
<TaskStart task={task} />
<TaskEnd task={task} />
<TaskDuration task={task} />
<TaskError task={task} />
</div>
)
const TransferMergeTask = ({ task }) => {
const size = get(() => task.result.size)
return (
<div>
<Icon icon='task' /> {task.message}{' '}
<TaskStateInfos status={task.status} />
<Warnings warnings={task.warnings} />
<TaskStart task={task} />
<TaskEnd task={task} />
<TaskDuration task={task} />
<TaskError task={task} />
{size > 0 && (
<div>
{_.keyValue(_('operationSize'), formatSize(size))}
<br />
{_.keyValue(
_('operationSpeed'),
formatSpeed(size, task.end - task.start)
)}
</div>
)}
</div>
)
}
const COMPONENT_BY_TYPE = {
vm: VmTask,
remote: RemoteTask,
sr: SrTask,
pool: PoolTask,
xo: XoTask,
}
const COMPONENT_BY_MESSAGE = {
snapshot: SnapshotTask,
merge: TransferMergeTask,
transfer: TransferMergeTask,
}
const TaskLi = ({ className, task, ...props }) => {
let Component
if (
(Component = defined(
() => COMPONENT_BY_TYPE[task.data.type.toLowerCase()],
COMPONENT_BY_MESSAGE[task.message]
)) === undefined
) {
return null
}
return (
<li className={className}>
<Component task={task} {...props} />
</li>
)
}
const UNHEALTHY_VDI_CHAIN_ERROR = 'unhealthy VDI chain'
const UNHEALTHY_VDI_CHAIN_LINK =
'https://xen-orchestra.com/docs/backup_troubleshooting.html#vdi-chain-protection'
export default decorate([
addSubscriptions(({ id }) => ({
@@ -326,39 +107,10 @@ export default decorate([
},
},
computed: {
log: (_, { log }) => {
if (log === undefined) {
return {}
}
if (log.tasks === undefined) {
return log
}
let newLog
log.tasks.forEach((task, key) => {
if (task.tasks === undefined || get(() => task.data.type) !== 'VM') {
return
}
const subTaskWithIsFull = task.tasks.find(
({ data = {} }) => data.isFull !== undefined
)
if (subTaskWithIsFull !== undefined) {
if (newLog === undefined) {
newLog = cloneDeep(log)
}
newLog.tasks[key].isFull = subTaskWithIsFull.data.isFull
}
})
return defined(newLog, log)
},
filteredTaskLogs: ({
defaultFilter,
filter: value = defaultFilter,
log,
}) =>
filteredTaskLogs: (
{ defaultFilter, filter: value = defaultFilter },
{ log = {} }
) =>
value === 'all'
? log.tasks
: filter(log.tasks, ({ status }) => status === value),
@@ -367,8 +119,8 @@ export default decorate([
{_(label)} ({countByStatus[value] || 0})
</span>
),
countByStatus: ({ log }) => ({
all: get(() => log.tasks.length),
countByStatus: (_, { log = {} }) => ({
all: get(log.tasks, 'length'),
...countBy(log.tasks, 'status'),
}),
options: ({ countByStatus }) => [
@@ -417,13 +169,13 @@ export default decorate([
},
}),
injectState,
({ remotes, state, effects }) => {
const { scheduleId, warnings, tasks = [] } = state.log
return tasks.length === 0 ? (
<div>
<Warnings warnings={warnings} />
<TaskError task={state.log} />
</div>
({ log = {}, remotes, state, effects }) => {
const { status, result, scheduleId } = log
return (status === 'failure' || status === 'skipped') &&
result !== undefined ? (
<span className={status === 'skipped' ? 'text-info' : 'text-danger'}>
<Icon icon='alarm' /> {result.message}
</span>
) : (
<div>
<Select
@@ -436,29 +188,238 @@ export default decorate([
value={state.filter || state.defaultFilter}
valueKey='value'
/>
<Warnings warnings={warnings} />
<Warnings warnings={log.warnings} />
<br />
<ul className='list-group'>
{map(state.filteredTaskLogs, taskLog => {
let globalIsFull
return (
<TaskLi
className='list-group-item'
key={taskLog.id}
restartVmJob={scheduleId && effects.restartVmJob}
task={taskLog}
>
<li key={taskLog.data.id} className='list-group-item'>
<Vm id={taskLog.data.id} link newTab /> (
{taskLog.data.id.slice(4, 8)}){' '}
<TaskStateInfos status={taskLog.status} />{' '}
{scheduleId !== undefined &&
taskLog.status !== 'success' &&
taskLog.status !== 'pending' && (
<ActionButton
handler={effects.restartVmJob}
icon='run'
size='small'
tooltip={_('backupRestartVm')}
data-vm={taskLog.data.id}
/>
)}
<Warnings warnings={taskLog.warnings} />
<ul>
{map(taskLog.tasks, subTaskLog => (
<TaskLi key={subTaskLog.id} task={subTaskLog}>
<ul>
{map(subTaskLog.tasks, subSubTaskLog => (
<TaskLi task={subSubTaskLog} key={subSubTaskLog.id} />
))}
</ul>
</TaskLi>
))}
{map(taskLog.tasks, subTaskLog => {
if (
subTaskLog.message !== 'export' &&
subTaskLog.message !== 'snapshot'
) {
return
}
const isFull = get(subTaskLog.data, 'isFull')
if (isFull !== undefined && globalIsFull === undefined) {
globalIsFull = isFull
}
return (
<li key={subTaskLog.id}>
{subTaskLog.message === 'snapshot' ? (
<span>
<Icon icon='task' /> {_('snapshotVmLabel')}
</span>
) : subTaskLog.data.type === 'remote' ? (
<span>
<Remote id={subTaskLog.data.id} link newTab /> (
{subTaskLog.data.id.slice(4, 8)})
</span>
) : (
<span>
<Sr id={subTaskLog.data.id} link newTab /> (
{subTaskLog.data.id.slice(4, 8)})
</span>
)}{' '}
<TaskStateInfos status={subTaskLog.status} />
<Warnings warnings={subTaskLog.warnings} />
<ul>
{map(subTaskLog.tasks, operationLog => {
if (
operationLog.message !== 'merge' &&
operationLog.message !== 'transfer'
) {
return
}
return (
<li key={operationLog.id}>
<span>
<Icon icon='task' /> {operationLog.message}
</span>{' '}
<TaskStateInfos status={operationLog.status} />
<Warnings warnings={operationLog.warnings} />
<br />
<TaskDate
label='taskStart'
value={operationLog.start}
/>
{operationLog.end !== undefined && (
<div>
<TaskDate
label='taskEnd'
value={operationLog.end}
/>
<br />
{_.keyValue(
_('taskDuration'),
<FormattedDuration
duration={
operationLog.end - operationLog.start
}
/>
)}
<br />
{operationLog.status === 'failure'
? _.keyValue(
_('taskError'),
<span className='text-danger'>
{operationLog.result.message}
</span>
)
: operationLog.result.size > 0 && (
<div>
{_.keyValue(
_('operationSize'),
formatSize(
operationLog.result.size
)
)}
<br />
{_.keyValue(
_('operationSpeed'),
formatSpeed(
operationLog.result.size,
operationLog.end -
operationLog.start
)
)}
</div>
)}
</div>
)}
</li>
)
})}
</ul>
<TaskDate label='taskStart' value={subTaskLog.start} />
{subTaskLog.end !== undefined && (
<div>
<TaskDate label='taskEnd' value={subTaskLog.end} />
<br />
{subTaskLog.message !== 'snapshot' &&
_.keyValue(
_('taskDuration'),
<FormattedDuration
duration={subTaskLog.end - subTaskLog.start}
/>
)}
<br />
{subTaskLog.status === 'failure' &&
subTaskLog.result !== undefined &&
_.keyValue(
_('taskError'),
<span className='text-danger'>
{subTaskLog.result.message}
</span>
)}
</div>
)}
</li>
)
})}
</ul>
</TaskLi>
<TaskDate label='taskStart' value={taskLog.start} />
<br />
{taskLog.end !== undefined && (
<div>
<TaskDate label='taskEnd' value={taskLog.end} />
<br />
{_.keyValue(
_('taskDuration'),
<FormattedDuration
duration={taskLog.end - taskLog.start}
/>
)}
<br />
{taskLog.result !== undefined ? (
taskLog.result.message === UNHEALTHY_VDI_CHAIN_ERROR ? (
<Tooltip content={_('clickForMoreInformation')}>
<a
className='text-info'
href={UNHEALTHY_VDI_CHAIN_LINK}
rel='noopener noreferrer'
target='_blank'
>
<Icon icon='info' /> {_('unhealthyVdiChainError')}
</a>
</Tooltip>
) : (
_.keyValue(
taskLog.status === 'skipped'
? _('taskReason')
: _('taskError'),
<span
className={
taskLog.status === 'skipped'
? 'text-info'
: 'text-danger'
}
>
{taskLog.result.message}
</span>
)
)
) : (
<div>
{taskLog.transfer !== undefined && (
<div>
{_.keyValue(
_('taskTransferredDataSize'),
formatSize(taskLog.transfer.size)
)}
<br />
{_.keyValue(
_('taskTransferredDataSpeed'),
formatSpeed(
taskLog.transfer.size,
taskLog.transfer.duration
)
)}
</div>
)}
{taskLog.merge !== undefined && (
<div>
{_.keyValue(
_('taskMergedDataSize'),
formatSize(taskLog.merge.size)
)}
<br />
{_.keyValue(
_('taskMergedDataSpeed'),
formatSpeed(
taskLog.merge.size,
taskLog.merge.duration
)
)}
</div>
)}
</div>
)}
</div>
)}
{globalIsFull !== undefined &&
_.keyValue(_('exportType'), globalIsFull ? 'full' : 'delta')}
</li>
)
})}
</ul>

View File

@@ -2,7 +2,9 @@ import _ from 'intl'
import classNames from 'classnames'
import Component from 'base-component'
import Icon from 'icon'
import isEmpty from 'lodash/isEmpty'
import Link from 'link'
import map from 'lodash/map'
import React from 'react'
import Tooltip from 'tooltip'
import { UpdateTag } from '../xoa/update'
@@ -11,7 +13,6 @@ import { addSubscriptions, connectStore, getXoaPlan, noop } from 'utils'
import {
connect,
signOut,
subscribeNotifications,
subscribePermissions,
subscribeResourceSets,
} from 'xo'
@@ -22,10 +23,8 @@ import {
getIsPoolAdmin,
getStatus,
getUser,
getXoaState,
isAdmin,
} from 'selectors'
import { every, identity, isEmpty, map } from 'lodash'
import styles from './index.css'
@@ -35,22 +34,20 @@ const returnTrue = () => true
() => ({
isAdmin,
isPoolAdmin: getIsPoolAdmin,
nHosts: createGetObjectsOfType('host').count(),
nTasks: createGetObjectsOfType('task').count([
task => task.status === 'pending',
]),
pools: createGetObjectsOfType('pool'),
nHosts: createGetObjectsOfType('host').count(),
srs: createGetObjectsOfType('SR'),
status: getStatus,
user: getUser,
xoaState: getXoaState,
}),
{
withRef: true,
}
)
@addSubscriptions({
notifications: subscribeNotifications,
permissions: subscribePermissions,
resourceSets: subscribeResourceSets,
})
@@ -91,11 +88,6 @@ export default class Menu extends Component {
isEmpty
)
_getNoNotifications = createSelector(
() => this.props.notifications,
notifications => every(notifications, { read: true })
)
get height() {
return this.refs.content.offsetHeight
}
@@ -126,11 +118,9 @@ export default class Menu extends Component {
pools,
nHosts,
srs,
xoaState,
} = this.props
const noOperatablePools = this._getNoOperatablePools()
const noResourceSets = this._getNoResourceSets()
const noNotifications = this._getNoNotifications()
/* eslint-disable object-property-newline */
const items = [
@@ -257,12 +247,16 @@ export default class Menu extends Component {
to: isAdmin ? 'xoa/update' : 'xoa/notifications',
icon: 'menu-xoa',
label: 'xoa',
extra: [
!isAdmin || xoaState === 'upToDate' ? null : (
<UpdateTag key='update' />
),
noNotifications ? null : <NotificationTag key='notification' />,
],
extra: (
<span>
{isAdmin && (
<span>
<UpdateTag />{' '}
</span>
)}
<NotificationTag />
</span>
),
subMenu: [
isAdmin && {
to: 'xoa/update',
@@ -534,7 +528,6 @@ export default class Menu extends Component {
const MenuLinkItem = props => {
const { item } = props
const { to, icon, label, subMenu, pill, extra } = item
const _extra = extra !== undefined ? extra.find(e => e !== null) : undefined
return (
<li className='nav-item xo-menu-item'>
@@ -544,7 +537,7 @@ const MenuLinkItem = props => {
to={to}
>
<Icon
className={classNames((pill || _extra) && styles.hiddenCollapsed)}
className={classNames((pill || extra) && styles.hiddenCollapsed)}
icon={`${icon}`}
size='lg'
fixedWidth
@@ -555,10 +548,7 @@ const MenuLinkItem = props => {
&nbsp;
</span>
{pill > 0 && <span className='tag tag-pill tag-primary'>{pill}</span>}
<span className={styles.hiddenUncollapsed}>{_extra}</span>
<span className={styles.hiddenCollapsed}>
{extra !== undefined && extra.map(identity)}
</span>
{extra}
</Link>
{subMenu && <SubMenu items={subMenu} />}
</li>

1444
yarn.lock

File diff suppressed because it is too large Load Diff