feat(xo-server/backup NG): file restore (#2889)

This commit is contained in:
Julien Fontanet 2018-05-06 18:38:47 +02:00 committed by GitHub
parent 68a34f7cdb
commit 7ab907a854
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1142 additions and 66 deletions

View File

@ -116,7 +116,8 @@
"xo-collection": "^0.4.1",
"xo-common": "^0.1.1",
"xo-remote-parser": "^0.3",
"xo-vmdk-to-vhd": "0.0.12"
"xo-vmdk-to-vhd": "0.0.12",
"yazl": "^2.4.3"
},
"devDependencies": {
"@babel/cli": "7.0.0-beta.44",

View File

@ -1,3 +1,7 @@
import { basename } from 'path'
import { safeDateFormat } from '../utils'
export function createJob ({ schedules, ...job }) {
job.userId = this.user.id
return this.createBackupNgJob(job, schedules)
@ -171,3 +175,88 @@ importVmBackup.params = {
type: 'string',
},
}
// -----------------------------------------------------------------------------
export function listPartitions ({ remote, disk }) {
return this.listBackupNgDiskPartitions(remote, disk)
}
listPartitions.permission = 'admin'
listPartitions.params = {
disk: {
type: 'string',
},
remote: {
type: 'string',
},
}
export function listFiles ({ remote, disk, partition, path }) {
return this.listBackupNgPartitionFiles(remote, disk, partition, path)
}
listFiles.permission = 'admin'
listFiles.params = {
disk: {
type: 'string',
},
partition: {
type: 'string',
optional: true,
},
path: {
type: 'string',
},
remote: {
type: 'string',
},
}
async function handleFetchFiles (req, res, { remote, disk, partition, paths }) {
const zipStream = await this.fetchBackupNgPartitionFiles(
remote,
disk,
partition,
paths
)
res.setHeader('content-disposition', 'attachment')
res.setHeader('content-type', 'application/octet-stream')
return zipStream
}
export async function fetchFiles (params) {
const { paths } = params
let filename = `restore_${safeDateFormat(new Date())}`
if (paths.length === 1) {
filename += `_${basename(paths[0])}`
}
filename += '.zip'
return this.registerHttpRequest(handleFetchFiles, params, {
suffix: encodeURI(`/${filename}`),
}).then(url => ({ $getFrom: url }))
}
fetchFiles.permission = 'admin'
fetchFiles.params = {
disk: {
type: 'string',
},
partition: {
optional: true,
type: 'string',
},
paths: {
items: { type: 'string' },
minLength: 1,
type: 'array',
},
remote: {
type: 'string',
},
}

View File

@ -1,16 +1,15 @@
import execa from 'execa'
import splitLines from 'split-lines'
import { createParser } from 'parse-pairs'
import { isArray, map } from 'lodash'
// ===================================================================
const parse = createParser({
keyTransform: key => key.slice(5).toLowerCase(),
})
const makeFunction = command => (fields, ...args) =>
execa
.stdout(command, [
const makeFunction = command => async (fields, ...args) => {
return splitLines(
await execa.stdout(command, [
'--noheading',
'--nosuffix',
'--nameprefixes',
@ -21,17 +20,8 @@ const makeFunction = command => (fields, ...args) =>
String(fields),
...args,
])
.then(stdout =>
map(
splitLines(stdout),
isArray(fields)
? parse
: line => {
const data = parse(line)
return data[fields]
}
)
)
).map(Array.isArray(fields) ? parse : line => parse(line)[fields])
}
export const lvs = makeFunction('lvs')
export const pvs = makeFunction('pvs')

View File

@ -539,6 +539,16 @@ export default class BackupNg {
// inject an id usable by importVmBackupNg()
backups.forEach(backup => {
backup.id = `${remoteId}/${backup._filename}`
const { vdis, vhds } = backup
backup.disks = Object.keys(vhds).map(vdiId => {
const vdi = vdis[vdiId]
return {
id: `${dirname(backup._filename)}/${vhds[vdiId]}`,
name: vdi.name_label,
uuid: vdi.uuid,
}
})
})
backupsByVm[vmUuid] = backups
@ -1096,7 +1106,11 @@ export default class BackupNg {
})
)
} catch (error) {
if (error == null || error.code !== 'ENOENT') {
let code
if (
error == null ||
((code = error.code) !== 'ENOENT' && code !== 'ENOTDIR')
) {
throw error
}
}

View File

@ -0,0 +1,332 @@
import defer from 'golike-defer'
import execa from 'execa'
import splitLines from 'split-lines'
import { createParser as createPairsParser } from 'parse-pairs'
import { normalize } from 'path'
import { readdir, rmdir, stat } from 'fs-extra'
import { ZipFile } from 'yazl'
import { lvs, pvs } from '../lvm'
import { resolveSubpath, tmpDir } from '../utils'
const IGNORED_PARTITION_TYPES = {
// https://github.com/jhermsmeier/node-mbr/blob/master/lib/partition.js#L38
0x05: true,
0x0f: true,
0x15: true,
0x5e: true,
0x5f: true,
0x85: true,
0x91: true,
0x9b: true,
0xc5: true,
0xcf: true,
0xd5: true,
0x82: true, // swap
}
const PARTITION_TYPE_NAMES = {
0x07: 'NTFS',
0x0c: 'FAT',
0x83: 'linux',
}
const RE_VHDI = /^vhdi(\d+)$/
const parsePartxLine = createPairsParser({
keyTransform: key => (key === 'UUID' ? 'id' : key.toLowerCase()),
valueTransform: (value, key) =>
key === 'start' || key === 'size'
? +value
: key === 'type' ? PARTITION_TYPE_NAMES[+value] || value : value,
})
const listLvmLogicalVolumes = defer(
async ($defer, devicePath, partition, results = []) => {
const pv = await mountLvmPhysicalVolume(devicePath, partition)
$defer(pv.unmount)
const lvs = await pvs(['lv_name', 'lv_path', 'lv_size', 'vg_name'], pv.path)
const partitionId = partition !== undefined ? partition.id : ''
lvs.forEach((lv, i) => {
const name = lv.lv_name
if (name !== '') {
results.push({
id: `${partitionId}/${lv.vg_name}/${name}`,
name,
size: lv.lv_size,
})
}
})
return results
}
)
async function mountLvmPhysicalVolume (devicePath, partition) {
const args = []
if (partition !== undefined) {
args.push('-o', partition.start * 512)
}
args.push('--show', '-f', devicePath)
const path = (await execa.stdout('losetup', args)).trim()
await execa('pvscan', ['--cache', path])
return {
path,
unmount: async () => {
try {
const vgNames = await pvs('vg_name', path)
await execa('vgchange', ['-an', ...vgNames])
} finally {
await execa('losetup', ['-d', path])
}
},
}
}
const mountPartition = defer(async ($defer, devicePath, partition) => {
const options = ['loop', 'ro']
if (partition !== undefined) {
const { start } = partition
if (start !== undefined) {
options.push(`offset=${start * 512}`)
}
}
const path = await tmpDir()
$defer.onFailure(rmdir, path)
const mount = options =>
execa('mount', [
`--options=${options.join(',')}`,
`--source=${devicePath}`,
`--target=${path}`,
])
// `norecovery` option is used for ext3/ext4/xfs, if it fails it might be
// another fs, try without
try {
await mount([...options, 'norecovery'])
} catch (error) {
await mount(options)
}
const unmount = async () => {
await execa('umount', ['--lazy', path])
return rmdir(path)
}
$defer.onFailure(unmount)
return { path, unmount }
})
// - [x] list partitions
// - [x] list files in a partition
// - [x] list files in a bare partition
// - [x] list LVM partitions
//
// - [ ] partitions with unmount debounce
// - [ ] handle directory restore
// - [ ] handle multiple entries restore (both dirs and files)
// - [ ] by default use common path as root
// - [ ] handle LVM partitions on multiple disks
// - [ ] find mounted disks/partitions on start (in case of interruptions)
//
// - [ ] manual mount/unmount (of disk) for advance file restore
// - could it stay mounted during the backup process?
// - [ ] mountDisk (VHD)
// - [ ] unmountDisk (only for manual mount)
// - [ ] getMountedDisks
// - [ ] mountPartition (optional)
// - [ ] getMountedPartitions
// - [ ] unmountPartition
export default class BackupNgFileRestore {
constructor (app) {
this._app = app
this._mounts = { __proto__: null }
}
@defer
async fetchBackupNgPartitionFiles (
$defer,
remoteId,
diskId,
partitionId,
paths
) {
const disk = await this._mountDisk(remoteId, diskId)
$defer.onFailure(disk.unmount)
const partition = await this._mountPartition(disk.path, partitionId)
$defer.onFailure(partition.unmount)
const zip = new ZipFile()
paths.forEach(file => {
zip.addFile(resolveSubpath(partition.path, file), normalize('./' + file))
})
zip.end()
return zip.outputStream.on('end', () =>
partition.unmount().then(disk.unmount)
)
}
@defer
async listBackupNgDiskPartitions ($defer, remoteId, diskId) {
const disk = await this._mountDisk(remoteId, diskId)
$defer(disk.unmount)
return this._listPartitions(disk.path)
}
@defer
async listBackupNgPartitionFiles (
$defer,
remoteId,
diskId,
partitionId,
path
) {
const disk = await this._mountDisk(remoteId, diskId)
$defer(disk.unmount)
const partition = await this._mountPartition(disk.path, partitionId)
$defer(partition.unmount)
path = resolveSubpath(partition.path, path)
const entriesMap = {}
await Promise.all(
readdir(path).map(async name => {
try {
const stats = await stat(`${path}/${name}`)
entriesMap[stats.isDirectory() ? `${name}/` : name] = {}
} catch (error) {
if (error == null || error.code !== 'ENOENT') {
throw error
}
}
})
)
return entriesMap
}
async _findPartition (devicePath, partitionId) {
const partitions = await this._listPartitions(devicePath, false)
const partition = partitions.find(_ => _.id === partitionId)
if (partition === undefined) {
throw new Error(`partition ${partitionId} not found`)
}
return partition
}
async _listPartitions (devicePath, inspectLvmPv = true) {
const stdout = await execa.stdout('partx', [
'--bytes',
'--output=NR,START,SIZE,NAME,UUID,TYPE',
'--pairs',
devicePath,
])
const promises = []
const partitions = []
splitLines(stdout).forEach(line => {
const partition = parsePartxLine(line)
let { type } = partition
if (type == null || (type = +type) in IGNORED_PARTITION_TYPES) {
return
}
if (inspectLvmPv && type === 0x8e) {
promises.push(listLvmLogicalVolumes(devicePath, partition, partitions))
return
}
partitions.push(partition)
})
await Promise.all(promises)
return partitions
}
@defer
async _mountDisk ($defer, remoteId, diskId) {
const handler = await this._app.getRemoteHandler(remoteId)
if (handler._getFilePath === undefined) {
throw new Error(`this remote is not supported`)
}
const diskPath = handler._getFilePath(diskId)
const mountDir = await tmpDir()
$defer.onFailure(rmdir, mountDir)
await execa('vhdimount', [diskPath, mountDir])
const unmount = async () => {
await execa('fusermount', ['-uz', mountDir])
return rmdir(mountDir)
}
$defer.onFailure(unmount)
let max = 0
let maxEntry
const entries = await readdir(mountDir)
entries.forEach(entry => {
const matches = RE_VHDI.exec(entry)
if (matches !== null) {
const value = +matches[1]
if (value > max) {
max = value
maxEntry = entry
}
}
})
if (max === 0) {
throw new Error('no disks found')
}
return {
path: `${mountDir}/${maxEntry}`,
unmount,
}
}
@defer
async _mountPartition ($defer, devicePath, partitionId) {
if (partitionId === undefined) {
return mountPartition(devicePath)
}
if (partitionId.includes('/')) {
const [pvId, vgName, lvName] = partitionId.split('/')
const lvmPartition =
pvId !== '' ? await this._findPartition(devicePath, pvId) : undefined
const pv = await mountLvmPhysicalVolume(devicePath, lvmPartition)
const unmountQueue = [pv.unmount]
const unmount = async () => {
let fn
while ((fn = unmountQueue.pop()) !== undefined) {
await fn()
}
}
$defer.onFailure(unmount)
await execa('vgchange', ['-ay', vgName])
unmountQueue.push(() => execa('vgchange', ['-an', vgName]))
const partition = await mountPartition(
(await lvs(['lv_name', 'lv_path'], vgName)).find(
_ => _.lv_name === lvName
).lv_path
)
unmountQueue.push(partition.unmount)
return { ...partition, unmount }
}
return mountPartition(
devicePath,
await this._findPartition(devicePath, partitionId)
)
}
}

View File

@ -225,9 +225,10 @@ export default class Jobs {
runningJobs[id] = runJobId
let session
try {
const app = this._app
const session = app.createUserConnection()
session = app.createUserConnection()
session.set('user_id', job.userId)
const status = await executor({
@ -255,6 +256,9 @@ export default class Jobs {
throw error
} finally {
delete runningJobs[id]
if (session !== undefined) {
session.close()
}
}
}

View File

@ -140,7 +140,11 @@ export default class Xo extends EventEmitter {
}).then(
result => {
if (result != null) {
res.end(JSON.stringify(result))
if (typeof result.pipe === 'function') {
result.pipe(res)
} else {
res.end(JSON.stringify(result))
}
}
},
error => {

View File

@ -5,6 +5,7 @@ import { startsWith } from 'lodash'
import Icon from './icon'
import propTypes from './prop-types-decorator'
import { createGetObject } from './selectors'
import { FormattedDate } from 'react-intl'
import { isSrWritable } from './xo'
import { connectStore, formatSize } from './utils'
@ -203,10 +204,29 @@ const xoItemToRender = {
: group.name_label}
</span>
),
backup: 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>
),
}
const renderXoItem = (item, { className } = {}) => {
const { id, type, label } = item
const renderXoItem = (item, { className, type: xoType } = {}) => {
const { id, label } = item
const type = xoType || item.type
if (item.removed) {
return (
@ -245,6 +265,9 @@ const renderXoItem = (item, { className } = {}) => {
export { renderXoItem as default }
export const getRenderXoItemOfType = type => (item, options = {}) =>
renderXoItem(item, { ...options, type })
const GenericXoItem = connectStore(() => {
const getObject = createGetObject()

View File

@ -1918,6 +1918,22 @@ export const fetchFiles = (remote, disk, partition, paths, format) =>
window.location = `.${url}`
})
// File restore NG ----------------------------------------------------
export const listPartitions = (remote, disk) =>
_call('backupNg.listPartitions', resolveIds({ remote, disk }))
export const listFiles = (remote, disk, path, partition) =>
_call('backupNg.listFiles', resolveIds({ remote, disk, path, partition }))
export const fetchFilesNg = (remote, disk, partition, paths, format) =>
_call(
'backupNg.fetchFiles',
resolveIds({ remote, disk, partition, paths, format })
).then(({ $getFrom: url }) => {
window.location = `.${url}`
})
// -------------------------------------------------------------------
export const probeSrNfs = (host, server) =>

View File

@ -1,8 +1,233 @@
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 { confirm } from 'modal'
import { error } from 'notification'
import { FormattedDate } from 'react-intl'
import {
deleteBackups,
fetchFilesNg as fetchFiles,
listVmBackups,
subscribeRemotes,
} from 'xo'
import {
assign,
filter,
flatMap,
forEach,
keyBy,
map,
reduce,
toArray,
} from 'lodash'
import DeleteBackupsModalBody from '../restore/delete-backups-modal-body'
import RestoreFileModalBody from './restore-file-modal'
// -----------------------------------------------------------------------------
const BACKUPS_COLUMNS = [
{
name: _('backupVmNameColumn'),
itemRenderer: ({ last }) => last.vm.name_label,
sortCriteria: 'last.vm.name_label',
},
{
name: _('backupVmDescriptionColumn'),
itemRenderer: ({ last }) => last.vm.name_description,
sortCriteria: 'last.vm.name_description',
},
{
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)}
month='long'
day='numeric'
year='numeric'
hour='2-digit'
minute='2-digit'
second='2-digit'
/>
),
sortCriteria: 'last.timestamp',
default: true,
sortOrder: 'desc',
},
{
name: _('availableBackupsColumn'),
itemRenderer: ({ count }) => count,
sortCriteria: 'count',
},
]
// -----------------------------------------------------------------------------
@addSubscriptions({
remotes: subscribeRemotes,
})
export default class Restore extends Component {
state = {
backupDataByVm: {},
}
componentWillReceiveProps (props) {
if (props.remotes !== this.props.remotes) {
this._refreshBackupList(props.remotes)
}
}
_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) => {
vmBackups = filter(vmBackups, { mode: 'delta' })
if (vmBackups.length === 0) {
return
}
if (backupDataByVm[vmId] === undefined) {
backupDataByVm[vmId] = { backups: [] }
}
backupDataByVm[vmId].backups.push(
...map(vmBackups, bkp => ({ ...bkp, remote }))
)
})
})
let first, last
forEach(backupDataByVm, (data, vmId) => {
first = { timestamp: Infinity }
last = { timestamp: 0 }
let count = 0 // Number since there's only 1 mode in file restore
forEach(data.backups, backup => {
if (backup.timestamp > last.timestamp) {
last = backup
}
if (backup.timestamp < first.timestamp) {
first = backup
}
count++
})
assign(data, { first, last, count, id: vmId })
})
this.setState({ backupDataByVm })
}
// Actions -------------------------------------------------------------------
_restore = ({ backups, last }) =>
confirm({
title: _('restoreFilesFromBackup', { name: last.vm.name_label }),
body: (
<RestoreFileModalBody vmName={last.vm.name_label} backups={backups} />
),
}).then(({ remote, disk, partition, paths, format }) => {
if (
remote === undefined ||
disk === undefined ||
paths === undefined ||
paths.length === 0
) {
return error(_('restoreFiles'), _('restoreFilesError'))
}
return fetchFiles(remote, disk, partition, paths, format)
}, noop)
_delete = data =>
confirm({
title: _('deleteVmBackupsTitle', { vm: data.last.vm.name_label }),
body: <DeleteBackupsModalBody backups={data.backups} />,
icon: 'delete',
})
.then(deleteBackups, 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) => sum + data.backups.length, 0),
},
},
})
.then(() => deleteBackups(flatMap(datas, 'backups')), noop)
.then(() => this._refreshBackupList())
// ---------------------------------------------------------------------------
_actions = [
{
handler: this._bulkDelete,
icon: 'delete',
individualHandler: this._delete,
label: _('deleteVmBackups'),
level: 'danger',
},
]
_individualActions = [
{
handler: this._restore,
icon: 'restore',
label: _('restoreVmBackups'),
level: 'primary',
},
]
export default class FileRestore extends Component {
render () {
return <p className='text-danger'>Available soon</p>
return (
<Upgrade place='restoreBackup' available={2}>
<div>
<div className='mb-1'>
<ActionButton
btnStyle='primary'
handler={this._refreshBackupList}
icon='refresh'
>
{_('restoreResfreshList')}
</ActionButton>
</div>
<em>
<Icon icon='info' /> {_('restoreDeltaBackupsInfo')}
</em>
<SortedTable
actions={this._actions}
collection={this.state.backupDataByVm}
columns={BACKUPS_COLUMNS}
individualActions={this._individualActions}
/>
</div>
</Upgrade>
)
}
}

