feat(Backups NG): second iteration (#2718)
This commit is contained in:
parent
831e36ae5f
commit
b1986dc275
@ -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
12
flow-typed/lodash.js
vendored
Normal 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
10
flow-typed/promise-toolbox.js
vendored
Normal 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
2
flow-typed/xo.js
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line no-undef
|
||||
declare type $Dict<T, K = string> = { [K]: T }
|
@ -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',
|
||||
},
|
||||
}
|
||||
|
@ -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 :-)')
|
||||
}
|
||||
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
49
packages/xo-server/src/xapi/index.js.flow
Normal file
49
packages/xo-server/src/xapi/index.js.flow
Normal 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>;
|
||||
}
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -1,7 +1,6 @@
|
||||
// @flow
|
||||
|
||||
import { createSchedule } from '@xen-orchestra/cron'
|
||||
// $FlowFixMe
|
||||
import { keyBy } from 'lodash'
|
||||
import { noSuchObject } from 'xo-common/api-errors'
|
||||
|
||||
|
@ -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',
|
||||
|
@ -235,7 +235,8 @@ export const confirm = ({ body, icon = 'alarm', title, strongConfirm }) =>
|
||||
resolve={resolve}
|
||||
strongConfirm={strongConfirm}
|
||||
title={title}
|
||||
/>
|
||||
/>,
|
||||
reject
|
||||
)
|
||||
})
|
||||
: chooseAction({
|
||||
|
505
packages/xo-web/src/common/scheduler-tmp.js
Normal file
505
packages/xo-web/src/common/scheduler-tmp.js
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
87
packages/xo-web/src/common/smart-backup-preview.js
Normal file
87
packages/xo-web/src/common/smart-backup-preview.js
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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 =>
|
||||
|
@ -259,6 +259,10 @@
|
||||
@extend .fa;
|
||||
@extend .fa-download;
|
||||
}
|
||||
&-restore {
|
||||
@extend .fa;
|
||||
@extend .fa-upload;
|
||||
}
|
||||
&-rolling-snapshot {
|
||||
@extend .fa;
|
||||
@extend .fa-camera;
|
||||
|
@ -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))
|
||||
|
@ -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: [
|
||||
|
@ -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))
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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>
|
||||
) : (
|
||||
|
Loading…
Reference in New Issue
Block a user