feat(xo-web): ability to restore a metadata backup (#4023)

Fixes #4004
This commit is contained in:
badrAZ
2019-03-29 13:54:54 +01:00
committed by Pierre Donias
parent ab34743250
commit b301997d4b
10 changed files with 613 additions and 4 deletions

View File

@@ -12,6 +12,7 @@
- [Import] Change wording of drop zone (PR [#4020](https://github.com/vatesfr/xen-orchestra/pull/4020))
- [Backup NG] Ability to set the interval of the full backups [#1783](https://github.com/vatesfr/xen-orchestra/issues/1783) (PR [#4083](https://github.com/vatesfr/xen-orchestra/pull/4083))
- [Hosts] Display a warning icon if you have XenServer license restrictions [#4091](https://github.com/vatesfr/xen-orchestra/issues/4091) (PR [#4094](https://github.com/vatesfr/xen-orchestra/pull/4094))
- [Restore] Ability to restore a metadata backup [#4004](https://github.com/vatesfr/xen-orchestra/issues/4004) (PR [#4023](https://github.com/vatesfr/xen-orchestra/pull/4023))
### Bug fixes

View File

@@ -39,6 +39,13 @@ const messages = {
hasInactivePath: 'Has an inactive path',
pools: 'Pools',
remotes: 'Remotes',
type: 'Type',
restore: 'Restore',
delete: 'Delete',
vms: 'VMs',
metadata: 'Metadata',
chooseBackup: 'Choose a backup',
clickToShowError: 'Click to show error',
// ----- Modals -----
alertOk: 'OK',
@@ -1434,6 +1441,7 @@ const messages = {
simpleBackup: 'simple',
delta: 'delta',
restoreBackups: 'Restore Backups',
noBackups: 'There are no backups!',
restoreBackupsInfo: 'Click on a VM to display restore options',
restoreDeltaBackupsInfo:
'Only the files of Delta Backup which are not on a SMB remote can be restored',
@@ -1474,10 +1482,16 @@ const messages = {
restoreVmBackupsStart:
'Start VM{nVms, plural, one {} other {s}} after restore',
restoreVmBackupsBulkErrorTitle: 'Multi-restore error',
restoreMetadataBackupTitle: 'Restore {item}',
bulkRestoreMetadataBackupTitle:
'Restore {nMetadataBackups, number} metadata backup{nMetadataBackups, plural, one {} other {s}}',
bulkRestoreMetadataBackupMessage:
'Restore {nMetadataBackups, number} metadata backup{nMetadataBackups, plural, one {} other {s}} from {nMetadataBackups, plural, one {its} other {their}} {oldestOrLatest} backup',
deleteMetadataBackupTitle: 'Delete {item} backup',
restoreVmBackupsBulkErrorMessage: 'You need to select a destination SR',
deleteVmBackups: 'Delete backups…',
deleteVmBackupsTitle: 'Delete {vm} backups',
deleteVmBackupsSelect: 'Select backups to delete:',
deleteBackupsSelect: 'Select backups to delete:',
deleteVmBackupsSelectAll: 'All',
deleteVmBackupsBulkTitle: 'Delete backups',
deleteVmBackupsBulkMessage:
@@ -1485,6 +1499,11 @@ const messages = {
deleteVmBackupsBulkConfirmText:
'delete {nBackups} backup{nBackups, plural, one {} other {s}}',
unknownJob: 'Unknown job',
bulkDeleteMetadataBackupsTitle: 'Delete metadata backups',
bulkDeleteMetadataBackupsMessage:
'Are you sure you want to delete all the backups from {nMetadataBackups, number} metadata backup{nMetadataBackups, plural, one {} other {s}}?',
bulkDeleteMetadataBackupsConfirmText:
'delete {nMetadataBackups} metadata backup{nMetadataBackups, plural, one {} other {s}}',
// ----- Restore files view -----
listRemoteBackups: 'List remote backups',
@@ -1919,6 +1938,7 @@ const messages = {
logsJobName: 'Job name',
logsBackupTime: 'Backup time',
logsRestoreTime: 'Restore time',
copyLogToClipboard: 'Copy log to clipboard',
logsVmNotFound: 'VM not found!',
logsMissingVms: 'Missing VMs skipped ({ vms })',
logsFailedRestoreError: 'Click to show error',

View File

@@ -2024,6 +2024,27 @@ export const editMetadataBackupJob = props =>
export const runMetadataBackupJob = params =>
_call('metadataBackup.runJob', params)
export const listMetadataBackups = remotes =>
_call('metadataBackup.list', { remotes: resolveIds(remotes) })
export const restoreMetadataBackup = backup =>
_call('metadataBackup.restore', {
id: resolveId(backup),
})::tap(subscribeBackupNgLogs.forceRefresh)
export const deleteMetadataBackup = backup =>
_call('metadataBackup.delete', {
id: resolveId(backup),
})
export const deleteMetadataBackups = async (backups = []) => {
// delete sequentially from newest to oldest
backups = backups.slice().sort((b1, b2) => b2.timestamp - b1.timestamp)
for (let i = 0, n = backups.length; i < n; ++i) {
await deleteMetadataBackup(backups[i])
}
}
// Plugins -----------------------------------------------------------
export const loadPlugin = async id =>

View File

@@ -42,7 +42,7 @@ import FileRestore from './file-restore'
import getSettingsWithNonDefaultValue from './_getSettingsWithNonDefaultValue'
import Health from './health'
import NewVmBackup, { NewMetadataBackup } from './new'
import Restore from './restore'
import Restore, { RestoreMetadata } from './restore'
import { destructPattern } from './utils'
const Ul = props => <ul {...props} style={{ listStyleType: 'none' }} />
@@ -427,6 +427,7 @@ export default routes('overview', {
'new/metadata': NewMetadataBackup,
overview: Overview,
restore: Restore,
'restore/metadata': RestoreMetadata,
'file-restore': FileRestore,
health: Health,
})(({ children }) => (

View File

@@ -49,7 +49,7 @@ export default class DeleteBackupsModalBody extends Component {
render() {
return (
<div>
<div>{_('deleteVmBackupsSelect')}</div>
<div>{_('deleteBackupsSelect')}</div>
<div className='list-group'>
{map(this._getBackups(), backup => (
<button

View File

@@ -0,0 +1,52 @@
import _ from 'intl'
import Component from 'base-component'
import PropTypes from 'prop-types'
import React from 'react'
import SingleLineRow from 'single-line-row'
import { Container, Col } from 'grid'
import { FormattedDate } from 'react-intl'
import { Select } from 'form'
export default class DeleteMetadataBackupModalBody extends Component {
static propTypes = {
backups: PropTypes.array,
}
get value() {
return this.state.backups
}
_optionRenderer = ({ timestamp }) => (
<FormattedDate
value={new Date(timestamp)}
month='long'
day='numeric'
year='numeric'
hour='2-digit'
minute='2-digit'
second='2-digit'
/>
)
render() {
return (
<Container>
<SingleLineRow>
<Col size={6}>{_('deleteBackupsSelect')}</Col>
<Col size={6}>
<Select
labelKey='timestamp'
multi
onChange={this.linkState('backups')}
optionRenderer={this._optionRenderer}
options={this.props.backups}
required
value={this.state.backups}
valueKey='id'
/>
</Col>
</SingleLineRow>
</Container>
)
}
}

View File

@@ -1,6 +1,8 @@
import _ from 'intl'
import ActionButton from 'action-button'
import ButtonLink from 'button-link'
import Component from 'base-component'
import Icon from 'icon'
import React from 'react'
import SortedTable from 'sorted-table'
import Upgrade from 'xoa-upgrade'
@@ -37,6 +39,8 @@ import RestoreLegacy from '../restore-legacy'
import Logs from '../../logs/restore'
export RestoreMetadata from './metadata'
// -----------------------------------------------------------------------------
const BACKUPS_COLUMNS = [
@@ -265,7 +269,10 @@ export default class Restore extends Component {
icon='refresh'
>
{_('restoreResfreshList')}
</ActionButton>
</ActionButton>{' '}
<ButtonLink to='backup-ng/restore/metadata'>
<Icon icon='database' /> {_('metadata')}
</ButtonLink>
</div>
<SortedTable
actions={this._actions}

View File

@@ -0,0 +1,280 @@
import _ from 'intl'
import addSubscriptions from 'add-subscriptions'
import ButtonLink from 'button-link'
import Copiable from 'copiable'
import decorate from 'apply-decorators'
import Icon from 'icon'
import NoObjects from 'no-objects'
import React from 'react'
import SortedTable from 'sorted-table'
import Upgrade from 'xoa-upgrade'
import { confirm } from 'modal'
import { error } from 'notification'
import { flatMap, forOwn, reduce, toArray } from 'lodash'
import { FormattedDate } from 'react-intl'
import { injectState, provideState } from 'reaclette'
import { noop } from 'utils'
import {
deleteMetadataBackups,
listMetadataBackups,
restoreMetadataBackup,
subscribeRemotes,
} from 'xo'
import Logs from '../../logs/restore-metadata'
import DeleteMetadataBackupModalBody from './delete-metadata-backups-modal-body'
import RestoreMetadataBackupModalBody, {
RestoreMetadataBackupsBulkModalBody,
} from './restore-metadata-backups-modal-body'
// Actions -------------------------------------------------------------------
const restore = entry =>
confirm({
title: _('restoreMetadataBackupTitle', {
item: `${entry.type} (${entry.label})`,
}),
body: <RestoreMetadataBackupModalBody backups={entry.backups} />,
icon: 'restore',
}).then(backup => {
if (backup === undefined) {
error(_('backupRestoreErrorTitle'), _('chooseBackup'))
return
}
return restoreMetadataBackup(backup)
}, noop)
const bulkRestore = entries => {
const nMetadataBackups = entries.length
return confirm({
title: _('bulkRestoreMetadataBackupTitle', { nMetadataBackups }),
body: (
<RestoreMetadataBackupsBulkModalBody
nMetadataBackups={nMetadataBackups}
/>
),
icon: 'restore',
}).then(
latest =>
Promise.all(
entries.map(({ first, last }) =>
restoreMetadataBackup(latest ? last : first)
)
),
noop
)
}
const delete_ = entry =>
confirm({
title: _('deleteMetadataBackupTitle', {
item: `${entry.type} (${entry.label})`,
}),
body: <DeleteMetadataBackupModalBody backups={entry.backups} />,
icon: 'delete',
}).then(deleteMetadataBackups, noop)
const bulkDelete = entries => {
confirm({
title: _('bulkDeleteMetadataBackupsTitle'),
body: (
<p>
{_('bulkDeleteMetadataBackupsMessage', {
nMetadataBackups: entries.length,
})}
</p>
),
icon: 'delete',
strongConfirm: {
messageId: 'bulkDeleteMetadataBackupsConfirmText',
values: {
nMetadataBackups: reduce(
entries,
(sum, entry) => sum + entry.backups.length,
0
),
},
},
}).then(
() => deleteMetadataBackups(flatMap(entries, ({ backups }) => backups)),
noop
)
}
// ---------------------------------------------------------------------------
const ACTIONS = [
{
handler: bulkRestore,
icon: 'restore',
individualHandler: restore,
label: _('restore'),
level: 'primary',
},
{
handler: bulkDelete,
icon: 'delete',
individualHandler: delete_,
label: _('delete'),
level: 'danger',
},
]
const COLUMNS = [
{
name: _('type'),
valuePath: 'type',
},
{
name: _('item'),
itemRenderer: ({ item }) => item,
sortCriteria: 'id',
},
{
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',
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',
default: true,
sortOrder: 'desc',
},
{
name: _('availableBackupsColumn'),
valuePath: 'available',
},
]
export default decorate([
addSubscriptions({
remotes: cb =>
subscribeRemotes(remotes => {
cb(toArray(remotes))
}),
}),
provideState({
computed: {
// {
// [jobId | poolId]: {
// available: Number,
// backups: Array,
// first: Number,
// id: jobId | poolId, // required by the SortedTable
// item: Node | String,
// label: String,
// last: Number,
// type: 'XO' | 'pool',
// }
// }
async backups(_, { remotes = [] }) {
if (remotes.length === 0) {
return
}
const { xo: xoType, pool: poolType } = await listMetadataBackups(
remotes
)
const collection = {}
forOwn(xoType, entries =>
entries.forEach(entry => {
const { jobName, jobId } = entry
let backup = collection[jobId]
if (backup === undefined) {
backup = collection[jobId] = {
backups: [],
id: jobId,
item: `Xen Orchestra (${jobName})`,
label: jobName,
type: 'XO',
}
}
backup.backups.push(entry)
})
)
forOwn(poolType, entriesByPool =>
forOwn(entriesByPool, (poolEntry, poolId) => {
let backup = collection[poolId]
if (backup === undefined) {
const { pool, poolMaster } = poolEntry[0]
const label = pool.name_label || poolMaster.name_label
backup = collection[poolId] = {
backups: [],
id: poolId,
item: (
<Copiable data={poolId} tagName='p'>
{label || poolId}
</Copiable>
),
label,
type: 'pool',
}
}
poolEntry.forEach(entry => {
backup.backups.push(entry)
})
})
)
forOwn(collection, entry => {
const backups = entry.backups
const size = backups.length
backups.sort((a, b) => a.timestamp - b.timestamp)
entry.first = backups[0]
entry.last = backups[size - 1]
entry.available = size
})
return collection
},
},
}),
injectState,
({ state, effects }) => (
<Upgrade place='restoreMetadataBackup' available={3}>
<div>
<div className='mb-1'>
<ButtonLink to='backup-ng/restore'>
<Icon icon='backup' /> {_('vms')}
</ButtonLink>
</div>
<NoObjects
actions={ACTIONS}
collection={state.backups}
columns={COLUMNS}
component={SortedTable}
emptyMessage={_('noBackups')}
/>
<br />
<Logs />
</div>
</Upgrade>
),
])

View File

@@ -0,0 +1,82 @@
import _ from 'intl'
import Component from 'base-component'
import PropTypes from 'prop-types'
import React from 'react'
import SingleLineRow from 'single-line-row'
import StateButton from 'state-button'
import { Container, Col } from 'grid'
import { FormattedDate } from 'react-intl'
import { Select } from 'form'
export default class RestoreMetadataBackupModalBody extends Component {
static propTypes = {
backups: PropTypes.array,
}
get value() {
return this.state.backup
}
_optionRenderer = ({ timestamp }) => (
<FormattedDate
value={new Date(timestamp)}
month='long'
day='numeric'
year='numeric'
hour='2-digit'
minute='2-digit'
second='2-digit'
/>
)
render() {
return (
<Container>
<SingleLineRow>
<Col size={6}>{_('chooseBackup')}</Col>
<Col size={6}>
<Select
labelKey='timestamp'
onChange={this.linkState('backup')}
optionRenderer={this._optionRenderer}
options={this.props.backups}
required
value={this.state.backup}
valueKey='id'
/>
</Col>
</SingleLineRow>
</Container>
)
}
}
export class RestoreMetadataBackupsBulkModalBody extends Component {
static propTypes = {
nMetadataBackups: PropTypes.number,
}
state = { latest: true }
get value() {
return this.state.latest
}
render() {
return (
<div>
{_('bulkRestoreMetadataBackupMessage', {
nMetadataBackups: this.props.nMetadataBackups,
oldestOrLatest: (
<StateButton
disabledLabel={_('oldest')}
enabledLabel={_('latest')}
handler={this.toggleState('latest')}
state={this.state.latest}
/>
),
})}
</div>
)
}
}

View File

@@ -0,0 +1,145 @@
import _, { FormattedDuration } from 'intl'
import ActionButton from 'action-button'
import addSubscriptions from 'add-subscriptions'
import Copiable from 'copiable'
import copy from 'copy-to-clipboard'
import decorate from 'apply-decorators'
import Icon from 'icon'
import NoObjects from 'no-objects'
import React from 'react'
import SortedTable from 'sorted-table'
import { alert } from 'modal'
import { Card, CardHeader, CardBlock } from 'card'
import { connectStore, downloadLog } from 'utils'
import { createGetObjectsOfType } from 'selectors'
import { filter } from 'lodash'
import { Pool } from 'render-xo-item'
import { subscribeBackupNgLogs } from 'xo'
import { STATUS_LABELS, LOG_FILTERS, LogDate } from './utils'
const showError = error =>
alert(
_('logError'),
<pre>{JSON.stringify(error, null, 2).replace(/\\n/g, '\n')}</pre>
)
const COLUMNS = [
{
name: _('job'),
itemRenderer: ({ data }) => (
<Copiable data={data.jobId} tagName='div'>
{data.jobName || data.jobId.slice(4, 8)}
</Copiable>
),
sortCriteria: 'data.jobId',
},
{
name: _('item'),
itemRenderer: ({ data }, { pools }) =>
data.pool === undefined ? (
'Xen Orchestra'
) : pools[data.pool.uuid] !== undefined ? (
<Pool id={data.pool.uuid} link newTab />
) : (
<Copiable data={data.pool.uuid} tagName='div'>
{data.pool.name_label || data.poolMaster.name_label}
</Copiable>
),
sortCriteria: ({ data }) =>
data.pool !== undefined ? data.pool.uuid : data.jobId,
},
{
name: _('logsBackupTime'),
itemRenderer: ({ data: { timestamp } }) => <LogDate time={timestamp} />,
sortCriteria: 'data.timestamp',
},
{
default: true,
name: _('logsRestoreTime'),
itemRenderer: task => <LogDate time={task.start} />,
sortCriteria: 'start',
sortOrder: 'desc',
},
{
name: _('jobDuration'),
itemRenderer: task =>
task.end !== undefined && (
<FormattedDuration duration={task.end - task.start} />
),
sortCriteria: task => task.end - task.start,
},
{
name: _('jobStatus'),
itemRenderer: task => {
const { className, label } = STATUS_LABELS[task.status]
// failed task
if (task.status !== 'success' && task.status !== 'pending') {
return (
<ActionButton
btnStyle={className}
handler={showError}
handlerParam={task.result}
icon='preview'
size='small'
tooltip={_('clickToShowError')}
>
{_(label)}
</ActionButton>
)
}
return <span className={`tag tag-${className}`}>{_(label)}</span>
},
sortCriteria: 'status',
},
]
const INDIVIDUAL_ACTIONS = [
{
icon: 'download',
label: _('logDownload'),
handler: task =>
downloadLog({
log: JSON.stringify(task, null, 2),
date: task.start,
type: 'Metadata restore',
}),
},
{
icon: 'clipboard',
label: _('copyLogToClipboard'),
handler: task => copy(JSON.stringify(task, null, 2)),
},
]
export default decorate([
connectStore({
pools: createGetObjectsOfType('pool'),
}),
addSubscriptions({
logs: cb =>
subscribeBackupNgLogs(logs =>
cb(logs && filter(logs, log => log.message === 'metadataRestore'))
),
}),
({ logs, pools }) => (
<Card>
<CardHeader>
<Icon icon='logs' /> {_('logTitle')}
</CardHeader>
<CardBlock>
<NoObjects
collection={logs}
columns={COLUMNS}
component={SortedTable}
data-pools={pools}
emptyMessage={_('noLogs')}
filters={LOG_FILTERS}
individualActions={INDIVIDUAL_ACTIONS}
/>
</CardBlock>
</Card>
),
])