@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
280
packages/xo-web/src/xo-app/backup-ng/restore/metadata.js
Normal file
280
packages/xo-web/src/xo-app/backup-ng/restore/metadata.js
Normal 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>
|
||||
),
|
||||
])
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
145
packages/xo-web/src/xo-app/logs/restore-metadata.js
Normal file
145
packages/xo-web/src/xo-app/logs/restore-metadata.js
Normal 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>
|
||||
),
|
||||
])
|
||||
Reference in New Issue
Block a user