View File

@ -0,0 +1,379 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import endsWith from 'lodash/endsWith'
import Icon from 'icon'
import React from 'react'
import replace from 'lodash/replace'
import Select from 'form/select'
import Tooltip from 'tooltip'
import { Container, Col, Row } from 'grid'
import { createSelector } from 'reselect'
import { formatSize } from 'utils'
import { filter, includes, isEmpty, map } from 'lodash'
import { getRenderXoItemOfType } from 'render-xo-item'
import { listPartitions, listFiles } from 'xo'
const BACKUP_RENDERER = getRenderXoItemOfType('backup')
const partitionOptionRenderer = partition => (
<span>
{partition.name} {partition.type}{' '}
{partition.size && `(${formatSize(+partition.size)})`}
</span>
)
const diskOptionRenderer = disk => <span>{disk.name}</span>
const fileOptionRenderer = file => <span>{file.name}</span>
const formatFilesOptions = (rawFiles, path) => {
const files =
path !== '/'
? [
{
name: '..',
id: '..',
path: getParentPath(path),
content: {},
},
]
: []
return files.concat(
map(rawFiles, (file, name) => ({
name,
id: `${path}${name}`,
path: `${path}${name}`,
content: file,
}))
)
}
const getParentPath = path => replace(path, /^(\/+.+)*(\/+.+)/, '$1/')
// -----------------------------------------------------------------------------
export default class RestoreFileModalBody extends Component {
state = {
format: 'zip',
}
get value () {
const { state } = this
return {
disk: state.disk,
format: state.format,
partition: state.partition,
paths: state.selectedFiles && map(state.selectedFiles, 'path'),
remote: state.backup.remote.id,
}
}
_listFiles = () => {
const { backup, disk, partition, path } = this.state
this.setState({ scanningFiles: true })
return listFiles(backup.remote.id, disk, path, partition).then(
rawFiles =>
this.setState({
files: formatFilesOptions(rawFiles, path),
scanningFiles: false,
listFilesError: false,
}),
error => {
this.setState({
scanningFiles: false,
listFilesError: true,
})
throw error
}
)
}
_getSelectableFiles = createSelector(
() => this.state.files,
() => this.state.selectedFiles,
(available, selected) =>
filter(available, file => !includes(selected, file))
)
_onBackupChange = backup => {
this.setState({
backup,
disk: undefined,
partition: undefined,
file: undefined,
selectedFiles: undefined,
scanDiskError: false,
listFilesError: false,
})
}
_onDiskChange = disk => {
this.setState({
partition: undefined,
file: undefined,
selectedFiles: undefined,
scanDiskError: false,
listFilesError: false,
})
if (!disk) {
return
}
listPartitions(this.state.backup.remote.id, disk).then(
partitions => {
if (isEmpty(partitions)) {
this.setState(
{
disk,
path: '/',
},
this._listFiles
)
return
}
this.setState({
disk,
partitions,
})
},
error => {
this.setState({
disk,
scanDiskError: true,
})
throw error
}
)
}
_onPartitionChange = partition => {
this.setState(
{
partition,
path: '/',
file: undefined,
selectedFiles: undefined,
},
partition && this._listFiles
)
}
_onFileChange = file => {
if (file == null) {
return
}
// Ugly workaround to keep the ReactSelect open after selecting a folder
// FIXME: Remove once something better is implemented in react-select:
// https://github.com/JedWatson/react-select/issues/1989
const select = document.activeElement
select.blur()
select.focus()
const isFile = file.id !== '..' && !endsWith(file.path, '/')
if (isFile) {
const { selectedFiles } = this.state
if (!includes(selectedFiles, file)) {
this.setState({
selectedFiles: (selectedFiles || []).concat(file),
})
}
} else {
this.setState(
{
path: file.id === '..' ? getParentPath(this.state.path) : file.path,
},
this._listFiles
)
}
}
_unselectFile = file => {
this.setState({
selectedFiles: filter(
this.state.selectedFiles,
({ id }) => id !== file.id
),
})
}
_unselectAllFiles = () => {
this.setState({
selectedFiles: undefined,
})
}
_selectAllFolderFiles = () => {
this.setState({
selectedFiles: (this.state.selectedFiles || []).concat(
filter(this._getSelectableFiles(), ({ path }) => !endsWith(path, '/'))
),
})
}
// ---------------------------------------------------------------------------
render () {
const { backups } = this.props
const {
backup,
disk,
format,
partition,
partitions,
path,
scanDiskError,
listFilesError,
scanningFiles,
selectedFiles,
} = this.state
const noPartitions = isEmpty(partitions)
return (
<div>
<Select
labelKey='name'
onChange={this._onBackupChange}
optionRenderer={BACKUP_RENDERER}
options={backups}
placeholder={_('restoreFilesSelectBackup')}
value={backup}
valueKey='id'
/>
{backup && [
<br />,
<Select
labelKey='name'
onChange={this._onDiskChange}
optionRenderer={diskOptionRenderer}
options={backup.disks}
placeholder={_('restoreFilesSelectDisk')}
value={disk}
valueKey='id'
/>,
]}
{scanDiskError && (
<span>
<Icon icon='error' /> {_('restoreFilesDiskError')}
</span>
)}
{disk &&
!scanDiskError &&
!noPartitions && [
<br />,
<Select
labelKey='name'
onChange={this._onPartitionChange}
optionRenderer={partitionOptionRenderer}
options={partitions}
placeholder={_('restoreFilesSelectPartition')}
value={partition}
valueKey='id'
/>,
]}
{(partition || (disk && !scanDiskError && noPartitions)) && [
<br />,
<Container>
<Row>
<Col size={10}>
<pre>
{path} {scanningFiles && <Icon icon='loading' />}
{listFilesError && <Icon icon='error' />}
</pre>
</Col>
<Col size={2}>
<span className='pull-right'>
<Tooltip content={_('restoreFilesSelectAllFiles')}>
<ActionButton
handler={this._selectAllFolderFiles}
icon='add'
size='small'
/>
</Tooltip>
</span>
</Col>
</Row>
</Container>,
<Select
labelKey='name'
onChange={this._onFileChange}
optionRenderer={fileOptionRenderer}
options={this._getSelectableFiles()}
placeholder={_('restoreFilesSelectFiles')}
value={null}
valueKey='id'
/>,
<br />,
<div>
<span className='mr-1'>
<input
checked={format === 'zip'}
name='format'
onChange={this.linkState('format')}
type='radio'
value='zip'
/>{' '}
ZIP
</span>
<span>
<input
checked={format === 'tar'}
name='format'
onChange={this.linkState('format')}
type='radio'
value='tar'
/>{' '}
TAR
</span>
</div>,
<br />,
selectedFiles && selectedFiles.length ? (
<Container>
<Row>
<Col className='pl-0 pb-1' size={10}>
<em>
{_('restoreFilesSelectedFiles', {
files: selectedFiles.length,
})}
</em>
</Col>
<Col size={2} className='text-xs-right'>
<ActionButton
handler={this._unselectAllFiles}
icon='remove'
size='small'
tooltip={_('restoreFilesUnselectAll')}
/>
</Col>
</Row>
{map(selectedFiles, file => (
<Row key={file.id}>
<Col size={10}>
<pre>{file.path}</pre>
</Col>
<Col size={2} className='text-xs-right'>
<ActionButton
handler={this._unselectFile}
handlerParam={file}
icon='remove'
size='small'
/>
</Col>
</Row>
))}
</Container>
) : (
<em>{_('restoreFilesNoFilesSelected')}</em>
),
]}
</div>
)
}
}

