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))
|
- [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
|
||||||
|
|
||||||
|
@ -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'
|
||||||
|
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 { 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
|
||||||
|
}
|
||||||
|
@ -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[] |}, |}
|
||||||
|
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,
|
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),
|
||||||
}))
|
}))
|
||||||
|
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 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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
@ -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',
|
||||||
|
@ -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) {
|
||||||
|
promises.push(
|
||||||
|
Promise.all(
|
||||||
|
backupIds.map(id => _call('backupNg.deleteJob', { id: resolveId(id) }))
|
||||||
)::tap(subscribeBackupNgJobs.forceRefresh)
|
)::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 =>
|
||||||
|
@ -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} />
|
||||||
|
),
|
||||||
])
|
])
|
||||||
|
@ -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,
|
||||||
|
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 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
|
||||||
? {
|
? {
|
||||||
|
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({
|
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')}
|
||||||
/>
|
/>
|
||||||
|
@ -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,
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
Loading…
Reference in New Issue
Block a user