feat(Backups NG): third iteration (#2729)
This commit is contained in:
parent
3ce4e86784
commit
80c1e39b53
@ -6,6 +6,7 @@ module.exports = {
|
|||||||
$Diff: true,
|
$Diff: true,
|
||||||
$Exact: true,
|
$Exact: true,
|
||||||
$Keys: true,
|
$Keys: true,
|
||||||
|
$PropertyType: true,
|
||||||
$Shape: true,
|
$Shape: true,
|
||||||
},
|
},
|
||||||
parser: 'babel-eslint',
|
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 isEmpty(mixed): boolean
|
||||||
declare export function keyBy<T>(array: T[], iteratee: string): boolean
|
declare export function keyBy<T>(array: T[], iteratee: string): boolean
|
||||||
declare export function last<T>(array?: T[]): T | void
|
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>(
|
declare export function mapValues<K, V1, V2>(
|
||||||
object: { [K]: V1 },
|
object: { [K]: V1 },
|
||||||
iteratee: (V1, K) => V2
|
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 module 'promise-toolbox' {
|
||||||
|
declare export function cancelable(Function): Function
|
||||||
declare export function defer<T>(): {|
|
declare export function defer<T>(): {|
|
||||||
promise: Promise<T>,
|
promise: Promise<T>,
|
||||||
reject: T => void,
|
reject: T => void,
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
"eslint-plugin-react": "^7.6.1",
|
"eslint-plugin-react": "^7.6.1",
|
||||||
"eslint-plugin-standard": "^3.0.1",
|
"eslint-plugin-standard": "^3.0.1",
|
||||||
"exec-promise": "^0.7.0",
|
"exec-promise": "^0.7.0",
|
||||||
"flow-bin": "^0.66.0",
|
"flow-bin": "^0.67.1",
|
||||||
"globby": "^8.0.0",
|
"globby": "^8.0.0",
|
||||||
"husky": "^0.14.3",
|
"husky": "^0.14.3",
|
||||||
"jest": "^22.0.4",
|
"jest": "^22.0.4",
|
||||||
|
@ -92,11 +92,11 @@ export default class RemoteHandlerAbstract {
|
|||||||
await promise
|
await promise
|
||||||
}
|
}
|
||||||
|
|
||||||
async readFile (file: string, options?: Object): Promise<Buffer | string> {
|
async readFile (file: string, options?: Object): Promise<Buffer> {
|
||||||
return this._readFile(file, options)
|
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)
|
return this.createReadStream(file, options).then(streamToBuffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,11 +119,25 @@ export default class RemoteHandlerAbstract {
|
|||||||
throw new Error('Not implemented')
|
throw new Error('Not implemented')
|
||||||
}
|
}
|
||||||
|
|
||||||
async list (dir: string = '.') {
|
async list (
|
||||||
return this._list(dir)
|
dir: string = '.',
|
||||||
|
{
|
||||||
|
filter,
|
||||||
|
prependDir = false,
|
||||||
|
}: { filter?: (name: string) => boolean, prependDir?: boolean } = {}
|
||||||
|
): Promise<string[]> {
|
||||||
|
const entries = await this._list(dir)
|
||||||
|
|
||||||
|
if (prependDir) {
|
||||||
|
entries.forEach((entry, i) => {
|
||||||
|
entries[i] = dir + '/' + entry
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter === undefined ? entries : entries.filter(filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
async _list (dir: string) {
|
async _list (dir: string): Promise<string[]> {
|
||||||
throw new Error('Not implemented')
|
throw new Error('Not implemented')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,7 +221,7 @@ export default class RemoteHandlerAbstract {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refreshChecksum (path: string): Promise<void> {
|
async refreshChecksum (path: string): Promise<void> {
|
||||||
const stream: any = (await this.createReadStream(path)).pipe(
|
const stream = (await this.createReadStream(path)).pipe(
|
||||||
createChecksumStream()
|
createChecksumStream()
|
||||||
)
|
)
|
||||||
stream.resume() // start reading the whole file
|
stream.resume() // start reading the whole file
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
import invert from 'lodash/invert'
|
// @flow
|
||||||
|
|
||||||
|
// $FlowFixMe
|
||||||
import through2 from 'through2'
|
import through2 from 'through2'
|
||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import { defer, fromEvent } from 'promise-toolbox'
|
import { defer, fromEvent } from 'promise-toolbox'
|
||||||
|
import { invert } from 'lodash'
|
||||||
|
import { type Readable, type Transform } from 'stream'
|
||||||
|
|
||||||
const ALGORITHM_TO_ID = {
|
const ALGORITHM_TO_ID = {
|
||||||
md5: '1',
|
md5: '1',
|
||||||
@ -21,7 +25,9 @@ const ID_TO_ALGORITHM = invert(ALGORITHM_TO_ID)
|
|||||||
// const checksumStream = source.pipe(createChecksumStream())
|
// const checksumStream = source.pipe(createChecksumStream())
|
||||||
// checksumStream.resume() // make the data flow without an output
|
// checksumStream.resume() // make the data flow without an output
|
||||||
// console.log(await checksumStream.checksum)
|
// 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]
|
const algorithmId = ALGORITHM_TO_ID[algorithm]
|
||||||
|
|
||||||
if (!algorithmId) {
|
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.
|
// 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
|
// The given stream is wrapped in a stream which emits an error event
|
||||||
// if the computed checksum is not equals to the expected checksum.
|
// 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(
|
const algorithmId = expectedChecksum.slice(
|
||||||
1,
|
1,
|
||||||
expectedChecksum.indexOf('$', 1)
|
expectedChecksum.indexOf('$', 1)
|
||||||
@ -60,7 +69,7 @@ export const validChecksumOfReadStream = (stream, expectedChecksum) => {
|
|||||||
|
|
||||||
const hash = createHash(ID_TO_ALGORITHM[algorithmId])
|
const hash = createHash(ID_TO_ALGORITHM[algorithmId])
|
||||||
|
|
||||||
const wrapper = stream.pipe(
|
const wrapper: any = stream.pipe(
|
||||||
through2(
|
through2(
|
||||||
{ highWaterMark: 0 },
|
{ highWaterMark: 0 },
|
||||||
(chunk, enc, callback) => {
|
(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) => {
|
const wrapper = through2((chunk, enc, cb) => {
|
||||||
wrapper.size += chunk.length
|
wrapper.size += chunk.length
|
||||||
cb(null, chunk)
|
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 isEqual from 'lodash/isEqual'
|
||||||
import { fromEvent } from 'promise-toolbox'
|
import { fromEvent } from 'promise-toolbox'
|
||||||
|
|
||||||
|
import type RemoteHandler from './remote-handlers/abstract'
|
||||||
import constantStream from './constant-stream'
|
import constantStream from './constant-stream'
|
||||||
import { noop, streamToBuffer } from './utils'
|
import { noop, streamToBuffer } from './utils'
|
||||||
|
|
||||||
@ -34,8 +35,8 @@ const VHD_PARENT_LOCATOR_ENTRIES = 8
|
|||||||
const VHD_PLATFORM_CODE_NONE = 0
|
const VHD_PLATFORM_CODE_NONE = 0
|
||||||
|
|
||||||
// Types of backup treated. Others are not supported.
|
// Types of backup treated. Others are not supported.
|
||||||
const HARD_DISK_TYPE_DYNAMIC = 3 // Full backup.
|
export const HARD_DISK_TYPE_DYNAMIC = 3 // Full backup.
|
||||||
const HARD_DISK_TYPE_DIFFERENCING = 4 // Delta backup.
|
export const HARD_DISK_TYPE_DIFFERENCING = 4 // Delta backup.
|
||||||
|
|
||||||
// Other.
|
// Other.
|
||||||
const BLOCK_UNUSED = 0xffffffff
|
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.')
|
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.
|
// Allocation table map is not yet implemented.
|
||||||
if (
|
if (
|
||||||
parentVhd.hasBlockAllocationTableMap() ||
|
parentVhd.hasBlockAllocationTableMap() ||
|
||||||
@ -694,6 +690,7 @@ export default concurrency(2)(async function vhdMerge (
|
|||||||
mergedDataSize += await parentVhd.coalesceBlock(childVhd, blockId)
|
mergedDataSize += await parentVhd.coalesceBlock(childVhd, blockId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cFooter = childVhd.footer
|
const cFooter = childVhd.footer
|
||||||
const pFooter = parentVhd.footer
|
const pFooter = parentVhd.footer
|
||||||
|
|
||||||
@ -701,6 +698,7 @@ export default concurrency(2)(async function vhdMerge (
|
|||||||
pFooter.diskGeometry = { ...cFooter.diskGeometry }
|
pFooter.diskGeometry = { ...cFooter.diskGeometry }
|
||||||
pFooter.originalSize = { ...cFooter.originalSize }
|
pFooter.originalSize = { ...cFooter.originalSize }
|
||||||
pFooter.timestamp = cFooter.timestamp
|
pFooter.timestamp = cFooter.timestamp
|
||||||
|
pFooter.uuid = cFooter.uuid
|
||||||
|
|
||||||
// necessary to update values and to recreate the footer after block
|
// necessary to update values and to recreate the footer after block
|
||||||
// creation
|
// creation
|
||||||
@ -759,3 +757,12 @@ export async function chainVhd (
|
|||||||
|
|
||||||
return false
|
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 (
|
async exportDeltaVm (
|
||||||
$defer,
|
$defer,
|
||||||
$cancelToken,
|
$cancelToken,
|
||||||
vmId,
|
vmId: string,
|
||||||
baseVmId = undefined,
|
baseVmId?: string,
|
||||||
{
|
{
|
||||||
bypassVdiChainsCheck = false,
|
bypassVdiChainsCheck = false,
|
||||||
|
|
||||||
@ -799,7 +799,7 @@ export default class Xapi extends XapiBase {
|
|||||||
disableBaseTags = false,
|
disableBaseTags = false,
|
||||||
snapshotNameLabel = undefined,
|
snapshotNameLabel = undefined,
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
): Promise<DeltaVmExport> {
|
||||||
let vm = this.getObject(vmId)
|
let vm = this.getObject(vmId)
|
||||||
if (!bypassVdiChainsCheck) {
|
if (!bypassVdiChainsCheck) {
|
||||||
this._assertHealthyVdiChains(vm)
|
this._assertHealthyVdiChains(vm)
|
||||||
@ -915,7 +915,7 @@ export default class Xapi extends XapiBase {
|
|||||||
@deferrable
|
@deferrable
|
||||||
async importDeltaVm (
|
async importDeltaVm (
|
||||||
$defer,
|
$defer,
|
||||||
delta,
|
delta: DeltaVmExport,
|
||||||
{
|
{
|
||||||
deleteBase = false,
|
deleteBase = false,
|
||||||
disableStartAfterImport = true,
|
disableStartAfterImport = true,
|
||||||
|
@ -1,23 +1,35 @@
|
|||||||
// @flow
|
// @flow
|
||||||
|
|
||||||
import {type Readable} from 'stream'
|
import { type Readable } from 'stream'
|
||||||
|
|
||||||
export type DeltaVmExport = {
|
type AugmentedReadable = Readable & {
|
||||||
streams: $Dict<() => Promise<Readable>>,
|
size?: number,
|
||||||
vbds: { [ref: string]: {} },
|
task?: Promise<mixed>
|
||||||
vdis: { [ref: string]: { $SR$uuid: string } },
|
|
||||||
vifs: { [ref: string]: {} }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
declare class XapiObject {
|
||||||
$id: string;
|
$id: string;
|
||||||
$ref: string;
|
$ref: string;
|
||||||
$type: string;
|
$type: string;
|
||||||
}
|
}
|
||||||
type AugmentedReadable = Readable & {
|
|
||||||
size?: number,
|
|
||||||
task?: Promise<mixed>
|
|
||||||
}
|
|
||||||
|
|
||||||
type Id = string | XapiObject
|
type Id = string | XapiObject
|
||||||
declare export class Vm extends XapiObject {
|
declare export class Vm extends XapiObject {
|
||||||
@ -31,19 +43,39 @@ declare export class Vm extends XapiObject {
|
|||||||
declare export class Xapi {
|
declare export class Xapi {
|
||||||
objects: { all: $Dict<Object> };
|
objects: { all: $Dict<Object> };
|
||||||
|
|
||||||
_importVm (cancelToken: mixed, stream: AugmentedReadable, sr?: XapiObject, onVmCreation?: XapiObject => any): Promise<string>;
|
_importVm(
|
||||||
_updateObjectMapProperty(object: XapiObject, property: string, entries: $Dict<string>): Promise<void>;
|
cancelToken: mixed,
|
||||||
_setObjectProperties(object: XapiObject, properties: $Dict<mixed>): Promise<void>;
|
stream: AugmentedReadable,
|
||||||
_snapshotVm (cancelToken: mixed, vm: Vm, nameLabel?: string): Promise<Vm>;
|
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>;
|
addTag(object: Id, tag: string): Promise<void>;
|
||||||
barrier(): void;
|
barrier(): void;
|
||||||
barrier(ref: string): XapiObject;
|
barrier(ref: string): XapiObject;
|
||||||
deleteVm (vm: Id): Promise<void>;
|
deleteVm(vm: Id): Promise<void>;
|
||||||
editVm(vm: Id, $Dict<mixed>): Promise<void>;
|
editVm(vm: Id, $Dict<mixed>): Promise<void>;
|
||||||
exportDeltaVm (cancelToken: mixed, snapshot: Id, baseSnapshot?: Id): Promise<DeltaVmExport>;
|
exportDeltaVm(
|
||||||
exportVm(cancelToken: mixed, vm: Vm, options?: Object): Promise<AugmentedReadable>;
|
cancelToken: mixed,
|
||||||
getObject (object: Id): XapiObject;
|
snapshot: Id,
|
||||||
importDeltaVm (data: DeltaVmExport, options: Object): Promise<{ vm: Vm }>;
|
baseSnapshot?: Id
|
||||||
importVm (stream: AugmentedReadable, options: Object): Promise<Vm>;
|
): 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
|
// $FlowFixMe
|
||||||
import defer from 'golike-defer'
|
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 Pattern, createPredicate } from 'value-matcher'
|
||||||
import { type Readable, PassThrough } from 'stream'
|
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 Executor, type Job } from '../jobs'
|
||||||
import { type Schedule } from '../scheduling'
|
import { type Schedule } from '../scheduling'
|
||||||
|
|
||||||
import createSizeStream from '../../size-stream'
|
|
||||||
import type RemoteHandler from '../../remote-handlers/abstract'
|
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 { 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'
|
type Mode = 'full' | 'delta'
|
||||||
|
|
||||||
@ -22,11 +32,11 @@ type Settings = {|
|
|||||||
deleteFirst?: boolean,
|
deleteFirst?: boolean,
|
||||||
exportRetention?: number,
|
exportRetention?: number,
|
||||||
snapshotRetention?: number,
|
snapshotRetention?: number,
|
||||||
vmTimeout?: number
|
vmTimeout?: number,
|
||||||
|}
|
|}
|
||||||
|
|
||||||
type SimpleIdPattern = {|
|
type SimpleIdPattern = {|
|
||||||
id: string | {| __or: string[] |}
|
id: string | {| __or: string[] |},
|
||||||
|}
|
|}
|
||||||
|
|
||||||
export type BackupJob = {|
|
export type BackupJob = {|
|
||||||
@ -37,34 +47,37 @@ export type BackupJob = {|
|
|||||||
settings: $Dict<Settings>,
|
settings: $Dict<Settings>,
|
||||||
srs?: SimpleIdPattern,
|
srs?: SimpleIdPattern,
|
||||||
type: 'backup',
|
type: 'backup',
|
||||||
vms: Pattern
|
vms: Pattern,
|
||||||
|}
|
|}
|
||||||
|
|
||||||
type BackupResult = {|
|
type BackupResult = {|
|
||||||
mergeDuration: number,
|
mergeDuration: number,
|
||||||
mergeSize: number,
|
mergeSize: number,
|
||||||
transferDuration: number,
|
transferDuration: number,
|
||||||
transferSize: number
|
transferSize: number,
|
||||||
|}
|
|}
|
||||||
|
|
||||||
type MetadataBase = {|
|
type MetadataBase = {|
|
||||||
|
_filename?: string,
|
||||||
jobId: string,
|
jobId: string,
|
||||||
mode: Mode,
|
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
version: '2.0.0',
|
version: '2.0.0',
|
||||||
vm: Object,
|
vm: Object,
|
||||||
vmSnapshot: Object
|
vmSnapshot: Object,
|
||||||
|}
|
|}
|
||||||
type MetadataDelta = {|
|
type MetadataDelta = {|
|
||||||
...MetadataBase,
|
...MetadataBase,
|
||||||
mode: 'delta',
|
mode: 'delta',
|
||||||
vdis: $Dict<{}>
|
vdis: $PropertyType<DeltaVmExport, 'vdis'>,
|
||||||
|
vbds: $PropertyType<DeltaVmExport, 'vbds'>,
|
||||||
|
vhds: { [vdiId: string]: string },
|
||||||
|
vifs: $PropertyType<DeltaVmExport, 'vifs'>,
|
||||||
|}
|
|}
|
||||||
type MetadataFull = {|
|
type MetadataFull = {|
|
||||||
...MetadataBase,
|
...MetadataBase,
|
||||||
data: string, // relative path to the XVA
|
mode: 'full',
|
||||||
mode: 'full'
|
xva: string,
|
||||||
|}
|
|}
|
||||||
type Metadata = MetadataDelta | MetadataFull
|
type Metadata = MetadataDelta | MetadataFull
|
||||||
|
|
||||||
@ -110,6 +123,7 @@ const BACKUP_DIR = 'xo-vm-backups'
|
|||||||
const getVmBackupDir = (uuid: string) => `${BACKUP_DIR}/${uuid}`
|
const getVmBackupDir = (uuid: string) => `${BACKUP_DIR}/${uuid}`
|
||||||
|
|
||||||
const isMetadataFile = (filename: string) => filename.endsWith('.json')
|
const isMetadataFile = (filename: string) => filename.endsWith('.json')
|
||||||
|
const isVhd = (filename: string) => filename.endsWith('.vhd')
|
||||||
|
|
||||||
const listReplicatedVms = (
|
const listReplicatedVms = (
|
||||||
xapi: Xapi,
|
xapi: Xapi,
|
||||||
@ -134,6 +148,100 @@ const listReplicatedVms = (
|
|||||||
return values(vms).sort(compareSnapshotTime)
|
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 parseVmBackupId = (id: string) => {
|
||||||
const i = id.indexOf('/')
|
const i = id.indexOf('/')
|
||||||
return {
|
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 =>
|
const resolveRelativeFromFile = (file: string, path: string): string =>
|
||||||
resolve('/', dirname(file), path).slice(1)
|
resolve('/', dirname(file), path).slice(1)
|
||||||
|
|
||||||
@ -226,7 +334,7 @@ export default class BackupNg {
|
|||||||
getXapi: (id: string) => Xapi,
|
getXapi: (id: string) => Xapi,
|
||||||
getJob: (id: string, 'backup') => Promise<BackupJob>,
|
getJob: (id: string, 'backup') => Promise<BackupJob>,
|
||||||
updateJob: ($Shape<BackupJob>) => Promise<BackupJob>,
|
updateJob: ($Shape<BackupJob>) => Promise<BackupJob>,
|
||||||
removeJob: (id: string) => Promise<void>
|
removeJob: (id: string) => Promise<void>,
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor (app: any) {
|
constructor (app: any) {
|
||||||
@ -245,7 +353,7 @@ export default class BackupNg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const job: BackupJob = (job_: any)
|
const job: BackupJob = (job_: any)
|
||||||
const vms = app.getObjects({
|
const vms: $Dict<Vm> = app.getObjects({
|
||||||
filter: createPredicate({
|
filter: createPredicate({
|
||||||
type: 'VM',
|
type: 'VM',
|
||||||
...job.vms,
|
...job.vms,
|
||||||
@ -384,13 +492,15 @@ export default class BackupNg {
|
|||||||
const metadata: Metadata = JSON.parse(
|
const metadata: Metadata = JSON.parse(
|
||||||
String(await handler.readFile(metadataFilename))
|
String(await handler.readFile(metadataFilename))
|
||||||
)
|
)
|
||||||
|
metadata._filename = metadataFilename
|
||||||
|
|
||||||
if (metadata.mode === 'delta') {
|
if (metadata.mode === 'delta') {
|
||||||
throw new Error('not implemented')
|
await this._deleteDeltaVmBackups(handler, [metadata])
|
||||||
|
} else if (metadata.mode === 'full') {
|
||||||
|
await this._deleteFullVmBackups(handler, [metadata])
|
||||||
|
} else {
|
||||||
|
throw new Error(`no deleter for backup mode ${metadata.mode}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata._filename = metadataFilename
|
|
||||||
await this._deleteFullVmBackups(handler, [metadata])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async importVmBackupNg (id: string, srId: string): Promise<string> {
|
async importVmBackupNg (id: string, srId: string): Promise<string> {
|
||||||
@ -401,29 +511,20 @@ export default class BackupNg {
|
|||||||
String(await handler.readFile(metadataFilename))
|
String(await handler.readFile(metadataFilename))
|
||||||
)
|
)
|
||||||
|
|
||||||
if (metadata.mode === 'delta') {
|
const importer = importers[metadata.mode]
|
||||||
throw new Error('not implemented')
|
if (importer === undefined) {
|
||||||
|
throw new Error(`no importer for backup mode ${metadata.mode}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const xapi = app.getXapi(srId)
|
const xapi = app.getXapi(srId)
|
||||||
const sr = xapi.getObject(srId)
|
|
||||||
const xva = await handler.createReadStream(
|
return importer(
|
||||||
resolveRelativeFromFile(metadataFilename, metadata.data),
|
handler,
|
||||||
{
|
metadataFilename,
|
||||||
checksum: true,
|
metadata,
|
||||||
ignoreMissingChecksum: true, // provide an easy way to opt-out
|
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[]) {
|
async listVmBackupsNg (remotes: string[]) {
|
||||||
@ -469,9 +570,12 @@ export default class BackupNg {
|
|||||||
// - [ ] clones of replicated VMs should not be garbage collected
|
// - [ ] clones of replicated VMs should not be garbage collected
|
||||||
// - if storing uuids in source VM, how to detect them if the source is
|
// - if storing uuids in source VM, how to detect them if the source is
|
||||||
// lost?
|
// lost?
|
||||||
// - [ ] adding and removing VDIs should behave
|
// - [ ] validate VHDs after exports and before imports, how?
|
||||||
// - [ ] validate VHDs after exports and before imports
|
// - [ ] in case of merge failure
|
||||||
// - [ ] isolate VHD chains by job
|
// 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:
|
// Low:
|
||||||
// - [ ] check merge/transfert duration/size are what we want for delta
|
// - [ ] 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?
|
// - [ ] possibility to (re-)run a single VM in a backup?
|
||||||
// - [ ] display queued VMs
|
// - [ ] display queued VMs
|
||||||
// - [ ] snapshots and files of an old job should be detected and removed
|
// - [ ] 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:
|
// Triage:
|
||||||
// - [ ] protect against concurrent backup against a single VM (JFT: why?)
|
|
||||||
// - shouldn't be necessary now that VHD chains are separated by job
|
|
||||||
// - [ ] logs
|
// - [ ] logs
|
||||||
//
|
//
|
||||||
// Done:
|
// Done:
|
||||||
@ -493,6 +597,8 @@ export default class BackupNg {
|
|||||||
// - [x] deleteFirst per target
|
// - [x] deleteFirst per target
|
||||||
// - [x] timeout per VM
|
// - [x] timeout per VM
|
||||||
// - [x] backups should be deletable from the API
|
// - [x] backups should be deletable from the API
|
||||||
|
// - [x] adding and removing VDIs should behave
|
||||||
|
// - [x] isolate VHD chains by job
|
||||||
@defer
|
@defer
|
||||||
async _backupVm (
|
async _backupVm (
|
||||||
$defer: any,
|
$defer: any,
|
||||||
@ -540,17 +646,17 @@ export default class BackupNg {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
let snapshot = await xapi._snapshotVm(
|
let snapshot: Vm = (await xapi._snapshotVm(
|
||||||
$cancelToken,
|
$cancelToken,
|
||||||
vm,
|
vm,
|
||||||
`[XO Backup] ${vm.name_label}`
|
`[XO Backup ${job.name}] ${vm.name_label}`
|
||||||
)
|
): any)
|
||||||
$defer.onFailure.call(xapi, '_deleteVm', snapshot)
|
$defer.onFailure.call(xapi, '_deleteVm', snapshot)
|
||||||
await xapi._updateObjectMapProperty(snapshot, 'other_config', {
|
await xapi._updateObjectMapProperty(snapshot, 'other_config', {
|
||||||
'xo:backup:job': jobId,
|
'xo:backup:job': jobId,
|
||||||
'xo:backup:schedule': scheduleId,
|
'xo:backup:schedule': scheduleId,
|
||||||
})
|
})
|
||||||
snapshot = await xapi.barrier(snapshot.$ref)
|
snapshot = ((await xapi.barrier(snapshot.$ref): any): Vm)
|
||||||
|
|
||||||
if (exportRetention === 0) {
|
if (exportRetention === 0) {
|
||||||
return {
|
return {
|
||||||
@ -569,30 +675,19 @@ export default class BackupNg {
|
|||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const vmDir = getVmBackupDir(vm.uuid)
|
const vmDir = getVmBackupDir(vm.uuid)
|
||||||
const { mode } = job
|
|
||||||
|
|
||||||
const basename = safeDateFormat(now)
|
const basename = safeDateFormat(now)
|
||||||
|
|
||||||
const metadataFilename = `${vmDir}/${basename}.json`
|
const metadataFilename = `${vmDir}/${basename}.json`
|
||||||
|
|
||||||
const metadata: Metadata = {
|
if (job.mode === 'full') {
|
||||||
jobId,
|
|
||||||
mode,
|
|
||||||
scheduleId,
|
|
||||||
timestamp: now,
|
|
||||||
version: '2.0.0',
|
|
||||||
vm,
|
|
||||||
vmSnapshot: snapshot,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === 'full') {
|
|
||||||
// TODO: do not create the snapshot if there are no snapshotRetention and
|
// TODO: do not create the snapshot if there are no snapshotRetention and
|
||||||
// the VM is not running
|
// the VM is not running
|
||||||
if (snapshotRetention === 0) {
|
if (snapshotRetention === 0) {
|
||||||
$defer.call(xapi, 'deleteVm', snapshot)
|
$defer.call(xapi, 'deleteVm', snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
let xva = await xapi.exportVm($cancelToken, snapshot, {
|
let xva: any = await xapi.exportVm($cancelToken, snapshot, {
|
||||||
compress: job.compression === 'native',
|
compress: job.compression === 'native',
|
||||||
})
|
})
|
||||||
const exportTask = xva.task
|
const exportTask = xva.task
|
||||||
@ -600,7 +695,16 @@ export default class BackupNg {
|
|||||||
|
|
||||||
const dataBasename = `${basename}.xva`
|
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 dataFilename = `${vmDir}/${dataBasename}`
|
||||||
|
|
||||||
const jsonMetadata = JSON.stringify(metadata)
|
const jsonMetadata = JSON.stringify(metadata)
|
||||||
@ -614,14 +718,14 @@ export default class BackupNg {
|
|||||||
|
|
||||||
const handler = await app.getRemoteHandler(remoteId)
|
const handler = await app.getRemoteHandler(remoteId)
|
||||||
|
|
||||||
const oldBackups = getOldEntries(
|
const oldBackups: MetadataFull[] = (getOldEntries(
|
||||||
exportRetention,
|
exportRetention,
|
||||||
await this._listVmBackups(
|
await this._listVmBackups(
|
||||||
handler,
|
handler,
|
||||||
vm,
|
vm,
|
||||||
_ => _.mode === 'full' && _.scheduleId === scheduleId
|
_ => _.mode === 'full' && _.scheduleId === scheduleId
|
||||||
)
|
)
|
||||||
)
|
): any)
|
||||||
|
|
||||||
const deleteFirst = getSetting(settings, 'deleteFirst', remoteId)
|
const deleteFirst = getSetting(settings, 'deleteFirst', remoteId)
|
||||||
if (deleteFirst) {
|
if (deleteFirst) {
|
||||||
@ -697,190 +801,277 @@ export default class BackupNg {
|
|||||||
transferDuration: Date.now() - now,
|
transferDuration: Date.now() - now,
|
||||||
transferSize: xva.size,
|
transferSize: xva.size,
|
||||||
}
|
}
|
||||||
}
|
} else if (job.mode === 'delta') {
|
||||||
|
const baseSnapshot = last(snapshots)
|
||||||
// const vdiDir = `${vmDir}/${jobId}/vdis`
|
if (baseSnapshot !== undefined) {
|
||||||
|
console.log(baseSnapshot.$id) // TODO: remove
|
||||||
const baseSnapshot = last(snapshots)
|
// check current state
|
||||||
if (baseSnapshot !== undefined) {
|
// await Promise.all([asyncMap(remotes, remoteId => {})])
|
||||||
console.log(baseSnapshot.$id) // TODO: remove
|
|
||||||
// check current state
|
|
||||||
// await Promise.all([asyncMap(remotes, remoteId => {})])
|
|
||||||
}
|
|
||||||
|
|
||||||
const deltaExport = await xapi.exportDeltaVm(
|
|
||||||
$cancelToken,
|
|
||||||
snapshot,
|
|
||||||
baseSnapshot
|
|
||||||
)
|
|
||||||
|
|
||||||
metadata.vbds = deltaExport.vbds
|
|
||||||
metadata.vdis = deltaExport.vdis
|
|
||||||
metadata.vifs = deltaExport.vifs
|
|
||||||
// const jsonMetadata = JSON.stringify(metadata)
|
|
||||||
|
|
||||||
// create a fork of the delta export
|
|
||||||
const forkExport = (() => {
|
|
||||||
// replace the stream factories by fork factories
|
|
||||||
const streams = mapValues(deltaExport.streams, lazyStream => {
|
|
||||||
let forks = []
|
|
||||||
return () => {
|
|
||||||
if (forks === undefined) {
|
|
||||||
throw new Error('cannot fork the stream after it has been created')
|
|
||||||
}
|
|
||||||
if (forks.length === 0) {
|
|
||||||
lazyStream().then(
|
|
||||||
stream => {
|
|
||||||
// $FlowFixMe
|
|
||||||
forks.forEach(({ resolve }) => {
|
|
||||||
const fork = stream.pipe(new PassThrough())
|
|
||||||
fork.task = stream.task
|
|
||||||
resolve(fork)
|
|
||||||
})
|
|
||||||
forks = undefined
|
|
||||||
},
|
|
||||||
error => {
|
|
||||||
// $FlowFixMe
|
|
||||||
forks.forEach(({ reject }) => {
|
|
||||||
reject(error)
|
|
||||||
})
|
|
||||||
forks = undefined
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// $FlowFixMe
|
|
||||||
forks.push({ reject, resolve })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
return {
|
|
||||||
__proto__: deltaExport,
|
|
||||||
streams,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})()
|
|
||||||
|
|
||||||
const mergeStart = 0
|
const deltaExport = await xapi.exportDeltaVm(
|
||||||
const mergeEnd = 0
|
$cancelToken,
|
||||||
let transferStart = 0
|
snapshot,
|
||||||
let transferEnd = 0
|
baseSnapshot
|
||||||
const errors = []
|
)
|
||||||
await waitAll(
|
|
||||||
[
|
|
||||||
// ...remotes.map(
|
|
||||||
// defer(async ($defer, remoteId) => {
|
|
||||||
// const fork = forkExport()
|
|
||||||
//
|
|
||||||
// const handler = await app.getRemoteHandler(remoteId)
|
|
||||||
//
|
|
||||||
// // const oldBackups = getOldEntries(
|
|
||||||
// // exportRetention,
|
|
||||||
// // await this._listVmBackups(
|
|
||||||
// // handler,
|
|
||||||
// // vm,
|
|
||||||
// // _ => _.mode === 'delta' && _.scheduleId === scheduleId
|
|
||||||
// // )
|
|
||||||
// // )
|
|
||||||
//
|
|
||||||
// const deleteFirst = getSetting(settings, 'deleteFirst', remoteId)
|
|
||||||
// if (deleteFirst) {
|
|
||||||
// // TODO
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// await asyncMap(fork.vdis, (vdi, id) =>
|
|
||||||
// writeStream(
|
|
||||||
// fork.streams[`${id}.vhd`](),
|
|
||||||
// handler,
|
|
||||||
// `${vdiDir}/${vdi.uuid}/${basename}.vhd`
|
|
||||||
// )
|
|
||||||
// )
|
|
||||||
//
|
|
||||||
// await handler.outputFile(metadataFilename, jsonMetadata)
|
|
||||||
//
|
|
||||||
// if (!deleteFirst) {
|
|
||||||
// // TODO
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// ),
|
|
||||||
...srs.map(
|
|
||||||
defer(async ($defer, srId) => {
|
|
||||||
const fork = forkExport()
|
|
||||||
|
|
||||||
const xapi = app.getXapi(srId)
|
const metadata: MetadataDelta = {
|
||||||
const sr = xapi.getObject(srId)
|
jobId,
|
||||||
|
mode: 'delta',
|
||||||
const oldVms = getOldEntries(
|
scheduleId,
|
||||||
exportRetention,
|
timestamp: now,
|
||||||
listReplicatedVms(xapi, scheduleId, srId)
|
vbds: deltaExport.vbds,
|
||||||
)
|
vdis: deltaExport.vdis,
|
||||||
|
version: '2.0.0',
|
||||||
const deleteFirst = getSetting(settings, 'deleteFirst', srId)
|
vifs: deltaExport.vifs,
|
||||||
if (deleteFirst) {
|
vhds: mapValues(
|
||||||
await this._deleteVms(xapi, oldVms)
|
deltaExport.vdis,
|
||||||
}
|
vdi =>
|
||||||
|
`vdis/${jobId}/${
|
||||||
transferStart = Math.min(transferStart, Date.now())
|
(xapi.getObject(vdi.snapshot_of): Object).uuid
|
||||||
|
}/${basename}.vhd`
|
||||||
const { vm } = await xapi.importDeltaVm(fork, {
|
|
||||||
disableStartAfterImport: false, // we'll take care of that
|
|
||||||
name_label: `${metadata.vm.name_label} (${safeDateFormat(
|
|
||||||
metadata.timestamp
|
|
||||||
)})`,
|
|
||||||
srId: sr.$id,
|
|
||||||
})
|
|
||||||
|
|
||||||
transferEnd = Math.max(transferEnd, Date.now())
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
xapi.addTag(vm.$ref, 'Continuous Replication'),
|
|
||||||
xapi._updateObjectMapProperty(vm, 'blocked_operations', {
|
|
||||||
start:
|
|
||||||
'Start operation for this vm is blocked, clone it if you want to use it.',
|
|
||||||
}),
|
|
||||||
xapi._updateObjectMapProperty(vm, 'other_config', {
|
|
||||||
'xo:backup:sr': srId,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!deleteFirst) {
|
|
||||||
await this._deleteVms(xapi, oldVms)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
),
|
),
|
||||||
],
|
vm,
|
||||||
error => {
|
vmSnapshot: snapshot,
|
||||||
console.warn(error)
|
|
||||||
errors.push(error)
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
if (errors.length !== 0) {
|
|
||||||
throw errors
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
const jsonMetadata = JSON.stringify(metadata)
|
||||||
mergeDuration: mergeEnd - mergeStart,
|
|
||||||
mergeSize: 0,
|
// create a fork of the delta export
|
||||||
transferDuration: transferEnd - transferStart,
|
const forkExport = (() => {
|
||||||
transferSize: 0,
|
// replace the stream factories by fork factories
|
||||||
|
const streams: any = mapValues(deltaExport.streams, lazyStream => {
|
||||||
|
let forks = []
|
||||||
|
return () => {
|
||||||
|
if (forks === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
'cannot fork the stream after it has been created'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (forks.length === 0) {
|
||||||
|
lazyStream().then(
|
||||||
|
stream => {
|
||||||
|
// $FlowFixMe
|
||||||
|
forks.forEach(({ resolve }) => {
|
||||||
|
const fork: any = stream.pipe(new PassThrough())
|
||||||
|
fork.task = stream.task
|
||||||
|
resolve(fork)
|
||||||
|
})
|
||||||
|
forks = undefined
|
||||||
|
},
|
||||||
|
error => {
|
||||||
|
// $FlowFixMe
|
||||||
|
forks.forEach(({ reject }) => {
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
forks = undefined
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// $FlowFixMe
|
||||||
|
forks.push({ reject, resolve })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
return {
|
||||||
|
__proto__: deltaExport,
|
||||||
|
streams,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const mergeStart = 0
|
||||||
|
const mergeEnd = 0
|
||||||
|
let transferStart = 0
|
||||||
|
let transferEnd = 0
|
||||||
|
const errors = []
|
||||||
|
await waitAll(
|
||||||
|
[
|
||||||
|
...remotes.map(
|
||||||
|
defer(async ($defer, remoteId) => {
|
||||||
|
const fork = forkExport()
|
||||||
|
|
||||||
|
const handler = await app.getRemoteHandler(remoteId)
|
||||||
|
|
||||||
|
const oldBackups: MetadataDelta[] = (getOldEntries(
|
||||||
|
exportRetention,
|
||||||
|
await this._listVmBackups(
|
||||||
|
handler,
|
||||||
|
vm,
|
||||||
|
_ => _.mode === 'delta' && _.scheduleId === scheduleId
|
||||||
|
)
|
||||||
|
): any)
|
||||||
|
|
||||||
|
const deleteFirst = getSetting(settings, 'deleteFirst', remoteId)
|
||||||
|
if (deleteFirst) {
|
||||||
|
this._deleteDeltaVmBackups(handler, oldBackups)
|
||||||
|
}
|
||||||
|
|
||||||
|
await asyncMap(
|
||||||
|
fork.vdis,
|
||||||
|
defer(async ($defer, vdi, id) => {
|
||||||
|
const path = `${vmDir}/${metadata.vhds[id]}`
|
||||||
|
|
||||||
|
const isDelta = 'xo:base_delta' in vdi.other_config
|
||||||
|
let parentPath
|
||||||
|
if (isDelta) {
|
||||||
|
const vdiDir = dirname(path)
|
||||||
|
const parent = (await handler.list(vdiDir))
|
||||||
|
.filter(isVhd)
|
||||||
|
.sort()
|
||||||
|
.pop()
|
||||||
|
parentPath = `${vdiDir}/${parent}`
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeStream(fork.streams[`${id}.vhd`](), handler, path)
|
||||||
|
$defer.onFailure.call(handler, 'unlink', path)
|
||||||
|
|
||||||
|
if (isDelta) {
|
||||||
|
await chainVhd(handler, parentPath, handler, path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await handler.outputFile(metadataFilename, jsonMetadata)
|
||||||
|
|
||||||
|
if (!deleteFirst) {
|
||||||
|
this._deleteDeltaVmBackups(handler, oldBackups)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
|
...srs.map(
|
||||||
|
defer(async ($defer, srId) => {
|
||||||
|
const fork = forkExport()
|
||||||
|
|
||||||
|
const xapi = app.getXapi(srId)
|
||||||
|
const sr = xapi.getObject(srId)
|
||||||
|
|
||||||
|
const oldVms = getOldEntries(
|
||||||
|
exportRetention,
|
||||||
|
listReplicatedVms(xapi, scheduleId, srId)
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteFirst = getSetting(settings, 'deleteFirst', srId)
|
||||||
|
if (deleteFirst) {
|
||||||
|
await this._deleteVms(xapi, oldVms)
|
||||||
|
}
|
||||||
|
|
||||||
|
transferStart = Math.min(transferStart, Date.now())
|
||||||
|
|
||||||
|
const { vm } = await xapi.importDeltaVm(fork, {
|
||||||
|
disableStartAfterImport: false, // we'll take care of that
|
||||||
|
name_label: `${metadata.vm.name_label} (${safeDateFormat(
|
||||||
|
metadata.timestamp
|
||||||
|
)})`,
|
||||||
|
srId: sr.$id,
|
||||||
|
})
|
||||||
|
|
||||||
|
transferEnd = Math.max(transferEnd, Date.now())
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
xapi.addTag(vm.$ref, 'Continuous Replication'),
|
||||||
|
xapi._updateObjectMapProperty(vm, 'blocked_operations', {
|
||||||
|
start:
|
||||||
|
'Start operation for this vm is blocked, clone it if you want to use it.',
|
||||||
|
}),
|
||||||
|
xapi._updateObjectMapProperty(vm, 'other_config', {
|
||||||
|
'xo:backup:sr': srId,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!deleteFirst) {
|
||||||
|
await this._deleteVms(xapi, oldVms)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
error => {
|
||||||
|
console.warn(error)
|
||||||
|
errors.push(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (errors.length !== 0) {
|
||||||
|
throw errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mergeDuration: mergeEnd - mergeStart,
|
||||||
|
mergeSize: 0,
|
||||||
|
transferDuration: transferEnd - transferStart,
|
||||||
|
transferSize: 0,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`no exporter for backup mode ${job.mode}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _deleteDeltaVmBackups (
|
||||||
|
handler: RemoteHandler,
|
||||||
|
backups: MetadataDelta[]
|
||||||
|
): Promise<void> {
|
||||||
|
// TODO: remove VHD as well
|
||||||
|
await asyncMap(backups, async backup => {
|
||||||
|
const filename = ((backup._filename: any): string)
|
||||||
|
|
||||||
|
return Promise.all([
|
||||||
|
handler.unlink(filename),
|
||||||
|
asyncMap(backup.vhds, _ =>
|
||||||
|
// $FlowFixMe injected $defer param
|
||||||
|
this._deleteVhd(handler, resolveRelativeFromFile(filename, _))
|
||||||
|
),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async _deleteFullVmBackups (
|
async _deleteFullVmBackups (
|
||||||
handler: RemoteHandler,
|
handler: RemoteHandler,
|
||||||
backups: Metadata[]
|
backups: MetadataFull[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await asyncMap(backups, ({ _filename, data }) =>
|
await asyncMap(backups, ({ _filename, xva }) => {
|
||||||
Promise.all([
|
_filename = ((_filename: any): string)
|
||||||
|
return Promise.all([
|
||||||
handler.unlink(_filename),
|
handler.unlink(_filename),
|
||||||
handler.unlink(resolveRelativeFromFile(_filename, data)),
|
handler.unlink(resolveRelativeFromFile(_filename, xva)),
|
||||||
])
|
])
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async _deleteVms (xapi: Xapi, vms: Object[]): Promise<void> {
|
// FIXME: synchronize by job/VDI, otherwise it can cause issues with the merge
|
||||||
return asyncMap(vms, vm => xapi.deleteVm(vm))
|
@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 (
|
async _listVmBackups (
|
||||||
|
@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import type { Pattern } from 'value-matcher'
|
import type { Pattern } from 'value-matcher'
|
||||||
|
|
||||||
// $FlowFixMe
|
|
||||||
import { cancelable } from 'promise-toolbox'
|
import { cancelable } from 'promise-toolbox'
|
||||||
|
import { map as mapToArray } from 'lodash'
|
||||||
import { noSuchObject } from 'xo-common/api-errors'
|
import { noSuchObject } from 'xo-common/api-errors'
|
||||||
|
|
||||||
import Collection from '../../collection/redis'
|
import Collection from '../../collection/redis'
|
||||||
import patch from '../../patch'
|
import patch from '../../patch'
|
||||||
import { mapToArray, serializeError } from '../../utils'
|
import { serializeError } from '../../utils'
|
||||||
|
|
||||||
import type Logger from '../logs/loggers/abstract'
|
import type Logger from '../logs/loggers/abstract'
|
||||||
import { type Schedule } from '../scheduling'
|
import { type Schedule } from '../scheduling'
|
||||||
@ -165,12 +165,15 @@ export default class Jobs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getJob (id: string, type?: string): Promise<Job> {
|
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)) {
|
if (job === null || (type !== undefined && job.properties.type !== type)) {
|
||||||
throw noSuchObject(id, 'job')
|
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> {
|
createJob (job: $Diff<Job, {| id: string |}>): Promise<Job> {
|
||||||
|
@ -290,6 +290,8 @@ const messages = {
|
|||||||
jobEditMessage:
|
jobEditMessage:
|
||||||
'You are editing job {name} ({id}). Saving will override previous job state.',
|
'You are editing job {name} ({id}). Saving will override previous job state.',
|
||||||
scheduleEdit: 'Edit',
|
scheduleEdit: 'Edit',
|
||||||
|
scheduleSave: 'Save',
|
||||||
|
cancelScheduleEdition: 'Cancel',
|
||||||
scheduleAdd: 'Add a schedule',
|
scheduleAdd: 'Add a schedule',
|
||||||
scheduleDelete: 'Delete',
|
scheduleDelete: 'Delete',
|
||||||
deleteSelectedSchedules: 'Delete selected schedules',
|
deleteSelectedSchedules: 'Delete selected schedules',
|
||||||
@ -316,6 +318,8 @@ const messages = {
|
|||||||
smartBackupModeSelection: 'Select backup mode:',
|
smartBackupModeSelection: 'Select backup mode:',
|
||||||
normalBackup: 'Normal backup',
|
normalBackup: 'Normal backup',
|
||||||
smartBackup: 'Smart backup',
|
smartBackup: 'Smart backup',
|
||||||
|
exportRetention: 'Export retention',
|
||||||
|
snapshotRetention: 'Snapshot retention',
|
||||||
backupName: 'Name',
|
backupName: 'Name',
|
||||||
useDelta: 'Use delta',
|
useDelta: 'Use delta',
|
||||||
useCompression: 'Use compression',
|
useCompression: 'Use compression',
|
||||||
@ -1192,7 +1196,6 @@ const messages = {
|
|||||||
deleteVmBackupsTitle: 'Delete {vm} backups',
|
deleteVmBackupsTitle: 'Delete {vm} backups',
|
||||||
deleteVmBackupsSelect: 'Select backups to delete:',
|
deleteVmBackupsSelect: 'Select backups to delete:',
|
||||||
deleteVmBackupsSelectAll: 'All',
|
deleteVmBackupsSelectAll: 'All',
|
||||||
deleteVmBackupsDeltaInfo: 'Delta backup deletion will be available soon',
|
|
||||||
deleteVmBackupsBulkTitle: 'Delete backups',
|
deleteVmBackupsBulkTitle: 'Delete backups',
|
||||||
deleteVmBackupsBulkMessage:
|
deleteVmBackupsBulkMessage:
|
||||||
'Are you sure you want to delete all the backups from {nVms, number} VM{nVms, plural, one {} other {s}}?',
|
'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 _ from 'intl'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import Component from 'base-component'
|
import Component from 'base-component'
|
||||||
import Icon from 'icon'
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FormattedDate } from 'react-intl'
|
import { FormattedDate } from 'react-intl'
|
||||||
import { forEach, map, orderBy } from 'lodash'
|
import { forEach, map, orderBy } from 'lodash'
|
||||||
@ -19,8 +18,7 @@ export default class DeleteBackupsModalBody extends Component {
|
|||||||
const selected = this._getSelectedBackups().length === 0
|
const selected = this._getSelectedBackups().length === 0
|
||||||
|
|
||||||
const state = {}
|
const state = {}
|
||||||
// TODO: [DELTA] remove filter
|
forEach(this.props.backups, backup => {
|
||||||
forEach(this.props.backups.filter(b => b.mode !== 'delta'), backup => {
|
|
||||||
state[_escapeDot(backup.id)] = selected
|
state[_escapeDot(backup.id)] = selected
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -38,9 +36,7 @@ export default class DeleteBackupsModalBody extends Component {
|
|||||||
_getAllSelected = createSelector(
|
_getAllSelected = createSelector(
|
||||||
() => this.props.backups,
|
() => this.props.backups,
|
||||||
this._getSelectedBackups,
|
this._getSelectedBackups,
|
||||||
(backups, selectedBackups) =>
|
(backups, selectedBackups) => backups.length === selectedBackups.length
|
||||||
// TODO: [DELTA] remove filter
|
|
||||||
backups.filter(b => b.mode !== 'delta').length === selectedBackups.length
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_getBackups = createSelector(
|
_getBackups = createSelector(
|
||||||
@ -58,15 +54,11 @@ export default class DeleteBackupsModalBody extends Component {
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
'list-group-item',
|
'list-group-item',
|
||||||
'list-group-item-action',
|
'list-group-item-action',
|
||||||
backup.mode === 'delta' && 'disabled', // TODO: [DELTA] remove
|
|
||||||
this.state[_escapeDot(backup.id)] && 'active'
|
this.state[_escapeDot(backup.id)] && 'active'
|
||||||
)}
|
)}
|
||||||
data-id={backup.id}
|
data-id={backup.id}
|
||||||
key={backup.id}
|
key={backup.id}
|
||||||
onClick={
|
onClick={this.toggleState(_escapeDot(backup.id))}
|
||||||
backup.mode !== 'delta' && // TODO: [DELTA] remove
|
|
||||||
this.toggleState(_escapeDot(backup.id))
|
|
||||||
}
|
|
||||||
type='button'
|
type='button'
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@ -96,12 +88,6 @@ export default class DeleteBackupsModalBody extends Component {
|
|||||||
/>{' '}
|
/>{' '}
|
||||||
{_('deleteVmBackupsSelectAll')}
|
{_('deleteVmBackupsSelectAll')}
|
||||||
</div>
|
</div>
|
||||||
{/* TODO: [DELTA] remove div and i18n message */}
|
|
||||||
<div>
|
|
||||||
<em>
|
|
||||||
<Icon icon='info' /> {_('deleteVmBackupsDeltaInfo')}
|
|
||||||
</em>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -202,24 +202,11 @@ export default class Restore extends Component {
|
|||||||
strongConfirm: {
|
strongConfirm: {
|
||||||
messageId: 'deleteVmBackupsBulkConfirmText',
|
messageId: 'deleteVmBackupsBulkConfirmText',
|
||||||
values: {
|
values: {
|
||||||
nBackups: reduce(
|
nBackups: reduce(datas, (sum, data) => sum + data.backups.length, 0),
|
||||||
datas,
|
|
||||||
(sum, data) =>
|
|
||||||
// TODO: [DELTA] remove filter
|
|
||||||
sum + data.backups.filter(b => b.mode !== 'delta').length,
|
|
||||||
0
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(
|
.then(() => deleteBackups(flatMap(datas, 'backups')), noop)
|
||||||
() =>
|
|
||||||
deleteBackups(
|
|
||||||
// TODO: [DELTA] remove filter
|
|
||||||
flatMap(datas, 'backups').filter(b => b.mode !== 'delta')
|
|
||||||
),
|
|
||||||
noop
|
|
||||||
)
|
|
||||||
.then(() => this._refreshBackupList())
|
.then(() => this._refreshBackupList())
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
@ -4735,9 +4735,9 @@ flat-cache@^1.2.1:
|
|||||||
graceful-fs "^4.1.2"
|
graceful-fs "^4.1.2"
|
||||||
write "^0.2.1"
|
write "^0.2.1"
|
||||||
|
|
||||||
flow-bin@^0.66.0:
|
flow-bin@^0.67.1:
|
||||||
version "0.66.0"
|
version "0.67.1"
|
||||||
resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.66.0.tgz#a96dde7015dc3343fd552a7b4963c02be705ca26"
|
resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.67.1.tgz#eabb7197cce870ac9442cfd04251c7ddc30377db"
|
||||||
|
|
||||||
flush-write-stream@^1.0.2:
|
flush-write-stream@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
|
Loading…
Reference in New Issue
Block a user