feat(Backups NG): second iteration (#2718)

This commit is contained in:
badrAZ 2018-03-07 20:57:28 +01:00 committed by Julien Fontanet
parent 831e36ae5f
commit b1986dc275
26 changed files with 2287 additions and 694 deletions

View File

@ -2,6 +2,7 @@ module.exports = {
extends: ['standard', 'standard-jsx'],
globals: {
__DEV__: true,
$Dict: true,
$Diff: true,
$Exact: true,
$Keys: true,

12
flow-typed/lodash.js vendored Normal file
View File

@ -0,0 +1,12 @@
declare module 'lodash' {
declare export function invert<K, V>(object: { [K]: V }): { [V]: K }
declare export function isEmpty(mixed): boolean
declare export function keyBy<T>(array: T[], iteratee: string): boolean
declare export function last<T>(array?: T[]): T | void
declare export function mapValues<K, V1, V2>(
object: { [K]: V1 },
iteratee: (V1, K) => V2
): { [K]: V2 }
declare export function noop(...args: mixed[]): void
declare export function values<K, V>(object: { [K]: V }): V[]
}

10
flow-typed/promise-toolbox.js vendored Normal file
View File

@ -0,0 +1,10 @@
declare module 'promise-toolbox' {
declare export function defer<T>(): {|
promise: Promise<T>,
reject: T => void,
resolve: T => void
|}
declare export function fromEvent(emitter: mixed, string): Promise<mixed>
declare export function ignoreErrors(): Promise<void>
declare export function timeout<T>(delay: number): Promise<T>
}

2
flow-typed/xo.js vendored Normal file
View File

@ -0,0 +1,2 @@
// eslint-disable-next-line no-undef
declare type $Dict<T, K = string> = { [K]: T }

View File

@ -78,13 +78,13 @@ editJob.params = {
}
export function getAllJobs () {
return this.getAllBackupNgJobs()
return this.getAllJobs('backup')
}
getAllJobs.permission = 'admin'
export function getJob ({ id }) {
return this.getBackupNgJob(id)
return this.getJob(id, 'backup')
}
getJob.permission = 'admin'
@ -95,8 +95,8 @@ getJob.params = {
},
}
export async function runJob ({ id, scheduleId }) {
return this.runJobSequence([id], await this.getSchedule(scheduleId))
export async function runJob ({ id, schedule }) {
return this.runJobSequence([id], await this.getSchedule(schedule))
}
runJob.permission = 'admin'
@ -105,7 +105,7 @@ runJob.params = {
id: {
type: 'string',
},
scheduleId: {
schedule: {
type: 'string',
},
}

View File

@ -3,7 +3,6 @@ import bind from 'lodash/bind'
import blocked from 'blocked'
import createExpress from 'express'
import createLogger from 'debug'
import eventToPromise from 'event-to-promise'
import has from 'lodash/has'
import helmet from 'helmet'
import includes from 'lodash/includes'
@ -13,6 +12,7 @@ import startsWith from 'lodash/startsWith'
import WebSocket from 'ws'
import { compile as compilePug } from 'pug'
import { createServer as createProxyServer } from 'http-proxy'
import { fromEvent } from 'promise-toolbox'
import { join as joinPath } from 'path'
import JsonRpcPeer from 'json-rpc-peer'
@ -644,7 +644,7 @@ export default async function main (args) {
})
})
await eventToPromise(xo, 'stopped')
await fromEvent(xo, 'stopped')
debug('bye :-)')
}

View File