View File

@ -184,7 +184,7 @@ const HEADER = (
</Col>
<Col mediumSize={9}>
<NavTabs className='pull-right'>
<NavLink exact to='/backup-ng'>
<NavLink exact to='/backup-ng/overview'>
<Icon icon='menu-backup-overview' /> {_('backupOverviewPage')}
</NavLink>
<NavLink to='/backup-ng/new'>
@ -203,9 +203,10 @@ const HEADER = (
</Container>
)
export default routes(Overview, {
export default routes('overview', {
':id/edit': Edit,
new: New,
overview: Overview,
restore: Restore,
'file-restore': FileRestore,
})(({ children }) => (

View File

@ -2,10 +2,12 @@ import _ from 'intl'
import classNames from 'classnames'
import Component from 'base-component'
import React from 'react'
import { FormattedDate } from 'react-intl'
import { forEach, map, orderBy } from 'lodash'
import { createFilter, createSelector } from 'selectors'
import { Toggle } from 'form'
import { getRenderXoItemOfType } from 'render-xo-item'
const BACKUP_RENDERER = getRenderXoItemOfType('backup')
const _escapeDot = id => id.replace('.', '\0')
@ -61,22 +63,7 @@ export default class DeleteBackupsModalBody extends Component {
onClick={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'
/>
{BACKUP_RENDERER(backup)}
</button>
))}
</div>

View File

@ -2,9 +2,11 @@ import _ from 'intl'
import React from 'react'
import Component from 'base-component'
import StateButton from 'state-button'
import { getRenderXoItemOfType } from 'render-xo-item'
import { Select, Toggle } from 'form'
import { SelectSr } from 'select-objects'
import { FormattedDate } from 'react-intl'
const BACKUP_RENDERER = getRenderXoItemOfType('backup')
export default class RestoreBackupsModalBody extends Component {
get value () {
@ -15,26 +17,7 @@ export default class RestoreBackupsModalBody extends Component {
<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>
)}
optionRenderer={BACKUP_RENDERER}
options={this.props.data.backups}
onChange={this.linkState('backup')}
placeholder={_('importBackupModalSelectBackup')}

View File

@ -203,9 +203,31 @@ export default class Menu extends Component {
],
},
isAdmin && {
to: '/backup-ng',
to: '/backup-ng/overview',
icon: 'menu-backup',
label: ['Backup NG'],
label: <span>Backup NG</span>,
subMenu: [
{
to: '/backup-ng/overview',
icon: 'menu-backup-overview',
label: 'backupOverviewPage',
},
{
to: '/backup-ng/new',
icon: 'menu-backup-new',
label: 'backupNewPage',
},
{
to: '/backup-ng/restore',
icon: 'menu-backup-restore',
label: 'backupRestorePage',
},
{
to: '/backup-ng/file-restore',
icon: 'menu-backup-file-restore',
label: 'backupFileRestorePage',
},
],
},
isAdmin && {
to: 'xoa/update',

View File

@ -2566,7 +2566,7 @@ buffer-alloc-unsafe@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.0.0.tgz#474aa88f34e7bc75fa311d2e6457409c5846c3fe"
buffer-crc32@^0.2.1, buffer-crc32@^0.2.13:
buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3:
version "0.2.13"
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
@ -12011,6 +12011,12 @@ yargs@~3.10.0:
decamelize "^1.0.0"
window-size "0.1.0"
yazl@^2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/yazl/-/yazl-2.4.3.tgz#ec26e5cc87d5601b9df8432dbdd3cd2e5173a071"
dependencies:
buffer-crc32 "~0.2.3"
zip-stream@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-1.2.0.tgz#a8bc45f4c1b49699c6b90198baacaacdbcd4ba04"