feat(metadata backup): backup XO config and pool metadata (#3912)

Fixes #3501
This commit is contained in:
badrAZ 2019-02-28 15:31:17 +01:00 committed by Julien Fontanet
parent 468a2c5bf3
commit fea5117ed8
20 changed files with 1308 additions and 55 deletions

View File

@ -8,6 +8,7 @@
- [Network] Dedicated page for network creation [#3895](https://github.com/vatesfr/xen-orchestra/issues/3895) (PR [#3906](https://github.com/vatesfr/xen-orchestra/pull/3906)) - [Network] Dedicated page for network creation [#3895](https://github.com/vatesfr/xen-orchestra/issues/3895) (PR [#3906](https://github.com/vatesfr/xen-orchestra/pull/3906))
- [Logs] Add button to download the log [#3957](https://github.com/vatesfr/xen-orchestra/issues/3957) (PR [#3985](https://github.com/vatesfr/xen-orchestra/pull/3985)) - [Logs] Add button to download the log [#3957](https://github.com/vatesfr/xen-orchestra/issues/3957) (PR [#3985](https://github.com/vatesfr/xen-orchestra/pull/3985))
- [Continuous Replication] Share full copy between schedules [#3973](https://github.com/vatesfr/xen-orchestra/issues/3973) (PR [#3995](https://github.com/vatesfr/xen-orchestra/pull/3995)) - [Continuous Replication] Share full copy between schedules [#3973](https://github.com/vatesfr/xen-orchestra/issues/3973) (PR [#3995](https://github.com/vatesfr/xen-orchestra/pull/3995))
- [Backup] Ability to backup XO configuration and pool metadata [#808](https://github.com/vatesfr/xen-orchestra/issues/808) [#3501](https://github.com/vatesfr/xen-orchestra/issues/3501) (PR [#3912](https://github.com/vatesfr/xen-orchestra/pull/3912))
### Bug fixes ### Bug fixes

View File

@ -154,6 +154,10 @@ class BackupReportsXoPlugin {
} }
_wrapper(status, job, schedule, runJobId) { _wrapper(status, job, schedule, runJobId) {
if (job.type === 'metadataBackup') {
return
}
return new Promise(resolve => return new Promise(resolve =>
resolve( resolve(
job.type === 'backup' job.type === 'backup'

View File

@ -0,0 +1,103 @@
export function createJob({ schedules, ...job }) {
job.userId = this.user.id
return this.createMetadataBackupJob(job, schedules)
}
createJob.permission = 'admin'
createJob.params = {
name: {
type: 'string',
optional: true,
},
pools: {
type: 'object',
optional: true,
},
remotes: {
type: 'object',
},
schedules: {
type: 'object',
},
settings: {
type: 'object',
},
xoMetadata: {
type: 'boolean',
optional: true,
},
}
export function getAllJobs() {
return this.getAllJobs('metadataBackup')
}
getAllJobs.permission = 'admin'
export function getJob({ id }) {
return this.getJob(id, 'metadataBackup')
}
getJob.permission = 'admin'
getJob.params = {
id: {
type: 'string',
},
}
export function deleteJob({ id }) {
return this.deleteMetadataBackupJob(id)
}
deleteJob.permission = 'admin'
deleteJob.params = {
id: {
type: 'string',
},
}
export function editJob(props) {
return this.updateJob(props)
}
editJob.permission = 'admin'
editJob.params = {
id: {
type: 'string',
},
name: {
type: 'string',
optional: true,
},
pools: {
type: ['object', 'null'],
optional: true,
},
settings: {
type: 'object',
optional: true,
},
remotes: {
type: 'object',
optional: true,
},
xoMetadata: {
type: 'boolean',
optional: true,
},
}
export async function runJob({ id, schedule }) {
return this.runJobSequence([id], await this.getSchedule(schedule))
}
runJob.permission = 'admin'
runJob.params = {
id: {
type: 'string',
},
schedule: {
type: 'string',
},
}

View File

@ -15,6 +15,8 @@ import { dirname, resolve } from 'path'
import { utcFormat, utcParse } from 'd3-time-format' import { utcFormat, utcParse } from 'd3-time-format'
import { fromCallback, pAll, pReflect, promisify } from 'promise-toolbox' import { fromCallback, pAll, pReflect, promisify } from 'promise-toolbox'
import { type SimpleIdPattern } from './utils'
// =================================================================== // ===================================================================
export function camelToSnakeCase(string) { export function camelToSnakeCase(string) {
@ -417,3 +419,13 @@ export const getFirstPropertyName = object => {
} }
} }
} }
// -------------------------------------------------------------------
export const unboxIdsFromPattern = (pattern?: SimpleIdPattern): string[] => {
if (pattern === undefined) {
return []
}
const { id } = pattern
return typeof id === 'string' ? [id] : id.__or
}

View File

@ -11,3 +11,5 @@ declare export function safeDateFormat(timestamp: number): string
declare export function serializeError(error: Error): Object declare export function serializeError(error: Error): Object
declare export function streamToBuffer(stream: Readable): Promise<Buffer> declare export function streamToBuffer(stream: Readable): Promise<Buffer>
export type SimpleIdPattern = {| id: string | {| __or: string[] |}, |}

View File

@ -0,0 +1,14 @@
import { cancelable } from 'promise-toolbox'
export default {
@cancelable
exportPoolMetadata($cancelToken) {
const { pool } = this
return this.getResource($cancelToken, '/pool/xmldbdump', {
task: this.createTask(
'Pool metadata',
pool.name_label ?? pool.$master.name_label
),
})
},
}

View File

@ -54,6 +54,8 @@ import {
resolveRelativeFromFile, resolveRelativeFromFile,
safeDateFormat, safeDateFormat,
serializeError, serializeError,
type SimpleIdPattern,
unboxIdsFromPattern,
} from '../../utils' } from '../../utils'
import { translateLegacyJob } from './migration' import { translateLegacyJob } from './migration'
@ -75,10 +77,6 @@ type Settings = {|
vmTimeout?: number, vmTimeout?: number,
|} |}
type SimpleIdPattern = {|
id: string | {| __or: string[] |},
|}
export type BackupJob = {| export type BackupJob = {|
...$Exact<Job>, ...$Exact<Job>,
compression?: 'native' | 'zstd' | '', compression?: 'native' | 'zstd' | '',
@ -310,14 +308,6 @@ const parseVmBackupId = (id: string) => {
} }
} }
const unboxIds = (pattern?: SimpleIdPattern): string[] => {
if (pattern === undefined) {
return []
}
const { id } = pattern
return typeof id === 'string' ? [id] : id.__or
}
// similar to Promise.all() but do not gather results // similar to Promise.all() but do not gather results
async function waitAll<T>( async function waitAll<T>(
promises: Promise<T>[], promises: Promise<T>[],
@ -605,7 +595,7 @@ export default class BackupNg {
} }
} }
const jobId = job.id const jobId = job.id
const srs = unboxIds(job.srs).map(id => { const srs = unboxIdsFromPattern(job.srs).map(id => {
const xapi = app.getXapi(id) const xapi = app.getXapi(id)
return { return {
__proto__: xapi.getObject(id), __proto__: xapi.getObject(id),
@ -613,7 +603,7 @@ export default class BackupNg {
} }
}) })
const remotes = await Promise.all( const remotes = await Promise.all(
unboxIds(job.remotes).map(async id => ({ unboxIdsFromPattern(job.remotes).map(async id => ({
id, id,
handler: await app.getRemoteHandler(id), handler: await app.getRemoteHandler(id),
})) }))

View File

@ -0,0 +1,264 @@
// @flow
import asyncMap from '@xen-orchestra/async-map'
import defer from 'golike-defer'
import { fromEvent, ignoreErrors } from 'promise-toolbox'
import { type Xapi } from '../xapi'
import {
safeDateFormat,
type SimpleIdPattern,
unboxIdsFromPattern,
} from '../utils'
import { type Executor, type Job } from './jobs'
import { type Schedule } from './scheduling'
const METADATA_BACKUP_JOB_TYPE = 'metadataBackup'
type Settings = {|
retentionXoMetadata?: number,
retentionPoolMetadata?: number,
|}
type MetadataBackupJob = {
...$Exact<Job>,
pools?: SimpleIdPattern,
remotes: SimpleIdPattern,
settings: $Dict<Settings>,
type: METADATA_BACKUP_JOB_TYPE,
xoMetadata?: boolean,
}
// File structure on remotes:
//
// <remote>
// ├─ xo-config-backups
// │ └─ <schedule ID>
// │ └─ <YYYYMMDD>T<HHmmss>
// │ ├─ metadata.json
// │ └─ data.json
// └─ xo-pool-metadata-backups
// └─ <schedule ID>
// └─ <pool UUID>
// └─ <YYYYMMDD>T<HHmmss>
// ├─ metadata.json
// └─ data
export default class metadataBackup {
_app: {
createJob: (
$Diff<MetadataBackupJob, {| id: string |}>
) => Promise<MetadataBackupJob>,
createSchedule: ($Diff<Schedule, {| id: string |}>) => Promise<Schedule>,
deleteSchedule: (id: string) => Promise<void>,
getXapi: (id: string) => Xapi,
getJob: (
id: string,
?METADATA_BACKUP_JOB_TYPE
) => Promise<MetadataBackupJob>,
updateJob: (
$Shape<MetadataBackupJob>,
?boolean
) => Promise<MetadataBackupJob>,
removeJob: (id: string) => Promise<void>,
}
constructor(app: any) {
this._app = app
app.on('start', () => {
app.registerJobExecutor(
METADATA_BACKUP_JOB_TYPE,
this._executor.bind(this)
)
})
}
async _executor({ cancelToken, job: job_, schedule }): Executor {
if (schedule === undefined) {
throw new Error('backup job cannot run without a schedule')
}
const job: MetadataBackupJob = (job_: any)
const remoteIds = unboxIdsFromPattern(job.remotes)
if (remoteIds.length === 0) {
throw new Error('metadata backup job cannot run without remotes')
}
const poolIds = unboxIdsFromPattern(job.pools)
const isEmptyPools = poolIds.length === 0
if (!job.xoMetadata && isEmptyPools) {
throw new Error('no metadata mode found')
}
const app = this._app
const { retentionXoMetadata, retentionPoolMetadata } =
job?.settings[schedule.id] || {}
const timestamp = Date.now()
const formattedTimestamp = safeDateFormat(timestamp)
const commonMetadata = {
jobId: job.id,
jobName: job.name,
scheduleId: schedule.id,
scheduleName: schedule.name,
timestamp,
}
const files = []
if (job.xoMetadata && retentionXoMetadata > 0) {
const xoMetadataDir = `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),
])
}),
dir: xoMetadataDir,
retention: retentionXoMetadata,
})
}
if (!isEmptyPools && retentionPoolMetadata > 0) {
files.push(
...(await Promise.all(
poolIds.map(async id => {
const poolMetadataDir = `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 xapi = this._app.getXapi(id)
const metadata = JSON.stringify(
{
...commonMetadata,
pool: xapi.pool,
poolMaster: await xapi.getRecord('host', xapi.pool.master),
},
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),
])
}),
dir: poolMetadataDir,
retention: retentionPoolMetadata,
}
})
))
)
}
if (files.length === 0) {
throw new Error('no retentions corresponding to the metadata modes found')
}
cancelToken.throwIfRequested()
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
}
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}`)
)
)
})
})
)
}
)
}
async createMetadataBackupJob(
props: $Diff<MetadataBackupJob, {| id: string |}>,
schedules: $Dict<$Diff<Schedule, {| id: string |}>>
): Promise<MetadataBackupJob> {
const app = this._app
const job: MetadataBackupJob = await app.createJob({
...props,
type: METADATA_BACKUP_JOB_TYPE,
})
const { id: jobId, settings } = job
await asyncMap(schedules, async (schedule, tmpId) => {
const { id: scheduleId } = await app.createSchedule({
...schedule,
jobId,
})
settings[scheduleId] = settings[tmpId]
delete settings[tmpId]
})
await app.updateJob({ id: jobId, settings })
return job
}
async deleteMetadataBackupJob(id: string): Promise<void> {
const app = this._app
const [schedules] = await Promise.all([
app.getAllSchedules(),
// it test if the job is of type metadataBackup
app.getJob(id, METADATA_BACKUP_JOB_TYPE),
])
await Promise.all([
app.removeJob(id),
asyncMap(schedules, schedule => {
if (schedule.id === id) {
return app.deleteSchedule(id)
}
}),
])
}
}

View File

@ -1,9 +1,11 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import React from 'react' import React from 'react'
import { injectState, provideState } from 'reaclette' import { injectState, provideState } from 'reaclette'
import { startsWith } from 'lodash'
import decorate from '../apply-decorators' import decorate from '../apply-decorators'
// it provide `data-*` to add params to the `onChange`
const Number_ = decorate([ const Number_ = decorate([
provideState({ provideState({
effects: { effects: {
@ -18,7 +20,16 @@ const Number_ = decorate([
} }
} }
props.onChange(value) const params = {}
let empty = true
Object.keys(props).forEach(key => {
if (startsWith(key, 'data-')) {
empty = false
params[key.slice(5)] = props[key]
}
})
props.onChange(value, empty ? undefined : params)
}, },
}, },
}), }),

View File

@ -37,6 +37,8 @@ const messages = {
paths: 'Paths', paths: 'Paths',
pbdDisconnected: 'PBD disconnected', pbdDisconnected: 'PBD disconnected',
hasInactivePath: 'Has an inactive path', hasInactivePath: 'Has an inactive path',
pools: 'Pools',
remotes: 'Remotes',
// ----- Modals ----- // ----- Modals -----
alertOk: 'OK', alertOk: 'OK',
@ -125,6 +127,11 @@ const messages = {
deltaBackup: 'Delta Backup', deltaBackup: 'Delta Backup',
disasterRecovery: 'Disaster Recovery', disasterRecovery: 'Disaster Recovery',
continuousReplication: 'Continuous Replication', continuousReplication: 'Continuous Replication',
backupType: 'Backup type',
poolMetadata: 'Pool metadata',
xoConfig: 'XO config',
backupVms: 'Backup VMs',
backupMetadata: 'Backup metadata',
jobsOverviewPage: 'Overview', jobsOverviewPage: 'Overview',
jobsNewPage: 'New', jobsNewPage: 'New',
jobsSchedulingPage: 'Scheduling', jobsSchedulingPage: 'Scheduling',
@ -370,14 +377,17 @@ const messages = {
missingBackupMode: 'You need to choose a backup mode!', missingBackupMode: 'You need to choose a backup mode!',
missingRemotes: 'Missing remotes!', missingRemotes: 'Missing remotes!',
missingSrs: 'Missing SRs!', missingSrs: 'Missing SRs!',
missingPools: 'Missing pools!',
missingSchedules: 'Missing schedules!', missingSchedules: 'Missing schedules!',
missingRetentions:
'The modes need at least a schedule with retention higher than 0',
missingExportRetention: missingExportRetention:
'The Backup mode and The Delta Backup mode require backup retention to be higher than 0!', 'The Backup mode and The Delta Backup mode require backup retention to be higher than 0!',
missingCopyRetention: missingCopyRetention:
'The CR mode and The DR mode require replication retention to be higher than 0!', 'The CR mode and The DR mode require replication retention to be higher than 0!',
missingSnapshotRetention: missingSnapshotRetention:
'The Rolling Snapshot mode requires snapshot retention to be higher than 0!', 'The Rolling Snapshot mode requires snapshot retention to be higher than 0!',
retentionNeeded: 'One of the retentions needs to be higher than 0!', retentionNeeded: 'Requires one retention to be higher than 0!',
newScheduleError: 'Invalid schedule', newScheduleError: 'Invalid schedule',
createRemoteMessage: createRemoteMessage:
'No remotes found, please click on the remotes settings button to create one!', 'No remotes found, please click on the remotes settings button to create one!',
@ -1396,6 +1406,8 @@ const messages = {
scheduleExportRetention: 'Backup ret.', scheduleExportRetention: 'Backup ret.',
scheduleCopyRetention: 'Replication ret.', scheduleCopyRetention: 'Replication ret.',
scheduleSnapshotRetention: 'Snapshot ret.', scheduleSnapshotRetention: 'Snapshot ret.',
poolMetadataRetention: 'Pool ret.',
xoMetadataRetention: 'XO ret.',
getRemote: 'Get remote', getRemote: 'Get remote',
listRemote: 'List Remote', listRemote: 'List Remote',
simpleBackup: 'simple', simpleBackup: 'simple',

View File

@ -1922,15 +1922,22 @@ export const subscribeBackupNgLogs = createSubscription(() =>
_call('backupNg.getAllLogs') _call('backupNg.getAllLogs')
) )
export const subscribeMetadataBackupJobs = createSubscription(() =>
_call('metadataBackup.getAllJobs')
)
export const createBackupNgJob = props => export const createBackupNgJob = props =>
_call('backupNg.createJob', props)::tap(subscribeBackupNgJobs.forceRefresh) _call('backupNg.createJob', props)::tap(subscribeBackupNgJobs.forceRefresh)
export const deleteBackupNgJobs = async ids => { export const deleteBackupJobs = async ({
const { length } = ids backupIds = [],
if (length === 0) { metadataBackupIds = [],
}) => {
const nJobs = backupIds.length + metadataBackupIds.length
if (nJobs === 0) {
return return
} }
const vars = { nJobs: length } const vars = { nJobs }
try { try {
await confirm({ await confirm({
title: _('confirmDeleteBackupJobsTitle', vars), title: _('confirmDeleteBackupJobsTitle', vars),
@ -1940,9 +1947,25 @@ export const deleteBackupNgJobs = async ids => {
return return
} }
return Promise.all( const promises = []
ids.map(id => _call('backupNg.deleteJob', { id: resolveId(id) })) if (backupIds.length !== 0) {
)::tap(subscribeBackupNgJobs.forceRefresh) promises.push(
Promise.all(
backupIds.map(id => _call('backupNg.deleteJob', { id: resolveId(id) }))
)::tap(subscribeBackupNgJobs.forceRefresh)
)
}
if (metadataBackupIds.length !== 0) {
promises.push(
Promise.all(
metadataBackupIds.map(id =>
_call('metadataBackup.deleteJob', { id: resolveId(id) })
)
)::tap(subscribeMetadataBackupJobs.forceRefresh)
)
}
return Promise.all(promises)::tap(subscribeSchedules.forceRefresh)
} }
export const editBackupNgJob = props => export const editBackupNgJob = props =>
@ -1979,6 +2002,19 @@ export const deleteBackups = async backups => {
} }
} }
export const createMetadataBackupJob = props =>
_call('metadataBackup.createJob', props)
::tap(subscribeMetadataBackupJobs.forceRefresh)
::tap(subscribeSchedules.forceRefresh)
export const editMetadataBackupJob = props =>
_call('metadataBackup.editJob', props)
::tap(subscribeMetadataBackupJobs.forceRefresh)
::tap(subscribeSchedules.forceRefresh)
export const runMetadataBackupJob = params =>
_call('metadataBackup.runJob', params)
// Plugins ----------------------------------------------------------- // Plugins -----------------------------------------------------------
export const loadPlugin = async id => export const loadPlugin = async id =>

View File

@ -1,15 +1,22 @@
import addSubscriptions from 'add-subscriptions' import addSubscriptions from 'add-subscriptions'
import decorate from 'apply-decorators' import decorate from 'apply-decorators'
import defined from '@xen-orchestra/defined'
import React from 'react' import React from 'react'
import { injectState, provideState } from 'reaclette' import { injectState, provideState } from 'reaclette'
import { subscribeBackupNgJobs, subscribeSchedules } from 'xo'
import { find, groupBy, keyBy } from 'lodash' import { find, groupBy, keyBy } from 'lodash'
import {
subscribeBackupNgJobs,
subscribeMetadataBackupJobs,
subscribeSchedules,
} from 'xo'
import Metadata from './new/metadata'
import New from './new' import New from './new'
export default decorate([ export default decorate([
addSubscriptions({ addSubscriptions({
jobs: subscribeBackupNgJobs, jobs: subscribeBackupNgJobs,
metadataJobs: subscribeMetadataBackupJobs,
schedulesByJob: cb => schedulesByJob: cb =>
subscribeSchedules(schedules => { subscribeSchedules(schedules => {
cb(groupBy(schedules, 'jobId')) cb(groupBy(schedules, 'jobId'))
@ -17,11 +24,17 @@ export default decorate([
}), }),
provideState({ provideState({
computed: { computed: {
job: (_, { jobs, routeParams: { id } }) => find(jobs, { id }), job: (_, { jobs, metadataJobs, routeParams: { id } }) =>
defined(find(jobs, { id }), find(metadataJobs, { id })),
schedules: (_, { schedulesByJob, routeParams: { id } }) => schedules: (_, { schedulesByJob, routeParams: { id } }) =>
schedulesByJob && keyBy(schedulesByJob[id], 'id'), schedulesByJob && keyBy(schedulesByJob[id], 'id'),
}, },
}), }),
injectState, injectState,
({ state: { job, schedules } }) => <New job={job} schedules={schedules} />, ({ state: { job = {}, schedules } }) =>
job.type === 'backup' ? (
<New job={job} schedules={schedules} />
) : (
<Metadata job={job} schedules={schedules} />
),
]) ])

View File

@ -2,6 +2,7 @@ import _ from 'intl'
import ActionButton from 'action-button' import ActionButton from 'action-button'
import addSubscriptions from 'add-subscriptions' import addSubscriptions from 'add-subscriptions'
import Button from 'button' import Button from 'button'
import ButtonLink from 'button-link'
import Copiable from 'copiable' import Copiable from 'copiable'
import CopyToClipboard from 'react-copy-to-clipboard' import CopyToClipboard from 'react-copy-to-clipboard'
import decorate from 'apply-decorators' import decorate from 'apply-decorators'
@ -16,29 +17,31 @@ import { confirm } from 'modal'
import { connectStore, routes } from 'utils' import { connectStore, routes } from 'utils'
import { constructQueryString } from 'smart-backup' import { constructQueryString } from 'smart-backup'
import { Container, Row, Col } from 'grid' import { Container, Row, Col } from 'grid'
import { createGetLoneSnapshots } from 'selectors' import { createGetLoneSnapshots, createSelector } from 'selectors'
import { get } from '@xen-orchestra/defined' import { get } from '@xen-orchestra/defined'
import { isEmpty, map, groupBy, some } from 'lodash' import { isEmpty, map, groupBy, some } from 'lodash'
import { NavLink, NavTabs } from 'nav' import { NavLink, NavTabs } from 'nav'
import { import {
cancelJob, cancelJob,
deleteBackupNgJobs, deleteBackupJobs,
disableSchedule, disableSchedule,
enableSchedule, enableSchedule,
runBackupNgJob, runBackupNgJob,
runMetadataBackupJob,
subscribeBackupNgJobs, subscribeBackupNgJobs,
subscribeBackupNgLogs, subscribeBackupNgLogs,
subscribeMetadataBackupJobs,
subscribeSchedules, subscribeSchedules,
} from 'xo' } from 'xo'
import LogsTable, { LogStatus } from '../logs/backup-ng' import LogsTable, { LogStatus } from '../logs/backup-ng'
import Page from '../page' import Page from '../page'
import NewVmBackup, { NewMetadataBackup } from './new'
import Edit from './edit' import Edit from './edit'
import New from './new'
import FileRestore from './file-restore' import FileRestore from './file-restore'
import Restore from './restore'
import Health from './health' import Health from './health'
import Restore from './restore'
import { destructPattern } from './utils' import { destructPattern } from './utils'
const Ul = props => <ul {...props} style={{ listStyleType: 'none' }} /> const Ul = props => <ul {...props} style={{ listStyleType: 'none' }} />
@ -51,14 +54,26 @@ const Li = props => (
/> />
) )
const _runBackupNgJob = ({ id, name, schedule }) => const _runBackupJob = ({ id, name, schedule, type }) =>
confirm({ confirm({
title: _('runJob'), title: _('runJob'),
body: _('runBackupNgJobConfirm', { body: _('runBackupNgJobConfirm', {
id: id.slice(0, 5), id: id.slice(0, 5),
name: <strong>{name}</strong>, name: <strong>{name}</strong>,
}), }),
}).then(() => runBackupNgJob({ id, schedule })) }).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([ const SchedulePreviewBody = decorate([
addSubscriptions(({ schedule }) => ({ addSubscriptions(({ schedule }) => ({
@ -119,7 +134,8 @@ const SchedulePreviewBody = decorate([
data-id={job.id} data-id={job.id}
data-name={job.name} data-name={job.name}
data-schedule={schedule.id} data-schedule={schedule.id}
handler={_runBackupNgJob} data-type={job.type}
handler={_runBackupJob}
icon='run-schedule' icon='run-schedule'
key='run' key='run'
size='small' size='small'
@ -159,10 +175,19 @@ const MODES = [
test: job => test: job =>
job.mode === 'full' && !isEmpty(get(() => destructPattern(job.srs))), job.mode === 'full' && !isEmpty(get(() => destructPattern(job.srs))),
}, },
{
label: 'poolMetadata',
test: job => !isEmpty(destructPattern(job.pools)),
},
{
label: 'xoConfig',
test: job => job.xoMetadata,
},
] ]
@addSubscriptions({ @addSubscriptions({
jobs: subscribeBackupNgJobs, jobs: subscribeBackupNgJobs,
metadataJobs: subscribeMetadataBackupJobs,
schedulesByJob: cb => schedulesByJob: cb =>
subscribeSchedules(schedules => { subscribeSchedules(schedules => {
cb(groupBy(schedules, 'jobId')) cb(groupBy(schedules, 'jobId'))
@ -176,7 +201,7 @@ class JobsTable extends React.Component {
static tableProps = { static tableProps = {
actions: [ actions: [
{ {
handler: deleteBackupNgJobs, handler: _deleteBackupJobs,
label: _('deleteBackupSchedule'), label: _('deleteBackupSchedule'),
icon: 'delete', icon: 'delete',
level: 'danger', level: 'danger',
@ -261,6 +286,7 @@ class JobsTable extends React.Component {
pathname: '/home', pathname: '/home',
query: { t: 'VM', s: constructQueryString(job.vms) }, query: { t: 'VM', s: constructQueryString(job.vms) },
}), }),
disabled: job => job.type !== 'backup',
label: _('redirectToMatchingVms'), label: _('redirectToMatchingVms'),
icon: 'preview', icon: 'preview',
}, },
@ -277,11 +303,17 @@ class JobsTable extends React.Component {
this.context.router.push(path) this.context.router.push(path)
} }
_getCollection = createSelector(
() => this.props.jobs,
() => this.props.metadataJobs,
(jobs = [], metadataJobs = []) => [...jobs, ...metadataJobs]
)
render() { render() {
return ( return (
<SortedTable <SortedTable
{...JobsTable.tableProps} {...JobsTable.tableProps}
collection={this.props.jobs} collection={this._getCollection()}
data-goTo={this._goTo} data-goTo={this._goTo}
data-schedulesByJob={this.props.schedulesByJob} data-schedulesByJob={this.props.schedulesByJob}
/> />
@ -353,9 +385,31 @@ const HEADER = (
</Container> </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', { export default routes('overview', {
':id/edit': Edit, ':id/edit': Edit,
new: New, new: ChooseBackupType,
'new/vms': NewVmBackup,
'new/metadata': NewMetadataBackup,
overview: Overview, overview: Overview,
restore: Restore, restore: Restore,
'file-restore': FileRestore, 'file-restore': FileRestore,

View File

@ -0,0 +1,224 @@
import _ from 'intl'
import ActionButton from 'action-button'
import decorate from 'apply-decorators'
import Icon from 'icon'
import moment from 'moment-timezone'
import PropTypes from 'prop-types'
import React from 'react'
import SortedTable from 'sorted-table'
import StateButton from 'state-button'
import UserError from 'user-error'
import { Card, CardBlock, CardHeader } from 'card'
import { form } from 'modal'
import { generateRandomId } from 'utils'
import { get } from '@xen-orchestra/defined'
import { injectState, provideState } from 'reaclette'
import { FormFeedback } from '../../utils'
import NewSchedule from './new'
const DEFAULT_SCHEDULE = {
cron: '0 0 * * *',
timezone: moment.tz.guess(),
}
const setDefaultRetentions = (schedule, retentions) => {
retentions.forEach(({ defaultValue, valuePath }) => {
if (schedule[valuePath] === undefined) {
schedule[valuePath] = defaultValue
}
})
return schedule
}
export const areRetentionsMissing = (value, retentions) =>
retentions.length !== 0 &&
!retentions.some(({ valuePath }) => value[valuePath] > 0)
const COLUMNS = [
{
valuePath: 'name',
name: _('scheduleName'),
default: true,
},
{
itemRenderer: (schedule, { toggleScheduleState }) => (
<StateButton
disabledLabel={_('stateDisabled')}
disabledTooltip={_('logIndicationToEnable')}
enabledLabel={_('stateEnabled')}
enabledTooltip={_('logIndicationToDisable')}
handler={toggleScheduleState}
handlerParam={schedule.id}
state={schedule.enabled}
/>
),
sortCriteria: 'enabled',
name: _('state'),
},
{
valuePath: 'cron',
name: _('scheduleCron'),
},
{
valuePath: 'timezone',
name: _('scheduleTimezone'),
},
]
const INDIVIDUAL_ACTIONS = [
{
handler: (schedule, { showModal }) => showModal(schedule),
icon: 'edit',
label: _('scheduleEdit'),
level: 'primary',
},
{
handler: ({ id }, { deleteSchedule }) => deleteSchedule(id),
icon: 'delete',
label: _('scheduleDelete'),
level: 'danger',
},
]
const Schedules = decorate([
provideState({
effects: {
deleteSchedule: (_, id) => (state, props) => {
const schedules = { ...props.schedules }
delete schedules[id]
props.handlerSchedules(schedules)
const settings = { ...props.settings }
delete settings[id]
props.handlerSettings(settings)
},
showModal: (
effects,
{ id = generateRandomId(), name, cron, timezone } = DEFAULT_SCHEDULE
) => async (state, props) => {
const schedule = get(() => props.schedules[id])
const setting = get(() => props.settings[id])
const {
cron: newCron,
name: newName,
timezone: newTimezone,
...newSetting
} = await form({
defaultValue: setDefaultRetentions(
{ cron, name, timezone, ...setting },
state.retentions
),
render: props => (
<NewSchedule retentions={state.retentions} {...props} />
),
header: (
<span>
<Icon icon='schedule' /> {_('schedule')}
</span>
),
size: 'large',
handler: value => {
if (areRetentionsMissing(value, state.retentions)) {
throw new UserError(_('newScheduleError'), _('retentionNeeded'))
}
return value
},
})
props.handlerSchedules({
...props.schedules,
[id]: {
...schedule,
cron: newCron,
id,
name: newName,
timezone: newTimezone,
},
})
props.handlerSettings({
...props.settings,
[id]: {
...setting,
...newSetting,
},
})
},
toggleScheduleState: (_, id) => (
state,
{ handlerSchedules, schedules }
) => {
const schedule = schedules[id]
handlerSchedules({
...schedules,
[id]: {
...schedule,
enabled: !schedule.enabled,
},
})
},
},
computed: {
columns: (_, { retentions }) => [
...COLUMNS,
...retentions.map(({ defaultValue, ...props }) => props),
],
rowTransform: (_, { settings = {}, retentions }) => schedule => {
schedule = { ...schedule, ...settings[schedule.id] }
return setDefaultRetentions(schedule, retentions)
},
},
}),
injectState,
({ state, effects, schedules, missingSchedules, missingRetentions }) => (
<FormFeedback
component={Card}
error={missingSchedules || missingRetentions}
message={
missingSchedules ? _('missingSchedules') : _('missingRetentions')
}
>
<CardHeader>
{_('backupSchedules')}*
<ActionButton
btnStyle='primary'
className='pull-right'
handler={effects.showModal}
icon='add'
tooltip={_('scheduleAdd')}
/>
</CardHeader>
<CardBlock>
<SortedTable
collection={schedules}
columns={state.columns}
data-deleteSchedule={effects.deleteSchedule}
data-showModal={effects.showModal}
data-toggleScheduleState={effects.toggleScheduleState}
individualActions={INDIVIDUAL_ACTIONS}
rowTransform={state.rowTransform}
/>
</CardBlock>
</FormFeedback>
),
])
Schedules.propTypes = {
handlerSchedules: PropTypes.func.isRequired,
handlerSettings: PropTypes.func.isRequired,
missingRetentions: PropTypes.bool,
missingSchedules: PropTypes.bool,
retentions: PropTypes.arrayOf(
PropTypes.shape({
defaultValue: PropTypes.number,
name: PropTypes.node.isRequired,
valuePath: PropTypes.string.isRequired,
})
).isRequired,
schedules: PropTypes.object,
settings: PropTypes.object,
}
export { Schedules as default }

View File

@ -0,0 +1,94 @@
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'
import { generateId } from 'reaclette-utils'
import { injectState, provideState } from 'reaclette'
import { Number } from 'form'
import { FormGroup, Input } from '../../utils'
import { areRetentionsMissing } from '.'
export default decorate([
provideState({
effects: {
setSchedule: (_, params) => (_, { value, onChange }) => {
onChange({
...value,
...params,
})
},
setCronTimezone: (
{ setSchedule },
{ cronPattern: cron, timezone }
) => () => {
setSchedule({
cron,
timezone,
})
},
setName: ({ setSchedule }, { target: { value } }) => () => {
setSchedule({
name: value.trim() === '' ? null : value,
})
},
setRetention: ({ setSchedule }, value, { name }) => () => {
setSchedule({
[name]: defined(value, null),
})
},
},
computed: {
idInputName: generateId,
missingRetentions: (_, { value, retentions }) =>
areRetentionsMissing(value, retentions),
},
}),
injectState,
({ effects, state, retentions, value: schedule }) => (
<div>
{state.missingRetentions && (
<div className='text-danger text-md-center'>
<Icon icon='alarm' /> {_('retentionNeeded')}
</div>
)}
<FormGroup>
<label htmlFor={state.idInputName}>
<strong>{_('formName')}</strong>
</label>
<Input
id={state.idInputName}
onChange={effects.setName}
value={schedule.name}
/>
</FormGroup>
{/* retentions effects are defined on initialize() */}
{retentions.map(({ name, valuePath }) => (
<FormGroup key={valuePath}>
<label>
<strong>{name}</strong>
</label>
<Number
data-name={valuePath}
min='0'
onChange={effects.setRetention}
value={schedule[valuePath]}
/>
</FormGroup>
))}
<Scheduler
onChange={effects.setCronTimezone}
cronPattern={schedule.cron}
timezone={schedule.timezone}
/>
<SchedulePreview
cronPattern={schedule.cron}
timezone={schedule.timezone}
/>
</div>
),
])

View File

@ -46,6 +46,7 @@ import Schedules from './schedules'
import SmartBackup from './smart-backup' import SmartBackup from './smart-backup'
import { import {
canDeltaBackup, canDeltaBackup,
constructPattern,
destructPattern, destructPattern,
FormFeedback, FormFeedback,
FormGroup, FormGroup,
@ -54,6 +55,8 @@ import {
Ul, Ul,
} from './../utils' } from './../utils'
export NewMetadataBackup from './metadata'
// =================================================================== // ===================================================================
const DEFAULT_RETENTION = 1 const DEFAULT_RETENTION = 1
@ -102,17 +105,6 @@ const normalizeSettings = ({ settings, exportMode, copyMode, snapshotMode }) =>
: setting : setting
) )
const constructPattern = values =>
values.length === 1
? {
id: resolveId(values[0]),
}
: {
id: {
__or: resolveIds(values),
},
}
const destructVmsPattern = pattern => const destructVmsPattern = pattern =>
pattern.id === undefined pattern.id === undefined
? { ? {

View File

@ -0,0 +1,410 @@
import _ from 'intl'
import ActionButton from 'action-button'
import addSubscriptions from 'add-subscriptions'
import decorate from 'apply-decorators'
import defined from '@xen-orchestra/defined'
import Icon from 'icon'
import Link from 'link'
import React from 'react'
import Upgrade from 'xoa-upgrade'
import { Card, CardBlock, CardHeader } from 'card'
import { Container, Col, Row } from 'grid'
import { generateId, linkState } from 'reaclette-utils'
import { injectState, provideState } from 'reaclette'
import { every, isEmpty, mapValues, map } from 'lodash'
import { Remote } from 'render-xo-item'
import { SelectPool, SelectRemote } from 'select-objects'
import {
createMetadataBackupJob,
createSchedule,
deleteSchedule,
editMetadataBackupJob,
editSchedule,
subscribeRemotes,
} from 'xo'
import {
constructPattern,
destructPattern,
FormFeedback,
FormGroup,
Input,
Li,
Ul,
} from '../../utils'
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 = {
defaultValue: DEFAULT_RETENTION,
name: _('poolMetadataRetention'),
valuePath: 'retentionPoolMetadata',
}
const RETENTION_XO_METADATA = {
defaultValue: DEFAULT_RETENTION,
name: _('xoMetadataRetention'),
valuePath: 'retentionXoMetadata',
}
const getInitialState = () => ({
_modePoolMetadata: undefined,
_modeXoMetadata: undefined,
_name: undefined,
_pools: undefined,
_remotes: undefined,
_schedules: undefined,
_settings: undefined,
showErrors: false,
})
export default decorate([
New => props => (
<Upgrade place='newMetadataBackup' required={3}>
<New {...props} />
</Upgrade>
),
addSubscriptions({
remotes: subscribeRemotes,
}),
provideState({
initialState: getInitialState,
effects: {
createJob: () => async state => {
if (state.isJobInvalid) {
return { showErrors: true }
}
await createMetadataBackupJob({
name: state.name,
pools: state.modePoolMetadata
? constructPattern(state.pools)
: undefined,
remotes: constructPattern(state.remotes),
xoMetadata: state.modeXoMetadata,
schedules: mapValues(
state.schedules,
({ id, ...schedule }) => schedule
),
settings: mapValues(
state.settings,
({ retentionPoolMetadata, retentionXoMetadata }) => ({
retentionPoolMetadata: state.modePoolMetadata
? defined(retentionPoolMetadata, DEFAULT_RETENTION)
: undefined,
retentionXoMetadata: state.modeXoMetadata
? defined(retentionXoMetadata, DEFAULT_RETENTION)
: undefined,
})
),
})
},
editJob: () => async (state, props) => {
if (state.isJobInvalid) {
return { showErrors: true }
}
const settings = { ...state.settings }
await Promise.all([
...map(props.schedules, ({ id }) => {
const schedule = state.schedules[id]
if (schedule === undefined) {
return deleteSchedule(id)
}
return editSchedule({
id,
cron: schedule.cron,
name: schedule.name,
timezone: schedule.timezone,
enabled: schedule.enabled,
})
}),
...map(state.schedules, async schedule => {
if (props.schedules[schedule.id] === undefined) {
const { id } = await createSchedule(props.job.id, {
cron: schedule.cron,
name: schedule.name,
timezone: schedule.timezone,
enabled: schedule.enabled,
})
settings[id] = settings[schedule.id]
delete settings[schedule.id]
}
}),
])
await editMetadataBackupJob({
id: props.job.id,
name: state.name,
pools: state.modePoolMetadata ? constructPattern(state.pools) : null,
remotes: constructPattern(state.remotes),
xoMetadata: state.modeXoMetadata,
settings: mapValues(
settings,
({ retentionPoolMetadata, retentionXoMetadata }) => ({
retentionPoolMetadata: state.modePoolMetadata
? defined(retentionPoolMetadata, DEFAULT_RETENTION)
: undefined,
retentionXoMetadata: state.modeXoMetadata
? defined(retentionXoMetadata, DEFAULT_RETENTION)
: undefined,
})
),
})
},
linkState,
reset: () => getInitialState,
setPools: (_, _pools) => () => ({
_pools,
}),
setSchedules: (_, _schedules) => () => ({
_schedules,
}),
setSettings: (_, _settings) => () => ({
_settings,
}),
toggleMode: (_, { mode }) => state => ({
[`_${mode}`]: !state[mode],
}),
addRemote: (_, { id }) => state => ({
_remotes: [...state.remotes, id],
}),
deleteRemote: (_, key) => state => {
const _remotes = [...state.remotes]
_remotes.splice(key, 1)
return {
_remotes,
}
},
},
computed: {
idForm: generateId,
modePoolMetadata: ({ _modePoolMetadata }, { job }) =>
defined(_modePoolMetadata, () => !isEmpty(destructPattern(job.pools))),
modeXoMetadata: ({ _modeXoMetadata }, { job }) =>
defined(_modeXoMetadata, () => job.xoMetadata),
name: (state, { job }) => defined(state._name, () => job.name, ''),
pools: ({ _pools }, { job }) =>
defined(_pools, () => destructPattern(job.pools)),
retentions: ({ modePoolMetadata, modeXoMetadata }) => {
const retentions = []
if (modePoolMetadata) {
retentions.push(RETENTION_POOL_METADATA)
}
if (modeXoMetadata) {
retentions.push(RETENTION_XO_METADATA)
}
return retentions
},
schedules: ({ _schedules }, { schedules }) =>
defined(_schedules, schedules),
settings: ({ _settings }, { job }) =>
defined(_settings, () => job.settings),
remotes: ({ _remotes }, { job }) =>
defined(_remotes, () => destructPattern(job.remotes), []),
remotesPredicate: ({ remotes }) => ({ id }) => !remotes.includes(id),
isJobInvalid: state =>
state.missingModes ||
state.missingPools ||
state.missingRemotes ||
state.missingRetentionPoolMetadata ||
state.missingRetentionXoMetadata ||
state.missingSchedules,
missingModes: state => !state.modeXoMetadata && !state.modePoolMetadata,
missingPools: state => state.modePoolMetadata && isEmpty(state.pools),
missingRemotes: state => isEmpty(state.remotes),
missingRetentionPoolMetadata: state =>
state.modePoolMetadata &&
every(
state.settings,
({ retentionPoolMetadata }) => retentionPoolMetadata === null
),
missingRetentionXoMetadata: state =>
state.modeXoMetadata &&
every(
state.settings,
({ retentionXoMetadata }) => retentionXoMetadata === null
),
missingSchedules: state => isEmpty(state.schedules),
},
}),
injectState,
({ state, effects, job, remotes }) => {
const [submitHandler, submitTitle] =
job === undefined
? [effects.createJob, 'formCreate']
: [effects.editJob, 'formSave']
const {
missingModes,
missingPools,
missingRemotes,
missingRetentionPoolMetadata,
missingRetentionXoMetadata,
missingSchedules,
} = state.showErrors ? state : {}
return (
<form id={state.idForm}>
<Container>
<Row>
<Col mediumSize={6}>
<Card>
<CardHeader>{_('backupName')}*</CardHeader>
<CardBlock>
<Input
onChange={effects.linkState}
name='_name'
value={state.name}
/>
</CardBlock>
</Card>
<FormFeedback
component={Card}
error={missingModes}
message={_('missingBackupMode')}
>
<CardBlock>
<div className='text-xs-center'>
<ActionButton
active={state.modePoolMetadata}
data-mode='modePoolMetadata'
handler={effects.toggleMode}
icon='pool'
>
{_('poolMetadata')}
</ActionButton>{' '}
<ActionButton
active={state.modeXoMetadata}
data-mode='modeXoMetadata'
handler={effects.toggleMode}
icon='file'
>
{_('xoConfig')}
</ActionButton>{' '}
</div>
</CardBlock>
</FormFeedback>
<Card>
<CardHeader>
{_('remotes')}
<Link
className='btn btn-primary pull-right'
target='_blank'
to='/settings/remotes'
>
<Icon icon='settings' />{' '}
<strong>{_('remotesSettings')}</strong>
</Link>
</CardHeader>
<CardBlock>
{isEmpty(remotes) ? (
<span className='text-warning'>
<Icon icon='alarm' /> {_('createRemoteMessage')}
</span>
) : (
<FormGroup>
<label>
<strong>{_('backupTargetRemotes')}</strong>
</label>
<FormFeedback
component={SelectRemote}
message={_('missingRemotes')}
onChange={effects.addRemote}
predicate={state.remotesPredicate}
error={missingRemotes}
value={null}
/>
<br />
<Ul>
{state.remotes.map((id, key) => (
<Li key={id}>
<Remote id={id} />
<div className='pull-right'>
<ActionButton
btnStyle='danger'
handler={effects.deleteRemote}
handlerParam={key}
icon='delete'
size='small'
/>
</div>
</Li>
))}
</Ul>
</FormGroup>
)}
</CardBlock>
</Card>
</Col>
<Col mediumSize={6}>
{state.modePoolMetadata && (
<Card>
<CardHeader>{_('pools')}*</CardHeader>
<CardBlock>
<FormFeedback
component={SelectPool}
message={_('missingPools')}
multi
onChange={effects.setPools}
error={missingPools}
value={state.pools}
/>
</CardBlock>
</Card>
)}
<Schedules
handlerSchedules={effects.setSchedules}
handlerSettings={effects.setSettings}
missingRetentions={
missingRetentionPoolMetadata || missingRetentionXoMetadata
}
missingSchedules={missingSchedules}
retentions={state.retentions}
schedules={state.schedules}
settings={state.settings}
/>
</Col>
</Row>
<Row>
<Col>
<Card>
<CardBlock>
<ActionButton
btnStyle='primary'
form={state.idForm}
handler={submitHandler}
icon='save'
redirectOnSuccess={
state.isJobInvalid ? undefined : '/backup-ng'
}
size='large'
>
{_(submitTitle)}
</ActionButton>
<ActionButton
handler={effects.reset}
icon='undo'
className='pull-right'
size='large'
>
{_('formReset')}
</ActionButton>
</CardBlock>
</Card>
</Col>
</Row>
</Container>
</form>
)
},
])

View File

@ -24,8 +24,6 @@ export default decorate([
provideState({ provideState({
computed: { computed: {
disabledDeletion: state => size(state.schedules) <= 1, disabledDeletion: state => size(state.schedules) <= 1,
disabledEdition: state =>
!state.exportMode && !state.copyMode && !state.snapshotMode,
error: state => find(FEEDBACK_ERRORS, error => state[error]), error: state => find(FEEDBACK_ERRORS, error => state[error]),
individualActions: ( individualActions: (
{ disabledDeletion, disabledEdition }, { disabledDeletion, disabledEdition },
@ -129,7 +127,6 @@ export default decorate([
btnStyle='primary' btnStyle='primary'
className='pull-right' className='pull-right'
handler={effects.showScheduleModal} handler={effects.showScheduleModal}
disabled={state.disabledEdition}
icon='add' icon='add'
tooltip={_('scheduleAdd')} tooltip={_('scheduleAdd')}
/> />

View File

@ -1,13 +1,25 @@
import Icon from 'icon' import Icon from 'icon'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import React from 'react' import React from 'react'
import { resolveId, resolveIds } from 'utils'
export const FormGroup = props => <div {...props} className='form-group' /> export const FormGroup = props => <div {...props} className='form-group' />
export const Input = props => <input {...props} className='form-control' /> export const Input = props => <input {...props} className='form-control' />
export const Ul = props => <ul {...props} className='list-group' /> export const Ul = props => <ul {...props} className='list-group' />
export const Li = props => <li {...props} className='list-group-item' /> export const Li = props => <li {...props} className='list-group-item' />
export const destructPattern = pattern => pattern.id.__or || [pattern.id] export const destructPattern = pattern =>
pattern && (pattern.id.__or || [pattern.id])
export const constructPattern = values =>
values.length === 1
? {
id: resolveId(values[0]),
}
: {
id: {
__or: resolveIds(values),
},
}
export const FormFeedback = ({ export const FormFeedback = ({
component: Component, component: Component,

View File

@ -13,7 +13,11 @@ import { createGetObjectsOfType } from 'selectors'
import { get } from '@xen-orchestra/defined' import { get } from '@xen-orchestra/defined'
import { injectState, provideState } from 'reaclette' import { injectState, provideState } from 'reaclette'
import { isEmpty, filter, map, keyBy } from 'lodash' import { isEmpty, filter, map, keyBy } from 'lodash'
import { subscribeBackupNgJobs, subscribeBackupNgLogs } from 'xo' import {
subscribeBackupNgJobs,
subscribeBackupNgLogs,
subscribeMetadataBackupJobs,
} from 'xo'
import LogAlertBody from './log-alert-body' import LogAlertBody from './log-alert-body'
import LogAlertHeader from './log-alert-header' import LogAlertHeader from './log-alert-header'
@ -34,6 +38,7 @@ export const LogStatus = ({ log, tooltip = _('logDisplayDetails') }) => {
return ( return (
<ActionButton <ActionButton
btnStyle={className} btnStyle={className}
disabled={log.status !== 'failure' && isEmpty(log.tasks)}
handler={showTasks} handler={showTasks}
handlerParam={log.id} handlerParam={log.id}
icon='preview' icon='preview'
@ -84,8 +89,8 @@ const COLUMNS = [
}, },
{ {
name: _('labelSize'), name: _('labelSize'),
itemRenderer: ({ tasks: vmTasks }) => { itemRenderer: ({ tasks: vmTasks, jobId }, { jobs }) => {
if (isEmpty(vmTasks)) { if (get(() => jobs[jobId].type) !== 'backup' || isEmpty(vmTasks)) {
return null return null
} }
@ -151,6 +156,8 @@ export default decorate([
cb(logs && filter(logs, log => log.message !== 'restore')) cb(logs && filter(logs, log => log.message !== 'restore'))
), ),
jobs: cb => subscribeBackupNgJobs(jobs => cb(keyBy(jobs, 'id'))), jobs: cb => subscribeBackupNgJobs(jobs => cb(keyBy(jobs, 'id'))),
metadataJobs: cb =>
subscribeMetadataBackupJobs(jobs => cb(keyBy(jobs, 'id'))),
}), }),
provideState({ provideState({
computed: { computed: {
@ -167,6 +174,7 @@ export default decorate([
} }
: log : log
), ),
jobs: (_, { jobs, metadataJobs }) => ({ ...jobs, ...metadataJobs }),
}, },
}), }),
injectState, injectState,
@ -180,7 +188,7 @@ export default decorate([
collection={state.logs} collection={state.logs}
columns={COLUMNS} columns={COLUMNS}
component={SortedTable} component={SortedTable}
data-jobs={jobs} data-jobs={state.jobs}
emptyMessage={_('noLogs')} emptyMessage={_('noLogs')}
filters={LOG_FILTERS} filters={LOG_FILTERS}
/> />