@ -1,3 +1,6 @@
// @flow
import { type Readable, type Writable } from 'stream'
import { fromEvent, ignoreErrors } from 'promise-toolbox'
import { parse } from 'xo-remote-parser'
@ -5,43 +8,51 @@ import { getPseudoRandomBytes, streamToBuffer } from '../utils'
import { createChecksumStream, validChecksumOfReadStream } from './checksum'
type Data = Buffer | Readable | string
type FileDescriptor = {| fd: mixed, path: string |}
type LaxReadable = Readable & Object
type LaxWritable = Writable & Object
type File = FileDescriptor | string
const checksumFile = file => file + '.checksum'
export default class RemoteHandlerAbstract {
constructor (remote) {
_remote: Object
constructor (remote: any) {
this._remote = { ...remote, ...parse(remote.url) }
if (this._remote.type !== this.type) {
throw new Error('Incorrect remote type')
}
}
get type () {
get type (): string {
throw new Error('Not implemented')
}
/**
* Asks the handler to sync the state of the effective remote with its' metadata
*/
async sync () {
return this._sync()
async sync (): Promise<void> {
await this._sync()
}
async _sync () {
async _sync (): Promise<void> {
throw new Error('Not implemented')
}
/**
* Free the resources possibly dedicated to put the remote at work, when it is no more needed
*/
async forget () {
return this._forget()
async forget (): Promise<void> {
await this._forget()
}
async _forget () {
async _forget (): Promise<void> {
throw new Error('Not implemented')
}
async test () {
async test (): Promise<Object> {
const testFileName = `${Date.now()}.test`
const data = getPseudoRandomBytes(1024 * 1024)
let step = 'write'
@ -63,33 +74,37 @@ export default class RemoteHandlerAbstract {
error: error.message || String(error),
}
} finally {
;this.unlink(testFileName)::ignoreErrors()
ignoreErrors.call(this.unlink(testFileName))
}
}
async outputFile (file, data, options) {
async outputFile (file: string, data: Data, options?: Object): Promise<void> {
return this._outputFile(file, data, {
flags: 'wx',
...options,
})
}
async _outputFile (file, data, options) {
async _outputFile (file: string, data: Data, options?: Object): Promise<void> {
const stream = await this.createOutputStream(file, options)
const promise = fromEvent(stream, 'finish')
stream.end(data)
return promise
await promise
}
async readFile (file, options) {
async readFile (file: string, options?: Object): Promise<Buffer | string> {
return this._readFile(file, options)
}
_readFile (file, options) {
_readFile (file: string, options?: Object): Promise<Buffer | string> {
return this.createReadStream(file, options).then(streamToBuffer)
}
async rename (oldPath, newPath, { checksum = false } = {}) {
async rename (
oldPath: string,
newPath: string,
{ checksum = false }: Object = {}
) {
let p = this._rename(oldPath, newPath)
if (checksum) {
p = Promise.all([
@ -100,22 +115,22 @@ export default class RemoteHandlerAbstract {
return p
}
async _rename (oldPath, newPath) {
async _rename (oldPath: string, newPath: string) {
throw new Error('Not implemented')
}
async list (dir = '.') {
async list (dir: string = '.') {
return this._list(dir)
}
async _list (dir) {
async _list (dir: string) {
throw new Error('Not implemented')
}
createReadStream (
file,
{ checksum = false, ignoreMissingChecksum = false, ...options } = {}
) {
file: string,
{ checksum = false, ignoreMissingChecksum = false, ...options }: Object = {}
): Promise<LaxReadable> {
const path = typeof file === 'string' ? file : file.path
const streamP = this._createReadStream(file, options).then(stream => {
// detect early errors
@ -129,11 +144,11 @@ export default class RemoteHandlerAbstract {
) {
promise = Promise.all([
promise,
this.getSize(file)
.then(size => {
ignoreErrors.call(
this.getSize(file).then(size => {
stream.length = size
})
::ignoreErrors(),
),
])
}
@ -145,13 +160,16 @@ export default class RemoteHandlerAbstract {
}
// avoid a unhandled rejection warning
;streamP::ignoreErrors()
ignoreErrors.call(streamP)
return this.readFile(checksumFile(path)).then(
checksum =>
streamP.then(stream => {
const { length } = stream
stream = validChecksumOfReadStream(stream, String(checksum).trim())
stream = (validChecksumOfReadStream(
stream,
String(checksum).trim()
): LaxReadable)
stream.length = length
return stream
@ -165,35 +183,41 @@ export default class RemoteHandlerAbstract {
)
}
async _createReadStream (file, options) {
async _createReadStream (
file: string,
options?: Object
): Promise<LaxReadable> {
throw new Error('Not implemented')
}
async openFile (path, flags) {
async openFile (path: string, flags?: string): Promise<FileDescriptor> {
return { fd: await this._openFile(path, flags), path }
}
async _openFile (path, flags) {
async _openFile (path: string, flags?: string): Promise<mixed> {
throw new Error('Not implemented')
}
async closeFile (fd) {
return this._closeFile(fd.fd)
async closeFile (fd: FileDescriptor): Promise<void> {
await this._closeFile(fd.fd)
}
async _closeFile (fd) {
async _closeFile (fd: mixed): Promise<void> {
throw new Error('Not implemented')
}
async refreshChecksum (path) {
const stream = (await this.createReadStream(path)).pipe(
async refreshChecksum (path: string): Promise<void> {
const stream: any = (await this.createReadStream(path)).pipe(
createChecksumStream()
)
stream.resume() // start reading the whole file
await this.outputFile(checksumFile(path), await stream.checksum)
}
async createOutputStream (file, { checksum = false, ...options } = {}) {
async createOutputStream (
file: File,
{ checksum = false, ...options }: Object = {}
): Promise<LaxWritable> {
const path = typeof file === 'string' ? file : file.path
const streamP = this._createOutputStream(file, {
flags: 'wx',
@ -213,34 +237,38 @@ export default class RemoteHandlerAbstract {
stream.on('error', forwardError)
checksumStream.pipe(stream)
checksumStream.checksum
// $FlowFixMe
checksumStream.checksumWritten = checksumStream.checksum
.then(value => this.outputFile(checksumFile(path), value))
.catch(forwardError)
return checksumStream
}
async _createOutputStream (file, options) {
async _createOutputStream (
file: mixed,
options?: Object
): Promise<LaxWritable> {
throw new Error('Not implemented')
}
async unlink (file, { checksum = true } = {}) {
async unlink (file: string, { checksum = true }: Object = {}): Promise<void> {
if (checksum) {
;this._unlink(checksumFile(file))::ignoreErrors()
ignoreErrors.call(this._unlink(checksumFile(file)))
}
return this._unlink(file)
await this._unlink(file)
}
async _unlink (file) {
async _unlink (file: mixed): Promise<void> {
throw new Error('Not implemented')
}
async getSize (file) {
async getSize (file: mixed): Promise<number> {
return this._getSize(file)
}
async _getSize (file) {
async _getSize (file: mixed): Promise<number> {
throw new Error('Not implemented')
}
}

View File

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

View File

@ -2,24 +2,19 @@
// $FlowFixMe
import defer from 'golike-defer'
import { dirname, resolve } from 'path'
// $FlowFixMe
import { fromEvent, timeout as pTimeout } from 'promise-toolbox'
// $FlowFixMe
import { isEmpty, last, mapValues, values } from 'lodash'
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 { PassThrough } from 'stream'
import { type Readable, PassThrough } from 'stream'
import { type Executor, type Job } from '../jobs'
import { type Schedule } from '../scheduling'
import createSizeStream from '../../size-stream'
import type RemoteHandler from '../../remote-handlers/abstract'
import { asyncMap, safeDateFormat, serializeError } from '../../utils'
// import { parseDateTime } from '../../xapi/utils'
import { type RemoteHandlerAbstract } from '../../remote-handlers/abstract'
import { type Xapi } from '../../xapi'
type Dict<T, K = string> = { [K]: T }
import type { Vm, Xapi } from '../../xapi'
type Mode = 'full' | 'delta'
@ -39,7 +34,7 @@ export type BackupJob = {|
compression?: 'native',
mode: Mode,
remotes?: SimpleIdPattern,
settings: Dict<Settings>,
settings: $Dict<Settings>,
srs?: SimpleIdPattern,
type: 'backup',
vms: Pattern
@ -61,7 +56,11 @@ type MetadataBase = {|
vm: Object,
vmSnapshot: Object
|}
type MetadataDelta = {| ...MetadataBase, mode: 'delta' |}
type MetadataDelta = {|
...MetadataBase,
mode: 'delta',
vdis: $Dict<{}>
|}
type MetadataFull = {|
...MetadataBase,
data: string, // relative path to the XVA
@ -69,17 +68,16 @@ type MetadataFull = {|
|}
type Metadata = MetadataDelta | MetadataFull
const compareSnapshotTime = (a, b) =>
const compareSnapshotTime = (a: Vm, b: Vm): number =>
a.snapshot_time < b.snapshot_time ? -1 : 1
const compareTimestamp = (a, b) => a.timestamp - b.timestamp
const compareTimestamp = (a: Metadata, b: Metadata): number =>
a.timestamp - b.timestamp
// returns all entries but the last (retention - 1)-th
//
// the “-1” is because this code is usually run with entries computed before the
// new entry is created
//
// FIXME: check whether it take the new one into account
const getOldEntries = <T>(retention: number, entries?: T[]): T[] =>
entries === undefined
? []
@ -92,7 +90,7 @@ const defaultSettings: Settings = {
vmTimeout: 0,
}
const getSetting = (
settings: Dict<Settings>,
settings: $Dict<Settings>,
name: $Keys<Settings>,
...keys: string[]
): any => {
@ -113,7 +111,11 @@ const getVmBackupDir = (uuid: string) => `${BACKUP_DIR}/${uuid}`
const isMetadataFile = (filename: string) => filename.endsWith('.json')
const listReplicatedVms = (xapi: Xapi, scheduleId: string, srId) => {
const listReplicatedVms = (
xapi: Xapi,
scheduleId: string,
srId: string
): Vm[] => {
const { all } = xapi.objects
const vms = {}
for (const key in all) {
@ -132,7 +134,7 @@ const listReplicatedVms = (xapi: Xapi, scheduleId: string, srId) => {
return values(vms).sort(compareSnapshotTime)
}
const parseVmBackupId = id => {
const parseVmBackupId = (id: string) => {
const i = id.indexOf('/')
return {
metadataFilename: id.slice(i + 1),
@ -141,7 +143,7 @@ const parseVmBackupId = id => {
}
// used to resolve the data field from the metadata
const resolveRelativeFromFile = (file, path) =>
const resolveRelativeFromFile = (file: string, path: string): string =>
resolve('/', dirname(file), path).slice(1)
const unboxIds = (pattern?: SimpleIdPattern): string[] => {
@ -152,6 +154,44 @@ const unboxIds = (pattern?: SimpleIdPattern): string[] => {
return typeof id === 'string' ? [id] : id.__or
}
// similar to Promise.all() but do not gather results
const waitAll = async <T>(
promises: Promise<T>[],
onRejection: Function
): Promise<void> => {
promises = promises.map(promise => {
promise = promise.catch(onRejection)
promise.catch(noop) // prevent unhandled rejection warning
return promise
})
for (const promise of promises) {
await promise
}
}
// write a stream to a file using a temporary file
//
// TODO: merge into RemoteHandlerAbstract
const writeStream = async (
input: Readable | Promise<Readable>,
handler: RemoteHandler,
path: string
): Promise<void> => {
input = await input
const tmpPath = `${dirname(path)}/.${basename(path)}`
const output = await handler.createOutputStream(tmpPath, { checksum: true })
try {
input.pipe(output)
await output.checksumWritten
// $FlowFixMe
await input.task
await handler.rename(tmpPath, path, { checksum: true })
} catch (error) {
await handler.unlink(tmpPath)
throw error
}
}
// File structure on remotes:
//
// <remote>
@ -160,11 +200,14 @@ const unboxIds = (pattern?: SimpleIdPattern): string[] => {
// └─ <VM UUID>
// ├─ index.json // TODO
// ├─ vdis
// │ └─ <VDI UUID>
// │ ├─ index.json // TODO
// │ └─ <YYYYMMDD>T<HHmmss>.vhd
// │ └─ <job UUID>
// │ └─ <VDI UUID>
// │ ├─ index.json // TODO
// │ ├─ <YYYYMMDD>T<HHmmss>.vhd
// │ └─ <YYYYMMDD>T<HHmmss>.vhd.checksum (only for deltas)
// ├─ <YYYYMMDD>T<HHmmss>.json // backup metadata
// └─ <YYYYMMDD>T<HHmmss>.xva
// ├─ <YYYYMMDD>T<HHmmss>.xva
// └─ <YYYYMMDD>T<HHmmss>.xva.checksum
//
// Attributes of created VMs:
//
@ -174,7 +217,17 @@ const unboxIds = (pattern?: SimpleIdPattern): string[] => {
// - copy in full mode: `Disaster Recovery`
// - imported from backup: `restored from backup`
export default class BackupNg {
_app: any
_app: {
createJob: ($Diff<BackupJob, {| id: string |}>) => Promise<BackupJob>,
createSchedule: ($Diff<Schedule, {| id: string |}>) => Promise<Schedule>,
deleteSchedule: (id: string) => Promise<void>,
getAllSchedules: () => Promise<Schedule[]>,
getRemoteHandler: (id: string) => Promise<RemoteHandler>,
getXapi: (id: string) => Xapi,
getJob: (id: string, 'backup') => Promise<BackupJob>,
updateJob: ($Shape<BackupJob>) => Promise<BackupJob>,
removeJob: (id: string) => Promise<void>
}
constructor (app: any) {
this._app = app
@ -267,14 +320,14 @@ export default class BackupNg {
event: 'jobCall.end',
runJobId,
runCallId,
error: serializeError(error),
error: Array.isArray(error)
? error.map(serializeError)
: serializeError(error),
}
)
call.error = error
call.end = Date.now()
console.warn(error.stack) // TODO: remove
}
})
status.end = Date.now()
@ -286,7 +339,7 @@ export default class BackupNg {
async createBackupNgJob (
props: $Diff<BackupJob, {| id: string |}>,
schedules?: Dict<$Diff<Schedule, {| id: string |}>>
schedules?: $Dict<$Diff<Schedule, {| id: string |}>>
): Promise<BackupJob> {
const app = this._app
props.type = 'backup'
@ -329,7 +382,7 @@ export default class BackupNg {
const { metadataFilename, remoteId } = parseVmBackupId(id)
const handler = await app.getRemoteHandler(remoteId)
const metadata: Metadata = JSON.parse(
await handler.readFile(metadataFilename)
String(await handler.readFile(metadataFilename))
)
if (metadata.mode === 'delta') {
@ -340,20 +393,12 @@ export default class BackupNg {
await this._deleteFullVmBackups(handler, [metadata])
}
getAllBackupNgJobs (): Promise<BackupJob[]> {
return this._app.getAllJobs('backup')
}
getBackupNgJob (id: string): Promise<BackupJob> {
return this._app.getJob(id, 'backup')
}
async importVmBackupNg (id: string, srId: string): Promise<void> {
async importVmBackupNg (id: string, srId: string): Promise<string> {
const app = this._app
const { metadataFilename, remoteId } = parseVmBackupId(id)
const handler = await app.getRemoteHandler(remoteId)
const metadata: Metadata = JSON.parse(
await handler.readFile(metadataFilename)
String(await handler.readFile(metadataFilename))
)
if (metadata.mode === 'delta') {
@ -382,7 +427,7 @@ export default class BackupNg {
}
async listVmBackupsNg (remotes: string[]) {
const backupsByVmByRemote: Dict<Dict<Metadata[]>> = {}
const backupsByVmByRemote: $Dict<$Dict<Metadata[]>> = {}
const app = this._app
await Promise.all(
@ -420,22 +465,34 @@ export default class BackupNg {
return backupsByVmByRemote
}
// - [x] files (.tmp) should be renamed at the end of job
// High:
// - [ ] clones of replicated VMs should not be garbage collected
// - if storing uuids in source VM, how to detect them if the source is
// lost?
// - [ ] adding and removing VDIs should behave
// - [ ] validate VHDs after exports and before imports
// - [ ] isolate VHD chains by job
//
// Low:
// - [ ] check merge/transfert duration/size are what we want for delta
// - [ ] jobs should be cancelable
// - [ ] possibility to (re-)run a single VM in a backup?
// - [ ] display queued VMs
// - [ ] snapshots and files of an old job should be detected and removed
//
// Triage:
// - [ ] protect against concurrent backup against a single VM (JFT: why?)
// - shouldn't be necessary now that VHD chains are separated by job
// - [ ] logs
//
// Done:
//
// - [x] files (.tmp) should be renamed at the end of job
// - [x] detect full remote
// - [x] can the snapshot and export retention be different? → Yes
// - [ ] snapshots and files of an old job should be detected and removed
// - [ ] adding and removing VDIs should behave
// - [ ] key export?
// - [x] deleteFirst per target
// - [ ] possibility to (re-)run a single VM in a backup?
// - [x] timeout per VM
// - [ ] display queued VMs
// - [ ] jobs should be cancelable
// - [ ] logs
// - [x] backups should be deletable from the API
// - [ ] check merge/transfert duration/size are what we want for delta
@defer
async _backupVm (
$defer: any,
@ -446,7 +503,7 @@ export default class BackupNg {
): Promise<BackupResult> {
const app = this._app
const xapi = app.getXapi(vmId)
const vm = xapi.getObject(vmId)
const vm: Vm = (xapi.getObject(vmId): any)
const { id: jobId, settings } = job
const { id: scheduleId } = schedule
@ -462,25 +519,24 @@ export default class BackupNg {
scheduleId
)
let remotes, srs
if (exportRetention === 0) {
if (snapshotRetention === 0) {
throw new Error('export and snapshots retentions cannot both be 0')
}
} else {
remotes = unboxIds(job.remotes)
srs = unboxIds(job.srs)
if (remotes.length === 0 && srs.length === 0) {
throw new Error('export retention must be 0 without remotes and SRs')
}
}
const snapshots = vm.$snapshots
.filter(_ => _.other_config['xo:backup:schedule'] === scheduleId)
.filter(_ => _.other_config['xo:backup:job'] === jobId)
.sort(compareSnapshotTime)
$defer(() =>
asyncMap(getOldEntries(snapshotRetention, snapshots), _ =>
xapi.deleteVm(_)
asyncMap(
getOldEntries(
snapshotRetention,
snapshots.filter(
_ => _.other_config['xo:backup:schedule'] === scheduleId
)
),
_ => xapi.deleteVm(_)
)
)
@ -505,9 +561,20 @@ export default class BackupNg {
}
}
const remotes = unboxIds(job.remotes)
const srs = unboxIds(job.srs)
if (remotes.length === 0 && srs.length === 0) {
throw new Error('export retention must be 0 without remotes and SRs')
}
const now = Date.now()
const vmDir = getVmBackupDir(vm.uuid)
const { mode } = job
const basename = safeDateFormat(now)
const metadataFilename = `${vmDir}/${basename}.json`
const metadata: Metadata = {
jobId,
mode,
@ -531,109 +598,98 @@ export default class BackupNg {
const exportTask = xva.task
xva = xva.pipe(createSizeStream())
const dirname = getVmBackupDir(vm.uuid)
const basename = safeDateFormat(now)
const dataBasename = `${basename}.xva`
const metadataFilename = `${dirname}/${basename}.json`
metadata.data = `./${dataBasename}`
const dataFilename = `${dirname}/${dataBasename}`
const tmpFilename = `${dirname}/.${dataBasename}`
const dataFilename = `${vmDir}/${dataBasename}`
const jsonMetadata = JSON.stringify(metadata)
await Promise.all([
asyncMap(
remotes,
defer(async ($defer, remoteId) => {
const fork = xva.pipe(new PassThrough())
const errors = []
await waitAll(
[
...remotes.map(
defer(async ($defer, remoteId) => {
const fork = xva.pipe(new PassThrough())
const handler = await app.getRemoteHandler(remoteId)
const handler = await app.getRemoteHandler(remoteId)
const oldBackups = getOldEntries(
exportRetention,
await this._listVmBackups(
handler,
vm,
_ => _.mode === 'full' && _.scheduleId === scheduleId
const oldBackups = getOldEntries(
exportRetention,
await this._listVmBackups(
handler,
vm,
_ => _.mode === 'full' && _.scheduleId === scheduleId
)
)
)
const deleteFirst = getSetting(settings, 'deleteFirst', remoteId)
if (deleteFirst) {
await this._deleteFullVmBackups(handler, oldBackups)
}
const deleteFirst = getSetting(settings, 'deleteFirst', remoteId)
if (deleteFirst) {
await this._deleteFullVmBackups(handler, oldBackups)
}
const output = await handler.createOutputStream(tmpFilename, {
checksum: true,
await writeStream(fork, handler, dataFilename)
await handler.outputFile(metadataFilename, jsonMetadata)
if (!deleteFirst) {
await this._deleteFullVmBackups(handler, oldBackups)
}
})
$defer.onFailure.call(handler, 'unlink', tmpFilename)
$defer.onSuccess.call(
handler,
'rename',
tmpFilename,
dataFilename,
{ checksum: true }
)
),
...srs.map(
defer(async ($defer, srId) => {
const fork = xva.pipe(new PassThrough())
fork.task = exportTask
const promise = fromEvent(output, 'finish')
fork.pipe(output)
await Promise.all([exportTask, promise])
const xapi = app.getXapi(srId)
const sr = xapi.getObject(srId)
await handler.outputFile(metadataFilename, jsonMetadata)
if (!deleteFirst) {
await this._deleteFullVmBackups(handler, oldBackups)
}
})
),
asyncMap(
srs,
defer(async ($defer, srId) => {
const fork = xva.pipe(new PassThrough())
fork.task = exportTask
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)
}
const vm = await xapi.barrier(
await xapi._importVm($cancelToken, fork, sr, vm =>
xapi._setObjectProperties(vm, {
nameLabel: `${metadata.vm.name_label} (${safeDateFormat(
metadata.timestamp
)})`,
})
const oldVms = getOldEntries(
exportRetention,
listReplicatedVms(xapi, scheduleId, srId)
)
)
await Promise.all([
xapi.addTag(vm.$ref, 'Disaster Recovery'),
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,
}),
])
const deleteFirst = getSetting(settings, 'deleteFirst', srId)
if (deleteFirst) {
await this._deleteVms(xapi, oldVms)
}
if (!deleteFirst) {
await this._deleteVms(xapi, oldVms)
}
})
),
])
const vm = await xapi.barrier(
await xapi._importVm($cancelToken, fork, sr, vm =>
xapi._setObjectProperties(vm, {
nameLabel: `${metadata.vm.name_label} (${safeDateFormat(
metadata.timestamp
)})`,
})
)
)
await Promise.all([
xapi.addTag(vm.$ref, 'Disaster Recovery'),
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: 0,
@ -643,6 +699,8 @@ export default class BackupNg {
}
}
// const vdiDir = `${vmDir}/${jobId}/vdis`
const baseSnapshot = last(snapshots)
if (baseSnapshot !== undefined) {
console.log(baseSnapshot.$id) // TODO: remove
@ -656,75 +714,150 @@ export default class BackupNg {
baseSnapshot
)
// forks of the lazy streams
deltaExport.streams = mapValues(deltaExport.streams, lazyStream => {
let stream
return () => {
if (stream === undefined) {
stream = lazyStream()
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,
}
return Promise.resolve(stream).then(stream => {
const fork = stream.pipe(new PassThrough())
fork.task = stream.task
return fork
})
}
})
})()
const mergeStart = 0
const mergeEnd = 0
let transferStart = 0
let transferEnd = 0
await Promise.all([
asyncMap(remotes, defer(async ($defer, remote) => {})),
asyncMap(
srs,
defer(async ($defer, srId) => {
const xapi = app.getXapi(srId)
const sr = xapi.getObject(srId)
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 oldVms = getOldEntries(
exportRetention,
listReplicatedVms(xapi, scheduleId, srId)
)
const xapi = app.getXapi(srId)
const sr = xapi.getObject(srId)
const deleteFirst = getSetting(settings, 'deleteFirst', srId)
if (deleteFirst) {
await this._deleteVms(xapi, oldVms)
}
const oldVms = getOldEntries(
exportRetention,
listReplicatedVms(xapi, scheduleId, srId)
)
transferStart =
transferStart === 0
? Date.now()
: Math.min(transferStart, Date.now())
const deleteFirst = getSetting(settings, 'deleteFirst', srId)
if (deleteFirst) {
await this._deleteVms(xapi, oldVms)
}
const { vm } = await xapi.importDeltaVm(deltaExport, {
disableStartAfterImport: false, // we'll take care of that
name_label: `${metadata.vm.name_label} (${safeDateFormat(
metadata.timestamp
)})`,
srId: sr.$id,
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)
}
})
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,
@ -735,7 +868,7 @@ export default class BackupNg {
}
async _deleteFullVmBackups (
handler: RemoteHandlerAbstract,
handler: RemoteHandler,
backups: Metadata[]
): Promise<void> {
await asyncMap(backups, ({ _filename, data }) =>
@ -751,7 +884,7 @@ export default class BackupNg {
}
async _listVmBackups (
handler: RemoteHandlerAbstract,
handler: RemoteHandler,
vm: Object | string,
predicate?: Metadata => boolean
): Promise<Metadata[]> {
@ -764,7 +897,7 @@ export default class BackupNg {
files.filter(isMetadataFile).map(async file => {
const path = `${dir}/${file}`
try {
const metadata = JSON.parse(await handler.readFile(path))
const metadata = JSON.parse(String(await handler.readFile(path)))
if (predicate === undefined || predicate(metadata)) {
Object.defineProperty(metadata, '_filename', {
value: path,

View File

@ -150,7 +150,7 @@ export default class Jobs {
})
}
async getAllJobs (type: ?string): Promise<Array<Job>> {
async getAllJobs (type?: string): Promise<Array<Job>> {
// $FlowFixMe don't know what is the problem (JFT)
const jobs = await this._jobs.get()
const runningJobs = this._runningJobs

View File

@ -1,7 +1,6 @@
// @flow
import { createSchedule } from '@xen-orchestra/cron'
// $FlowFixMe
import { keyBy } from 'lodash'
import { noSuchObject } from 'xo-common/api-errors'

View File

@ -60,8 +60,6 @@ const messages = {
selfServicePage: 'Self service',
backupPage: 'Backup',
jobsPage: 'Jobs',
backupNG: 'Backups NG',
backupNGName: 'Name',
xoaPage: 'XOA',
updatePage: 'Updates',
licensesPage: 'Licenses',
@ -262,6 +260,7 @@ const messages = {
jobId: 'ID',
jobType: 'Type',
jobName: 'Name',
jobMode: 'Mode',
jobNamePlaceholder: 'Name of your job (forbidden: "_")',
jobStart: 'Start',
jobEnd: 'End',
@ -281,6 +280,7 @@ const messages = {
jobInterrupted: 'Interrupted',
jobStarted: 'Started',
saveBackupJob: 'Save',
createBackupJob: 'Create',
deleteBackupSchedule: 'Remove backup job',
deleteBackupScheduleQuestion:
'Are you sure you want to delete this backup job?',
@ -290,6 +290,7 @@ const messages = {
jobEditMessage:
'You are editing job {name} ({id}). Saving will override previous job state.',
scheduleEdit: 'Edit',
scheduleAdd: 'Add a schedule',
scheduleDelete: 'Delete',
deleteSelectedSchedules: 'Delete selected schedules',
noScheduledJobs: 'No scheduled jobs.',
@ -315,6 +316,12 @@ const messages = {
smartBackupModeSelection: 'Select backup mode:',
normalBackup: 'Normal backup',
smartBackup: 'Smart backup',
backupName: 'Name',
useDelta: 'Use delta',
useCompression: 'Use compression',
smartBackupModeTitle: 'Smart mode',
backupTargetRemotes: 'Target remotes (for Export)',
backupTargetSrs: 'Target SRs (for Replication)',
localRemoteWarningTitle: 'Local remote selected',
localRemoteWarningMessage:
'Warning: local remotes will use limited XOA disk space. Only for advanced users.',
@ -323,10 +330,12 @@ const messages = {
editBackupVmsTitle: 'VMs',
editBackupSmartStatusTitle: 'VMs statuses',
editBackupSmartResidentOn: 'Resident on',
editBackupSmartNotResidentOn: 'Not resident on',
editBackupSmartPools: 'Pools',
editBackupSmartTags: 'Tags',
sampleOfMatchingVms: 'Sample of matching Vms',
editBackupSmartTagsTitle: 'VMs Tags',
editBackupSmartExcludedTagsTitle: 'Excluded VMs tags',
editBackupNot: 'Reverse',
editBackupTagTitle: 'Tag',
editBackupReportTitle: 'Report',
@ -939,6 +948,7 @@ const messages = {
vmStateHalted: 'Halted',
vmStateOther: 'Other',
vmStateRunning: 'Running',
vmStateAll: 'All',
taskStatePanel: 'Pending tasks',
usersStatePanel: 'Users',
srStatePanel: 'Storage state',
@ -1128,6 +1138,14 @@ const messages = {
// ---- Backup views ---
backupSchedules: 'Schedules',
backupSavedSchedules: 'Saved schedules',
backupNewSchedules: 'New schedules',
scheduleCron: 'Cron pattern',
scheduleName: 'Name',
scheduleTimezone: 'Timezone',
scheduleExportRetention: 'Export ret.',
scheduleSnapshotRetention: 'Snapshot ret.',
scheduleRun: 'Run',
getRemote: 'Get remote',
listRemote: 'List Remote',
simpleBackup: 'simple',
@ -1140,8 +1158,10 @@ const messages = {
remoteError: 'Error',
noBackup: 'No backup available',
backupVmNameColumn: 'VM Name',
backupVmDescriptionColumn: 'VM Description',
backupTags: 'Tags',
lastBackupColumn: 'Last Backup',
firstBackupColumn: 'Oldest backup',
lastBackupColumn: 'Latest backup',
availableBackupsColumn: 'Available Backups',
backupRestoreErrorTitle: 'Missing parameters',
backupRestoreErrorMessage: 'Choose a SR and a backup',
@ -1153,6 +1173,29 @@ const messages = {
importBackupTitle: 'Import VM',
importBackupMessage: 'Starting your backup import',
vmsToBackup: 'VMs to backup',
restoreResfreshList: 'Refresh backup list',
restoreVmBackups: 'Restore',
restoreVmBackupsTitle: 'Restore {vm}',
restoreVmBackupsBulkTitle:
'Restore {nVms, number} VM{nVms, plural, one {} other {s}}',
restoreVmBackupsBulkMessage:
'Restore {nVms, number} VM{nVms, plural, one {} other {s}} from {nVms, plural, one {its} other {their}} {oldestOrLatest} backup.',
oldest: 'oldest',
latest: 'latest',
restoreVmBackupsStart:
'Start VM{nVms, plural, one {} other {s}} after restore',
restoreVmBackupsBulkErrorTitle: 'Multi-restore error',
restoreVmBackupsBulkErrorMessage: 'You need to select a destination SR',
deleteVmBackups: 'Delete backups…',
deleteVmBackupsTitle: 'Delete {vm} backups',
deleteVmBackupsSelect: 'Select backups to delete:',
deleteVmBackupsSelectAll: 'All',
deleteVmBackupsDeltaInfo: 'Delta backup deletion will be available soon',
deleteVmBackupsBulkTitle: 'Delete backups',
deleteVmBackupsBulkMessage:
'Are you sure you want to delete all the backups from {nVms, number} VM{nVms, plural, one {} other {s}}?',
deleteVmBackupsBulkConfirmText:
'delete {nBackups} backup{nBackups, plural, one {} other {s}}',
// ----- Restore files view -----
listRemoteBackups: 'List remote backups',
@ -1286,6 +1329,7 @@ const messages = {
importBackupModalTitle: 'Import a {name} Backup',
importBackupModalStart: 'Start VM after restore',
importBackupModalSelectBackup: 'Select your backup…',
importBackupModalSelectSr: 'Select a destination SR…',
removeAllOrphanedModalWarning:
'Are you sure you want to remove all orphaned snapshot VDIs?',
removeAllLogsModalTitle: 'Remove all logs',

View File

@ -235,7 +235,8 @@ export const confirm = ({ body, icon = 'alarm', title, strongConfirm }) =>
resolve={resolve}
strongConfirm={strongConfirm}
title={title}
/>
/>,
reject
)
})
: chooseAction({

View File

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

View File

@ -1,5 +1,5 @@
import * as CM from 'complex-matcher'
import { identity } from 'lodash'
import { get, identity, isEmpty } from 'lodash'
import { EMPTY_OBJECT } from './utils'
@ -21,6 +21,42 @@ export const constructPattern = (
return not ? { __not: pattern } : pattern
}
// ===================================================================
export const destructSmartPattern = (pattern, valueTransform = identity) =>
pattern && {
values: valueTransform(
pattern.__and !== undefined ? pattern.__and[0].__or : pattern.__or
),
notValues: valueTransform(
pattern.__and !== undefined
? pattern.__and[1].__not.__or
: get(pattern, '__not.__or')
),
}
export const constructSmartPattern = (
{ values, notValues } = EMPTY_OBJECT,
valueTransform = identity
) => {
const valuesExist = !isEmpty(values)
const notValuesExist = !isEmpty(notValues)
if (!valuesExist && !notValuesExist) {
return
}
const valuesPattern = valuesExist && { __or: valueTransform(values) }
const notValuesPattern = notValuesExist && {
__not: { __or: valueTransform(notValues) },
}
return valuesPattern && notValuesPattern
? { __and: [valuesPattern, notValuesPattern] }
: valuesPattern || notValuesPattern
}
// ===================================================================
const valueToComplexMatcher = pattern => {
if (typeof pattern === 'string') {
return new CM.String(pattern)

View File

@ -0,0 +1,87 @@
import _ from 'intl'
import PropTypes from 'prop-types'
import React from 'react'
import { createPredicate } from 'value-matcher'
import { createSelector } from 'reselect'
import { filter, map, pickBy } from 'lodash'
import Component from './base-component'
import Icon from './icon'
import Link from './link'
import renderXoItem from './render-xo-item'
import Tooltip from './tooltip'
import { Card, CardBlock, CardHeader } from './card'
import { constructQueryString } from './smart-backup-pattern'
const SAMPLE_SIZE_OF_MATCHING_VMS = 3
export default class SmartBackupPreview extends Component {
static propTypes = {
pattern: PropTypes.object.isRequired,
vms: PropTypes.object.isRequired,
}
_getMatchingVms = createSelector(
() => this.props.vms,
createSelector(
() => this.props.pattern,
pattern => createPredicate(pickBy(pattern, val => val != null))
),
(vms, predicate) => filter(vms, predicate)
)
_getSampleOfMatchingVms = createSelector(this._getMatchingVms, vms =>
vms.slice(0, SAMPLE_SIZE_OF_MATCHING_VMS)
)
_getQueryString = createSelector(
() => this.props.pattern,
constructQueryString
)
render () {
const nMatchingVms = this._getMatchingVms().length
const sampleOfMatchingVms = this._getSampleOfMatchingVms()
const queryString = this._getQueryString()
return (
<Card>
<CardHeader>{_('sampleOfMatchingVms')}</CardHeader>
<CardBlock>
{nMatchingVms === 0 ? (
<p className='text-xs-center'>{_('noMatchingVms')}</p>
) : (
<div>
<ul className='list-group'>
{map(sampleOfMatchingVms, vm => (
<li className='list-group-item' key={vm.id}>
{renderXoItem(vm)}
</li>
))}
</ul>
<br />
<Tooltip content={_('redirectToMatchingVms')}>
<Link
className='pull-right'
target='_blank'
to={{
pathname: '/home',
query: {
t: 'VM',
s: queryString,
},
}}
>
{_('allMatchingVms', {
icon: <Icon icon='preview' />,
nMatchingVms,
})}
</Link>
</Tooltip>
</div>
)}
</CardBlock>
</Card>
)
}
}

View File

@ -1636,18 +1636,29 @@ export const editBackupNgJob = props =>
export const getBackupNgJob = id => _call('backupNg.getJob', { id })
export const runBackupNgJob = ({ id, scheduleId }) =>
_call('backupNg.runJob', { id, scheduleId })
export const runBackupNgJob = params => _call('backupNg.runJob', params)
export const listVmBackups = remotes =>
_call('backupNg.listVmBackups', { remotes: resolveIds(remotes) })
export const restoreBackup = (backup, sr) =>
_call('backupNg.importVmBackup', {
export const restoreBackup = (backup, sr, startOnRestore) => {
const promise = _call('backupNg.importVmBackup', {
id: resolveId(backup),
sr: resolveId(sr),
})
if (startOnRestore) {
return promise.then(startVm)
}
return promise
}
export const deleteBackup = backup =>
_call('backupNg.deleteVmBackup', { id: resolveId(backup) })
export const deleteBackups = backups => Promise.all(map(backups, deleteBackup))
// Plugins -----------------------------------------------------------
export const loadPlugin = async id =>

View File

@ -259,6 +259,10 @@
@extend .fa;
@extend .fa-download;
}
&-restore {
@extend .fa;
@extend .fa-upload;
}
&-rolling-snapshot {
@extend .fa;
@extend .fa-camera;

View File

@ -1,21 +1,26 @@
import addSubscriptions from 'add-subscriptions'
import React from 'react'
import { injectState, provideState } from '@julien-f/freactal'
import { Debug } from 'utils'
import { subscribeBackupNgJobs, subscribeSchedules } from 'xo'
import { find, groupBy } from 'lodash'
import New from './new'
export default [
addSubscriptions({
jobs: subscribeBackupNgJobs,
schedules: subscribeSchedules,
schedulesByJob: cb =>
subscribeSchedules(schedules => {
cb(groupBy(schedules, 'jobId'))
}),
}),
provideState({
computed: {
value: ({ jobs, schedules }) => {},
job: (_, { jobs, routeParams: { id } }) => find(jobs, { id }),
schedules: (_, { schedulesByJob, routeParams: { id } }) =>
schedulesByJob && schedulesByJob[id],
},
}),
injectState,
props => ({ state }) => <New value={state.value} />,
({ state: { job, schedules } }) => <New job={job} schedules={schedules} />,
].reduceRight((value, decorator) => decorator(value))

View File

@ -4,6 +4,7 @@ import addSubscriptions from 'add-subscriptions'
import Icon from 'icon'
import React from 'react'
import SortedTable from 'sorted-table'
import StateButton from 'state-button'
import { map, groupBy } from 'lodash'
import { Card, CardHeader, CardBlock } from 'card'
import { constructQueryString } from 'smart-backup-pattern'
@ -12,9 +13,11 @@ import { NavLink, NavTabs } from 'nav'
import { routes } from 'utils'
import {
deleteBackupNgJobs,
disableSchedule,
enableSchedule,
runBackupNgJob,
subscribeBackupNgJobs,
subscribeSchedules,
runBackupNgJob,
} from 'xo'
import LogsTable from '../logs'
@ -25,40 +28,41 @@ import New from './new'
import FileRestore from './file-restore'
import Restore from './restore'
const Ul = ({ children, ...props }) => (
<ul {...props} style={{ display: 'inline', padding: '0 0.5em' }}>
{children}
</ul>
)
const Li = ({ children, ...props }) => (
<li {...props} style={{ listStyleType: 'none', display: 'inline-block' }}>
{children}
</li>
)
const Td = ({ children, ...props }) => (
<td {...props} style={{ borderRight: '1px solid black' }}>
{children}
</td>
)
const SchedulePreviewBody = ({ job, schedules }) => (
const SchedulePreviewBody = ({ item: job, userData: { schedulesByJob } }) => (
<table>
{map(schedules, schedule => (
<tr className='text-muted'>
<th>{_('scheduleCron')}</th>
<th>{_('scheduleTimezone')}</th>
<th>{_('scheduleExportRetention')}</th>
<th>{_('scheduleSnapshotRetention')}</th>
<th>{_('scheduleRun')}</th>
</tr>
{map(schedulesByJob && schedulesByJob[job.id], schedule => (
<tr key={schedule.id}>
<Td>{schedule.cron}</Td>
<Td>{schedule.timezone}</Td>
<Td>{job.settings[schedule.id].exportRetention}</Td>
<Td>{job.settings[schedule.id].snapshotRetention}</Td>
<td>{schedule.cron}</td>
<td>{schedule.timezone}</td>
<td>{job.settings[schedule.id].exportRetention}</td>
<td>{job.settings[schedule.id].snapshotRetention}</td>
<td>
<StateButton
disabledLabel={_('jobStateDisabled')}
disabledHandler={enableSchedule}
disabledTooltip={_('logIndicationToEnable')}
enabledLabel={_('jobStateEnabled')}
enabledHandler={disableSchedule}
enabledTooltip={_('logIndicationToDisable')}
handlerParam={schedule.id}
state={schedule.enabled}
/>
</td>
<td>
<ActionButton
handler={runBackupNgJob}
icon='run-schedule'
size='small'
data-id={job.id}
data-scheduleId={schedule.id}
btnStyle='warning'
data-schedule={schedule.id}
btnStyle='primary'
/>
</td>
</tr>
@ -66,13 +70,6 @@ const SchedulePreviewBody = ({ job, schedules }) => (
</table>
)
const SchedulePreviewHeader = ({ _ }) => (
<Ul>
<Li>Schedule ID</Li> | <Li>Cron</Li> | <Li>Timezone</Li> |{' '}
<Li>Export retention</Li> | <Li>Snapshot retention</Li> |{' '}
</Ul>
)
@addSubscriptions({
jobs: subscribeBackupNgJobs,
schedulesByJob: cb =>
@ -97,27 +94,24 @@ class JobsTable extends React.Component {
columns: [
{
itemRenderer: _ => _.id.slice(0, 5),
sortCriteria: _ => _.id,
name: _('jobId'),
},
{
itemRenderer: _ => _.name,
sortCriteria: _ => _.name,
sortCriteria: 'name',
name: _('jobName'),
default: true,
},
{
itemRenderer: _ => _.mode,
sortCriteria: _ => _.mode,
name: 'mode',
},
{
component: _ => (
<SchedulePreviewBody
job={_.item}
schedules={_.userData.schedulesByJob[_.item.id]}
/>
itemRenderer: _ => (
<span style={{ textTransform: 'capitalize' }}>{_.mode}</span>
),
name: <SchedulePreviewHeader />,
sortCriteria: 'mode',
name: _('jobMode'),
},
{
component: SchedulePreviewBody,
name: _('jobSchedules'),
},
],
individualActions: [

View File

@ -1,36 +1,477 @@
import _, { messages } from 'intl'
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import moment from 'moment-timezone'
import React from 'react'
import Scheduler, { SchedulePreview } from 'scheduling'
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 { cloneDeep, orderBy, size, isEmpty, map } from 'lodash'
import { SelectRemote, SelectSr, SelectVm } from 'select-objects'
import { resolveIds } from 'utils'
import { createBackupNgJob, editBackupNgJob, editSchedule } from 'xo'
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 FormGroup = props => <div {...props} className='form-group' />
const Input = props => <input {...props} className='form-control' />
// ===================================================================
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 removeScheduleFromSettings = tmpSchedules => {
const newTmpSchedules = cloneDeep(tmpSchedules)
const destructPattern = pattern => pattern.id.__or
for (let schedule in newTmpSchedules) {
delete newTmpSchedules[schedule].cron
delete newTmpSchedules[schedule].timezone
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 newTmpSchedules
return newSettings
}
const getRandomId = () =>
@ -44,86 +485,105 @@ export default [
<New {...props} />
</Upgrade>
),
connectStore({
allVms: createGetObjectsOfType('VM'),
}),
provideState({
initialState: () => ({
compression: true,
delta: false,
formId: getRandomId(),
tmpSchedule: {
cron: DEFAULT_CRON_PATTERN,
timezone: DEFAULT_TIMEZONE,
},
exportRetention: 0,
snapshotRetention: 0,
name: '',
paramsUpdated: false,
remotes: [],
schedules: {},
smartMode: false,
srs: [],
vms: [],
tmpSchedules: {},
...SMART_MODE_INITIAL_STATE,
...SCHEDULES_INITIAL_STATE,
}),
effects: {
addSchedule: () => state => {
const id = getRandomId()
addSchedule: () => async state => {
const { schedule, snapshotRetention, exportRetention } = await confirm({
title: 'New schedule',
body: <ScheduleModal />,
})
return {
...state,
tmpSchedules: {
...state.tmpSchedules,
[id]: {
...state.tmpSchedule,
exportRetention: state.exportRetention,
snapshotRetention: state.snapshotRetention,
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',
remotes: constructPattern(state.remotes),
compression: state.compression ? 'native' : '',
schedules: state.tmpSchedules,
settings: {
...removeScheduleFromSettings(state.tmpSchedules),
...state.schedules,
...getNewSettings(state.tmpSchedules),
},
remotes: constructPattern(state.remotes),
srs: constructPattern(state.srs),
vms: constructPattern(state.vms),
vms: state.smartMode
? state.vmsSmartPattern
: constructPattern(state.vms),
})
},
setTmpSchedule: (_, schedule) => state => ({
...state,
tmpSchedule: {
cron: schedule.cronPattern,
timezone: schedule.timezone,
},
}),
setExportRetention: (_, { target: { value } }) => state => ({
...state,
exportRetention: value,
}),
setSnapshotRetention: (_, { target: { value } }) => state => ({
...state,
snapshotRetention: value,
}),
editSchedule: (
_,
{ target: { dataset: { scheduleId } } }
) => state => ({}),
editTmpSchedule: (_, { scheduleId }) => state => ({
...state,
tmpSchedules: {
...state.tmpSchedules,
[scheduleId]: {
...state.tmpSchedule,
exportRetention: state.exportRetention,
snapshotRetention: state.snapshotRetention,
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: { value } }) => state => ({
})
},
setDelta: (_, { target: { checked } }) => state => ({
...state,
delta: value,
delta: checked,
}),
setCompression: (_, { target: { checked } }) => state => ({
...state,
compression: checked,
}),
setSmartMode: (_, smartMode) => state => ({
...state,
smartMode,
}),
setName: (_, { target: { value } }) => state => ({
...state,
@ -132,138 +592,150 @@ export default [
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)),
sortedSchedules: ({ schedules }) => orderBy(schedules, 'name'),
// TO DO: use sortedTmpSchedules
sortedTmpSchedules: ({ tmpSchedules }) => orderBy(tmpSchedules, 'id'),
(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 }) => (
<form id={state.formId}>
<FormGroup>
<h1>BackupNG</h1>
<label>
<strong>Name</strong>
</label>
<Input onChange={effects.setName} value={state.name} />
</FormGroup>
<FormGroup>
<label>Target remotes (for Export)</label>
<SelectRemote
multi
onChange={effects.setRemotes}
value={state.remotes}
/>
</FormGroup>
<FormGroup>
<label>
Target SRs (for Replication)
({ 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} />
</label>
</FormGroup>
<FormGroup>
<label>
Vms to Backup
<SelectVm multi onChange={effects.setVms} value={state.vms} />
</label>
</FormGroup>
{false /* TODO: remove when implemented */ && (!isEmpty(state.srs) || !isEmpty(state.remotes)) && (
<Upgrade place='newBackup' required={4}>
</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>
<input
type='checkbox'
onChange={effects.setDelta}
value={state.delta}
/>{' '}
Use delta
<strong>{_('vmsToBackup')}</strong>
</label>
<SelectVm multi onChange={effects.setVms} value={state.vms} />
</FormGroup>
</Upgrade>
)}
{!isEmpty(state.schedules) && (
<FormGroup>
<h3>Saved schedules</h3>
<ul>
{state.sortedSchedules.map(schedule => (
<li key={schedule.id}>
{schedule.name} {schedule.cron} {schedule.timezone}
<ActionButton
data-scheduleId={schedule.id}
handler={effects.editSchedule}
icon='edit'
size='small'
/>
</li>
))}
</ul>
</FormGroup>
)}
{!isEmpty(state.tmpSchedules) && (
<FormGroup>
<h3>New schedules</h3>
<ul>
{map(state.tmpSchedules, (schedule, key) => (
<li key={key}>
{schedule.cron} {schedule.timezone} {schedule.exportRetention}{' '}
{schedule.snapshotRetention}
<ActionButton
data-scheduleId={key}
handler={effects.editTmpSchedule}
icon='edit'
size='small'
/>
</li>
))}
</ul>
</FormGroup>
)}
<FormGroup>
<h1>BackupNG</h1>
<label>
<strong>Export retention</strong>
</label>
<Input
type='number'
onChange={effects.setExportRetention}
value={state.exportRetention}
/>
<label>
<strong>Snapshot retention</strong>
</label>
<Input
type='number'
onChange={effects.setSnapshotRetention}
value={state.snapshotRetention}
/>
<Scheduler
cronPattern={state.tmpSchedule.cron}
onChange={effects.setTmpSchedule}
timezone={state.tmpSchedule.timezone}
/>
<SchedulePreview
cronPattern={state.tmpSchedule.cron}
timezone={state.tmpSchedule.timezone}
/>
)}
{(!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'>
Add a schedule
<ActionButton
handler={effects.addSchedule}
icon='add'
className='pull-right'
>
{_('scheduleAdd')}
</ActionButton>
</FormGroup>
<ActionButton
disabled={state.isInvalid}
form={state.formId}
handler={effects.createJob}
redirectOnSuccess='/backup-ng'
icon='save'
>
Create
</ActionButton>
</form>
),
<br />
<br />
<SchedulesOverview />
<br />
{state.paramsUpdated ? (
<ActionButton
btnStyle='primary'
disabled={state.isInvalid}
form={state.formId}
handler={effects.editJob}
icon='save'
redirectOnSuccess='/backup-ng'
size='large'
>
{_('scheduleEdit')}
</ActionButton>
) : (
<ActionButton
btnStyle='primary'
disabled={state.isInvalid}
form={state.formId}
handler={effects.createJob}
icon='save'
redirectOnSuccess='/backup-ng'
size='large'
>
{_('createBackupJob')}
</ActionButton>
)}
</form>
)
},
].reduceRight((value, decorator) => decorator(value))

View File

@ -0,0 +1,108 @@
import _ from 'intl'
import classNames from 'classnames'
import Component from 'base-component'
import Icon from 'icon'
import React from 'react'
import { FormattedDate } from 'react-intl'
import { forEach, map, orderBy } from 'lodash'
import { createFilter, createSelector } from 'selectors'
import { Toggle } from 'form'
const _escapeDot = id => id.replace('.', '\0')
export default class DeleteBackupsModalBody extends Component {
get value () {
return this._getSelectedBackups()
}
_selectAll = () => {
const selected = this._getSelectedBackups().length === 0
const state = {}
// TODO: [DELTA] remove filter
forEach(this.props.backups.filter(b => b.mode !== 'delta'), backup => {
state[_escapeDot(backup.id)] = selected
})
this.setState(state)
}
_getSelectedBackups = createFilter(
() => this.props.backups,
createSelector(
() => this.state,
state => backup => state[_escapeDot(backup.id)]
)
)
_getAllSelected = createSelector(
() => this.props.backups,
this._getSelectedBackups,
(backups, selectedBackups) =>
// TODO: [DELTA] remove filter
backups.filter(b => b.mode !== 'delta').length === selectedBackups.length
)
_getBackups = createSelector(
() => this.props.backups,
backups => orderBy(backups, 'timestamp', 'desc')
)
render () {
return (
<div>
<div>{_('deleteVmBackupsSelect')}</div>
<div className='list-group'>
{map(this._getBackups(), backup => (
<button
className={classNames(
'list-group-item',
'list-group-item-action',
backup.mode === 'delta' && 'disabled', // TODO: [DELTA] remove
this.state[_escapeDot(backup.id)] && 'active'
)}
data-id={backup.id}
key={backup.id}
onClick={
backup.mode !== 'delta' && // TODO: [DELTA] remove
this.toggleState(_escapeDot(backup.id))
}
type='button'
>
<span
className='tag tag-info'
style={{ textTransform: 'capitalize' }}
>
{backup.mode}
</span>{' '}
<span className='tag tag-warning'>{backup.remote.name}</span>{' '}
<FormattedDate
value={new Date(backup.timestamp)}
month='long'
day='numeric'
year='numeric'
hour='2-digit'
minute='2-digit'
second='2-digit'
/>
</button>
))}
</div>
<div>
<Toggle
iconSize={1}
onChange={this._selectAll}
value={this._getAllSelected()}
/>{' '}
{_('deleteVmBackupsSelectAll')}
</div>
{/* TODO: [DELTA] remove div and i18n message */}
<div>
<em>
<Icon icon='info' /> {_('deleteVmBackupsDeltaInfo')}
</em>
</div>
</div>
)
}
}

View File

@ -1,42 +0,0 @@
import _ from 'intl'
import React from 'react'
import Component from 'base-component'
import { Select } from 'form'
import { SelectSr } from 'select-objects'
import { FormattedDate } from 'react-intl'
export default class ImportModalBody extends Component {
get value () {
return this.state
}
render () {
return (
<div>
<div className='mb-1'>
<Select
optionRenderer={backup => (
<span>
{`[${backup.mode}] `}
<FormattedDate
value={new Date(backup.timestamp)}
month='long'
day='numeric'
year='numeric'
hour='2-digit'
minute='2-digit'
second='2-digit'
/>
</span>
)}
options={this.props.data.backups}
onChange={this.linkState('backup')}
placeholder={_('importBackupModalSelectBackup')}
/>
</div>
<div>
<SelectSr onChange={this.linkState('sr')} />
</div>
</div>
)
}
}

View File

@ -1,30 +1,66 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import Icon from 'icon'
import React from 'react'
import SortedTable from 'sorted-table'
import Upgrade from 'xoa-upgrade'
import { addSubscriptions, noop } from 'utils'
import { subscribeRemotes, listVmBackups, restoreBackup } from 'xo'
import { assign, filter, forEach, map } from 'lodash'
import { confirm } from 'modal'
import { error } from 'notification'
import { FormattedDate } from 'react-intl'
import {
assign,
filter,
flatMap,
forEach,
keyBy,
map,
reduce,
toArray,
} from 'lodash'
import {
deleteBackups,
listVmBackups,
restoreBackup,
subscribeRemotes,
} from 'xo'
import ImportModalBody from './import-modal-body'
import RestoreBackupsModalBody, {
RestoreBackupsBulkModalBody,
} from './restore-backups-modal-body'
import DeleteBackupsModalBody from './delete-backups-modal-body'
// -----------------------------------------------------------------------------
const BACKUPS_COLUMNS = [
{
name: 'VM',
name: _('backupVmNameColumn'),
itemRenderer: ({ last }) => last.vm.name_label,
sortCriteria: 'last.vm.name_label',
},
{
name: 'VM description',
name: _('backupVmDescriptionColumn'),
itemRenderer: ({ last }) => last.vm.name_description,
sortCriteria: 'last.vm.name_description',
},
{
name: 'Last backup',
name: _('firstBackupColumn'),
itemRenderer: ({ first }) => (
<FormattedDate
value={new Date(first.timestamp)}
month='long'
day='numeric'
year='numeric'
hour='2-digit'
minute='2-digit'
second='2-digit'
/>
),
sortCriteria: 'first.timestamp',
sortOrder: 'desc',
},
{
name: _('lastBackupColumn'),
itemRenderer: ({ last }) => (
<FormattedDate
value={new Date(last.timestamp)}
@ -36,20 +72,31 @@ const BACKUPS_COLUMNS = [
second='2-digit'
/>
),
sortCriteria: 'last.timestamp',
default: true,
sortOrder: 'desc',
},
{
name: 'Available backups',
name: _('availableBackupsColumn'),
itemRenderer: ({ count }) =>
map(count, (n, mode) => `${mode}: ${n}`).join(', '),
map(count, (n, mode) => (
<span key={mode}>
<span style={{ textTransform: 'capitalize' }}>{mode}</span>{' '}
<span className='tag tag-pill tag-primary'>{n}</span>
<br />
</span>
)),
},
]
// -----------------------------------------------------------------------------
@addSubscriptions({
remotes: subscribeRemotes,
})
export default class Restore extends Component {
state = {
backupsByVm: {},
backupDataByVm: {},
}
componentWillReceiveProps (props) {
@ -58,74 +105,159 @@ export default class Restore extends Component {
}
}
_refreshBackupList = async remotes => {
const backupsByRemote = await listVmBackups(
filter(remotes, { enabled: true })
)
const backupsByVm = {}
forEach(backupsByRemote, backups => {
_refreshBackupList = async (_ = this.props.remotes) => {
const remotes = keyBy(filter(_, { enabled: true }), 'id')
const backupsByRemote = await listVmBackups(toArray(remotes))
const backupDataByVm = {}
forEach(backupsByRemote, (backups, remoteId) => {
const remote = remotes[remoteId]
forEach(backups, (vmBackups, vmId) => {
if (backupsByVm[vmId] === undefined) {
backupsByVm[vmId] = { backups: [] }
if (backupDataByVm[vmId] === undefined) {
backupDataByVm[vmId] = { backups: [] }
}
backupsByVm[vmId].backups.push(...vmBackups)
backupDataByVm[vmId].backups.push(
...map(vmBackups, bkp => ({ ...bkp, remote }))
)
})
})
// TODO: perf
let last
forEach(backupsByVm, (vmBackups, vmId) => {
let first, last
forEach(backupDataByVm, (data, vmId) => {
first = { timestamp: Infinity }
last = { timestamp: 0 }
const count = {}
forEach(vmBackups.backups, backup => {
forEach(data.backups, backup => {
if (backup.timestamp > last.timestamp) {
last = backup
}
if (backup.timestamp < first.timestamp) {
first = backup
}
count[backup.mode] = (count[backup.mode] || 0) + 1
})
assign(vmBackups, { last, count })
assign(data, { first, last, count, id: vmId })
})
this.setState({ backupsByVm })
this.setState({ backupDataByVm })
}
// Actions -------------------------------------------------------------------
_restore = data =>
confirm({
title: `Restore ${data.last.vm.name_label}`,
body: <ImportModalBody data={data} />,
}).then(({ backup, sr }) => {
if (backup == null || sr == null) {
error(_('backupRestoreErrorTitle'), _('backupRestoreErrorMessage'))
return
}
title: _('restoreVmBackupsTitle', { vm: data.last.vm.name_label }),
body: <RestoreBackupsModalBody data={data} />,
icon: 'restore',
})
.then(({ backup, sr, start }) => {
if (backup == null || sr == null) {
error(_('backupRestoreErrorTitle'), _('backupRestoreErrorMessage'))
return
}
return restoreBackup(backup, sr)
}, noop)
return restoreBackup(backup, sr, start)
}, noop)
.then(() => this._refreshBackupList())
_delete = data =>
confirm({
title: _('deleteVmBackupsTitle', { vm: data.last.vm.name_label }),
body: <DeleteBackupsModalBody backups={data.backups} />,
icon: 'delete',
})
.then(deleteBackups, noop)
.then(() => this._refreshBackupList())
_bulkRestore = datas =>
confirm({
title: _('restoreVmBackupsBulkTitle', { nVms: datas.length }),
body: <RestoreBackupsBulkModalBody datas={datas} />,
icon: 'restore',
})
.then(({ sr, latest, start }) => {
if (sr == null) {
error(
_(
'restoreVmBackupsBulkErrorTitle',
'restoreVmBackupsBulkErrorMessage'
)
)
return
}
const prop = latest ? 'last' : 'first'
return Promise.all(
map(datas, data => restoreBackup(data[prop], sr, start))
)
}, noop)
.then(() => this._refreshBackupList())
_bulkDelete = datas =>
confirm({
title: _('deleteVmBackupsBulkTitle'),
body: <p>{_('deleteVmBackupsBulkMessage', { nVms: datas.length })}</p>,
icon: 'delete',
strongConfirm: {
messageId: 'deleteVmBackupsBulkConfirmText',
values: {
nBackups: reduce(
datas,
(sum, data) =>
// TODO: [DELTA] remove filter
sum + data.backups.filter(b => b.mode !== 'delta').length,
0
),
},
},
})
.then(
() =>
deleteBackups(
// TODO: [DELTA] remove filter
flatMap(datas, 'backups').filter(b => b.mode !== 'delta')
),
noop
)
.then(() => this._refreshBackupList())
// ---------------------------------------------------------------------------
_actions = [
{
handler: this._bulkRestore,
icon: 'restore',
individualHandler: this._restore,
label: _('restoreVmBackups'),
level: 'primary',
},
{
handler: this._bulkDelete,
icon: 'delete',
individualHandler: this._delete,
label: _('deleteVmBackups'),
level: 'danger',
},
]
render () {
return (
<Upgrade place='restoreBackup' available={2}>
<div>
<h2>{_('restoreBackups')}</h2>
<div className='mb-1'>
<em>
<Icon icon='info' /> {_('restoreBackupsInfo')}
</em>
</div>
<div className='mb-1'>
<ActionButton
btnStyle='primary'
handler={this._refreshBackupList}
handlerParam={this.props.remotes}
icon='refresh'
>
Refresh backup list
{_('restoreResfreshList')}
</ActionButton>
</div>
<SortedTable
collection={this.state.backupsByVm}
actions={this._actions}
collection={this.state.backupDataByVm}
columns={BACKUPS_COLUMNS}
rowAction={this._restore}
/>
</div>
</Upgrade>

View File

@ -0,0 +1,95 @@
import _ from 'intl'
import React from 'react'
import Component from 'base-component'
import StateButton from 'state-button'
import { Select, Toggle } from 'form'
import { SelectSr } from 'select-objects'
import { FormattedDate } from 'react-intl'
export default class RestoreBackupsModalBody extends Component {
get value () {
return this.state
}
render () {
return (
<div>
<div className='mb-1'>
<Select
optionRenderer={backup => (
<span>
<span
className='tag tag-info'
style={{ textTransform: 'capitalize' }}
>
{backup.mode}
</span>{' '}
<span className='tag tag-warning'>{backup.remote.name}</span>{' '}
<FormattedDate
value={new Date(backup.timestamp)}
month='long'
day='numeric'
year='numeric'
hour='2-digit'
minute='2-digit'
second='2-digit'
/>
</span>
)}
options={this.props.data.backups}
onChange={this.linkState('backup')}
placeholder={_('importBackupModalSelectBackup')}
/>
</div>
<div className='mb-1'>
<SelectSr
onChange={this.linkState('sr')}
placeholder={_('importBackupModalSelectSr')}
/>
</div>
<div>
<Toggle iconSize={1} onChange={this.linkState('start')} />{' '}
{_('restoreVmBackupsStart', { nVms: 1 })}
</div>
</div>
)
}
}
export class RestoreBackupsBulkModalBody extends Component {
state = { latest: true }
get value () {
return this.state
}
render () {
const { datas } = this.props
return (
<div>
<div className='mb-1'>
{_('restoreVmBackupsBulkMessage', {
nVms: datas.length,
oldestOrLatest: (
<StateButton
disabledLabel={_('oldest')}
disabledHandler={() => this.setState({ latest: true })}
enabledLabel={_('latest')}
enabledHandler={() => this.setState({ latest: false })}
state={this.state.latest}
/>
),
})}
</div>
<div className='mb-1'>
<SelectSr
onChange={this.linkState('sr')}
placeholder={_('importBackupModalSelectSr')}
/>
</div>
<div>
<Toggle iconSize={1} onChange={this.linkState('start')} />{' '}
{_('restoreVmBackupsStart', { nVms: datas.length })}
</div>
</div>
)
}
}

View File

@ -5,40 +5,22 @@ import Component from 'base-component'
import GenericInput from 'json-schema-input'
import getEventValue from 'get-event-value'
import Icon from 'icon'
import Link from 'link'
import moment from 'moment-timezone'
import PropTypes from 'prop-types'
import React from 'react'
import renderXoItem from 'render-xo-item'
import Scheduler, { SchedulePreview } from 'scheduling'
import Tooltip from 'tooltip'
import SmartBackupPreview from 'smart-backup-preview'
import uncontrollableInput from 'uncontrollable-input'
import Upgrade from 'xoa-upgrade'
import Wizard, { Section } from 'wizard'
import { confirm } from 'modal'
import { Card, CardBlock, CardHeader } from 'card'
import { connectStore, EMPTY_OBJECT } from 'utils'
import { constructPattern, destructPattern } from 'smart-backup-pattern'
import { Container, Row, Col } from 'grid'
import { createPredicate } from 'value-matcher'
import { createGetObjectsOfType, getUser } from 'selectors'
import { createSelector } from 'reselect'
import { generateUiSchema } from 'xo-json-schema-input'
import { SelectSubject } from 'select-objects'
import { createGetObjectsOfType, getUser } from 'selectors'
import { connectStore, EMPTY_OBJECT } from 'utils'
import {
constructPattern,
destructPattern,
constructQueryString,
} from 'smart-backup-pattern'
import {
filter,
forEach,
isArray,
map,
mapValues,
noop,
pickBy,
startsWith,
} from 'lodash'
import { forEach, isArray, map, mapValues, noop, startsWith } from 'lodash'
import { createJob, createSchedule, getRemote, editJob, editSchedule } from 'xo'
@ -287,83 +269,6 @@ const BACKUP_METHOD_TO_INFO = {
// ===================================================================
const SAMPLE_SIZE_OF_MATCHING_VMS = 3
@connectStore({
vms: createGetObjectsOfType('VM'),
})
class SmartBackupPreview extends Component {
static propTypes = {
pattern: PropTypes.object.isRequired,
}
_getMatchingVms = createSelector(
() => this.props.vms,
createSelector(
() => this.props.pattern,
pattern => createPredicate(pickBy(pattern, val => val != null))
),
(vms, predicate) => filter(vms, predicate)
)
_getSampleOfMatchingVms = createSelector(this._getMatchingVms, vms =>
vms.slice(0, SAMPLE_SIZE_OF_MATCHING_VMS)
)
_getQueryString = createSelector(
() => this.props.pattern,
constructQueryString
)
render () {
const nMatchingVms = this._getMatchingVms().length
const sampleOfMatchingVms = this._getSampleOfMatchingVms()
const queryString = this._getQueryString()
return (
<Card>
<CardHeader>{_('sampleOfMatchingVms')}</CardHeader>
<CardBlock>
{nMatchingVms === 0 ? (
<p className='text-xs-center'>{_('noMatchingVms')}</p>
) : (
<div>
<ul className='list-group'>
{map(sampleOfMatchingVms, vm => (
<li className='list-group-item' key={vm.id}>
{renderXoItem(vm)}
</li>
))}
</ul>
<br />
<Tooltip content={_('redirectToMatchingVms')}>
<Link
className='pull-right'
target='_blank'
to={{
pathname: '/home',
query: {
t: 'VM',
s: queryString,
},
}}
>
{_('allMatchingVms', {
icon: <Icon icon='preview' />,
nMatchingVms,
})}
</Link>
</Tooltip>
</div>
)}
</CardBlock>
</Card>
)
}
}
// ===================================================================
@uncontrollableInput()
class TimeoutInput extends Component {
_onChange = event => {
@ -416,6 +321,7 @@ const normalizeMainParams = params => {
@connectStore({
currentUser: getUser,
vms: createGetObjectsOfType('VM'),
})
export default class New extends Component {
_getParams = createSelector(
@ -755,6 +661,7 @@ export default class New extends Component {
</Upgrade>
<SmartBackupPreview
pattern={this._constructPattern(vms)}
vms={this.props.vms}
/>
</div>
) : (