feat(Backups NG): third iteration (#2729)

This commit is contained in:
Julien Fontanet 2018-03-12 17:26:20 +01:00 committed by GitHub
parent 3ce4e86784
commit 80c1e39b53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1599 additions and 1563 deletions

View File

@ -6,6 +6,7 @@ module.exports = {
$Diff: true,
$Exact: true,
$Keys: true,
$PropertyType: true,
$Shape: true,
},
parser: 'babel-eslint',

View File

@ -3,6 +3,10 @@ declare module 'lodash' {
declare export function isEmpty(mixed): boolean
declare export function keyBy<T>(array: T[], iteratee: string): boolean
declare export function last<T>(array?: T[]): T | void
declare export function map<T1, T2>(
collection: T1[],
iteratee: (T1) => T2
): T2[]
declare export function mapValues<K, V1, V2>(
object: { [K]: V1 },
iteratee: (V1, K) => V2

View File

@ -1,4 +1,5 @@
declare module 'promise-toolbox' {
declare export function cancelable(Function): Function
declare export function defer<T>(): {|
promise: Promise<T>,
reject: T => void,

View File

@ -13,7 +13,7 @@
"eslint-plugin-react": "^7.6.1",
"eslint-plugin-standard": "^3.0.1",
"exec-promise": "^0.7.0",
"flow-bin": "^0.66.0",
"flow-bin": "^0.67.1",
"globby": "^8.0.0",
"husky": "^0.14.3",
"jest": "^22.0.4",

View File

@ -92,11 +92,11 @@ export default class RemoteHandlerAbstract {
await promise
}
async readFile (file: string, options?: Object): Promise<Buffer | string> {
async readFile (file: string, options?: Object): Promise<Buffer> {
return this._readFile(file, options)
}
_readFile (file: string, options?: Object): Promise<Buffer | string> {
_readFile (file: string, options?: Object): Promise<Buffer> {
return this.createReadStream(file, options).then(streamToBuffer)
}
@ -119,11 +119,25 @@ export default class RemoteHandlerAbstract {
throw new Error('Not implemented')
}
async list (dir: string = '.') {
return this._list(dir)
async list (
dir: string = '.',
{
filter,
prependDir = false,
}: { filter?: (name: string) => boolean, prependDir?: boolean } = {}
): Promise<string[]> {
const entries = await this._list(dir)
if (prependDir) {
entries.forEach((entry, i) => {
entries[i] = dir + '/' + entry
})
}
return filter === undefined ? entries : entries.filter(filter)
}
async _list (dir: string) {
async _list (dir: string): Promise<string[]> {
throw new Error('Not implemented')
}
@ -207,7 +221,7 @@ export default class RemoteHandlerAbstract {
}
async refreshChecksum (path: string): Promise<void> {
const stream: any = (await this.createReadStream(path)).pipe(
const stream = (await this.createReadStream(path)).pipe(
createChecksumStream()
)
stream.resume() // start reading the whole file

View File

@ -1,7 +1,11 @@
import invert from 'lodash/invert'
// @flow
// $FlowFixMe
import through2 from 'through2'
import { createHash } from 'crypto'
import { defer, fromEvent } from 'promise-toolbox'
import { invert } from 'lodash'
import { type Readable, type Transform } from 'stream'
const ALGORITHM_TO_ID = {
md5: '1',
@ -21,7 +25,9 @@ const ID_TO_ALGORITHM = invert(ALGORITHM_TO_ID)
// const checksumStream = source.pipe(createChecksumStream())
// checksumStream.resume() // make the data flow without an output
// console.log(await checksumStream.checksum)
export const createChecksumStream = (algorithm = 'md5') => {
export const createChecksumStream = (
algorithm: string = 'md5'
): Transform & { checksum: Promise<string> } => {
const algorithmId = ALGORITHM_TO_ID[algorithm]
if (!algorithmId) {
@ -48,7 +54,10 @@ export const createChecksumStream = (algorithm = 'md5') => {
// Check if the checksum of a readable stream is equals to an expected checksum.
// The given stream is wrapped in a stream which emits an error event
// if the computed checksum is not equals to the expected checksum.
export const validChecksumOfReadStream = (stream, expectedChecksum) => {
export const validChecksumOfReadStream = (
stream: Readable,
expectedChecksum: string
): Readable & { checksumVerified: Promise<void> } => {
const algorithmId = expectedChecksum.slice(
1,
expectedChecksum.indexOf('$', 1)
@ -60,7 +69,7 @@ export const validChecksumOfReadStream = (stream, expectedChecksum) => {
const hash = createHash(ID_TO_ALGORITHM[algorithmId])
const wrapper = stream.pipe(
const wrapper: any = stream.pipe(
through2(
{ highWaterMark: 0 },
(chunk, enc, callback) => {

View File

@ -1,6 +1,10 @@
import through2 from 'through2'
// @flow
const createSizeStream = () => {
// $FlowFixMe
import through2 from 'through2'
import { type Readable } from 'stream'
const createSizeStream = (): Readable & { size: number } => {
const wrapper = through2((chunk, enc, cb) => {
wrapper.size += chunk.length
cb(null, chunk)

View File

@ -0,0 +1,22 @@
// @flow
import { type Readable } from 'stream'
type MaybePromise<T> = Promise<T> | T
declare export function asyncMap<T1, T2>(
collection: MaybePromise<T1[]>,
(T1, number) => MaybePromise<T2>
): Promise<T2[]>
declare export function asyncMap<K, V1, V2>(
collection: MaybePromise<{ [K]: V1 }>,
(V1, K) => MaybePromise<V2>
): Promise<V2[]>
declare export function getPseudoRandomBytes(n: number): Buffer
declare export function safeDateFormat(timestamp: number): string
declare export function serializeError(error: Error): Object
declare export function streamToBuffer(stream: Readable): Promise<Buffer>

View File

@ -6,6 +6,7 @@ import fu from '@nraynaud/struct-fu'
import isEqual from 'lodash/isEqual'
import { fromEvent } from 'promise-toolbox'
import type RemoteHandler from './remote-handlers/abstract'
import constantStream from './constant-stream'
import { noop, streamToBuffer } from './utils'
@ -34,8 +35,8 @@ const VHD_PARENT_LOCATOR_ENTRIES = 8
const VHD_PLATFORM_CODE_NONE = 0
// Types of backup treated. Others are not supported.
const HARD_DISK_TYPE_DYNAMIC = 3 // Full backup.
const HARD_DISK_TYPE_DIFFERENCING = 4 // Delta backup.
export const HARD_DISK_TYPE_DYNAMIC = 3 // Full backup.
export const HARD_DISK_TYPE_DIFFERENCING = 4 // Delta backup.
// Other.
const BLOCK_UNUSED = 0xffffffff
@ -666,11 +667,6 @@ export default concurrency(2)(async function vhdMerge (
throw new Error('Unable to merge, child is not a delta backup.')
}
// Merging in differencing disk is prohibited in our case.
if (parentVhd.footer.diskType !== HARD_DISK_TYPE_DYNAMIC) {
throw new Error('Unable to merge, parent is not a full backup.')
}
// Allocation table map is not yet implemented.
if (
parentVhd.hasBlockAllocationTableMap() ||
@ -694,6 +690,7 @@ export default concurrency(2)(async function vhdMerge (
mergedDataSize += await parentVhd.coalesceBlock(childVhd, blockId)
}
}
const cFooter = childVhd.footer
const pFooter = parentVhd.footer
@ -701,6 +698,7 @@ export default concurrency(2)(async function vhdMerge (
pFooter.diskGeometry = { ...cFooter.diskGeometry }
pFooter.originalSize = { ...cFooter.originalSize }
pFooter.timestamp = cFooter.timestamp
pFooter.uuid = cFooter.uuid
// necessary to update values and to recreate the footer after block
// creation
@ -759,3 +757,12 @@ export async function chainVhd (
return false
}
export async function readVhdMetadata (handler: RemoteHandler, path: string) {
const vhd = new Vhd(handler, path)
await vhd.readHeaderAndFooter()
return {
footer: vhd.footer,
header: vhd.header,
}
}

View File

@ -788,8 +788,8 @@ export default class Xapi extends XapiBase {
async exportDeltaVm (
$defer,
$cancelToken,
vmId,
baseVmId = undefined,
vmId: string,
baseVmId?: string,
{
bypassVdiChainsCheck = false,
@ -799,7 +799,7 @@ export default class Xapi extends XapiBase {
disableBaseTags = false,
snapshotNameLabel = undefined,
} = {}
) {
): Promise<DeltaVmExport> {
let vm = this.getObject(vmId)
if (!bypassVdiChainsCheck) {
this._assertHealthyVdiChains(vm)
@ -915,7 +915,7 @@ export default class Xapi extends XapiBase {
@deferrable
async importDeltaVm (
$defer,
delta,
delta: DeltaVmExport,
{
deleteBase = false,
disableStartAfterImport = true,

View File

@ -1,23 +1,35 @@
// @flow
import {type Readable} from 'stream'
import { type Readable } from 'stream'
export type DeltaVmExport = {
streams: $Dict<() => Promise<Readable>>,
vbds: { [ref: string]: {} },
vdis: { [ref: string]: { $SR$uuid: string } },
vifs: { [ref: string]: {} }
type AugmentedReadable = Readable & {
size?: number,
task?: Promise<mixed>
}
type MaybeArray<T> = Array<T> | T
export type DeltaVmExport = {|
streams: $Dict<() => Promise<AugmentedReadable>>,
vbds: { [ref: string]: Object },
vdis: { [ref: string]: {
$SR$uuid: string,
snapshot_of: string,
} },
vifs: { [ref: string]: Object },
vm: Vm,
|}
export type DeltaVmImport = {|
...DeltaVmExport,
streams: $Dict<MaybeArray<AugmentedReadable | () => Promise<AugmentedReadable>>>,
|}
declare class XapiObject {
$id: string;
$ref: string;
$type: string;
}
type AugmentedReadable = Readable & {
size?: number,
task?: Promise<mixed>
}
type Id = string | XapiObject
declare export class Vm extends XapiObject {
@ -31,19 +43,39 @@ declare export class Vm extends XapiObject {
declare export class Xapi {
objects: { all: $Dict<Object> };
_importVm (cancelToken: mixed, stream: AugmentedReadable, sr?: XapiObject, onVmCreation?: XapiObject => any): Promise<string>;
_updateObjectMapProperty(object: XapiObject, property: string, entries: $Dict<string>): Promise<void>;
_setObjectProperties(object: XapiObject, properties: $Dict<mixed>): Promise<void>;
_snapshotVm (cancelToken: mixed, vm: Vm, nameLabel?: string): Promise<Vm>;
_importVm(
cancelToken: mixed,
stream: AugmentedReadable,
sr?: XapiObject,
onVmCreation?: (XapiObject) => any
): Promise<string>;
_updateObjectMapProperty(
object: XapiObject,
property: string,
entries: $Dict<string>
): Promise<void>;
_setObjectProperties(
object: XapiObject,
properties: $Dict<mixed>
): Promise<void>;
_snapshotVm(cancelToken: mixed, vm: Vm, nameLabel?: string): Promise<Vm>;
addTag(object: Id, tag: string): Promise<void>;
barrier(): void;
barrier(ref: string): XapiObject;
deleteVm (vm: Id): Promise<void>;
deleteVm(vm: Id): Promise<void>;
editVm(vm: Id, $Dict<mixed>): Promise<void>;
exportDeltaVm (cancelToken: mixed, snapshot: Id, baseSnapshot?: Id): Promise<DeltaVmExport>;
exportVm(cancelToken: mixed, vm: Vm, options?: Object): Promise<AugmentedReadable>;
getObject (object: Id): XapiObject;
importDeltaVm (data: DeltaVmExport, options: Object): Promise<{ vm: Vm }>;
importVm (stream: AugmentedReadable, options: Object): Promise<Vm>;
exportDeltaVm(
cancelToken: mixed,
snapshot: Id,
baseSnapshot?: Id
): Promise<DeltaVmExport>;
exportVm(
cancelToken: mixed,
vm: Vm,
options?: Object
): Promise<AugmentedReadable>;
getObject(object: Id): XapiObject;
importDeltaVm(data: DeltaVmImport, options: Object): Promise<{ vm: Vm }>;
importVm(stream: AugmentedReadable, options: Object): Promise<Vm>;
}

View File

@ -2,19 +2,29 @@
// $FlowFixMe
import defer from 'golike-defer'
import { basename, dirname, resolve } from 'path'
import { timeout as pTimeout } from 'promise-toolbox'
import { isEmpty, last, mapValues, noop, values } from 'lodash'
import { type Pattern, createPredicate } from 'value-matcher'
import { type Readable, PassThrough } from 'stream'
import { basename, dirname, resolve } from 'path'
import { isEmpty, last, mapValues, noop, values } from 'lodash'
import { timeout as pTimeout } from 'promise-toolbox'
import { type Executor, type Job } from '../jobs'
import { type Schedule } from '../scheduling'
import createSizeStream from '../../size-stream'
import type RemoteHandler from '../../remote-handlers/abstract'
import createSizeStream from '../../size-stream'
import {
type DeltaVmExport,
type DeltaVmImport,
type Vm,
type Xapi,
} from '../../xapi'
import { asyncMap, safeDateFormat, serializeError } from '../../utils'
import type { Vm, Xapi } from '../../xapi'
import mergeVhd, {
HARD_DISK_TYPE_DIFFERENCING,
chainVhd,
readVhdMetadata,
} from '../../vhd-merge'
type Mode = 'full' | 'delta'
@ -22,11 +32,11 @@ type Settings = {|
deleteFirst?: boolean,
exportRetention?: number,
snapshotRetention?: number,
vmTimeout?: number
vmTimeout?: number,
|}
type SimpleIdPattern = {|
id: string | {| __or: string[] |}
id: string | {| __or: string[] |},
|}
export type BackupJob = {|
@ -37,34 +47,37 @@ export type BackupJob = {|
settings: $Dict<Settings>,
srs?: SimpleIdPattern,
type: 'backup',
vms: Pattern
vms: Pattern,
|}
type BackupResult = {|
mergeDuration: number,
mergeSize: number,
transferDuration: number,
transferSize: number
transferSize: number,
|}
type MetadataBase = {|
_filename?: string,
jobId: string,
mode: Mode,
scheduleId: string,
timestamp: number,
version: '2.0.0',
vm: Object,
vmSnapshot: Object
vmSnapshot: Object,
|}
type MetadataDelta = {|
...MetadataBase,
mode: 'delta',
vdis: $Dict<{}>
vdis: $PropertyType<DeltaVmExport, 'vdis'>,
vbds: $PropertyType<DeltaVmExport, 'vbds'>,
vhds: { [vdiId: string]: string },
vifs: $PropertyType<DeltaVmExport, 'vifs'>,
|}
type MetadataFull = {|
...MetadataBase,
data: string, // relative path to the XVA
mode: 'full'
mode: 'full',
xva: string,
|}
type Metadata = MetadataDelta | MetadataFull
@ -110,6 +123,7 @@ const BACKUP_DIR = 'xo-vm-backups'
const getVmBackupDir = (uuid: string) => `${BACKUP_DIR}/${uuid}`
const isMetadataFile = (filename: string) => filename.endsWith('.json')
const isVhd = (filename: string) => filename.endsWith('.vhd')
const listReplicatedVms = (
xapi: Xapi,
@ -134,6 +148,100 @@ const listReplicatedVms = (
return values(vms).sort(compareSnapshotTime)
}
// returns the chain of parents of this VHD
//
// TODO: move to vhd-merge module
const getVhdChain = async (
handler: RemoteHandler,
path: string
): Promise<Object[]> => {
const chain = []
while (true) {
const vhd = await readVhdMetadata(handler, path)
vhd.path = path
chain.push(vhd)
if (vhd.header.type !== HARD_DISK_TYPE_DIFFERENCING) {
break
}
path = resolveRelativeFromFile(path, vhd.header.parentUnicodeName)
}
return chain
}
const importers: $Dict<
(
handler: RemoteHandler,
metadataFilename: string,
metadata: Metadata,
xapi: Xapi,
sr: { $id: string }
) => Promise<string>,
Mode
> = {
async delta (handler, metadataFilename, metadata, xapi, sr) {
metadata = ((metadata: any): MetadataDelta)
const { vdis, vhds, vm } = metadata
const streams = {}
await asyncMap(vdis, async (vdi, id) => {
const chain = await getVhdChain(
handler,
resolveRelativeFromFile(metadataFilename, vhds[id])
)
streams[`${id}.vhd`] = await asyncMap(chain, ({ path }) =>
handler.createReadStream(path, {
checksum: true,
ignoreMissingChecksum: true,
})
)
})
const delta: DeltaVmImport = {
streams,
vbds: metadata.vbds,
vdis,
vifs: metadata.vifs,
vm: {
...vm,
name_label: `${vm.name_label} ({${safeDateFormat(
metadata.timestamp
)}})`,
tags: [...vm.tags, 'restored from backup'],
},
}
const { vm: newVm } = await xapi.importDeltaVm(delta, {
disableStartAfterImport: false,
srId: sr,
// TODO: support mapVdisSrs
})
return newVm.$id
},
async full (handler, metadataFilename, metadata, xapi, sr) {
metadata = ((metadata: any): MetadataFull)
const xva = await handler.createReadStream(
resolveRelativeFromFile(metadataFilename, metadata.xva),
{
checksum: true,
ignoreMissingChecksum: true, // provide an easy way to opt-out
}
)
const vm = await xapi.importVm(xva, { srId: sr.$id })
await Promise.all([
xapi.addTag(vm.$id, 'restored from backup'),
xapi.editVm(vm.$id, {
name_label: `${metadata.vm.name_label} (${safeDateFormat(
metadata.timestamp
)})`,
}),
])
return vm.$id
},
}
const parseVmBackupId = (id: string) => {
const i = id.indexOf('/')
return {
@ -142,7 +250,7 @@ const parseVmBackupId = (id: string) => {
}
}
// used to resolve the data field from the metadata
// used to resolve the xva field from the metadata
const resolveRelativeFromFile = (file: string, path: string): string =>
resolve('/', dirname(file), path).slice(1)
@ -226,7 +334,7 @@ export default class BackupNg {
getXapi: (id: string) => Xapi,
getJob: (id: string, 'backup') => Promise<BackupJob>,
updateJob: ($Shape<BackupJob>) => Promise<BackupJob>,
removeJob: (id: string) => Promise<void>
removeJob: (id: string) => Promise<void>,
}
constructor (app: any) {
@ -245,7 +353,7 @@ export default class BackupNg {
}
const job: BackupJob = (job_: any)
const vms = app.getObjects({
const vms: $Dict<Vm> = app.getObjects({
filter: createPredicate({
type: 'VM',
...job.vms,
@ -384,13 +492,15 @@ export default class BackupNg {
const metadata: Metadata = JSON.parse(
String(await handler.readFile(metadataFilename))
)
metadata._filename = metadataFilename
if (metadata.mode === 'delta') {
throw new Error('not implemented')
await this._deleteDeltaVmBackups(handler, [metadata])
} else if (metadata.mode === 'full') {
await this._deleteFullVmBackups(handler, [metadata])
} else {
throw new Error(`no deleter for backup mode ${metadata.mode}`)
}
metadata._filename = metadataFilename
await this._deleteFullVmBackups(handler, [metadata])
}
async importVmBackupNg (id: string, srId: string): Promise<string> {
@ -401,29 +511,20 @@ export default class BackupNg {
String(await handler.readFile(metadataFilename))
)
if (metadata.mode === 'delta') {
throw new Error('not implemented')
const importer = importers[metadata.mode]
if (importer === undefined) {
throw new Error(`no importer for backup mode ${metadata.mode}`)
}
const xapi = app.getXapi(srId)
const sr = xapi.getObject(srId)
const xva = await handler.createReadStream(
resolveRelativeFromFile(metadataFilename, metadata.data),
{
checksum: true,
ignoreMissingChecksum: true, // provide an easy way to opt-out
}
return importer(
handler,
metadataFilename,
metadata,
xapi,
xapi.getObject(srId)
)
const vm = await xapi.importVm(xva, { srId: sr.$id })
await Promise.all([
xapi.addTag(vm.$id, 'restored from backup'),
xapi.editVm(vm.$id, {
name_label: `${metadata.vm.name_label} (${safeDateFormat(
metadata.timestamp
)})`,
}),
])
return vm.$id
}
async listVmBackupsNg (remotes: string[]) {
@ -469,9 +570,12 @@ export default class BackupNg {
// - [ ] clones of replicated VMs should not be garbage collected
// - if storing uuids in source VM, how to detect them if the source is
// lost?
// - [ ] adding and removing VDIs should behave
// - [ ] validate VHDs after exports and before imports
// - [ ] isolate VHD chains by job
// - [ ] validate VHDs after exports and before imports, how?
// - [ ] in case of merge failure
// 1. delete (or isolate) the tainted VHD
// 2. next run should be a full
// - [ ] add a lock on the job/VDI during merge which should prevent other merges and restoration
// - [ ] import for delta
//
// Low:
// - [ ] check merge/transfert duration/size are what we want for delta
@ -479,10 +583,10 @@ export default class BackupNg {
// - [ ] possibility to (re-)run a single VM in a backup?
// - [ ] display queued VMs
// - [ ] snapshots and files of an old job should be detected and removed
// - [ ] delta import should support mapVdisSrs
// - [ ] size of the path? (base64url(Buffer.from(uuid.split('-').join(''), 'hex')))
//
// Triage:
// - [ ] protect against concurrent backup against a single VM (JFT: why?)
// - shouldn't be necessary now that VHD chains are separated by job
// - [ ] logs
//
// Done:
@ -493,6 +597,8 @@ export default class BackupNg {
// - [x] deleteFirst per target
// - [x] timeout per VM
// - [x] backups should be deletable from the API
// - [x] adding and removing VDIs should behave
// - [x] isolate VHD chains by job
@defer
async _backupVm (
$defer: any,
@ -540,17 +646,17 @@ export default class BackupNg {
)
)
let snapshot = await xapi._snapshotVm(
let snapshot: Vm = (await xapi._snapshotVm(
$cancelToken,
vm,
`[XO Backup] ${vm.name_label}`
)
`[XO Backup ${job.name}] ${vm.name_label}`
): any)
$defer.onFailure.call(xapi, '_deleteVm', snapshot)
await xapi._updateObjectMapProperty(snapshot, 'other_config', {
'xo:backup:job': jobId,
'xo:backup:schedule': scheduleId,
})
snapshot = await xapi.barrier(snapshot.$ref)
snapshot = ((await xapi.barrier(snapshot.$ref): any): Vm)
if (exportRetention === 0) {
return {
@ -569,30 +675,19 @@ export default class BackupNg {
const now = Date.now()
const vmDir = getVmBackupDir(vm.uuid)
const { mode } = job
const basename = safeDateFormat(now)
const metadataFilename = `${vmDir}/${basename}.json`
const metadata: Metadata = {
jobId,
mode,
scheduleId,
timestamp: now,
version: '2.0.0',
vm,
vmSnapshot: snapshot,
}
if (mode === 'full') {
if (job.mode === 'full') {
// TODO: do not create the snapshot if there are no snapshotRetention and
// the VM is not running
if (snapshotRetention === 0) {
$defer.call(xapi, 'deleteVm', snapshot)
}
let xva = await xapi.exportVm($cancelToken, snapshot, {
let xva: any = await xapi.exportVm($cancelToken, snapshot, {
compress: job.compression === 'native',
})
const exportTask = xva.task
@ -600,7 +695,16 @@ export default class BackupNg {
const dataBasename = `${basename}.xva`
metadata.data = `./${dataBasename}`
const metadata: MetadataFull = {
jobId,
mode: 'full',
scheduleId,
timestamp: now,
version: '2.0.0',
vm,
vmSnapshot: snapshot,
xva: `./${dataBasename}`,
}
const dataFilename = `${vmDir}/${dataBasename}`
const jsonMetadata = JSON.stringify(metadata)
@ -614,14 +718,14 @@ export default class BackupNg {
const handler = await app.getRemoteHandler(remoteId)
const oldBackups = getOldEntries(
const oldBackups: MetadataFull[] = (getOldEntries(
exportRetention,
await this._listVmBackups(
handler,
vm,
_ => _.mode === 'full' && _.scheduleId === scheduleId
)
)
): any)
const deleteFirst = getSetting(settings, 'deleteFirst', remoteId)
if (deleteFirst) {
@ -697,190 +801,277 @@ export default class BackupNg {
transferDuration: Date.now() - now,
transferSize: xva.size,
}
}
// const vdiDir = `${vmDir}/${jobId}/vdis`
const baseSnapshot = last(snapshots)
if (baseSnapshot !== undefined) {
console.log(baseSnapshot.$id) // TODO: remove
// check current state
// await Promise.all([asyncMap(remotes, remoteId => {})])
}
const deltaExport = await xapi.exportDeltaVm(
$cancelToken,
snapshot,
baseSnapshot
)
metadata.vbds = deltaExport.vbds
metadata.vdis = deltaExport.vdis
metadata.vifs = deltaExport.vifs
// const jsonMetadata = JSON.stringify(metadata)
// create a fork of the delta export
const forkExport = (() => {
// replace the stream factories by fork factories
const streams = mapValues(deltaExport.streams, lazyStream => {
let forks = []
return () => {
if (forks === undefined) {
throw new Error('cannot fork the stream after it has been created')
}
if (forks.length === 0) {
lazyStream().then(
stream => {
// $FlowFixMe
forks.forEach(({ resolve }) => {
const fork = stream.pipe(new PassThrough())
fork.task = stream.task
resolve(fork)
})
forks = undefined
},
error => {
// $FlowFixMe
forks.forEach(({ reject }) => {
reject(error)
})
forks = undefined
}
)
}
return new Promise((resolve, reject) => {
// $FlowFixMe
forks.push({ reject, resolve })
})
}
})
return () => {
return {
__proto__: deltaExport,
streams,
}
} else if (job.mode === 'delta') {
const baseSnapshot = last(snapshots)
if (baseSnapshot !== undefined) {
console.log(baseSnapshot.$id) // TODO: remove
// check current state
// await Promise.all([asyncMap(remotes, remoteId => {})])
}
})()
const mergeStart = 0
const mergeEnd = 0
let transferStart = 0
let transferEnd = 0
const errors = []
await waitAll(
[
// ...remotes.map(
// defer(async ($defer, remoteId) => {
// const fork = forkExport()
//
// const handler = await app.getRemoteHandler(remoteId)
//
// // const oldBackups = getOldEntries(
// // exportRetention,
// // await this._listVmBackups(
// // handler,
// // vm,
// // _ => _.mode === 'delta' && _.scheduleId === scheduleId
// // )
// // )
//
// const deleteFirst = getSetting(settings, 'deleteFirst', remoteId)
// if (deleteFirst) {
// // TODO
// }
//
// await asyncMap(fork.vdis, (vdi, id) =>
// writeStream(
// fork.streams[`${id}.vhd`](),
// handler,
// `${vdiDir}/${vdi.uuid}/${basename}.vhd`
// )
// )
//
// await handler.outputFile(metadataFilename, jsonMetadata)
//
// if (!deleteFirst) {
// // TODO
// }
// })
// ),
...srs.map(
defer(async ($defer, srId) => {
const fork = forkExport()
const deltaExport = await xapi.exportDeltaVm(
$cancelToken,
snapshot,
baseSnapshot
)
const xapi = app.getXapi(srId)
const sr = xapi.getObject(srId)
const oldVms = getOldEntries(
exportRetention,
listReplicatedVms(xapi, scheduleId, srId)
)
const deleteFirst = getSetting(settings, 'deleteFirst', srId)
if (deleteFirst) {
await this._deleteVms(xapi, oldVms)
}
transferStart = Math.min(transferStart, Date.now())
const { vm } = await xapi.importDeltaVm(fork, {
disableStartAfterImport: false, // we'll take care of that
name_label: `${metadata.vm.name_label} (${safeDateFormat(
metadata.timestamp
)})`,
srId: sr.$id,
})
transferEnd = Math.max(transferEnd, Date.now())
await Promise.all([
xapi.addTag(vm.$ref, 'Continuous Replication'),
xapi._updateObjectMapProperty(vm, 'blocked_operations', {
start:
'Start operation for this vm is blocked, clone it if you want to use it.',
}),
xapi._updateObjectMapProperty(vm, 'other_config', {
'xo:backup:sr': srId,
}),
])
if (!deleteFirst) {
await this._deleteVms(xapi, oldVms)
}
})
const metadata: MetadataDelta = {
jobId,
mode: 'delta',
scheduleId,
timestamp: now,
vbds: deltaExport.vbds,
vdis: deltaExport.vdis,
version: '2.0.0',
vifs: deltaExport.vifs,
vhds: mapValues(
deltaExport.vdis,
vdi =>
`vdis/${jobId}/${
(xapi.getObject(vdi.snapshot_of): Object).uuid
}/${basename}.vhd`
),
],
error => {
console.warn(error)
errors.push(error)
vm,
vmSnapshot: snapshot,
}
)
if (errors.length !== 0) {
throw errors
}
return {
mergeDuration: mergeEnd - mergeStart,
mergeSize: 0,
transferDuration: transferEnd - transferStart,
transferSize: 0,
const jsonMetadata = JSON.stringify(metadata)
// create a fork of the delta export
const forkExport = (() => {
// replace the stream factories by fork factories
const streams: any = mapValues(deltaExport.streams, lazyStream => {
let forks = []
return () => {
if (forks === undefined) {
throw new Error(
'cannot fork the stream after it has been created'
)
}
if (forks.length === 0) {
lazyStream().then(
stream => {
// $FlowFixMe
forks.forEach(({ resolve }) => {
const fork: any = stream.pipe(new PassThrough())
fork.task = stream.task
resolve(fork)
})
forks = undefined
},
error => {
// $FlowFixMe
forks.forEach(({ reject }) => {
reject(error)
})
forks = undefined
}
)
}
return new Promise((resolve, reject) => {
// $FlowFixMe
forks.push({ reject, resolve })
})
}
})
return () => {
return {
__proto__: deltaExport,
streams,
}
}
})()
const mergeStart = 0
const mergeEnd = 0
let transferStart = 0
let transferEnd = 0
const errors = []
await waitAll(
[
...remotes.map(
defer(async ($defer, remoteId) => {
const fork = forkExport()
const handler = await app.getRemoteHandler(remoteId)
const oldBackups: MetadataDelta[] = (getOldEntries(
exportRetention,
await this._listVmBackups(
handler,
vm,
_ => _.mode === 'delta' && _.scheduleId === scheduleId
)
): any)
const deleteFirst = getSetting(settings, 'deleteFirst', remoteId)
if (deleteFirst) {
this._deleteDeltaVmBackups(handler, oldBackups)
}
await asyncMap(
fork.vdis,
defer(async ($defer, vdi, id) => {
const path = `${vmDir}/${metadata.vhds[id]}`
const isDelta = 'xo:base_delta' in vdi.other_config
let parentPath
if (isDelta) {
const vdiDir = dirname(path)
const parent = (await handler.list(vdiDir))
.filter(isVhd)
.sort()
.pop()
parentPath = `${vdiDir}/${parent}`
}
await writeStream(fork.streams[`${id}.vhd`](), handler, path)
$defer.onFailure.call(handler, 'unlink', path)
if (isDelta) {
await chainVhd(handler, parentPath, handler, path)
}
})
)
await handler.outputFile(metadataFilename, jsonMetadata)
if (!deleteFirst) {
this._deleteDeltaVmBackups(handler, oldBackups)
}
})
),
...srs.map(
defer(async ($defer, srId) => {
const fork = forkExport()
const xapi = app.getXapi(srId)
const sr = xapi.getObject(srId)
const oldVms = getOldEntries(
exportRetention,
listReplicatedVms(xapi, scheduleId, srId)
)
const deleteFirst = getSetting(settings, 'deleteFirst', srId)
if (deleteFirst) {
await this._deleteVms(xapi, oldVms)
}
transferStart = Math.min(transferStart, Date.now())
const { vm } = await xapi.importDeltaVm(fork, {
disableStartAfterImport: false, // we'll take care of that
name_label: `${metadata.vm.name_label} (${safeDateFormat(
metadata.timestamp
)})`,
srId: sr.$id,
})
transferEnd = Math.max(transferEnd, Date.now())
await Promise.all([
xapi.addTag(vm.$ref, 'Continuous Replication'),
xapi._updateObjectMapProperty(vm, 'blocked_operations', {
start:
'Start operation for this vm is blocked, clone it if you want to use it.',
}),
xapi._updateObjectMapProperty(vm, 'other_config', {
'xo:backup:sr': srId,
}),
])
if (!deleteFirst) {
await this._deleteVms(xapi, oldVms)
}
})
),
],
error => {
console.warn(error)
errors.push(error)
}
)
if (errors.length !== 0) {
throw errors
}
return {
mergeDuration: mergeEnd - mergeStart,
mergeSize: 0,
transferDuration: transferEnd - transferStart,
transferSize: 0,
}
} else {
throw new Error(`no exporter for backup mode ${job.mode}`)
}
}
async _deleteDeltaVmBackups (
handler: RemoteHandler,
backups: MetadataDelta[]
): Promise<void> {
// TODO: remove VHD as well
await asyncMap(backups, async backup => {
const filename = ((backup._filename: any): string)
return Promise.all([
handler.unlink(filename),
asyncMap(backup.vhds, _ =>
// $FlowFixMe injected $defer param
this._deleteVhd(handler, resolveRelativeFromFile(filename, _))
),
])
})
}
async _deleteFullVmBackups (
handler: RemoteHandler,
backups: Metadata[]
backups: MetadataFull[]
): Promise<void> {
await asyncMap(backups, ({ _filename, data }) =>
Promise.all([
await asyncMap(backups, ({ _filename, xva }) => {
_filename = ((_filename: any): string)
return Promise.all([
handler.unlink(_filename),
handler.unlink(resolveRelativeFromFile(_filename, data)),
handler.unlink(resolveRelativeFromFile(_filename, xva)),
])
)
})
}
async _deleteVms (xapi: Xapi, vms: Object[]): Promise<void> {
return asyncMap(vms, vm => xapi.deleteVm(vm))
// FIXME: synchronize by job/VDI, otherwise it can cause issues with the merge
@defer
async _deleteVhd ($defer: any, handler: RemoteHandler, path: string) {
const vhds = await asyncMap(
await handler.list(dirname(path), { filter: isVhd, prependDir: true }),
async path => {
const metadata = await readVhdMetadata(handler, path)
metadata.path = path
return metadata
}
)
const base = basename(path)
const child = vhds.find(_ => _.header.parentUnicodeName === base)
if (child === undefined) {
return handler.unlink(path)
}
$defer.onFailure.call(handler, 'unlink', path)
const childPath = child.path
await Promise.all([
mergeVhd(handler, path, handler, childPath),
handler.unlink(path + '.checksum'),
])
await Promise.all([
handler.rename(path, childPath),
handler.unlink(childPath + '.checksum'),
])
}
async _deleteVms (xapi: Xapi, vms: Vm[]): Promise<void> {
await asyncMap(vms, vm => xapi.deleteVm(vm))
}
async _listVmBackups (

View File

@ -2,13 +2,13 @@
import type { Pattern } from 'value-matcher'
// $FlowFixMe
import { cancelable } from 'promise-toolbox'
import { map as mapToArray } from 'lodash'
import { noSuchObject } from 'xo-common/api-errors'
import Collection from '../../collection/redis'
import patch from '../../patch'
import { mapToArray, serializeError } from '../../utils'
import { serializeError } from '../../utils'
import type Logger from '../logs/loggers/abstract'
import { type Schedule } from '../scheduling'
@ -165,12 +165,15 @@ export default class Jobs {
}
async getJob (id: string, type?: string): Promise<Job> {
const job = await this._jobs.first(id)
let job = await this._jobs.first(id)
if (job === null || (type !== undefined && job.properties.type !== type)) {
throw noSuchObject(id, 'job')
}
return job.properties
job = job.properties
job.runId = this._runningJobs[id]
return job
}
createJob (job: $Diff<Job, {| id: string |}>): Promise<Job> {

View File

@ -290,6 +290,8 @@ const messages = {
jobEditMessage:
'You are editing job {name} ({id}). Saving will override previous job state.',
scheduleEdit: 'Edit',
scheduleSave: 'Save',
cancelScheduleEdition: 'Cancel',
scheduleAdd: 'Add a schedule',
scheduleDelete: 'Delete',
deleteSelectedSchedules: 'Delete selected schedules',
@ -316,6 +318,8 @@ const messages = {
smartBackupModeSelection: 'Select backup mode:',
normalBackup: 'Normal backup',
smartBackup: 'Smart backup',
exportRetention: 'Export retention',
snapshotRetention: 'Snapshot retention',
backupName: 'Name',
useDelta: 'Use delta',
useCompression: 'Use compression',
@ -1192,7 +1196,6 @@ const messages = {
deleteVmBackupsTitle: 'Delete {vm} backups',
deleteVmBackupsSelect: 'Select backups to delete:',
deleteVmBackupsSelectAll: 'All',
deleteVmBackupsDeltaInfo: 'Delta backup deletion will be available soon',
deleteVmBackupsBulkTitle: 'Delete backups',
deleteVmBackupsBulkMessage:
'Are you sure you want to delete all the backups from {nVms, number} VM{nVms, plural, one {} other {s}}?',

View File

@ -1,505 +0,0 @@
import classNames from 'classnames'
import React from 'react'
import { forEach, includes, isArray, map, sortedIndex } from 'lodash'
import { FormattedDate } from 'react-intl'
import _ from './intl'
import Button from './button'
import Component from './base-component'
import propTypes from './prop-types-decorator'
import TimezonePicker from './timezone-picker'
import Icon from './icon'
import Tooltip from './tooltip'
import { Card, CardHeader, CardBlock } from './card'
import { Col, Row } from './grid'
import { Range, Toggle } from './form'
// ===================================================================
const CLICKABLE = { cursor: 'pointer' }
// ===================================================================
const UNITS = ['minute', 'hour', 'monthDay', 'month', 'weekDay']
const MINUTES_RANGE = [2, 30]
const HOURS_RANGE = [2, 12]
const MONTH_DAYS_RANGE = [2, 15]
const MONTHS_RANGE = [2, 6]
const MONTHS = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [9, 10, 11]]
const DAYS = (() => {
const days = []
for (let i = 0; i < 4; i++) {
days[i] = []
for (let j = 1; j < 8; j++) {
days[i].push(7 * i + j)
}
}
days.push([29, 30, 31])
return days
})()
const WEEK_DAYS = [[0, 1, 2], [3, 4, 5], [6]]
const HOURS = (() => {
const hours = []
for (let i = 0; i < 4; i++) {
hours[i] = []
for (let j = 0; j < 6; j++) {
hours[i].push(6 * i + j)
}
}
return hours
})()
const MINS = (() => {
const minutes = []
for (let i = 0; i < 6; i++) {
minutes[i] = []
for (let j = 0; j < 10; j++) {
minutes[i].push(10 * i + j)
}
}
return minutes
})()
const PICKTIME_TO_ID = {
minute: 0,
hour: 1,
monthDay: 2,
month: 3,
weekDay: 4,
}
// ===================================================================
// monthNum: [ 0 : 11 ]
const getMonthName = monthNum => (
<FormattedDate value={Date.UTC(1970, monthNum)} month='long' timeZone='UTC' />
)
// dayNum: [ 0 : 6 ]
const getDayName = dayNum => (
// January, 1970, 5th => Monday
<FormattedDate
value={Date.UTC(1970, 0, 4 + dayNum)}
weekday='long'
timeZone='UTC'
/>
)
// ===================================================================
@propTypes({
children: propTypes.any.isRequired,
onChange: propTypes.func.isRequired,
tdId: propTypes.number.isRequired,
value: propTypes.bool.isRequired,
})
class ToggleTd extends Component {
_onClick = () => {
const { props } = this
props.onChange(props.tdId, !props.value)
}
render () {
const { props } = this
return (
<td
className={classNames('text-xs-center', props.value && 'table-success')}
onClick={this._onClick}
style={CLICKABLE}
>
{props.children}
</td>
)
}
}
// ===================================================================
@propTypes({
labelId: propTypes.string.isRequired,
options: propTypes.array.isRequired,
optionRenderer: propTypes.func,
onChange: propTypes.func.isRequired,
value: propTypes.array.isRequired,
})
class TableSelect extends Component {
static defaultProps = {
optionRenderer: value => value,
}
_reset = () => {
this.props.onChange([])
}
_handleChange = (tdId, tdValue) => {
const { props } = this
const newValue = props.value.slice()
const index = sortedIndex(newValue, tdId)
if (tdValue) {
// Add
if (newValue[index] !== tdId) {
newValue.splice(index, 0, tdId)
}
} else {
// Remove
if (newValue[index] === tdId) {
newValue.splice(index, 1)
}
}
props.onChange(newValue)
}
render () {
const { labelId, options, optionRenderer, value } = this.props
return (
<div>
<table className='table table-bordered table-sm'>
<tbody>
{map(options, (line, i) => (
<tr key={i}>
{map(line, tdOption => (
<ToggleTd
children={optionRenderer(tdOption)}
tdId={tdOption}
key={tdOption}
onChange={this._handleChange}
value={includes(value, tdOption)}
/>
))}
</tr>
))}
</tbody>
</table>
<Button className='pull-right' onClick={this._reset}>
{_(`selectTableAll${labelId}`)}{' '}
{value && !value.length && <Icon icon='success' />}
</Button>
</div>
)
}
}
// ===================================================================
// "2,7" => [2,7] "*/2" => 2 "*" => []
const cronToValue = (cron, range) => {
if (cron.indexOf('/') === 1) {
return +cron.split('/')[1]
}
if (cron === '*') {
return []
}
return map(cron.split(','), Number)
}
// [2,7] => "2,7" 2 => "*/2" [] => "*"
const valueToCron = value => {
if (!isArray(value)) {
return `*/${value}`
}
if (!value.length) {
return '*'
}
return value.join(',')
}
@propTypes({
headerAddon: propTypes.node,
optionRenderer: propTypes.func,
onChange: propTypes.func.isRequired,
range: propTypes.array,
labelId: propTypes.string.isRequired,
value: propTypes.any.isRequired,
})
class TimePicker extends Component {
_update = cron => {
const { tableValue, rangeValue } = this.state
const newValue = cronToValue(cron)
const periodic = !isArray(newValue)
this.setState({
periodic,
tableValue: periodic ? tableValue : newValue,
rangeValue: periodic ? newValue : rangeValue,
})
}
componentWillReceiveProps (props) {
if (props.value !== this.props.value) {
this._update(props.value)
}
}
componentDidMount () {
this._update(this.props.value)
}
_onChange = value => {
this.props.onChange(valueToCron(value))
}
_tableTab = () => this._onChange(this.state.tableValue || [])
_periodicTab = () =>
this._onChange(this.state.rangeValue || this.props.range[0])
render () {
const { headerAddon, labelId, options, optionRenderer, range } = this.props
const { periodic, tableValue, rangeValue } = this.state
return (
<Card>
<CardHeader>
{_(`scheduling${labelId}`)}
{headerAddon}
</CardHeader>
<CardBlock>
{range && (
<ul className='nav nav-tabs mb-1'>
<li className='nav-item'>
<a
onClick={this._tableTab}
className={classNames('nav-link', !periodic && 'active')}
style={CLICKABLE}
>
{_(`schedulingEachSelected${labelId}`)}
</a>
</li>
<li className='nav-item'>
<a
onClick={this._periodicTab}
className={classNames('nav-link', periodic && 'active')}
style={CLICKABLE}
>
{_(`schedulingEveryN${labelId}`)}
</a>
</li>
</ul>
)}
{periodic ? (
<Range
ref='range'
min={range[0]}
max={range[1]}
onChange={this._onChange}
value={rangeValue}
/>
) : (
<TableSelect
labelId={labelId}
onChange={this._onChange}
options={options}
optionRenderer={optionRenderer}
value={tableValue || []}
/>
)}
</CardBlock>
</Card>
)
}
}
const isWeekDayMode = ({ monthDayPattern, weekDayPattern }) => {
if (monthDayPattern === '*' && weekDayPattern === '*') {
return
}
return weekDayPattern !== '*'
}
@propTypes({
monthDayPattern: propTypes.string.isRequired,
weekDayPattern: propTypes.string.isRequired,
})
class DayPicker extends Component {
state = {
weekDayMode: isWeekDayMode(this.props),
}
componentWillReceiveProps (props) {
const weekDayMode = isWeekDayMode(props)
if (weekDayMode !== undefined) {
this.setState({ weekDayMode })
}
}
_setWeekDayMode = weekDayMode => {
this.props.onChange(['*', '*'])
this.setState({ weekDayMode })
}
_onChange = cron => {
const isMonthDayPattern = !this.state.weekDayMode || includes(cron, '/')
this.props.onChange([
isMonthDayPattern ? cron : '*',
isMonthDayPattern ? '*' : cron,
])
}
render () {
const { monthDayPattern, weekDayPattern } = this.props
const { weekDayMode } = this.state
const dayModeToggle = (
<Tooltip
content={_(
weekDayMode ? 'schedulingSetMonthDayMode' : 'schedulingSetWeekDayMode'
)}
>
<span className='pull-right'>
<Toggle
onChange={this._setWeekDayMode}
iconSize={1}
value={weekDayMode}
/>
</span>
</Tooltip>
)
return (
<TimePicker
headerAddon={dayModeToggle}
key={weekDayMode ? 'week' : 'month'}
labelId='Day'
optionRenderer={weekDayMode ? getDayName : undefined}
options={weekDayMode ? WEEK_DAYS : DAYS}
onChange={this._onChange}
range={MONTH_DAYS_RANGE}
setWeekDayMode={this._setWeekDayMode}
value={weekDayMode ? weekDayPattern : monthDayPattern}
/>
)
}
}
// ===================================================================
@propTypes({
cronPattern: propTypes.string,
onChange: propTypes.func,
timezone: propTypes.string,
value: propTypes.shape({
cronPattern: propTypes.string.isRequired,
timezone: propTypes.string,
}),
})
export default class Scheduler extends Component {
constructor (props) {
super(props)
this._onCronChange = newCrons => {
const cronPattern = this._getCronPattern().split(' ')
forEach(newCrons, (cron, unit) => {
cronPattern[PICKTIME_TO_ID[unit]] = cron
})
this.props.onChange({
cronPattern: cronPattern.join(' '),
timezone: this._getTimezone(),
})
}
forEach(UNITS, unit => {
this[`_${unit}Change`] = cron => this._onCronChange({ [unit]: cron })
})
this._dayChange = ([monthDay, weekDay]) =>
this._onCronChange({ monthDay, weekDay })
}
_onTimezoneChange = timezone => {
this.props.onChange({
cronPattern: this._getCronPattern(),
timezone,
})
}
_getCronPattern = () => {
const { value, cronPattern = value.cronPattern } = this.props
return cronPattern
}
_getTimezone = () => {
const { value, timezone = value && value.timezone } = this.props
return timezone
}
render () {
const cronPatternArr = this._getCronPattern().split(' ')
const timezone = this._getTimezone()
return (
<div className='card-block'>
<Row>
<TimePicker
labelId='Month'
optionRenderer={getMonthName}
options={MONTHS}
onChange={this._monthChange}
range={MONTHS_RANGE}
value={cronPatternArr[PICKTIME_TO_ID['month']]}
/>
</Row>
<Row>
<DayPicker
onChange={this._dayChange}
monthDayPattern={cronPatternArr[PICKTIME_TO_ID['monthDay']]}
weekDayPattern={cronPatternArr[PICKTIME_TO_ID['weekDay']]}
/>
</Row>
<Row>
<TimePicker
labelId='Hour'
options={HOURS}
range={HOURS_RANGE}
onChange={this._hourChange}
value={cronPatternArr[PICKTIME_TO_ID['hour']]}
/>
</Row>
<Row>
<TimePicker
labelId='Minute'
options={MINS}
range={MINUTES_RANGE}
onChange={this._minuteChange}
value={cronPatternArr[PICKTIME_TO_ID['minute']]}
/>
</Row>
<Row>
<Col>
<hr />
<TimezonePicker
value={timezone}
onChange={this._onTimezoneChange}
/>
</Col>
</Row>
</div>
)
}
}

View File

@ -1,741 +0,0 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import moment from 'moment-timezone'
import React from 'react'
import Scheduler from 'scheduler-tmp'
import SmartBackupPreview from 'smart-backup-preview'
import SortedTable from 'sorted-table'
import Upgrade from 'xoa-upgrade'
import { Card, CardBlock, CardHeader } from 'card'
import { connectStore, resolveIds } from 'utils'
import { confirm } from 'modal'
import { SchedulePreview } from 'scheduling'
import {
constructSmartPattern,
destructSmartPattern,
} from 'smart-backup-pattern'
import { createGetObjectsOfType } from 'selectors'
import { injectState, provideState } from '@julien-f/freactal'
import { Select, Toggle } from 'form'
import { findKey, flatten, get, isEmpty, map, size, some } from 'lodash'
import {
SelectPool,
SelectRemote,
SelectSr,
SelectTag,
SelectVm,
} from 'select-objects'
import {
createBackupNgJob,
createSchedule,
deleteSchedule,
editBackupNgJob,
editSchedule,
} from 'xo'
// ===================================================================
const SMART_MODE_INITIAL_STATE = {
powerState: 'All',
$pool: {},
tags: {},
}
const SMART_MODE_FUNCTIONS = {
setPowerState: (_, powerState) => state => ({
...state,
powerState,
}),
setPoolValues: (_, values) => state => ({
...state,
$pool: {
...state.$pool,
values,
},
}),
setPoolNotValues: (_, notValues) => state => ({
...state,
$pool: {
...state.$pool,
notValues,
},
}),
setTagValues: (_, values) => state => ({
...state,
tags: {
...state.tags,
values,
},
}),
setTagNotValues: (_, notValues) => state => ({
...state,
tags: {
...state.tags,
notValues,
},
}),
}
const normaliseTagValues = values => resolveIds(values).map(value => [value])
const SMART_MODE_COMPUTED = {
vmsSmartPattern: ({ $pool, powerState, tags }) => ({
$pool: constructSmartPattern($pool, resolveIds),
power_state: powerState === 'All' ? undefined : powerState,
tags: constructSmartPattern(tags, normaliseTagValues),
type: 'VM',
}),
allVms: (state, { allVms }) => allVms,
}
const VMS_STATUSES_OPTIONS = [
{ value: 'All', label: _('vmStateAll') },
{ value: 'Running', label: _('vmStateRunning') },
{ value: 'Halted', label: _('vmStateHalted') },
]
const SmartBackup = injectState(({ state, effects }) => (
<Card>
<CardHeader>{_('smartBackupModeTitle')}</CardHeader>
<CardBlock>
<FormGroup>
<label>
<strong>{_('editBackupSmartStatusTitle')}</strong>
</label>
<Select
options={VMS_STATUSES_OPTIONS}
onChange={effects.setPowerState}
value={state.powerState}
simpleValue
required
/>
</FormGroup>
<h3>{_('editBackupSmartPools')}</h3>
<hr />
<FormGroup>
<label>
<strong>{_('editBackupSmartResidentOn')}</strong>
</label>
<SelectPool
multi
onChange={effects.setPoolValues}
value={get(state.$pool, 'values')}
/>
</FormGroup>
<FormGroup>
<label>
<strong>{_('editBackupSmartNotResidentOn')}</strong>
</label>
<SelectPool
multi
onChange={effects.setPoolNotValues}
value={get(state.$pool, 'notValues')}
/>
</FormGroup>
<h3>{_('editBackupSmartTags')}</h3>
<hr />
<FormGroup>
<label>
<strong>{_('editBackupSmartTagsTitle')}</strong>
</label>
<SelectTag
multi
onChange={effects.setTagValues}
value={get(state.tags, 'values')}
/>
</FormGroup>
<FormGroup>
<label>
<strong>{_('editBackupSmartExcludedTagsTitle')}</strong>
</label>
<SelectTag
multi
onChange={effects.setTagNotValues}
value={get(state.tags, 'notValues')}
/>
</FormGroup>
<SmartBackupPreview vms={state.allVms} pattern={state.vmsSmartPattern} />
</CardBlock>
</Card>
))
// ===================================================================
const SCHEDULES_INITIAL_STATE = {
schedules: {},
tmpSchedules: {},
}
const SCHEDULES_COMPUTED = {
jobSettings: (state, { job }) => job && job.settings,
schedules: (state, { schedules }) => schedules,
canDeleteSchedule: state =>
((state.schedules && state.schedules.length) || 0) +
size(state.tmpSchedules) >
1,
}
const isScheduleValid = (snapshotRetention, exportRetention) =>
(+snapshotRetention !== 0 && snapshotRetention !== '') ||
(+exportRetention !== 0 && exportRetention !== '')
const SCHEDULES_FUNCTIONS = {
editSchedule: (_, value) => async (_, props) => {
const { schedule, snapshotRetention, exportRetention } = await confirm({
title: 'New schedule',
body: <ScheduleModal {...value} />,
})
if (isScheduleValid(snapshotRetention, exportRetention)) {
await editSchedule({
id: value.id,
jobId: props.job.id,
...schedule,
})
await editBackupNgJob({
id: props.job.id,
settings: {
...props.job.settings,
[value.id]: {
exportRetention: +exportRetention,
snapshotRetention: +snapshotRetention,
},
},
})
}
},
deleteSchedule: (_, id) => async (state, props) => {
await deleteSchedule(id)
delete props.job.settings[id]
await editBackupNgJob({
id: props.job.id,
settings: {
...props.job.settings,
},
})
},
editTmpSchedule: (_, value) => async state => {
const { schedule, snapshotRetention, exportRetention } = await confirm({
title: 'New schedule',
body: <ScheduleModal {...value} />,
})
if (isScheduleValid(snapshotRetention, exportRetention)) {
return {
...state,
tmpSchedules: {
...state.tmpSchedules,
[value.id]: {
...schedule,
exportRetention: exportRetention,
snapshotRetention: snapshotRetention,
},
},
}
}
},
deleteTmpSchedule: (_, id) => state => {
const tmpSchedules = { ...state.tmpSchedules }
delete tmpSchedules[id]
return {
...state,
tmpSchedules,
}
},
}
const SAVED_SCHEDULES_INDIVIDUAL_ACTIONS = [
{
handler: (schedule, { effects: { editSchedule } }) =>
editSchedule(schedule),
label: _('scheduleEdit'),
icon: 'edit',
level: 'warning',
},
{
handler: (schedule, { effects: { deleteSchedule } }) =>
deleteSchedule(schedule.id),
label: _('scheduleDelete'),
disabled: (_, { disabled }) => disabled,
icon: 'delete',
level: 'danger',
},
]
const NEW_SCHEDULES_INDIVIDUAL_ACTIONS = [
{
handler: (schedule, { effects: { editTmpSchedule }, tmpSchedules }) =>
editTmpSchedule({
id: findKey(tmpSchedules, schedule),
...schedule,
}),
label: _('scheduleEdit'),
icon: 'edit',
level: 'warning',
},
{
handler: (schedule, { effects: { deleteTmpSchedule }, tmpSchedules }) =>
deleteTmpSchedule(findKey(tmpSchedules, schedule)),
label: _('scheduleDelete'),
disabled: (_, { disabled }) => disabled,
icon: 'delete',
level: 'danger',
},
]
const SCHEDULES_COLUMNS = [
{
itemRenderer: _ => _.cron,
sortCriteria: 'cron',
name: _('scheduleCron'),
},
{
itemRenderer: _ => _.timezone,
sortCriteria: 'timezone',
name: _('scheduleTimezone'),
},
{
itemRenderer: _ => _.exportRetention,
sortCriteria: _ => _.exportRetention,
name: _('scheduleExportRetention'),
},
{
itemRenderer: _ => _.snapshotRetention,
sortCriteria: _ => _.snapshotRetention,
name: _('scheduleSnapshotRetention'),
},
]
const SAVED_SCHEDULES_COLUMNS = [
{
itemRenderer: _ => _.name,
sortCriteria: 'name',
name: _('scheduleName'),
default: true,
},
...SCHEDULES_COLUMNS,
]
const rowTransform = (schedule, { jobSettings }) => {
const jobShedule = jobSettings[schedule.id]
return {
...schedule,
exportRetention: jobShedule && jobShedule.exportRetention,
snapshotRetention: jobShedule && jobShedule.snapshotRetention,
}
}
const SchedulesOverview = injectState(({ state, effects }) => (
<Card>
<CardHeader>{_('backupSchedules')}</CardHeader>
<CardBlock>
{isEmpty(state.schedules) &&
isEmpty(state.tmpSchedules) && (
<p className='text-xs-center'>{_('noSchedules')}</p>
)}
{!isEmpty(state.schedules) && (
<FormGroup>
<label>
<strong>{_('backupSavedSchedules')}</strong>
</label>
<SortedTable
collection={state.schedules}
columns={SAVED_SCHEDULES_COLUMNS}
data-disabled={!state.canDeleteSchedule}
data-effects={effects}
data-jobSettings={state.jobSettings}
individualActions={SAVED_SCHEDULES_INDIVIDUAL_ACTIONS}
rowTransform={rowTransform}
/>
</FormGroup>
)}
{!isEmpty(state.tmpSchedules) && (
<FormGroup>
<label>
<strong>{_('backupNewSchedules')}</strong>
</label>
<SortedTable
collection={state.tmpSchedules}
columns={SCHEDULES_COLUMNS}
data-disabled={!state.canDeleteSchedule}
data-effects={effects}
data-tmpSchedules={state.tmpSchedules}
individualActions={NEW_SCHEDULES_INDIVIDUAL_ACTIONS}
/>
</FormGroup>
)}
</CardBlock>
</Card>
))
// ===================================================================
const DEFAULT_CRON_PATTERN = '0 0 * * *'
const DEFAULT_TIMEZONE = moment.tz.guess()
class ScheduleModal extends Component {
get value () {
return this.state
}
constructor (props) {
super(props)
this.state = {
schedule: {
cron: props.cron || DEFAULT_CRON_PATTERN,
timezone: props.timezone || DEFAULT_TIMEZONE,
},
exportRetention: props.exportRetention || 0,
snapshotRetention: props.snapshotRetention || 0,
}
}
_onChange = ({ cronPattern, timezone }) => {
this.setState({
schedule: {
cron: cronPattern,
timezone,
},
})
}
render () {
return (
<div>
<FormGroup>
<label>
<strong>Export retention</strong>
</label>
<Input
type='number'
onChange={this.linkState('exportRetention')}
value={this.state.exportRetention}
/>
</FormGroup>
<FormGroup>
<label>
<strong>Snapshot retention</strong>
</label>
<Input
type='number'
onChange={this.linkState('snapshotRetention')}
value={this.state.snapshotRetention}
/>
</FormGroup>
<Scheduler
cronPattern={this.state.schedule.cron}
onChange={this._onChange}
timezone={this.state.schedule.timezone}
/>
<SchedulePreview
cronPattern={this.state.schedule.cron}
timezone={this.state.schedule.timezone}
/>
</div>
)
}
}
// ===================================================================
const constructPattern = values => ({
id: {
__or: resolveIds(values),
},
})
const destructPattern = pattern => pattern.id.__or
const destructVmsPattern = pattern =>
pattern.id === undefined
? {
powerState: pattern.power_state || 'All',
$pool: destructSmartPattern(pattern.$pool),
tags: destructSmartPattern(pattern.tags, flatten),
}
: {
vms: destructPattern(pattern),
}
const FormGroup = props => <div {...props} className='form-group' />
const Input = props => <input {...props} className='form-control' />
const getNewSettings = schedules => {
const newSettings = {}
for (const schedule in schedules) {
newSettings[schedule] = {
exportRetention: +schedules[schedule].exportRetention,
snapshotRetention: +schedules[schedule].snapshotRetention,
}
}
return newSettings
}
const getRandomId = () =>
Math.random()
.toString(36)
.slice(2)
export default [
New => props => (
<Upgrade place='newBackup' required={2}>
<New {...props} />
</Upgrade>
),
connectStore({
allVms: createGetObjectsOfType('VM'),
}),
provideState({
initialState: () => ({
compression: true,
delta: false,
formId: getRandomId(),
name: '',
paramsUpdated: false,
remotes: [],
smartMode: false,
srs: [],
vms: [],
...SMART_MODE_INITIAL_STATE,
...SCHEDULES_INITIAL_STATE,
}),
effects: {
addSchedule: () => async state => {
const { schedule, snapshotRetention, exportRetention } = await confirm({
title: 'New schedule',
body: <ScheduleModal />,
})
if (isScheduleValid(snapshotRetention, exportRetention)) {
const id = getRandomId()
return {
...state,
tmpSchedules: {
...state.tmpSchedules,
[id]: {
...schedule,
exportRetention: exportRetention,
snapshotRetention: snapshotRetention,
},
},
}
}
},
createJob: () => async state => {
await createBackupNgJob({
name: state.name,
mode: state.delta ? 'delta' : 'full',
compression: state.compression ? 'native' : '',
schedules: state.tmpSchedules,
settings: {
...getNewSettings(state.tmpSchedules),
},
remotes: constructPattern(state.remotes),
srs: constructPattern(state.srs),
vms: state.smartMode
? state.vmsSmartPattern
: constructPattern(state.vms),
})
},
editJob: () => async (state, props) => {
const newSettings = {}
if (!isEmpty(state.tmpSchedules)) {
await Promise.all(
map(state.tmpSchedules, async schedule => {
const scheduleId = (await createSchedule(props.job.id, {
cron: schedule.cron,
timezone: schedule.timezone,
})).id
newSettings[scheduleId] = {
exportRetention: +schedule.exportRetention,
snapshotRetention: +schedule.snapshotRetention,
}
})
)
}
await editBackupNgJob({
id: props.job.id,
name: state.name,
mode: state.delta ? 'delta' : 'full',
compression: state.compression ? 'native' : '',
remotes: constructPattern(state.remotes),
srs: constructPattern(state.srs),
vms: state.smartMode
? state.vmsSmartPattern
: constructPattern(state.vms),
settings: {
...newSettings,
...props.job.settings,
},
})
},
setDelta: (_, { target: { checked } }) => state => ({
...state,
delta: checked,
}),
setCompression: (_, { target: { checked } }) => state => ({
...state,
compression: checked,
}),
setSmartMode: (_, smartMode) => state => ({
...state,
smartMode,
}),
setName: (_, { target: { value } }) => state => ({
...state,
name: value,
}),
setRemotes: (_, remotes) => state => ({ ...state, remotes }),
setSrs: (_, srs) => state => ({ ...state, srs }),
setVms: (_, vms) => state => ({ ...state, vms }),
updateParams: () => (state, { job }) => ({
...state,
compression: job.compression === 'native',
delta: job.mode === 'delta',
name: job.name,
paramsUpdated: true,
smartMode: job.vms.id === undefined,
remotes: destructPattern(job.remotes),
srs: destructPattern(job.srs),
...destructVmsPattern(job.vms),
}),
...SMART_MODE_FUNCTIONS,
...SCHEDULES_FUNCTIONS,
},
computed: {
needUpdateParams: (state, { job }) =>
job !== undefined && !state.paramsUpdated,
isInvalid: state =>
state.name.trim() === '' ||
(isEmpty(state.schedules) && isEmpty(state.tmpSchedules)) ||
(isEmpty(state.vms) && !state.smartMode),
showCompression: (state, { job }) =>
!state.delta &&
(some(
state.tmpSchedules,
schedule => +schedule.exportRetention !== 0
) ||
(job &&
some(job.settings, schedule => schedule.exportRetention !== 0))),
...SMART_MODE_COMPUTED,
...SCHEDULES_COMPUTED,
},
}),
injectState,
({ effects, state }) => {
if (state.needUpdateParams) {
effects.updateParams()
}
return (
<form id={state.formId}>
<FormGroup>
<label>
<strong>{_('backupName')}</strong>
</label>
<Input onChange={effects.setName} value={state.name} />
</FormGroup>
<FormGroup>
<label>
<strong>{_('backupTargetRemotes')}</strong>
</label>
<SelectRemote
multi
onChange={effects.setRemotes}
value={state.remotes}
/>
</FormGroup>
<FormGroup>
<label>
<strong>{_('backupTargetSrs')}</strong>
</label>
<SelectSr multi onChange={effects.setSrs} value={state.srs} />
</FormGroup>
<FormGroup>
<label>
<strong>{_('smartBackupModeTitle')}</strong>
</label>
<br />
<Toggle onChange={effects.setSmartMode} value={state.smartMode} />
</FormGroup>
{state.smartMode ? (
<Upgrade place='newBackup' required={3}>
<SmartBackup />
</Upgrade>
) : (
<FormGroup>
<label>
<strong>{_('vmsToBackup')}</strong>
</label>
<SelectVm multi onChange={effects.setVms} value={state.vms} />
</FormGroup>
)}
{(!isEmpty(state.srs) || !isEmpty(state.remotes)) && (
<Upgrade place='newBackup' required={4}>
<FormGroup>
<label>
<input
type='checkbox'
onChange={effects.setDelta}
checked={state.delta}
/>{' '}
<strong>{_('useDelta')}</strong>
</label>
</FormGroup>
</Upgrade>
)}
{state.showCompression && (
<label>
<input
type='checkbox'
onChange={effects.setCompression}
checked={state.compression}
/>{' '}
<strong>{_('useCompression')}</strong>
</label>
)}
<br />
<ActionButton
handler={effects.addSchedule}
icon='add'
className='pull-right'
>
{_('scheduleAdd')}
</ActionButton>
<br />
<br />
<SchedulesOverview />
<br />
{state.paramsUpdated ? (
<ActionButton
btnStyle='primary'
disabled={state.isInvalid}
form={state.formId}
handler={effects.editJob}
icon='save'
redirectOnSuccess='/backup-ng'
size='large'
>
{_('scheduleEdit')}
</ActionButton>
) : (
<ActionButton
btnStyle='primary'
disabled={state.isInvalid}
form={state.formId}
handler={effects.createJob}
icon='save'
redirectOnSuccess='/backup-ng'
size='large'
>
{_('createBackupJob')}
</ActionButton>
)}
</form>
)
},
].reduceRight((value, decorator) => decorator(value))

View File

@ -0,0 +1,728 @@
import _ from 'intl'
import ActionButton from 'action-button'
import React from 'react'
import renderXoItem, { renderXoItemFromId } from 'render-xo-item'
import SmartBackupPreview from 'smart-backup-preview'
import Tooltip from 'tooltip'
import Upgrade from 'xoa-upgrade'
import { addSubscriptions, connectStore, resolveId, resolveIds } from 'utils'
import { Card, CardBlock, CardHeader } from 'card'
import { Container, Col, Row } from 'grid'
import { createGetObjectsOfType } from 'selectors'
import { flatten, get, keyBy, isEmpty, map, some } from 'lodash'
import { injectState, provideState } from '@julien-f/freactal'
import { Select, Toggle } from 'form'
import {
constructSmartPattern,
destructSmartPattern,
} from 'smart-backup-pattern'
import {
SelectPool,
SelectRemote,
SelectSr,
SelectTag,
SelectVm,
} from 'select-objects'
import {
createBackupNgJob,
createSchedule,
deleteSchedule,
editBackupNgJob,
editSchedule,
subscribeRemotes,
} from 'xo'
import Schedules from './schedules'
import { FormGroup, getRandomId, Input, Ul, Li } from './utils'
// ===================================================================
const SMART_MODE_INITIAL_STATE = {
powerState: 'All',
$pool: {},
tags: {},
}
const SMART_MODE_FUNCTIONS = {
setPowerState: (_, powerState) => state => ({
...state,
powerState,
}),
setPoolValues: (_, values) => state => ({
...state,
$pool: {
...state.$pool,
values,
},
}),
setPoolNotValues: (_, notValues) => state => ({
...state,
$pool: {
...state.$pool,
notValues,
},
}),
setTagValues: (_, values) => state => ({
...state,
tags: {
...state.tags,
values,
},
}),
setTagNotValues: (_, notValues) => state => ({
...state,
tags: {
...state.tags,
notValues,
},
}),
}
const normaliseTagValues = values => resolveIds(values).map(value => [value])
const SMART_MODE_COMPUTED = {
vmsSmartPattern: ({ $pool, powerState, tags }) => ({
$pool: constructSmartPattern($pool, resolveIds),
power_state: powerState === 'All' ? undefined : powerState,
tags: constructSmartPattern(tags, normaliseTagValues),
type: 'VM',
}),
allVms: (state, { allVms }) => allVms,
}
const VMS_STATUSES_OPTIONS = [
{ value: 'All', label: _('vmStateAll') },
{ value: 'Running', label: _('vmStateRunning') },
{ value: 'Halted', label: _('vmStateHalted') },
]
const SmartBackup = injectState(({ state, effects }) => (
<div>
<FormGroup>
<label>
<strong>{_('editBackupSmartStatusTitle')}</strong>
</label>
<Select
options={VMS_STATUSES_OPTIONS}
onChange={effects.setPowerState}
value={state.powerState}
simpleValue
required
/>
</FormGroup>
<h3>{_('editBackupSmartPools')}</h3>
<hr />
<FormGroup>
<label>
<strong>{_('editBackupSmartResidentOn')}</strong>
</label>
<SelectPool
multi
onChange={effects.setPoolValues}
value={get(state.$pool, 'values')}
/>
</FormGroup>
<FormGroup>
<label>
<strong>{_('editBackupSmartNotResidentOn')}</strong>
</label>
<SelectPool
multi
onChange={effects.setPoolNotValues}
value={get(state.$pool, 'notValues')}
/>
</FormGroup>
<h3>{_('editBackupSmartTags')}</h3>
<hr />
<FormGroup>
<label>
<strong>{_('editBackupSmartTagsTitle')}</strong>
</label>
<SelectTag
multi
onChange={effects.setTagValues}
value={get(state.tags, 'values')}
/>
</FormGroup>
<FormGroup>
<label>
<strong>{_('editBackupSmartExcludedTagsTitle')}</strong>
</label>
<SelectTag
multi
onChange={effects.setTagNotValues}
value={get(state.tags, 'notValues')}
/>
</FormGroup>
<SmartBackupPreview vms={state.allVms} pattern={state.vmsSmartPattern} />
</div>
))
// ===================================================================
const constructPattern = values => ({
id: {
__or: resolveIds(values),
},
})
const destructPattern = pattern => pattern.id.__or
const destructVmsPattern = pattern =>
pattern.id === undefined
? {
powerState: pattern.power_state || 'All',
$pool: destructSmartPattern(pattern.$pool),
tags: destructSmartPattern(pattern.tags, flatten),
}
: {
vms: destructPattern(pattern),
}
const getNewSettings = schedules => {
const newSettings = {}
for (const schedule in schedules) {
newSettings[schedule] = {
exportRetention: +schedules[schedule].exportRetention,
snapshotRetention: +schedules[schedule].snapshotRetention,
}
}
return newSettings
}
const getNewSchedules = schedules => {
const newSchedules = {}
for (const schedule in schedules) {
newSchedules[schedule] = {
cron: schedules[schedule].cron,
timezone: schedules[schedule].timezone,
}
}
return newSchedules
}
export default [
New => props => (
<Upgrade place='newBackup' required={2}>
<New {...props} />
</Upgrade>
),
connectStore({
allVms: createGetObjectsOfType('VM'),
}),
addSubscriptions({
remotes: cb =>
subscribeRemotes(remotes => {
cb(keyBy(remotes, 'id'))
}),
}),
provideState({
initialState: () => ({
compression: true,
backupMode: undefined,
drMode: undefined,
deltaMode: undefined,
crMode: undefined,
snapshotMode: undefined,
formId: getRandomId(),
name: '',
paramsUpdated: false,
remotes: [],
smartMode: false,
srs: [],
vms: [],
tmpSchedule: {},
newSchedules: {},
editionMode: undefined,
...SMART_MODE_INITIAL_STATE,
}),
effects: {
createJob: () => async state => {
await createBackupNgJob({
name: state.name,
mode: state.isDelta ? 'delta' : 'full',
compression: state.compression ? 'native' : '',
schedules: getNewSchedules(state.newSchedules),
settings: {
...getNewSettings(state.newSchedules),
},
remotes:
(state.deltaMode || state.backupMode) &&
constructPattern(state.remotes),
srs: (state.crMode || state.drMode) && constructPattern(state.srs),
vms: state.smartMode
? state.vmsSmartPattern
: constructPattern(state.vms),
})
},
editJob: () => async (state, props) => {
const newSettings = {}
if (!isEmpty(state.newSchedules)) {
await Promise.all(
map(state.newSchedules, async schedule => {
const scheduleId = (await createSchedule(props.job.id, {
cron: schedule.cron,
timezone: schedule.timezone,
})).id
newSettings[scheduleId] = {
exportRetention: +schedule.exportRetention,
snapshotRetention: +schedule.snapshotRetention,
}
})
)
}
await editBackupNgJob({
id: props.job.id,
name: state.name,
mode: state.isDelta ? 'delta' : 'full',
compression: state.compression ? 'native' : '',
settings: {
...newSettings,
...props.job.settings,
},
remotes:
(state.deltaMode || state.backupMode) &&
constructPattern(state.remotes),
srs: (state.crMode || state.drMode) && constructPattern(state.srs),
vms: state.smartMode
? state.vmsSmartPattern
: constructPattern(state.vms),
})
},
setSnapshotMode: () => state => ({
...state,
snapshotMode: !state.snapshotMode || undefined,
}),
setBackupMode: () => state => ({
...state,
backupMode: !state.backupMode || undefined,
}),
setDeltaMode: () => state => ({
...state,
deltaMode: !state.deltaMode || undefined,
}),
setDrMode: () => state => ({
...state,
drMode: !state.drMode || undefined,
}),
setCrMode: () => state => ({
...state,
crMode: !state.crMode || undefined,
}),
setCompression: (_, { target: { checked } }) => state => ({
...state,
compression: checked,
}),
setSmartMode: (_, smartMode) => state => ({
...state,
smartMode,
}),
setName: (_, { target: { value } }) => state => ({
...state,
name: value,
}),
addRemote: (_, remote) => state => {
const remotes = [...state.remotes]
remotes.push(resolveId(remote))
return {
...state,
remotes,
}
},
deleteRemote: (_, key) => state => {
const remotes = [...state.remotes]
remotes.splice(key, 1)
return {
...state,
remotes,
}
},
addSr: (_, sr) => state => {
const srs = [...state.srs]
srs.push(resolveId(sr))
return {
...state,
srs,
}
},
deleteSr: (_, key) => state => {
const srs = [...state.srs]
srs.splice(key, 1)
return {
...state,
srs,
}
},
setVms: (_, vms) => state => ({ ...state, vms }),
updateParams: () => (state, { job }) => ({
...state,
compression: job.compression === 'native',
delta: job.mode === 'delta',
name: job.name,
paramsUpdated: true,
smartMode: job.vms.id === undefined,
snapshotMode:
some(
job.settings,
({ snapshotRetention }) => snapshotRetention > 0
) || undefined,
backupMode: (job.mode === 'full' && !isEmpty(job.remotes)) || undefined,
deltaMode: (job.mode === 'delta' && !isEmpty(job.remotes)) || undefined,
drMode: (job.mode === 'full' && !isEmpty(job.srs)) || undefined,
crMode: (job.mode === 'delta' && !isEmpty(job.srs)) || undefined,
remotes: job.remotes !== undefined ? destructPattern(job.remotes) : [],
srs: job.srs !== undefined ? destructPattern(job.srs) : [],
...destructVmsPattern(job.vms),
}),
addSchedule: () => state => ({
...state,
editionMode: 'creation',
}),
cancelSchedule: () => state => ({
...state,
tmpSchedule: {},
editionMode: undefined,
}),
editSchedule: (_, schedule) => state => {
const { snapshotRetention, exportRetention } =
state.settings[schedule.id] || {}
return {
...state,
editionMode: 'editSchedule',
tmpSchedule: {
exportRetention,
snapshotRetention,
...schedule,
},
}
},
deleteSchedule: (_, id) => async (state, props) => {
await deleteSchedule(id)
delete props.job.settings[id]
await editBackupNgJob({
id: props.job.id,
settings: {
...props.job.settings,
},
})
},
editNewSchedule: (_, schedule) => state => ({
...state,
editionMode: 'editNewSchedule',
tmpSchedule: {
...schedule,
},
}),
deleteNewSchedule: (_, id) => async (state, props) => {
const newSchedules = { ...state.newSchedules }
delete newSchedules[id]
return {
...state,
newSchedules,
}
},
saveSchedule: (
_,
{ cron, timezone, exportRetention, snapshotRetention }
) => async (state, props) => {
if (state.editionMode === 'creation') {
return {
...state,
editionMode: undefined,
newSchedules: {
...state.newSchedules,
[getRandomId()]: {
cron,
timezone,
exportRetention,
snapshotRetention,
},
},
}
}
if (state.editionMode === 'editSchedule') {
await editSchedule({
id: state.tmpSchedule.id,
jobId: props.job.id,
cron,
timezone,
})
await editBackupNgJob({
id: props.job.id,
settings: {
...props.job.settings,
[state.tmpSchedule.id]: {
exportRetention: +exportRetention,
snapshotRetention: +snapshotRetention,
},
},
})
return {
...state,
editionMode: undefined,
tmpSchedule: {},
}
}
return {
...state,
editionMode: undefined,
tmpSchedule: {},
newSchedules: {
...state.newSchedules,
[state.tmpSchedule.id]: {
cron,
timezone,
exportRetention,
snapshotRetention,
},
},
}
},
...SMART_MODE_FUNCTIONS,
},
computed: {
needUpdateParams: (state, { job }) =>
job !== undefined && !state.paramsUpdated,
isJobInvalid: state =>
state.name.trim() === '' ||
(isEmpty(state.schedules) && isEmpty(state.newSchedules)) ||
(isEmpty(state.vms) && !state.smartMode) ||
((state.backupMode || state.deltaMode) && isEmpty(state.remotes)) ||
((state.drMode || state.crMode) && isEmpty(state.srs)) ||
(!state.isDelta && !state.isFull && !state.snapshotMode),
showCompression: (state, { job }) =>
state.isFull &&
(some(
state.newSchedules,
schedule => +schedule.exportRetention !== 0
) ||
(job &&
some(job.settings, schedule => schedule.exportRetention !== 0))),
exportMode: state =>
state.backupMode || state.deltaMode || state.drMode || state.crMode,
settings: (state, { job }) => get(job, 'settings') || {},
schedules: (state, { schedules }) => schedules || [],
isDelta: state => state.deltaMode || state.crMode,
isFull: state => state.backupMode || state.drMode,
allRemotes: (state, { remotes }) => remotes,
...SMART_MODE_COMPUTED,
},
}),
injectState,
({ effects, state }) => {
if (state.needUpdateParams) {
effects.updateParams()
}
return (
<form id={state.formId}>
<Container>
<Row>
<Col mediumSize={6}>
<Card>
<CardHeader>
{_('backupName')}
<Tooltip content={_('smartBackupModeTitle')}>
<Toggle
className='pull-right'
onChange={effects.setSmartMode}
value={state.smartMode}
iconSize={1}
/>
</Tooltip>
</CardHeader>
<CardBlock>
<FormGroup>
<label>
<strong>{_('backupName')}</strong>
</label>
<Input onChange={effects.setName} value={state.name} />
</FormGroup>
{state.smartMode ? (
<Upgrade place='newBackup' required={3}>
<SmartBackup />
</Upgrade>
) : (
<FormGroup>
<label>
<strong>{_('vmsToBackup')}</strong>
</label>
<SelectVm
multi
onChange={effects.setVms}
value={state.vms}
/>
</FormGroup>
)}
{state.showCompression && (
<label>
<input
type='checkbox'
onChange={effects.setCompression}
checked={state.compression}
/>{' '}
<strong>{_('useCompression')}</strong>
</label>
)}
</CardBlock>
</Card>
<Card>
<CardBlock>
<div className='btn-toolbar text-xs-center'>
<ActionButton
active={state.snapshotMode}
handler={effects.setSnapshotMode}
icon='rolling-snapshot'
>
{_('rollingSnapshot')}
</ActionButton>
<ActionButton
active={state.backupMode}
disabled={state.isDelta}
handler={effects.setBackupMode}
icon='backup'
>
{_('backup')}
</ActionButton>
<ActionButton
active={state.deltaMode}
disabled={state.isFull}
handler={effects.setDeltaMode}
icon='delta-backup'
>
{_('deltaBackup')}
</ActionButton>
<ActionButton
active={state.drMode}
disabled={state.isDelta}
handler={effects.setDrMode}
icon='disaster-recovery'
>
{_('disasterRecovery')}
</ActionButton>
<ActionButton
active={state.crMode}
disabled={state.isFull}
handler={effects.setCrMode}
icon='continuous-replication'
>
{_('continuousReplication')}
</ActionButton>
</div>
</CardBlock>
</Card>
{(state.backupMode || state.deltaMode) && (
<Card>
<CardHeader>
{_(state.backupMode ? 'backup' : 'deltaBackup')}
</CardHeader>
<CardBlock>
<FormGroup>
<label>
<strong>{_('backupTargetRemotes')}</strong>
</label>
<SelectRemote onChange={effects.addRemote} value={null} />
<br />
<Ul>
{map(state.remotes, (id, key) => (
<Li key={id}>
{state.allRemotes &&
renderXoItem({
type: 'remote',
value: state.allRemotes[id],
})}
<ActionButton
btnStyle='danger'
className='pull-right'
handler={effects.deleteRemote}
handlerParam={key}
icon='delete'
size='small'
/>
</Li>
))}
</Ul>
</FormGroup>
</CardBlock>
</Card>
)}
{(state.drMode || state.crMode) && (
<Card>
<CardHeader>
{_(
state.drMode
? 'disasterRecovery'
: 'continuousReplication'
)}
</CardHeader>
<CardBlock>
<FormGroup>
<label>
<strong>{_('backupTargetSrs')}</strong>
</label>
<SelectSr onChange={effects.addSr} value={null} />
<br />
<Ul>
{map(state.srs, (id, key) => (
<Li key={id}>
{renderXoItemFromId(id)}
<ActionButton
btnStyle='danger'
className='pull-right'
icon='delete'
size='small'
handler={effects.deleteSr}
handlerParam={key}
/>
</Li>
))}
</Ul>
</FormGroup>
</CardBlock>
</Card>
)}
</Col>
<Col mediumSize={6}>
<Schedules />
</Col>
</Row>
<Row>
{state.paramsUpdated ? (
<ActionButton
btnStyle='primary'
disabled={state.isJobInvalid}
form={state.formId}
handler={effects.editJob}
icon='save'
redirectOnSuccess='/backup-ng'
size='large'
>
{_('scheduleEdit')}
</ActionButton>
) : (
<ActionButton
btnStyle='primary'
disabled={state.isJobInvalid}
form={state.formId}
handler={effects.createJob}
icon='save'
redirectOnSuccess='/backup-ng'
size='large'
>
{_('createBackupJob')}
</ActionButton>
)}
</Row>
</Container>
</form>
)
},
].reduceRight((value, decorator) => decorator(value))

View File

@ -0,0 +1,111 @@
import _ from 'intl'
import ActionButton from 'action-button'
import moment from 'moment-timezone'
import React from 'react'
import Scheduler, { SchedulePreview } from 'scheduling'
import { Card, CardBlock } from 'card'
import { injectState, provideState } from '@julien-f/freactal'
import { FormGroup, getRandomId, Input } from './utils'
export default [
injectState,
provideState({
initialState: ({
schedule: {
cron = '0 0 * * *',
exportRetention = 0,
snapshotRetention = 0,
timezone = moment.tz.guess(),
},
}) => ({
cron,
exportRetention,
formId: getRandomId(),
snapshotRetention,
timezone,
}),
effects: {
setExportRetention: (_, { target: { value } }) => state => ({
...state,
exportRetention: value,
}),
setSnapshotRetention: (_, { target: { value } }) => state => ({
...state,
snapshotRetention: value,
}),
setSchedule: (_, { cronPattern, timezone }) => state => ({
...state,
cron: cronPattern,
timezone,
}),
},
computed: {
isScheduleInvalid: ({ snapshotRetention, exportRetention }) =>
(+snapshotRetention === 0 || snapshotRetention === '') &&
(+exportRetention === 0 || exportRetention === ''),
},
}),
injectState,
({ effects, state }) => (
<form id={state.formId}>
<Card>
<CardBlock>
{state.exportMode && (
<FormGroup>
<label>
<strong>{_('exportRetention')}</strong>
</label>
<Input
type='number'
onChange={effects.setExportRetention}
value={state.exportRetention}
/>
</FormGroup>
)}
{state.snapshotMode && (
<FormGroup>
<label>
<strong>{_('snapshotRetention')}</strong>
</label>
<Input
type='number'
onChange={effects.setSnapshotRetention}
value={state.snapshotRetention}
/>
</FormGroup>
)}
<Scheduler
onChange={effects.setSchedule}
cronPattern={state.cron}
timezone={state.timezone}
/>
<SchedulePreview cronPattern={state.cron} timezone={state.timezone} />
<br />
<ActionButton
btnStyle='primary'
data-cron={state.cron}
data-exportRetention={state.exportRetention}
data-snapshotRetention={state.snapshotRetention}
data-timezone={state.timezone}
disabled={state.isScheduleInvalid}
form={state.formId}
handler={effects.saveSchedule}
icon='save'
size='large'
>
{_('scheduleSave')}
</ActionButton>
<ActionButton
className='pull-right'
handler={effects.cancelSchedule}
icon='save'
size='large'
>
{_('cancelScheduleEdition')}
</ActionButton>
</CardBlock>
</Card>
</form>
),
].reduceRight((value, decorator) => decorator(value))

View File

@ -0,0 +1,168 @@
import _ from 'intl'
import ActionButton from 'action-button'
import React from 'react'
import SortedTable from 'sorted-table'
import { Card, CardBlock, CardHeader } from 'card'
import { injectState, provideState } from '@julien-f/freactal'
import { isEmpty, findKey } from 'lodash'
import NewSchedule from './new-schedule'
import { FormGroup } from './utils'
// ===================================================================
const SCHEDULES_COLUMNS = [
{
itemRenderer: _ => _.cron,
sortCriteria: 'cron',
name: _('scheduleCron'),
},
{
itemRenderer: _ => _.timezone,
sortCriteria: 'timezone',
name: _('scheduleTimezone'),
},
{
itemRenderer: _ => _.exportRetention,
sortCriteria: _ => _.exportRetention,
name: _('scheduleExportRetention'),
},
{
itemRenderer: _ => _.snapshotRetention,
sortCriteria: _ => _.snapshotRetention,
name: _('scheduleSnapshotRetention'),
},
]
const SAVED_SCHEDULES_COLUMNS = [
{
itemRenderer: _ => _.name,
sortCriteria: 'name',
name: _('scheduleName'),
default: true,
},
...SCHEDULES_COLUMNS,
]
const rowTransform = (schedule, { settings }) => {
const { exportRetention, snapshotRetention } = settings[schedule.id] || {}
return {
...schedule,
exportRetention,
snapshotRetention,
}
}
const SAVED_SCHEDULES_INDIVIDUAL_ACTIONS = [
{
handler: (schedule, { editSchedule }) => editSchedule(schedule),
label: _('scheduleEdit'),
icon: 'edit',
disabled: (_, { disabledEdition }) => disabledEdition,
level: 'primary',
},
{
handler: (schedule, { deleteSchedule }) => deleteSchedule(schedule.id),
label: _('scheduleDelete'),
disabled: (_, { disabledDeletion }) => disabledDeletion,
icon: 'delete',
level: 'danger',
},
]
const NEW_SCHEDULES_INDIVIDUAL_ACTIONS = [
{
handler: (schedule, { editNewSchedule, newSchedules }) =>
editNewSchedule({
id: findKey(newSchedules, schedule),
...schedule,
}),
label: _('scheduleEdit'),
disabled: (_, { disabledEdition }) => disabledEdition,
icon: 'edit',
level: 'primary',
},
{
handler: (schedule, { deleteNewSchedule, newSchedules }) =>
deleteNewSchedule(findKey(newSchedules, schedule)),
label: _('scheduleDelete'),
icon: 'delete',
level: 'danger',
},
]
// ===================================================================
export default [
injectState,
provideState({
computed: {
disabledDeletion: state => state.schedules.length <= 1,
disabledEdition: state =>
state.editionMode !== undefined ||
(!state.exportMode && !state.snapshotMode),
},
}),
injectState,
({ effects, state }) => (
<div>
<Card>
<CardHeader>
{_('backupSchedules')}
<ActionButton
btnStyle='primary'
className='pull-right'
handler={effects.addSchedule}
disabled={state.disabledEdition}
icon='add'
tooltip={_('scheduleAdd')}
/>
</CardHeader>
<CardBlock>
{isEmpty(state.schedules) &&
isEmpty(state.newSchedules) && (
<p className='text-md-center'>{_('noSchedules')}</p>
)}
{!isEmpty(state.schedules) && (
<FormGroup>
<label>
<strong>{_('backupSavedSchedules')}</strong>
</label>
<SortedTable
collection={state.schedules}
columns={SAVED_SCHEDULES_COLUMNS}
data-deleteSchedule={effects.deleteSchedule}
data-disabledDeletion={state.disabledDeletion}
data-disabledEdition={state.disabledEdition}
data-editSchedule={effects.editSchedule}
data-settings={state.settings}
individualActions={SAVED_SCHEDULES_INDIVIDUAL_ACTIONS}
rowTransform={rowTransform}
/>
</FormGroup>
)}
{!isEmpty(state.newSchedules) && (
<FormGroup>
<label>
<strong>{_('backupNewSchedules')}</strong>
</label>
<SortedTable
collection={state.newSchedules}
columns={SCHEDULES_COLUMNS}
data-deleteNewSchedule={effects.deleteNewSchedule}
data-disabledEdition={state.disabledEdition}
data-editNewSchedule={effects.editNewSchedule}
data-newSchedules={state.newSchedules}
individualActions={NEW_SCHEDULES_INDIVIDUAL_ACTIONS}
/>
</FormGroup>
)}
</CardBlock>
</Card>
{state.editionMode !== undefined && (
<NewSchedule schedule={state.tmpSchedule} />
)}
</div>
),
].reduceRight((value, decorator) => decorator(value))

View File

@ -0,0 +1,11 @@
import React from 'react'
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 getRandomId = () =>
Math.random()
.toString(36)
.slice(2)

View File

@ -1,7 +1,6 @@
import _ from 'intl'
import classNames from 'classnames'
import Component from 'base-component'
import Icon from 'icon'
import React from 'react'
import { FormattedDate } from 'react-intl'
import { forEach, map, orderBy } from 'lodash'
@ -19,8 +18,7 @@ export default class DeleteBackupsModalBody extends Component {
const selected = this._getSelectedBackups().length === 0
const state = {}
// TODO: [DELTA] remove filter
forEach(this.props.backups.filter(b => b.mode !== 'delta'), backup => {
forEach(this.props.backups, backup => {
state[_escapeDot(backup.id)] = selected
})
@ -38,9 +36,7 @@ export default class DeleteBackupsModalBody extends Component {
_getAllSelected = createSelector(
() => this.props.backups,
this._getSelectedBackups,
(backups, selectedBackups) =>
// TODO: [DELTA] remove filter
backups.filter(b => b.mode !== 'delta').length === selectedBackups.length
(backups, selectedBackups) => backups.length === selectedBackups.length
)
_getBackups = createSelector(
@ -58,15 +54,11 @@ export default class DeleteBackupsModalBody extends Component {
className={classNames(
'list-group-item',
'list-group-item-action',
backup.mode === 'delta' && 'disabled', // TODO: [DELTA] remove
this.state[_escapeDot(backup.id)] && 'active'
)}
data-id={backup.id}
key={backup.id}
onClick={
backup.mode !== 'delta' && // TODO: [DELTA] remove
this.toggleState(_escapeDot(backup.id))
}
onClick={this.toggleState(_escapeDot(backup.id))}
type='button'
>
<span
@ -96,12 +88,6 @@ export default class DeleteBackupsModalBody extends Component {
/>{' '}
{_('deleteVmBackupsSelectAll')}
</div>
{/* TODO: [DELTA] remove div and i18n message */}
<div>
<em>
<Icon icon='info' /> {_('deleteVmBackupsDeltaInfo')}
</em>
</div>
</div>
)
}

View File

@ -202,24 +202,11 @@ export default class Restore extends Component {
strongConfirm: {
messageId: 'deleteVmBackupsBulkConfirmText',
values: {
nBackups: reduce(
datas,
(sum, data) =>
// TODO: [DELTA] remove filter
sum + data.backups.filter(b => b.mode !== 'delta').length,
0
),
nBackups: reduce(datas, (sum, data) => sum + data.backups.length, 0),
},
},
})
.then(
() =>
deleteBackups(
// TODO: [DELTA] remove filter
flatMap(datas, 'backups').filter(b => b.mode !== 'delta')
),
noop
)
.then(() => deleteBackups(flatMap(datas, 'backups')), noop)
.then(() => this._refreshBackupList())
// ---------------------------------------------------------------------------

View File

@ -4735,9 +4735,9 @@ flat-cache@^1.2.1:
graceful-fs "^4.1.2"
write "^0.2.1"
flow-bin@^0.66.0:
version "0.66.0"
resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.66.0.tgz#a96dde7015dc3343fd552a7b4963c02be705ca26"
flow-bin@^0.67.1:
version "0.67.1"
resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.67.1.tgz#eabb7197cce870ac9442cfd04251c7ddc30377db"
flush-write-stream@^1.0.2:
version "1.0.2"