feat(metadata backup): backup XO config and pool metadata (#3912)
Fixes #3501
This commit is contained in:
parent
468a2c5bf3
commit
fea5117ed8
@ -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))
|
||||
- [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))
|
||||
- [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
|
||||
|
||||
|
@ -154,6 +154,10 @@ class BackupReportsXoPlugin {
|
||||
}
|
||||
|
||||
_wrapper(status, job, schedule, runJobId) {
|
||||
if (job.type === 'metadataBackup') {
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise(resolve =>
|
||||
resolve(
|
||||
job.type === 'backup'
|
||||
|
103
packages/xo-server/src/api/metadata-backup.js
Normal file
103
packages/xo-server/src/api/metadata-backup.js
Normal 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',
|
||||
},
|
||||
}
|
@ -15,6 +15,8 @@ import { dirname, resolve } from 'path'
|
||||
import { utcFormat, utcParse } from 'd3-time-format'
|
||||
import { fromCallback, pAll, pReflect, promisify } from 'promise-toolbox'
|
||||
|
||||
import { type SimpleIdPattern } from './utils'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -11,3 +11,5 @@ declare export function safeDateFormat(timestamp: number): string
|
||||
declare export function serializeError(error: Error): Object
|
||||
|
||||
declare export function streamToBuffer(stream: Readable): Promise<Buffer>
|
||||
|
||||
export type SimpleIdPattern = {| id: string | {| __or: string[] |}, |}
|
||||
|
14
packages/xo-server/src/xapi/mixins/pool.js
Normal file
14
packages/xo-server/src/xapi/mixins/pool.js
Normal 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
|
||||
),
|
||||
})
|
||||
},
|
||||
}
|
@ -54,6 +54,8 @@ import {
|
||||
resolveRelativeFromFile,
|
||||
safeDateFormat,
|
||||
serializeError,
|
||||
type SimpleIdPattern,
|
||||
unboxIdsFromPattern,
|
||||
} from '../../utils'
|
||||
|
||||
import { translateLegacyJob } from './migration'
|
||||
@ -75,10 +77,6 @@ type Settings = {|
|
||||
vmTimeout?: number,
|
||||
|}
|
||||
|
||||
type SimpleIdPattern = {|
|
||||
id: string | {| __or: string[] |},
|
||||
|}
|
||||
|
||||
export type BackupJob = {|
|
||||
...$Exact<Job>,
|
||||
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
|
||||
async function waitAll<T>(
|
||||
promises: Promise<T>[],
|
||||
@ -605,7 +595,7 @@ export default class BackupNg {
|
||||
}
|
||||
}
|
||||
const jobId = job.id
|
||||
const srs = unboxIds(job.srs).map(id => {
|
||||
const srs = unboxIdsFromPattern(job.srs).map(id => {
|
||||
const xapi = app.getXapi(id)
|
||||
return {
|
||||
__proto__: xapi.getObject(id),
|
||||
@ -613,7 +603,7 @@ export default class BackupNg {
|
||||
}
|
||||
})
|
||||
const remotes = await Promise.all(
|
||||
unboxIds(job.remotes).map(async id => ({
|
||||
unboxIdsFromPattern(job.remotes).map(async id => ({
|
||||
id,
|
||||
handler: await app.getRemoteHandler(id),
|
||||
}))
|
||||
|
264
packages/xo-server/src/xo-mixins/metadata-backups.js
Normal file
264
packages/xo-server/src/xo-mixins/metadata-backups.js
Normal 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)
|
||||
}
|
||||
}),
|
||||
])
|
||||
}
|
||||
}
|
@ -1,9 +1,11 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { startsWith } from 'lodash'
|
||||
|
||||
import decorate from '../apply-decorators'
|
||||
|
||||
// it provide `data-*` to add params to the `onChange`
|
||||
const Number_ = decorate([
|
||||
provideState({
|
||||
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)
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
@ -37,6 +37,8 @@ const messages = {
|
||||
paths: 'Paths',
|
||||
pbdDisconnected: 'PBD disconnected',
|
||||
hasInactivePath: 'Has an inactive path',
|
||||
pools: 'Pools',
|
||||
remotes: 'Remotes',
|
||||
|
||||
// ----- Modals -----
|
||||
alertOk: 'OK',
|
||||
@ -125,6 +127,11 @@ const messages = {
|
||||
deltaBackup: 'Delta Backup',
|
||||
disasterRecovery: 'Disaster Recovery',
|
||||
continuousReplication: 'Continuous Replication',
|
||||
backupType: 'Backup type',
|
||||
poolMetadata: 'Pool metadata',
|
||||
xoConfig: 'XO config',
|
||||
backupVms: 'Backup VMs',
|
||||
backupMetadata: 'Backup metadata',
|
||||
jobsOverviewPage: 'Overview',
|
||||
jobsNewPage: 'New',
|
||||
jobsSchedulingPage: 'Scheduling',
|
||||
@ -370,14 +377,17 @@ const messages = {
|
||||
missingBackupMode: 'You need to choose a backup mode!',
|
||||
missingRemotes: 'Missing remotes!',
|
||||
missingSrs: 'Missing SRs!',
|
||||
missingPools: 'Missing pools!',
|
||||
missingSchedules: 'Missing schedules!',
|
||||
missingRetentions:
|
||||
'The modes need at least a schedule with retention higher than 0',
|
||||
missingExportRetention:
|
||||
'The Backup mode and The Delta Backup mode require backup retention to be higher than 0!',
|
||||
missingCopyRetention:
|
||||
'The CR mode and The DR mode require replication retention to be higher than 0!',
|
||||
missingSnapshotRetention:
|
||||
'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',
|
||||
createRemoteMessage:
|
||||
'No remotes found, please click on the remotes settings button to create one!',
|
||||
@ -1396,6 +1406,8 @@ const messages = {
|
||||
scheduleExportRetention: 'Backup ret.',
|
||||
scheduleCopyRetention: 'Replication ret.',
|
||||
scheduleSnapshotRetention: 'Snapshot ret.',
|
||||
poolMetadataRetention: 'Pool ret.',
|
||||
xoMetadataRetention: 'XO ret.',
|
||||
getRemote: 'Get remote',
|
||||
listRemote: 'List Remote',
|
||||
simpleBackup: 'simple',
|
||||
|
@ -1922,15 +1922,22 @@ export const subscribeBackupNgLogs = createSubscription(() =>
|
||||
_call('backupNg.getAllLogs')
|
||||
)
|
||||
|
||||
export const subscribeMetadataBackupJobs = createSubscription(() =>
|
||||
_call('metadataBackup.getAllJobs')
|
||||
)
|
||||
|
||||
export const createBackupNgJob = props =>
|
||||
_call('backupNg.createJob', props)::tap(subscribeBackupNgJobs.forceRefresh)
|
||||
|
||||
export const deleteBackupNgJobs = async ids => {
|
||||
const { length } = ids
|
||||
if (length === 0) {
|
||||
export const deleteBackupJobs = async ({
|
||||
backupIds = [],
|
||||
metadataBackupIds = [],
|
||||
}) => {
|
||||
const nJobs = backupIds.length + metadataBackupIds.length
|
||||
if (nJobs === 0) {
|
||||
return
|
||||
}
|
||||
const vars = { nJobs: length }
|
||||
const vars = { nJobs }
|
||||
try {
|
||||
await confirm({
|
||||
title: _('confirmDeleteBackupJobsTitle', vars),
|
||||
@ -1940,9 +1947,25 @@ export const deleteBackupNgJobs = async ids => {
|
||||
return
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
ids.map(id => _call('backupNg.deleteJob', { id: resolveId(id) }))
|
||||
)::tap(subscribeBackupNgJobs.forceRefresh)
|
||||
const promises = []
|
||||
if (backupIds.length !== 0) {
|
||||
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 =>
|
||||
@ -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 -----------------------------------------------------------
|
||||
|
||||
export const loadPlugin = async id =>
|
||||
|
@ -1,15 +1,22 @@
|
||||
import addSubscriptions from 'add-subscriptions'
|
||||
import decorate from 'apply-decorators'
|
||||
import defined from '@xen-orchestra/defined'
|
||||
import React from 'react'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { subscribeBackupNgJobs, subscribeSchedules } from 'xo'
|
||||
import { find, groupBy, keyBy } from 'lodash'
|
||||
import {
|
||||
subscribeBackupNgJobs,
|
||||
subscribeMetadataBackupJobs,
|
||||
subscribeSchedules,
|
||||
} from 'xo'
|
||||
|
||||
import Metadata from './new/metadata'
|
||||
import New from './new'
|
||||
|
||||
export default decorate([
|
||||
addSubscriptions({
|
||||
jobs: subscribeBackupNgJobs,
|
||||
metadataJobs: subscribeMetadataBackupJobs,
|
||||
schedulesByJob: cb =>
|
||||
subscribeSchedules(schedules => {
|
||||
cb(groupBy(schedules, 'jobId'))
|
||||
@ -17,11 +24,17 @@ export default decorate([
|
||||
}),
|
||||
provideState({
|
||||
computed: {
|
||||
job: (_, { jobs, routeParams: { id } }) => find(jobs, { id }),
|
||||
job: (_, { jobs, metadataJobs, routeParams: { id } }) =>
|
||||
defined(find(jobs, { id }), find(metadataJobs, { id })),
|
||||
schedules: (_, { schedulesByJob, routeParams: { id } }) =>
|
||||
schedulesByJob && keyBy(schedulesByJob[id], 'id'),
|
||||
},
|
||||
}),
|
||||
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} />
|
||||
),
|
||||
])
|
||||
|
@ -2,6 +2,7 @@ import _ from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import addSubscriptions from 'add-subscriptions'
|
||||
import Button from 'button'
|
||||
import ButtonLink from 'button-link'
|
||||
import Copiable from 'copiable'
|
||||
import CopyToClipboard from 'react-copy-to-clipboard'
|
||||
import decorate from 'apply-decorators'
|
||||
@ -16,29 +17,31 @@ import { confirm } from 'modal'
|
||||
import { connectStore, routes } from 'utils'
|
||||
import { constructQueryString } from 'smart-backup'
|
||||
import { Container, Row, Col } from 'grid'
|
||||
import { createGetLoneSnapshots } from 'selectors'
|
||||
import { createGetLoneSnapshots, createSelector } from 'selectors'
|
||||
import { get } from '@xen-orchestra/defined'
|
||||
import { isEmpty, map, groupBy, some } from 'lodash'
|
||||
import { NavLink, NavTabs } from 'nav'
|
||||
import {
|
||||
cancelJob,
|
||||
deleteBackupNgJobs,
|
||||
deleteBackupJobs,
|
||||
disableSchedule,
|
||||
enableSchedule,
|
||||
runBackupNgJob,
|
||||
runMetadataBackupJob,
|
||||
subscribeBackupNgJobs,
|
||||
subscribeBackupNgLogs,
|
||||
subscribeMetadataBackupJobs,
|
||||
subscribeSchedules,
|
||||
} from 'xo'
|
||||
|
||||
import LogsTable, { LogStatus } from '../logs/backup-ng'
|
||||
import Page from '../page'
|
||||
|
||||
import NewVmBackup, { NewMetadataBackup } from './new'
|
||||
import Edit from './edit'
|
||||
import New from './new'
|
||||
import FileRestore from './file-restore'
|
||||
import Restore from './restore'
|
||||
import Health from './health'
|
||||
import Restore from './restore'
|
||||
import { destructPattern } from './utils'
|
||||
|
||||
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({
|
||||
title: _('runJob'),
|
||||
body: _('runBackupNgJobConfirm', {
|
||||
id: id.slice(0, 5),
|
||||
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([
|
||||
addSubscriptions(({ schedule }) => ({
|
||||
@ -119,7 +134,8 @@ const SchedulePreviewBody = decorate([
|
||||
data-id={job.id}
|
||||
data-name={job.name}
|
||||
data-schedule={schedule.id}
|
||||
handler={_runBackupNgJob}
|
||||
data-type={job.type}
|
||||
handler={_runBackupJob}
|
||||
icon='run-schedule'
|
||||
key='run'
|
||||
size='small'
|
||||
@ -159,10 +175,19 @@ const MODES = [
|
||||
test: job =>
|
||||
job.mode === 'full' && !isEmpty(get(() => destructPattern(job.srs))),
|
||||
},
|
||||
{
|
||||
label: 'poolMetadata',
|
||||
test: job => !isEmpty(destructPattern(job.pools)),
|
||||
},
|
||||
{
|
||||
label: 'xoConfig',
|
||||
test: job => job.xoMetadata,
|
||||
},
|
||||
]
|
||||
|
||||
@addSubscriptions({
|
||||
jobs: subscribeBackupNgJobs,
|
||||
metadataJobs: subscribeMetadataBackupJobs,
|
||||
schedulesByJob: cb =>
|
||||
subscribeSchedules(schedules => {
|
||||
cb(groupBy(schedules, 'jobId'))
|
||||
@ -176,7 +201,7 @@ class JobsTable extends React.Component {
|
||||
static tableProps = {
|
||||
actions: [
|
||||
{
|
||||
handler: deleteBackupNgJobs,
|
||||
handler: _deleteBackupJobs,
|
||||
label: _('deleteBackupSchedule'),
|
||||
icon: 'delete',
|
||||
level: 'danger',
|
||||
@ -261,6 +286,7 @@ class JobsTable extends React.Component {
|
||||
pathname: '/home',
|
||||
query: { t: 'VM', s: constructQueryString(job.vms) },
|
||||
}),
|
||||
disabled: job => job.type !== 'backup',
|
||||
label: _('redirectToMatchingVms'),
|
||||
icon: 'preview',
|
||||
},
|
||||
@ -277,11 +303,17 @@ class JobsTable extends React.Component {
|
||||
this.context.router.push(path)
|
||||
}
|
||||
|
||||
_getCollection = createSelector(
|
||||
() => this.props.jobs,
|
||||
() => this.props.metadataJobs,
|
||||
(jobs = [], metadataJobs = []) => [...jobs, ...metadataJobs]
|
||||
)
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SortedTable
|
||||
{...JobsTable.tableProps}
|
||||
collection={this.props.jobs}
|
||||
collection={this._getCollection()}
|
||||
data-goTo={this._goTo}
|
||||
data-schedulesByJob={this.props.schedulesByJob}
|
||||
/>
|
||||
@ -353,9 +385,31 @@ const HEADER = (
|
||||
</Container>
|
||||
)
|
||||
|
||||
const ChooseBackupType = () => (
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
<Card>
|
||||
<CardHeader>{_('backupType')}</CardHeader>
|
||||
<CardBlock className='text-md-center'>
|
||||
<ButtonLink to='backup-ng/new/vms'>
|
||||
<Icon icon='backup' /> {_('backupVms')}
|
||||
</ButtonLink>{' '}
|
||||
<ButtonLink to='backup-ng/new/metadata'>
|
||||
<Icon icon='database' /> {_('backupMetadata')}
|
||||
</ButtonLink>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
|
||||
export default routes('overview', {
|
||||
':id/edit': Edit,
|
||||
new: New,
|
||||
new: ChooseBackupType,
|
||||
'new/vms': NewVmBackup,
|
||||
'new/metadata': NewMetadataBackup,
|
||||
overview: Overview,
|
||||
restore: Restore,
|
||||
'file-restore': FileRestore,
|
||||
|
224
packages/xo-web/src/xo-app/backup-ng/new/_schedules/index.js
Normal file
224
packages/xo-web/src/xo-app/backup-ng/new/_schedules/index.js
Normal 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 }
|
94
packages/xo-web/src/xo-app/backup-ng/new/_schedules/new.js
Normal file
94
packages/xo-web/src/xo-app/backup-ng/new/_schedules/new.js
Normal 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>
|
||||
),
|
||||
])
|
@ -46,6 +46,7 @@ import Schedules from './schedules'
|
||||
import SmartBackup from './smart-backup'
|
||||
import {
|
||||
canDeltaBackup,
|
||||
constructPattern,
|
||||
destructPattern,
|
||||
FormFeedback,
|
||||
FormGroup,
|
||||
@ -54,6 +55,8 @@ import {
|
||||
Ul,
|
||||
} from './../utils'
|
||||
|
||||
export NewMetadataBackup from './metadata'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const DEFAULT_RETENTION = 1
|
||||
@ -102,17 +105,6 @@ const normalizeSettings = ({ settings, exportMode, copyMode, snapshotMode }) =>
|
||||
: setting
|
||||
)
|
||||
|
||||
const constructPattern = values =>
|
||||
values.length === 1
|
||||
? {
|
||||
id: resolveId(values[0]),
|
||||
}
|
||||
: {
|
||||
id: {
|
||||
__or: resolveIds(values),
|
||||
},
|
||||
}
|
||||
|
||||
const destructVmsPattern = pattern =>
|
||||
pattern.id === undefined
|
||||
? {
|
||||
|
410
packages/xo-web/src/xo-app/backup-ng/new/metadata/index.js
Normal file
410
packages/xo-web/src/xo-app/backup-ng/new/metadata/index.js
Normal 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>
|
||||
)
|
||||
},
|
||||
])
|
@ -24,8 +24,6 @@ export default decorate([
|
||||
provideState({
|
||||
computed: {
|
||||
disabledDeletion: state => size(state.schedules) <= 1,
|
||||
disabledEdition: state =>
|
||||
!state.exportMode && !state.copyMode && !state.snapshotMode,
|
||||
error: state => find(FEEDBACK_ERRORS, error => state[error]),
|
||||
individualActions: (
|
||||
{ disabledDeletion, disabledEdition },
|
||||
@ -129,7 +127,6 @@ export default decorate([
|
||||
btnStyle='primary'
|
||||
className='pull-right'
|
||||
handler={effects.showScheduleModal}
|
||||
disabled={state.disabledEdition}
|
||||
icon='add'
|
||||
tooltip={_('scheduleAdd')}
|
||||
/>
|
||||
|
@ -1,13 +1,25 @@
|
||||
import Icon from 'icon'
|
||||
import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
import { resolveId, resolveIds } from 'utils'
|
||||
|
||||
export const FormGroup = props => <div {...props} className='form-group' />
|
||||
export const Input = props => <input {...props} className='form-control' />
|
||||
export const Ul = props => <ul {...props} className='list-group' />
|
||||
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 = ({
|
||||
component: Component,
|
||||
|
@ -13,7 +13,11 @@ import { createGetObjectsOfType } from 'selectors'
|
||||
import { get } from '@xen-orchestra/defined'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
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 LogAlertHeader from './log-alert-header'
|
||||
@ -34,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'
|
||||
@ -84,8 +89,8 @@ const COLUMNS = [
|
||||
},
|
||||
{
|
||||
name: _('labelSize'),
|
||||
itemRenderer: ({ tasks: vmTasks }) => {
|
||||
if (isEmpty(vmTasks)) {
|
||||
itemRenderer: ({ tasks: vmTasks, jobId }, { jobs }) => {
|
||||
if (get(() => jobs[jobId].type) !== 'backup' || isEmpty(vmTasks)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@ -151,6 +156,8 @@ export default decorate([
|
||||
cb(logs && filter(logs, log => log.message !== 'restore'))
|
||||
),
|
||||
jobs: cb => subscribeBackupNgJobs(jobs => cb(keyBy(jobs, 'id'))),
|
||||
metadataJobs: cb =>
|
||||
subscribeMetadataBackupJobs(jobs => cb(keyBy(jobs, 'id'))),
|
||||
}),
|
||||
provideState({
|
||||
computed: {
|
||||
@ -167,6 +174,7 @@ export default decorate([
|
||||
}
|
||||
: log
|
||||
),
|
||||
jobs: (_, { jobs, metadataJobs }) => ({ ...jobs, ...metadataJobs }),
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
@ -180,7 +188,7 @@ export default decorate([
|
||||
collection={state.logs}
|
||||
columns={COLUMNS}
|
||||
component={SortedTable}
|
||||
data-jobs={jobs}
|
||||
data-jobs={state.jobs}
|
||||
emptyMessage={_('noLogs')}
|
||||
filters={LOG_FILTERS}
|
||||
/>
|
||||
|
Loading…
Reference in New Issue
Block a user