feat(Backups NG): third iteration (#2729)
This commit is contained in:
parent
3ce4e86784
commit
80c1e39b53
@ -6,6 +6,7 @@ module.exports = {
|
||||
$Diff: true,
|
||||
$Exact: true,
|
||||
$Keys: true,
|
||||
$PropertyType: true,
|
||||
$Shape: true,
|
||||
},
|
||||
parser: 'babel-eslint',
|
||||
|
4
flow-typed/lodash.js
vendored
4
flow-typed/lodash.js
vendored
@ -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
|
||||
|
1
flow-typed/promise-toolbox.js
vendored
1
flow-typed/promise-toolbox.js
vendored
@ -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,
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
async _list (dir: string) {
|
||||
return filter === undefined ? entries : entries.filter(filter)
|
||||
}
|
||||
|
||||
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
|
||||
|
@ -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) => {
|
||||
|
@ -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)
|
||||
|
22
packages/xo-server/src/utils.js.flow
Normal file
22
packages/xo-server/src/utils.js.flow
Normal 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>
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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')
|
||||
}
|
||||
|
||||
metadata._filename = metadataFilename
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
||||
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,10 +801,7 @@ export default class BackupNg {
|
||||
transferDuration: Date.now() - now,
|
||||
transferSize: xva.size,
|
||||
}
|
||||
}
|
||||
|
||||
// const vdiDir = `${vmDir}/${jobId}/vdis`
|
||||
|
||||
} else if (job.mode === 'delta') {
|
||||
const baseSnapshot = last(snapshots)
|
||||
if (baseSnapshot !== undefined) {
|
||||
console.log(baseSnapshot.$id) // TODO: remove
|
||||
@ -714,26 +815,45 @@ export default class BackupNg {
|
||||
baseSnapshot
|
||||
)
|
||||
|
||||
metadata.vbds = deltaExport.vbds
|
||||
metadata.vdis = deltaExport.vdis
|
||||
metadata.vifs = deltaExport.vifs
|
||||
// const jsonMetadata = JSON.stringify(metadata)
|
||||
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`
|
||||
),
|
||||
vm,
|
||||
vmSnapshot: snapshot,
|
||||
}
|
||||
|
||||
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 => {
|
||||
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')
|
||||
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())
|
||||
const fork: any = stream.pipe(new PassThrough())
|
||||
fork.task = stream.task
|
||||
resolve(fork)
|
||||
})
|
||||
@ -769,41 +889,58 @@ export default class BackupNg {
|
||||
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
|
||||
// }
|
||||
// })
|
||||
// ),
|
||||
...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()
|
||||
@ -865,22 +1002,76 @@ export default class BackupNg {
|
||||
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 (
|
||||
|
@ -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> {
|
||||
|
@ -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}}?',
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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))
|
728
packages/xo-web/src/xo-app/backup-ng/new/index.js
Normal file
728
packages/xo-web/src/xo-app/backup-ng/new/index.js
Normal 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))
|
111
packages/xo-web/src/xo-app/backup-ng/new/new-schedule.js
Normal file
111
packages/xo-web/src/xo-app/backup-ng/new/new-schedule.js
Normal 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))
|
168
packages/xo-web/src/xo-app/backup-ng/new/schedules.js
Normal file
168
packages/xo-web/src/xo-app/backup-ng/new/schedules.js
Normal 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))
|
11
packages/xo-web/src/xo-app/backup-ng/new/utils.js
Normal file
11
packages/xo-web/src/xo-app/backup-ng/new/utils.js
Normal 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)
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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())
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user