Compare commits

...

30 Commits

Author SHA1 Message Date
Julien Fontanet
794c1cf89b feat(vhd-lib): 0.2.0 2018-06-28 14:10:08 +02:00
Julien Fontanet
9a5eea6e78 chore(CHANGELOG): 5.21.0 2018-06-28 13:58:44 +02:00
badrAZ
40568cd61f feat(xo-web/backup-ng/logs): copy full log and report a failed job (#3110)
Fixes #3100
2018-06-28 10:57:35 +02:00
Pierre Donias
358e1441cc fix(xo-server/pool.mergeInto): fail when product brands are different (#3118)
Fixes #3061
2018-06-28 10:10:55 +02:00
badrAZ
be930e127e fix(xo-web/vm/snapshots): creation from snapshot (#3117) 2018-06-28 10:03:19 +02:00
badrAZ
3656e83df5 feat(backup-ng/logs): add the job's name to the modal's title (#3115)
See #2711
2018-06-27 17:43:06 +02:00
badrAZ
abbb0450f8 feat(xo-web/backup-ng/logs): remove Mode column (#3116)
See #2711
2018-06-27 17:41:03 +02:00
Pierre Donias
8e4beeb00f feat(xo-web/backup-ng/health): legacy snapshots table (#3111)
Fixes #3082
2018-06-27 17:25:52 +02:00
Julien Fontanet
05d10ef985 feat(xo-web/settings/remotes): type defaults to NFS (#3114)
Fixes #3103
2018-06-27 17:04:55 +02:00
Pierre Donias
989d27154d fix(xo-web/backup/health): missing schedule subscription (#3104)
Also, move the table from `Backup/Health` to `Backup NG/Health`.
2018-06-26 16:12:29 +02:00
Pierre Donias
ec9957bd86 feat(xo-server,xo-web/tasks): disable cancel/destroy when not relevant (#3109)
* feat(xo-server,xo-web/tasks): disable cancel/destroy when not relevant

Fixes #3076

* cancellable/destroyable → allowedOperations

* Update index.js

* Update index.js
2018-06-26 15:40:53 +02:00
Julien Fontanet
dc8a7c46e0 feat(xo-server): dont create default remote (#3107)
Related to #3105
2018-06-26 14:47:24 +02:00
Pierre Donias
9ee2d8e0c2 feat(xo-web): new backup/health view (#3102)
Fixes #3090

Move "VM snapshots related to non-existent backups" table
from dashboard/health to backup/health

### Check list

> Check items when done or if not relevant

- [x] PR reference the relevant issue (e.g. `Fixes #007`)
- [x] if UI changes, a screenshot has been added to the PR
- [x] CHANGELOG updated
- [x] documentation updated

### Process

1. create a PR as soon as possible
1. mark it as `WiP:` (Work in Progress) if not ready to be merged
1. when you want a review, add a reviewer
1. if necessary, update your PR, and readd a reviewer

### List of packages to release

> No need to mention xo-server and xo-web.

### Screenshots

![capture_2018-06-25_12 18 01](https://user-images.githubusercontent.com/10992860/41858496-56af27d0-789a-11e8-9f86-e3ce1ac28e54.png)
2018-06-25 17:59:36 +02:00
badrAZ
6c62d6840a fix(backup-ng/new): "new-schedules" dont exist anymore (#3098) 2018-06-25 14:02:10 +02:00
badrAZ
2a2135ac71 fix(xo-server-load-balancer): make the metric "memoryFree" optional (#3073) 2018-06-22 19:00:27 +02:00
badrAZ
efaad2efb2 feat(backup-ng/new): ability to enable/disable a schedule (#3094)
Fixes #3062
2018-06-22 17:25:31 +02:00
badrAZ
3b244c24d7 feat(backup-ng/new): group saved & new schedules (#3093)
See #2711
2018-06-22 16:23:12 +02:00
badrAZ
915052d5f6 feat(backup NG): ability to cancel a running job (#3053)
Fixes #3047
2018-06-22 09:10:10 +02:00
Pierre Donias
05c6c7830d feat(xo-web/backups): add blog link to deprecation message (#3092)
Fixes #3089
2018-06-21 16:48:28 +02:00
badrAZ
0217c51559 fix(backup-ng/new): only list writable SRs for CR/DR (#3085)
Fixes #3050
2018-06-21 15:41:16 +02:00
badrAZ
0c514198bb feat: ability to save cloud configs (#3054)
Fixes #2984
2018-06-21 15:02:52 +02:00
Pierre Donias
0e68834b4c chore(xo-web): multiple minor fixes (#3091) 2018-06-21 12:10:38 +02:00
Julien Fontanet
ee99ef6264 feat(vhd-lib): createContentStream (#3086)
Export the raw content of the VHD as a stream.

This features is exposed in CLI: `vhd-cli raw input.vhd output.raw`

Related to #3083

Perf comparison between qemu-img and our vhd-cli to convert a 10GiB VHD file to raw:

```
> time qemu-img convert -f vpc -O raw origin.vhd expected.raw
1.40user 15.19system 1:01.88elapsed 26%CPU (0avgtext+0avgdata 24264maxresident)k
20979008inputs+20971520outputs (12major+4648minor)pagefaults 0swaps
> time vhd-cli raw origin.vhd actual.raw
21.97user 16.03system 1:09.11elapsed 54%CPU (0avgtext+0avgdata 65208maxresident)k
20956008inputs+20972448outputs (1major+754101minor)pagefaults 0swaps

> md5sum *.raw
b55ec6924be750edd2423e4a7aa262c3  actual.raw
b55ec6924be750edd2423e4a7aa262c3  expected.raw
```
2018-06-20 17:53:20 +02:00
badrAZ
bebb9bf0df fix(XapiStats/iowait): don't scale XAPI values (#3079)
Fixes #2969
2018-06-19 16:16:07 +02:00
badrAZ
4830ac9623 fix(xo-server/xapi-stats): specify the wanted step when requesting the RRD (#3078)
Fixes #3026
Fixes #3075
2018-06-18 18:32:16 +02:00
badrAZ
58b1d0fba8 fix(xo-server-load-balancer): issue whith a mutation of a var (#3077) 2018-06-18 16:01:26 +02:00
Julien Fontanet
cc4e69e631 feat(xo-server): 5.20.3 2018-06-16 17:05:54 +02:00
Julien Fontanet
e14fda6e8a feat(xen-api): 0.1.3 2018-06-16 17:05:27 +02:00
Julien Fontanet
ec48b77af3 feat(xen-api): add task record to task errors 2018-06-16 15:24:26 +02:00
Julien Fontanet
c7d6a19864 fix(xo-server/full backup NG): do not fork if 1 target 2018-06-16 15:24:26 +02:00
56 changed files with 1291 additions and 672 deletions

View File

@@ -9,6 +9,7 @@
[options]
esproposal.decorators=ignore
esproposal.optional_chaining=enable
include_warnings=true
module.use_strict=true

View File

@@ -4,6 +4,12 @@
### Enhancements
### Bugs
## **5.21.0** (2018-06-28)
### Enhancements
- Hide legacy backup creation view [#2956](https://github.com/vatesfr/xen-orchestra/issues/2956)
- [Delta Backup NG logs] Display wether the export is a full or a delta [#2711](https://github.com/vatesfr/xen-orchestra/issues/2711)
- Copy VDIs' UUID from SR/disks view [#3051](https://github.com/vatesfr/xen-orchestra/issues/3051)
@@ -11,6 +17,17 @@
- [Backup NG form] Improve feedback [#2711](https://github.com/vatesfr/xen-orchestra/issues/2711)
- [Backup NG] Different retentions for backup and replication [#2895](https://github.com/vatesfr/xen-orchestra/issues/2895)
- Possibility to use a fast clone when creating a VM from a snapshot [#2937](https://github.com/vatesfr/xen-orchestra/issues/2937)
- Ability to customize cloud config templates [#2984](https://github.com/vatesfr/xen-orchestra/issues/2984)
- Add Backup deprecation message and link to Backup NG migration blog post [#3089](https://github.com/vatesfr/xen-orchestra/issues/3089)
- [Backup NG] Ability to cancel a running backup job [#3047](https://github.com/vatesfr/xen-orchestra/issues/3047)
- [Backup NG form] Ability to enable/disable a schedule [#3062](https://github.com/vatesfr/xen-orchestra/issues/3062)
- New backup/health view with non-existent backup snapshots table [#3090](https://github.com/vatesfr/xen-orchestra/issues/3090)
- Disable cancel/destroy tasks when not allowed [#3076](https://github.com/vatesfr/xen-orchestra/issues/3076)
- Default remote type is NFS [#3103](https://github.com/vatesfr/xen-orchestra/issues/3103) (PR [#3114](https://github.com/vatesfr/xen-orchestra/pull/3114))
- Add legacy backups snapshots to backup/health [#3082](https://github.com/vatesfr/xen-orchestra/issues/3082) (PR [#3111](https://github.com/vatesfr/xen-orchestra/pull/3111))
- [Backup NG logs] Add the job's name to the modal's title [#2711](https://github.com/vatesfr/xen-orchestra/issues/2711) (PR [#3115](https://github.com/vatesfr/xen-orchestra/pull/3115))
- Adding a XCP-ng host to a XS pool now fails fast [#3061](https://github.com/vatesfr/xen-orchestra/issues/3061) (PR [#3118](https://github.com/vatesfr/xen-orchestra/pull/3118))
- [Backup NG logs] Ability to report a failed job and copy its log to the clipboard [#3100](https://github.com/vatesfr/xen-orchestra/issues/3100) (PR [#3110](https://github.com/vatesfr/xen-orchestra/pull/3110))
### Bugs
@@ -19,6 +36,10 @@
- Fix the retry of a single failed/interrupted VM backup [#2912](https://github.com/vatesfr/xen-orchestra/issues/2912#issuecomment-395480321)
- New VM with Self: filter out networks that are not in the template's pool [#3011](https://github.com/vatesfr/xen-orchestra/issues/3011)
- [Backup NG] Auto-detect when a full export is necessary.
- Fix Load Balancer [#3075](https://github.com/vatesfr/xen-orchestra/issues/3075#event-1685469551) [#3026](https://github.com/vatesfr/xen-orchestra/issues/3026)
- [SR stats] Don't scale XAPI iowait values [#2969](https://github.com/vatesfr/xen-orchestra/issues/2969)
- [Backup NG] Don't list unusable SRs for CR/DR [#3050](https://github.com/vatesfr/xen-orchestra/issues/3050)
- Fix creating VM from snapshot (PR [3117](https://github.com/vatesfr/xen-orchestra/pull/3117))
## **5.20.0** (2018-05-31)

View File

@@ -1,4 +1,7 @@
declare module 'promise-toolbox' {
declare export class CancelToken {
static source(): { cancel: (message: any) => void, token: CancelToken };
}
declare export function cancelable(Function): Function
declare export function defer<T>(): {|
promise: Promise<T>,

View File

@@ -29,7 +29,7 @@
"@xen-orchestra/fs": "^0.1.0",
"exec-promise": "^0.7.0",
"struct-fu": "^1.2.0",
"vhd-lib": "^0.1.2"
"vhd-lib": "^0.2.0"
},
"devDependencies": {
"@babel/cli": "^7.0.0-beta.49",

View File

@@ -0,0 +1,23 @@
const { createWriteStream } = require('fs')
const { PassThrough } = require('stream')
const createOutputStream = path => {
if (path !== undefined && path !== '-') {
return createWriteStream(path)
}
// introduce a through stream because stdout is not a normal stream!
const stream = new PassThrough()
stream.pipe(process.stdout)
return stream
}
export const writeStream = (input, path) => {
const output = createOutputStream(path)
return new Promise((resolve, reject) =>
input
.on('error', reject)
.pipe(output.on('error', reject).on('finish', resolve))
)
}

View File

@@ -0,0 +1,16 @@
import { createContentStream } from 'vhd-lib'
import { getHandler } from '@xen-orchestra/fs'
import { resolve } from 'path'
import { writeStream } from '../_utils'
export default async args => {
if (args.length < 2 || args.some(_ => _ === '-h' || _ === '--help')) {
return `Usage: ${this.command} <input VHD> [<output raw>]`
}
await writeStream(
createContentStream(getHandler({ url: 'file:///' }), resolve(args[0])),
args[1]
)
}

View File

@@ -1,6 +1,6 @@
{
"name": "vhd-lib",
"version": "0.1.2",
"version": "0.2.0",
"license": "AGPL-3.0",
"description": "Primitives for VHD file handling",
"keywords": [],

View File

@@ -0,0 +1,31 @@
import asyncIteratorToStream from 'async-iterator-to-stream'
import Vhd from './vhd'
export default asyncIteratorToStream(async function * (handler, path) {
const fd = await handler.openFile(path, 'r')
try {
const vhd = new Vhd(handler, fd)
await vhd.readHeaderAndFooter()
await vhd.readBlockAllocationTable()
const {
footer: { currentSize },
header: { blockSize },
} = vhd
const nFullBlocks = Math.floor(currentSize / blockSize)
const nLeftoverBytes = currentSize % blockSize
const emptyBlock = Buffer.alloc(blockSize)
for (let i = 0; i < nFullBlocks; ++i) {
yield vhd.containsBlock(i) ? (await vhd._readBlock(i)).data : emptyBlock
}
if (nLeftoverBytes !== 0) {
yield (vhd.containsBlock(nFullBlocks)
? (await vhd._readBlock(nFullBlocks)).data
: emptyBlock
).slice(0, nLeftoverBytes)
}
} finally {
await handler.closeFile(fd)
}
})

View File

@@ -1,5 +1,6 @@
export { default } from './vhd'
export { default as chainVhd } from './chain'
export { default as createContentStream } from './createContentStream'
export { default as createReadableRawStream } from './createReadableRawStream'
export {
default as createReadableSparseStream,

View File

@@ -96,6 +96,7 @@ class XapiError extends BaseError {
// slots than can be assigned later
this.method = undefined
this.url = undefined
this.task = undefined
}
}
@@ -188,7 +189,9 @@ const getTaskResult = task => {
return Promise.reject(new Cancel('task canceled'))
}
if (status === 'failure') {
return Promise.reject(wrapError(task.error_info))
const error = wrapError(task.error_info)
error.task = task
return Promise.reject(error)
}
if (status === 'success') {
// the result might be:

View File

@@ -7,9 +7,13 @@ import { debug } from './utils'
export default class DensityPlan extends Plan {
_checkRessourcesThresholds (objects, averages) {
const { low } = this._thresholds.memoryFree
return filter(
objects,
object => averages[object.id].memoryFree > this._thresholds.memoryFree.low
object => {
const { memory, memoryFree = memory } = averages[object.id]
return memoryFree > low
}
)
}
@@ -145,7 +149,9 @@ export default class DensityPlan extends Plan {
// Test if a VM migration on a destination (of a destinations set) is possible.
_testMigration ({ vm, destinations, hostsAverages, vmsAverages }) {
const { _thresholds: { critical: criticalThreshold } } = this
const {
_thresholds: { critical: criticalThreshold },
} = this
// Sort the destinations by available memory. (- -> +)
destinations.sort(

View File

@@ -56,7 +56,7 @@ export default class PerformancePlan extends Plan {
}
const { averages, toOptimize } = results
let { hosts } = results
const { hosts } = results
toOptimize.sort((a, b) => {
a = averages[a.id]
@@ -69,12 +69,12 @@ export default class PerformancePlan extends Plan {
const { id } = exceededHost
debug(`Try to optimize Host (${exceededHost.id}).`)
hosts = filter(hosts, host => host.id !== id)
const availableHosts = filter(hosts, host => host.id !== id)
// Search bests combinations for the worst host.
await this._optimize({
exceededHost,
hosts,
hosts: availableHosts,
hostsAverages: averages,
})
}

View File

@@ -1,4 +1,4 @@
import { filter, includes, map as mapToArray } from 'lodash'
import { filter, includes, map as mapToArray, size } from 'lodash'
import { EXECUTION_DELAY, debug } from './utils'
@@ -23,13 +23,18 @@ const numberOrDefault = (value, def) => (value >= 0 ? value : def)
// Averages.
// ===================================================================
function computeAverage (values, nPoints = values.length) {
function computeAverage (values, nPoints) {
if (values === undefined) {
return
}
let sum = 0
let tot = 0
const { length } = values
const start = nPoints !== undefined ? length - nPoints : 0
for (let i = length - nPoints; i < length; i++) {
for (let i = start; i < length; i++) {
const value = values[i]
sum += value || 0
@@ -53,7 +58,7 @@ function computeRessourcesAverage (objects, objectsStats, nPoints) {
cpu: computeAverage(
mapToArray(stats.cpus, cpu => computeAverage(cpu, nPoints))
),
nCpus: stats.cpus.length,
nCpus: size(stats.cpus),
memoryFree: computeAverage(stats.memoryFree, nPoints),
memory: computeAverage(stats.memory, nPoints),
}
@@ -69,9 +74,13 @@ function computeRessourcesAverageWithWeight (averages1, averages2, ratio) {
const objectAverages = (averages[id] = {})
for (const averageName in averages1[id]) {
const average1 = averages1[id][averageName]
if (average1 === undefined) {
continue
}
objectAverages[averageName] =
averages1[id][averageName] * ratio +
averages2[id][averageName] * (1 - ratio)
average1 * ratio + averages2[id][averageName] * (1 - ratio)
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server",
"version": "5.20.2",
"version": "5.20.3",
"license": "AGPL-3.0",
"description": "Server part of Xen-Orchestra",
"keywords": [
@@ -111,7 +111,7 @@
"tmp": "^0.0.33",
"uuid": "^3.0.1",
"value-matcher": "^0.2.0",
"vhd-lib": "^0.1.2",
"vhd-lib": "^0.2.0",
"ws": "^5.0.0",
"xen-api": "^0.16.10",
"xml2js": "^0.4.19",

View File

@@ -0,0 +1,41 @@
export function getAll () {
return this.getAllCloudConfigs()
}
getAll.permission = 'admin'
getAll.description = 'Gets all existing cloud configs templates'
export function create (props) {
return this.createCloudConfig(props)
}
create.permission = 'admin'
create.description = 'Creates a new cloud config template'
create.params = {
name: { type: 'string' },
template: { type: 'string' },
}
export function update (props) {
return this.updateCloudConfig(props)
}
update.permission = 'admin'
update.description = 'Modifies an existing cloud config template'
update.params = {
id: { type: 'string' },
name: { type: 'string', optional: true },
template: { type: 'string', optional: true },
}
function delete_ ({ id }) {
return this.deleteCloudConfig(id)
}
delete_.permission = 'admin'
delete_.description = 'Deletes an existing cloud config template'
delete_.params = {
id: { type: 'string' },
}
export { delete_ as delete }

View File

@@ -146,8 +146,18 @@ export { uploadPatch as patch }
export async function mergeInto ({ source, target, force }) {
const sourceHost = this.getObject(source.master)
const targetHost = this.getObject(target.master)
if (sourceHost.productBrand !== targetHost.productBrand) {
throw new Error(
`a ${sourceHost.productBrand} pool cannot be merged into a ${
targetHost.productBrand
} pool`
)
}
const sourcePatches = sourceHost.patches
const targetPatches = this.getObject(target.master).patches
const targetPatches = targetHost.patches
const counterDiff = differenceBy(sourcePatches, targetPatches, 'name')
if (counterDiff.length > 0) {

View File

@@ -595,6 +595,7 @@ const TRANSFORMS = {
task (obj) {
return {
allowedOperations: obj.allowed_operations,
created: toTimestamp(obj.created),
current_operations: obj.current_operations,
finished: toTimestamp(obj.finished),

View File

@@ -98,7 +98,9 @@ const getValuesFromDepth = (obj, targetPath) => {
const testMetric = (test, type) =>
typeof test === 'string'
? test === type
: typeof test === 'function' ? test(type) : test.exec(type)
: typeof test === 'function'
? test(type)
: test.exec(type)
const findMetric = (metrics, metricType) => {
let testResult
@@ -193,7 +195,6 @@ const STATS = {
iowait: {
test: /^iowait_(\w+)$/,
getPath: matches => ['iowait', matches[1]],
transformValue: value => value * 1e2,
},
},
vm: {
@@ -242,13 +243,14 @@ export default class XapiStats {
// Execute one http request on a XenServer for get stats
// Return stats (Json format) or throws got exception
@limitConcurrency(3)
_getJson (xapi, host, timestamp) {
_getJson (xapi, host, timestamp, step) {
return xapi
.getResource('/rrd_updates', {
host,
query: {
cf: 'AVERAGE',
host: 'true',
interval: step,
json: 'true',
start: timestamp,
},
@@ -316,7 +318,7 @@ export default class XapiStats {
}
const timestamp = await this._getNextTimestamp(xapi, host, step)
const json = await this._getJson(xapi, host, timestamp)
const json = await this._getJson(xapi, host, timestamp, step)
if (json.meta.step !== step) {
throw new FaultyGranularity(
`Unable to get the true granularity: ${json.meta.step}`

View File

@@ -915,7 +915,7 @@ export default class BackupNg {
xva = xva.pipe(createSizeStream())
const forkExport =
nTargets === 0
nTargets === 1
? () => xva
: () => {
const fork = xva.pipe(new PassThrough())

View File

@@ -0,0 +1,71 @@
// @flow
import { noSuchObject } from 'xo-common/api-errors'
import Collection from '../collection/redis'
import patch from '../patch'
type CloudConfig = {|
id: string,
name: string,
template: string,
|}
class CloudConfigs extends Collection {
get (properties) {
return super.get(properties)
}
}
export default class {
_app: any
_db: {|
add: Function,
first: Function,
get: Function,
remove: Function,
update: Function,
|}
constructor (app: any) {
this._app = app
const db = (this._db = new CloudConfigs({
connection: app._redis,
prefix: 'xo:cloudConfig',
}))
app.on('clean', () => db.rebuildIndexes())
app.on('start', () =>
app.addConfigManager(
'cloudConfigs',
() => db.get(),
cloudConfigs => db.update(cloudConfigs)
)
)
}
createCloudConfig (cloudConfig: $Diff<CloudConfig, {| id: string |}>) {
return this._db.add(cloudConfig).properties
}
async updateCloudConfig ({ id, name, template }: $Shape<CloudConfig>) {
const cloudConfig = await this.getCloudConfig(id)
patch(cloudConfig, { name, template })
return this._db.update(cloudConfig)
}
deleteCloudConfig (id: string) {
return this._db.remove(id)
}
getAllCloudConfigs (): Promise<Array<CloudConfig>> {
return this._db.get()
}
async getCloudConfig (id: string): Promise<CloudConfig> {
const cloudConfig = await this._db.first(id)
if (cloudConfig === null) {
throw noSuchObject(id, 'cloud config')
}
return cloudConfig.properties
}
}

View File

@@ -23,7 +23,6 @@ export default class {
Promise.all(mapToArray(remotes, remote => this._remotes.save(remote)))
)
await this.initRemotes()
await this.syncAllRemotes()
})
xo.on('stop', () => this.forgetAllRemotes())
@@ -111,15 +110,4 @@ export default class {
} catch (_) {}
}
}
// TODO: Should it be private?
async initRemotes () {
const remotes = await this.getAllRemotes()
if (!remotes || !remotes.length) {
await this.createRemote({
name: 'default',
url: 'file://var/lib/xoa-backups',
})
}
}
}

View File

@@ -28,7 +28,7 @@
"pipette": "^0.9.3",
"promise-toolbox": "^0.9.5",
"tmp": "^0.0.33",
"vhd-lib": "^0.1.2"
"vhd-lib": "^0.2.0"
},
"devDependencies": {
"@babel/cli": "7.0.0-beta.49",

View File

@@ -0,0 +1,37 @@
import _ from 'intl'
import React from 'react'
import { map } from 'lodash'
import Icon from './icon'
import Tooltip from './tooltip'
import { alert } from './modal'
const AVAILABLE_TEMPLATE_VARS = {
'{name}': 'templateNameInfo',
'%': 'templateIndexInfo',
}
const showAvailableTemplateVars = () =>
alert(
_('availableTemplateVarsTitle'),
<ul>
{map(AVAILABLE_TEMPLATE_VARS, (value, key) => (
<li key={key}>{_.keyValue(key, _(value))}</li>
))}
</ul>
)
export const AvailableTemplateVars = () => (
<Tooltip content={_('availableTemplateVarsInfo')}>
<a
className='text-info'
style={{ cursor: 'pointer' }}
onClick={showAvailableTemplateVars}
>
<Icon icon='info' />
</a>
</Tooltip>
)
export const DEFAULT_CLOUD_CONFIG_TEMPLATE =
'#cloud-config\n#hostname: {name}%\n#ssh_authorized_keys:\n# - ssh-rsa <myKey>\n#packages:\n# - htop\n'

View File

@@ -71,6 +71,7 @@ const messages = {
settingsAclsPage: 'ACLs',
settingsPluginsPage: 'Plugins',
settingsLogsPage: 'Logs',
settingsCloudConfigsPage: 'Cloud configs',
settingsIpsPage: 'IPs',
settingsConfigPage: 'Config',
aboutPage: 'About',
@@ -84,8 +85,9 @@ const messages = {
newImport: 'Import',
xosan: 'XOSAN',
backupDeprecatedMessage:
'Backup is deprecated, use Backup NG instead to create new backups.',
backupNgNewPage: 'New backup NG',
'Warning: Backup is deprecated, use Backup NG instead.',
backupMigrationLink: 'How to migrate to Backup NG',
backupNgNewPage: 'Create a new backup with Backup NG',
backupOverviewPage: 'Overview',
backupNewPage: 'New',
backupRemotesPage: 'Remotes',
@@ -188,10 +190,17 @@ const messages = {
sortedTableNumberOfSelectedItems: '{nSelected, number} selected',
sortedTableSelectAllItems: 'Click here to select all items',
// ----- state -----
state: 'State',
stateDisabled: 'Disabled',
stateEnabled: 'Enabled',
// ----- Forms -----
formCancel: 'Cancel',
formCreate: 'Create',
formEdit: 'Edit',
formId: 'ID',
formName: 'Name',
formReset: 'Reset',
formSave: 'Save',
add: 'Add',
@@ -226,9 +235,10 @@ const messages = {
selectIp: 'Select IP(s)…',
selectIpPool: 'Select IP pool(s)…',
selectVgpuType: 'Select VGPU type(s)…',
fillRequiredInformations: 'Fill required informations.',
fillOptionalInformations: 'Fill informations (optional)',
fillRequiredInformations: 'Fill required information.',
fillOptionalInformations: 'Fill information (optional)',
selectTableReset: 'Reset',
selectCloudConfigs: 'Select Cloud Config(s)…',
// --- Dates/Scheduler ---
@@ -283,12 +293,10 @@ const messages = {
jobAction: 'Action',
jobTag: 'Tag',
jobScheduling: 'Scheduling',
jobState: 'State',
jobStateEnabled: 'Enabled',
jobStateDisabled: 'Disabled',
jobTimezone: 'Timezone',
jobServerTimezone: 'Server',
runJob: 'Run job',
cancelJob: 'Cancel job',
runJobConfirm: 'Are you sure you want to run {backupType} {id} ({tag})?',
runJobVerbose: 'One shot running started. See overview for logs.',
jobEdit: 'Edit job',
@@ -343,6 +351,7 @@ const messages = {
scheduleRun: 'Run schedule',
deleteSelectedSchedules: 'Delete selected schedules',
noScheduledJobs: 'No scheduled jobs.',
legacySnapshotsLink: 'You can delete all your legacy backup snapshots.',
newSchedule: 'New schedule',
noJobs: 'No jobs found.',
noSchedules: 'No schedules found',
@@ -364,6 +373,7 @@ const messages = {
migrateBackupScheduleMessage:
'This will migrate this backup to a backup NG. This operation is not reversible. Do you want to continue?',
runBackupNgJobConfirm: 'Are you sure you want to run {name} ({id})?',
cancelJobConfirm: 'Are you sure you want to cancel {name} ({id})?',
// ------ New backup -----
newBackupAdvancedSettings: 'Advanced settings',
@@ -815,10 +825,6 @@ const messages = {
powerStateSuspended: 'suspended',
// ----- VM home -----
vmStatus: 'No Xen tools detected',
vmName: 'No IPv4 record',
vmDescription: 'No IP record',
vmSettings: 'Started {ago}',
vmCurrentStatus: 'Current status:',
vmNotRunning: 'Not running',
vmHaltedSince: 'Halted {ago}',
@@ -1105,6 +1111,8 @@ const messages = {
vmContainer: 'Resident on',
vmSnapshotsRelatedToNonExistentBackups:
'VM snapshots related to non-existent backups',
snapshotOf: 'Snapshot of',
legacySnapshots: 'Legacy backups snapshots',
alarmMessage: 'Alarms',
noAlarms: 'No alarms',
alarmDate: 'Date',
@@ -1145,7 +1153,7 @@ const messages = {
newVmReset: 'Reset',
newVmSelectTemplate: 'Select template',
newVmSshKey: 'SSH key',
newVmConfigDrive: 'Config drive',
noConfigDrive: 'No config drive',
newVmCustomConfig: 'Custom config',
availableTemplateVarsInfo:
'Click here to see the available template variables',
@@ -1237,7 +1245,7 @@ const messages = {
noVmImportErrorDescription: 'No description available',
vmImportError: 'Error:',
vmImportFileType: '{type} file:',
vmImportConfigAlert: 'Please to check and/or modify the VM configuration.',
vmImportConfigAlert: 'Please check and/or modify the VM configuration.',
// ---- Tasks ---
noTasks: 'No pending tasks',
@@ -1252,8 +1260,6 @@ const messages = {
// ---- Backup views ---
backupSchedules: 'Schedules',
backupSavedSchedules: 'Saved schedules',
backupNewSchedules: 'New schedules',
scheduleCron: 'Cron pattern',
scheduleName: 'Name',
scheduleTimezone: 'Timezone',
@@ -1768,6 +1774,16 @@ const messages = {
settingsAclsButtonTooltipSR: 'SR',
settingsAclsButtonTooltipnetwork: 'Network',
// ----- Settings/Cloud configs -----
settingsCloudConfigTemplate: 'Template',
confirmDeleteCloudConfigsTitle:
'Delete cloud config{nCloudConfigs, plural, one {} other {s}}',
confirmDeleteCloudConfigsBody:
'Are you sure you want to delete {nCloudConfigs, number} cloud config{nCloudConfigs, plural, one {} other {s}}?',
deleteCloudConfig: 'Delete cloud config',
editCloudConfig: 'Edit cloud config',
deleteSelectedCloudConfigs: 'Delete selected cloud configs',
// ----- Config -----
noConfigFile: 'No config file selected',
importTip:

View File

@@ -16,7 +16,11 @@ export default class BooleanInput extends Component {
return (
<PrimitiveInputWrapper {...props}>
<div className='checkbox form-control'>
<Toggle disabled={disabled} onChange={onChange} value={value} />
<Toggle
disabled={disabled}
onChange={onChange}
value={value || false}
/>
</div>
</PrimitiveInputWrapper>
)

View File

@@ -94,6 +94,11 @@ const VgpuItem = connectStore(() => ({
const xoItemToRender = {
// Subscription objects.
cloudConfig: template => (
<span>
<Icon icon='template' /> {template.name}
</span>
),
group: group => (
<span>
<Icon icon='group' /> {group.name}

View File

@@ -0,0 +1,51 @@
import _ from 'intl'
import React from 'react'
import ActionButton from './action-button'
import ActionRowButton from './action-row-button'
import propTypes from './prop-types-decorator'
export const CAN_REPORT_BUG = process.env.XOA_PLAN > 1
const reportBug = ({ formatMessage, message, title }) => {
const encodedTitle = encodeURIComponent(title)
const encodedMessage = encodeURIComponent(
formatMessage !== undefined ? formatMessage(message) : message
)
window.open(
process.env.XOA_PLAN < 5
? `https://xen-orchestra.com/#!/member/support?title=${encodedTitle}&message=${encodedMessage}`
: `https://github.com/vatesfr/xen-orchestra/issues/new?title=${encodedTitle}&body=${encodedMessage}`
)
}
const ReportBugButton = ({
formatMessage,
message,
rowButton,
title,
...props
}) => {
const Button = rowButton ? ActionRowButton : ActionButton
return (
<Button
{...props}
data-formatMessage={formatMessage}
data-message={message}
data-title={title}
handler={reportBug}
icon='bug'
tooltip={_('reportBug')}
/>
)
}
propTypes(ReportBugButton)({
formatMessage: propTypes.func,
message: propTypes.string.isRequired,
rowButton: propTypes.bool,
title: propTypes.string.isRequired,
})
export default ReportBugButton

View File

@@ -41,6 +41,7 @@ import {
import { addSubscriptions, connectStore, resolveResourceSets } from './utils'
import {
isSrWritable,
subscribeCloudConfigs,
subscribeCurrentUser,
subscribeGroups,
subscribeIpPools,
@@ -1038,3 +1039,18 @@ export const SelectIpPool = makeSubscriptionSelect(
},
{ placeholder: _('selectIpPool') }
)
// ===================================================================
export const SelectCloudConfig = makeSubscriptionSelect(
subscriber =>
subscribeCloudConfigs(cloudConfigs => {
subscriber({
xoObjects: map(sortBy(cloudConfigs, 'name'), cloudConfig => ({
...cloudConfig,
type: 'cloudConfig',
})),
})
}),
{ placeholder: _('selectCloudConfigs') }
)

View File

@@ -198,7 +198,7 @@ const actionsShape = propTypes.arrayOf(
disabled: propTypes.oneOfType([propTypes.bool, propTypes.func]),
handler: propTypes.func.isRequired,
icon: propTypes.string.isRequired,
label: propTypes.node.isRequired,
label: propTypes.oneOfType([propTypes.node, propTypes.func]).isRequired,
level: propTypes.oneOf(['primary', 'warning', 'danger']),
redirectOnSuccess: propTypes.oneOfType([propTypes.func, propTypes.string]),
})

View File

@@ -205,7 +205,7 @@ export const formatSizeRaw = bytes =>
humanFormat.raw(bytes, { scale: 'binary', unit: 'B' })
export const formatSpeed = (bytes, milliseconds) =>
humanFormat(bytes * 1e3 / milliseconds, { scale: 'binary', unit: 'B/s' })
humanFormat((bytes * 1e3) / milliseconds, { scale: 'binary', unit: 'B/s' })
const timeScale = new humanFormat.Scale({
ns: 1e-6,
@@ -516,7 +516,7 @@ export const createFakeProgress = (() => {
const startTime = Date.now() / 1e3
return () => {
const x = Date.now() / 1e3 - startTime
return -Math.exp(x * Math.log(1 - S) / d) + 1
return -Math.exp((x * Math.log(1 - S)) / d) + 1
}
}
})()
@@ -563,3 +563,10 @@ export const isLatestXosanPackInstalled = (latestXosanPack, hosts) =>
export const getMemoryUsedMetric = ({ memory, memoryFree = memory }) =>
map(memory, (value, key) => value - memoryFree[key])
// ===================================================================
export const generateRandomId = () =>
Math.random()
.toString(36)
.slice(2)

View File

@@ -596,7 +596,11 @@ export const IopsLineChart = injectIntl(
data: propTypes.array.isRequired,
options: propTypes.object,
})(({ addSumSeries, data, options = {}, intl }) => {
const { endTimestamp, interval, stats: { iops } } = data
const {
endTimestamp,
interval,
stats: { iops },
} = data
const { length } = get(iops, 'r')
@@ -635,7 +639,11 @@ export const IoThroughputChart = injectIntl(
data: propTypes.array.isRequired,
options: propTypes.object,
})(({ addSumSeries, data, options = {}, intl }) => {
const { endTimestamp, interval, stats: { ioThroughput } } = data
const {
endTimestamp,
interval,
stats: { ioThroughput },
} = data
const { length } = get(ioThroughput, 'r') || []
@@ -674,7 +682,11 @@ export const LatencyChart = injectIntl(
data: propTypes.array.isRequired,
options: propTypes.object,
})(({ addSumSeries, data, options = {}, intl }) => {
const { endTimestamp, interval, stats: { latency } } = data
const {
endTimestamp,
interval,
stats: { latency },
} = data
const { length } = get(latency, 'r') || []
@@ -713,7 +725,11 @@ export const IowaitChart = injectIntl(
data: propTypes.array.isRequired,
options: propTypes.object,
})(({ addSumSeries, data, options = {}, intl }) => {
const { endTimestamp, interval, stats: { iowait } } = data
const {
endTimestamp,
interval,
stats: { iowait },
} = data
const { length } = iowait[Object.keys(iowait)[0]] || []
@@ -737,7 +753,7 @@ export const IowaitChart = injectIntl(
nValues: length,
endTimestamp,
interval,
valueTransform: value => `${round(value, 2)}%`,
valueTransform: value => `${round(value, 3)}%`,
}),
...options,
}}

View File

@@ -1088,7 +1088,7 @@ export const migrateVm = (vm, host) =>
_('migrateVmNoTargetHostMessage')
)
}
_call('vm.migrate', { vm: vm.id, ...params })
return _call('vm.migrate', { vm: vm.id, ...params })
}, noop)
import MigrateVmsModalBody from './migrate-vms-modal' // eslint-disable-line import/first
@@ -1679,6 +1679,15 @@ export const runJob = job => {
return _call('job.runSequence', { idSequence: [resolveId(job)] })
}
export const cancelJob = ({ id, name, runId }) =>
confirm({
title: _('cancelJob'),
body: _('cancelJobConfirm', {
id: id.slice(0, 5),
name: <strong>{name}</strong>,
}),
}).then(() => _call('job.cancel', { runId }))
// Backup/Schedule ---------------------------------------------------------
export const createSchedule = (
@@ -1843,7 +1852,7 @@ export const purgePluginConfiguration = async id => {
subscribePlugins.forceRefresh()
}
export const testPlugin = async (id, data) => _call('plugin.test', { id, data })
export const testPlugin = (id, data) => _call('plugin.test', { id, data })
export const sendUsageReport = () => _call('plugin.usageReport.send')
@@ -2393,6 +2402,39 @@ export const setIpPool = (ipPool, { name, addresses, networks }) =>
networks: resolveIds(networks),
})::tap(subscribeIpPools.forceRefresh)
// Cloud configs --------------------------------------------------------------------
export const subscribeCloudConfigs = createSubscription(() =>
_call('cloudConfig.getAll')
)
export const createCloudConfig = props =>
_call('cloudConfig.create', props)::tap(subscribeCloudConfigs.forceRefresh)
export const deleteCloudConfigs = ids => {
const { length } = ids
if (length === 0) {
return
}
const vars = { nCloudConfigs: length }
return confirm({
title: _('confirmDeleteCloudConfigsTitle', vars),
body: <p>{_('confirmDeleteCloudConfigsBody', vars)}</p>,
}).then(
() =>
Promise.all(
ids.map(id => _call('cloudConfig.delete', { id: resolveId(id) }))
)::tap(subscribeCloudConfigs.forceRefresh),
noop
)
}
export const editCloudConfig = (cloudConfig, props) =>
_call('cloudConfig.update', { ...props, id: resolveId(cloudConfig) })::tap(
subscribeCloudConfigs.forceRefresh
)
// XO SAN ----------------------------------------------------------------------
export const getVolumeInfo = (xosanSr, infoType) =>

View File

@@ -2,7 +2,7 @@ import addSubscriptions from 'add-subscriptions'
import React from 'react'
import { injectState, provideState } from '@julien-f/freactal'
import { subscribeBackupNgJobs, subscribeSchedules } from 'xo'
import { find, groupBy } from 'lodash'
import { find, groupBy, keyBy } from 'lodash'
import New from './new'
@@ -18,7 +18,7 @@ export default [
computed: {
job: (_, { jobs, routeParams: { id } }) => find(jobs, { id }),
schedules: (_, { schedulesByJob, routeParams: { id } }) =>
schedulesByJob && schedulesByJob[id],
schedulesByJob && keyBy(schedulesByJob[id], 'id'),
},
}),
injectState,

View File

@@ -0,0 +1,156 @@
import _ from 'intl'
import Component from 'base-component'
import Icon from 'icon'
import Link from 'link'
import NoObjects from 'no-objects'
import React from 'react'
import renderXoItem from 'render-xo-item'
import SortedTable from 'sorted-table'
import { Card, CardHeader, CardBlock } from 'card'
import { addSubscriptions, connectStore } from 'utils'
import { Container, Row, Col } from 'grid'
import { FormattedRelative, FormattedTime } from 'react-intl'
import { includes, map } from 'lodash'
import { deleteSnapshot, deleteSnapshots, subscribeSchedules } from 'xo'
import {
createSelector,
createGetObjectsOfType,
createCollectionWrapper,
} from 'selectors'
const SNAPSHOT_COLUMNS = [
{
name: _('snapshotDate'),
itemRenderer: snapshot => (
<span>
<FormattedTime
day='numeric'
hour='numeric'
minute='numeric'
month='long'
value={snapshot.snapshot_time * 1000}
year='numeric'
/>{' '}
(<FormattedRelative value={snapshot.snapshot_time * 1000} />)
</span>
),
sortCriteria: 'snapshot_time',
sortOrder: 'desc',
},
{
name: _('vmNameLabel'),
itemRenderer: renderXoItem,
sortCriteria: 'name_label',
},
{
name: _('vmNameDescription'),
itemRenderer: snapshot => snapshot.name_description,
sortCriteria: 'name_description',
},
{
name: _('snapshotOf'),
itemRenderer: (snapshot, { vms }) => {
const vm = vms[snapshot.$snapshot_of]
return vm && <Link to={`/vms/${vm.id}`}>{renderXoItem(vm)}</Link>
},
sortCriteria: (snapshot, { vms }) => {
const vm = vms[snapshot.$snapshot_of]
return vm && vm.name_label
},
},
]
const ACTIONS = [
{
label: _('deleteSnapshots'),
individualLabel: _('deleteSnapshot'),
icon: 'delete',
level: 'danger',
handler: deleteSnapshots,
individualHandler: deleteSnapshot,
},
]
@addSubscriptions({
schedules: subscribeSchedules,
})
@connectStore(() => {
const getSnapshots = createGetObjectsOfType('VM-snapshot')
return {
loneSnapshots: getSnapshots.filter(
createSelector(
createCollectionWrapper(
(_, props) =>
props.schedules !== undefined && map(props.schedules, 'id')
),
scheduleIds =>
scheduleIds
? _ => {
const scheduleId = _.other['xo:backup:schedule']
return (
scheduleId !== undefined && !includes(scheduleIds, scheduleId)
)
}
: false
)
),
legacySnapshots: getSnapshots.filter([
(() => {
const RE = /^(?:XO_DELTA_EXPORT:|XO_DELTA_BASE_VM_SNAPSHOT_|rollingSnapshot_)/
return (
{ name_label } // eslint-disable-line camelcase
) => RE.test(name_label)
})(),
]),
vms: createGetObjectsOfType('VM'),
}
})
export default class Health extends Component {
render () {
return (
<Container>
<Row className='lone-snapshots'>
<Col>
<Card>
<CardHeader>
<Icon icon='vm' /> {_('vmSnapshotsRelatedToNonExistentBackups')}
</CardHeader>
<CardBlock>
<NoObjects
actions={ACTIONS}
collection={this.props.loneSnapshots}
columns={SNAPSHOT_COLUMNS}
component={SortedTable}
data-vms={this.props.vms}
emptyMessage={_('noSnapshots')}
shortcutsTarget='.lone-snapshots'
/>
</CardBlock>
</Card>
</Col>
</Row>
<Row className='legacy-snapshots'>
<Col>
<Card>
<CardHeader>
<Icon icon='vm' /> {_('legacySnapshots')}
</CardHeader>
<CardBlock>
<NoObjects
actions={ACTIONS}
collection={this.props.legacySnapshots}
columns={SNAPSHOT_COLUMNS}
component={SortedTable}
data-vms={this.props.vms}
emptyMessage={_('noSnapshots')}
shortcutsTarget='.legacy-snapshots'
/>
</CardBlock>
</Card>
</Col>
</Row>
</Container>
)
}
}

View File

@@ -13,6 +13,7 @@ import { Container, Row, Col } from 'grid'
import { NavLink, NavTabs } from 'nav'
import { routes } from 'utils'
import {
cancelJob,
deleteBackupNgJobs,
disableSchedule,
enableSchedule,
@@ -28,6 +29,7 @@ import Edit from './edit'
import New from './new'
import FileRestore from './file-restore'
import Restore from './restore'
import Health from './health'
const _runBackupNgJob = ({ id, name, schedule }) =>
confirm({
@@ -57,10 +59,10 @@ const SchedulePreviewBody = ({ item: job, userData: { schedulesByJob } }) => (
<td>{job.settings[schedule.id].snapshotRetention}</td>
<td>
<StateButton
disabledLabel={_('jobStateDisabled')}
disabledLabel={_('stateDisabled')}
disabledHandler={enableSchedule}
disabledTooltip={_('logIndicationToEnable')}
enabledLabel={_('jobStateEnabled')}
enabledLabel={_('stateEnabled')}
enabledHandler={disableSchedule}
enabledTooltip={_('logIndicationToDisable')}
handlerParam={schedule.id}
@@ -68,15 +70,28 @@ const SchedulePreviewBody = ({ item: job, userData: { schedulesByJob } }) => (
/>
</td>
<td>
<ActionButton
btnStyle='primary'
data-id={job.id}
data-name={job.name}
data-schedule={schedule.id}
handler={_runBackupNgJob}
icon='run-schedule'
size='small'
/>
{job.runId !== undefined ? (
<ActionButton
btnStyle='danger'
handler={cancelJob}
handlerParam={job}
icon='cancel'
key='cancel'
size='small'
tooltip={_('formCancel')}
/>
) : (
<ActionButton
btnStyle='primary'
data-id={job.id}
data-name={job.name}
data-schedule={schedule.id}
handler={_runBackupNgJob}
icon='run-schedule'
key='run'
size='small'
/>
)}
</td>
</tr>
))}
@@ -199,6 +214,10 @@ const HEADER = (
<Icon icon='menu-backup-file-restore' />{' '}
{_('backupFileRestorePage')}
</NavLink>
<NavLink to='/backup-ng/health'>
<Icon icon='menu-dashboard-health' />{' '}
{_('overviewHealthDashboardPage')}
</NavLink>
</NavTabs>
</Col>
</Row>
@@ -211,6 +230,7 @@ export default routes('overview', {
overview: Overview,
restore: Restore,
'file-restore': FileRestore,
health: Health,
})(({ children }) => (
<Page header={HEADER} title='backupPage' formatTitle>
{children}

View File

@@ -6,7 +6,12 @@ import renderXoItem, { renderXoItemFromId } from 'render-xo-item'
import Select from 'form/select'
import Tooltip from 'tooltip'
import Upgrade from 'xoa-upgrade'
import { addSubscriptions, resolveId, resolveIds } from 'utils'
import {
addSubscriptions,
generateRandomId,
resolveId,
resolveIds,
} from 'utils'
import { Card, CardBlock, CardHeader } from 'card'
import { constructSmartPattern, destructSmartPattern } from 'smart-backup'
import { Container, Col, Row } from 'grid'
@@ -14,14 +19,14 @@ import { injectState, provideState } from '@julien-f/freactal'
import { SelectRemote, SelectSr, SelectVm } from 'select-objects'
import { Toggle } from 'form'
import {
find,
findKey,
cloneDeep,
flatten,
forEach,
includes,
isEmpty,
keyBy,
map,
mapValues,
some,
} from 'lodash'
import {
@@ -30,26 +35,19 @@ import {
deleteSchedule,
editBackupNgJob,
editSchedule,
isSrWritable,
subscribeRemotes,
} from 'xo'
import Schedules from './schedules'
import SmartBackup from './smart-backup'
import {
FormFeedback,
FormGroup,
getRandomId,
Input,
Number,
Ul,
Li,
} from './utils'
import { FormFeedback, FormGroup, Input, Number, Ul, Li } from './utils'
// ===================================================================
const normaliseTagValues = values => resolveIds(values).map(value => [value])
const normalizeTagValues = values => resolveIds(values).map(value => [value])
const normaliseCopyRentention = settings => {
const normalizeCopyRetention = settings => {
forEach(settings, schedule => {
if (schedule.copyRetention === undefined) {
schedule.copyRetention = schedule.exportRetention
@@ -57,7 +55,7 @@ const normaliseCopyRentention = settings => {
})
}
const normaliseSettings = ({
const normalizeSettings = ({
settings,
exportMode,
copyMode,
@@ -103,35 +101,6 @@ const destructVmsPattern = pattern =>
vms: destructPattern(pattern),
}
const getNewSettings = ({ schedules, exportMode, copyMode, snapshotMode }) => {
const newSettings = {}
for (const id in schedules) {
newSettings[id] = {
exportRetention: exportMode ? schedules[id].exportRetention : undefined,
copyRetention: copyMode ? schedules[id].copyRetention : undefined,
snapshotRetention: snapshotMode
? schedules[id].snapshotRetention
: undefined,
}
}
return newSettings
}
const getNewSchedules = schedules => {
const newSchedules = {}
for (const id in schedules) {
newSchedules[id] = {
cron: schedules[id].cron,
timezone: schedules[id].timezone,
}
}
return newSchedules
}
const REPORT_WHEN_FILTER_OPTIONS = [
{
label: 'reportWhenAlways',
@@ -149,6 +118,11 @@ const REPORT_WHEN_FILTER_OPTIONS = [
const getOptionRenderer = ({ label }) => <span>{_(label)}</span>
const createDoesRetentionExist = name => {
const predicate = setting => setting[name] > 0
return ({ settings }) => some(settings, predicate)
}
const getInitialState = () => ({
$pool: {},
backupMode: false,
@@ -158,15 +132,14 @@ const getInitialState = () => ({
deltaMode: false,
drMode: false,
editionMode: undefined,
formId: getRandomId(),
formId: generateRandomId(),
name: '',
newSchedules: {},
offlineSnapshot: false,
paramsUpdated: false,
powerState: 'All',
remotes: [],
reportWhen: 'failure',
schedules: [],
schedules: {},
settings: {},
showErrors: false,
smartMode: false,
@@ -204,10 +177,13 @@ export default [
name: state.name,
mode: state.isDelta ? 'delta' : 'full',
compression: state.compression ? 'native' : '',
schedules: getNewSchedules(state.newSchedules),
schedules: mapValues(
state.schedules,
({ id, ...schedule }) => schedule
),
settings: {
...getNewSettings({
schedules: state.newSchedules,
...normalizeSettings({
settings: cloneDeep(state.settings),
exportMode: state.exportMode,
copyMode: state.copyMode,
snapshotMode: state.snapshotMode,
@@ -239,75 +215,49 @@ export default [
}
}
const newSettings = {}
if (!isEmpty(state.newSchedules)) {
await Promise.all(
map(state.newSchedules, async schedule => {
const scheduleId = (await createSchedule(props.job.id, {
cron: schedule.cron,
timezone: schedule.timezone,
})).id
newSettings[scheduleId] = {
exportRetention: schedule.exportRetention,
copyRetention: schedule.copyRetention,
snapshotRetention: schedule.snapshotRetention,
}
})
)
}
await Promise.all(
map(props.schedules, oldSchedule => {
const scheduleId = oldSchedule.id
const newSchedule = find(state.schedules, { id: scheduleId })
if (
newSchedule !== undefined &&
newSchedule.cron === oldSchedule.cron &&
newSchedule.timezone === oldSchedule.timezone
) {
return
}
const id = oldSchedule.id
const newSchedule = state.schedules[id]
if (newSchedule === undefined) {
return deleteSchedule(scheduleId)
return deleteSchedule(id)
}
return editSchedule({
id: scheduleId,
jobId: props.job.id,
cron: newSchedule.cron,
timezone: newSchedule.timezone,
})
if (
newSchedule.cron !== oldSchedule.cron ||
newSchedule.timezone !== oldSchedule.timezone
) {
return editSchedule({
id,
cron: newSchedule.cron,
timezone: newSchedule.timezone,
})
}
})
)
const oldSettings = props.job.settings
const settings = state.settings
if (!('' in oldSettings)) {
oldSettings[''] = {}
}
for (const id in oldSettings) {
const oldSetting = oldSettings[id]
const newSetting = settings[id]
const settings = cloneDeep(state.settings)
await Promise.all(
map(state.schedules, async schedule => {
const tmpId = schedule.id
if (props.schedules[tmpId] === undefined) {
const { id } = await createSchedule(props.job.id, {
cron: schedule.cron,
timezone: schedule.timezone,
})
if (id === '') {
oldSetting.reportWhen = state.reportWhen
oldSetting.concurrency = state.concurrency
oldSetting.offlineSnapshot = state.offlineSnapshot
} else if (!(id in settings)) {
delete oldSettings[id]
} else if (
oldSetting.snapshotRetention !== newSetting.snapshotRetention ||
oldSetting.exportRetention !== newSetting.exportRetention ||
oldSetting.copyRetention !== newSetting.copyRetention
) {
newSettings[id] = {
exportRetention: newSetting.exportRetention,
copyRetention: newSetting.copyRetention,
snapshotRetention: newSetting.snapshotRetention,
settings[id] = settings[tmpId]
delete settings[tmpId]
}
}
})
)
settings[''] = {
...props.job.settings[''],
reportWhen: state.reportWhen,
concurrency: state.concurrency,
offlineSnapshot: state.offlineSnapshot,
}
await editBackupNgJob({
@@ -315,11 +265,8 @@ export default [
name: state.name,
mode: state.isDelta ? 'delta' : 'full',
compression: state.compression ? 'native' : '',
settings: normaliseSettings({
settings: {
...oldSettings,
...newSettings,
},
settings: normalizeSettings({
settings,
exportMode: state.exportMode,
copyMode: state.copyMode,
snapshotMode: state.snapshotMode,
@@ -345,6 +292,16 @@ export default [
...state,
[name]: checked,
}),
toggleScheduleState: (_, id) => state => ({
...state,
schedules: {
...state.schedules,
[id]: {
...state.schedules[id],
enabled: !state.schedules[id].enabled,
},
},
}),
toggleSmartMode: (_, smartMode) => state => ({
...state,
smartMode,
@@ -386,13 +343,13 @@ export default [
const srs = job.srs !== undefined ? destructPattern(job.srs) : []
const { concurrency, reportWhen, offlineSnapshot } =
job.settings[''] || {}
const settings = { ...job.settings }
const settings = cloneDeep(job.settings)
delete settings['']
const drMode = job.mode === 'full' && !isEmpty(srs)
const crMode = job.mode === 'delta' && !isEmpty(srs)
if (drMode || crMode) {
normaliseCopyRentention(settings)
normalizeCopyRetention(settings)
}
return {
@@ -428,42 +385,24 @@ export default [
tmpSchedule: {},
editionMode: undefined,
}),
editSchedule: (_, schedule) => state => {
const { snapshotRetention, exportRetention, copyRetention } =
state.settings[schedule.id] || {}
return {
...state,
editionMode: 'editSchedule',
tmpSchedule: {
exportRetention,
copyRetention,
snapshotRetention,
...schedule,
},
}
},
deleteSchedule: (_, id) => async (state, props) => {
const schedules = [...state.schedules]
schedules.splice(findKey(state.schedules, { id }), 1)
return {
...state,
schedules,
}
},
editNewSchedule: (_, schedule) => state => ({
editSchedule: (_, schedule) => state => ({
...state,
editionMode: 'editNewSchedule',
editionMode: 'editSchedule',
tmpSchedule: {
...schedule,
},
}),
deleteNewSchedule: (_, id) => async (state, props) => {
const newSchedules = { ...state.newSchedules }
delete newSchedules[id]
deleteSchedule: (_, schedule) => state => {
const id = resolveId(schedule)
const schedules = { ...state.schedules }
const settings = { ...state.settings }
delete schedules[id]
delete settings[id]
return {
...state,
newSchedules,
schedules,
settings,
}
},
saveSchedule: (
@@ -471,14 +410,21 @@ export default [
{ cron, timezone, exportRetention, copyRetention, snapshotRetention }
) => async (state, props) => {
if (state.editionMode === 'creation') {
const id = generateRandomId()
return {
...state,
editionMode: undefined,
newSchedules: {
...state.newSchedules,
[getRandomId()]: {
schedules: {
...state.schedules,
[id]: {
id,
cron,
timezone,
},
},
settings: {
...state.settings,
[id]: {
exportRetention,
copyRetention,
snapshotRetention,
@@ -488,45 +434,27 @@ export default [
}
const id = state.tmpSchedule.id
if (state.editionMode === 'editSchedule') {
const scheduleKey = findKey(state.schedules, { id })
const schedules = [...state.schedules]
schedules[scheduleKey] = {
...schedules[scheduleKey],
cron,
timezone,
}
const schedules = { ...state.schedules }
const settings = { ...state.settings }
const settings = { ...state.settings }
settings[id] = {
exportRetention,
copyRetention,
snapshotRetention,
}
return {
...state,
editionMode: undefined,
schedules,
settings,
tmpSchedule: {},
}
schedules[id] = {
...schedules[id],
cron,
timezone,
}
settings[id] = {
...settings[id],
exportRetention,
copyRetention,
snapshotRetention,
}
return {
...state,
editionMode: undefined,
schedules,
settings,
tmpSchedule: {},
newSchedules: {
...state.newSchedules,
[id]: {
cron,
timezone,
exportRetention,
copyRetention,
snapshotRetention,
},
},
}
},
setPowerState: (_, powerState) => state => ({
@@ -597,8 +525,7 @@ export default [
missingRemotes: state =>
(state.backupMode || state.deltaMode) && isEmpty(state.remotes),
missingSrs: state => (state.drMode || state.crMode) && isEmpty(state.srs),
missingSchedules: state =>
isEmpty(state.schedules) && isEmpty(state.newSchedules),
missingSchedules: state => isEmpty(state.schedules),
missingExportRetention: state =>
state.exportMode && !state.exportRetentionExists,
missingCopyRetention: state =>
@@ -610,30 +537,18 @@ export default [
(state.exportRetentionExists || state.copyRetentionExists),
exportMode: state => state.backupMode || state.deltaMode,
copyMode: state => state.drMode || state.crMode,
exportRetentionExists: ({ newSchedules, settings }) =>
some(
{ ...newSchedules, ...settings },
({ exportRetention }) => exportRetention > 0
),
copyRetentionExists: ({ newSchedules, settings }) =>
some(
{ ...newSchedules, ...settings },
({ copyRetention }) => copyRetention > 0
),
snapshotRetentionExists: ({ newSchedules, settings }) =>
some(
{ ...newSchedules, ...settings },
({ snapshotRetention }) => snapshotRetention > 0
),
exportRetentionExists: createDoesRetentionExist('exportRetention'),
copyRetentionExists: createDoesRetentionExist('copyRetention'),
snapshotRetentionExists: createDoesRetentionExist('snapshotRetention'),
isDelta: state => state.deltaMode || state.crMode,
isFull: state => state.backupMode || state.drMode,
vmsSmartPattern: ({ $pool, powerState, tags }) => ({
$pool: constructSmartPattern($pool, resolveIds),
power_state: powerState === 'All' ? undefined : powerState,
tags: constructSmartPattern(tags, normaliseTagValues),
tags: constructSmartPattern(tags, normalizeTagValues),
type: 'VM',
}),
srPredicate: ({ srs }) => ({ id }) => !includes(srs, id),
srPredicate: ({ srs }) => sr => isSrWritable(sr) && !includes(srs, sr.id),
remotePredicate: ({ remotes }) => ({ id }) => !includes(remotes, id),
},
}),

View File

@@ -4,10 +4,11 @@ import moment from 'moment-timezone'
import React from 'react'
import Scheduler, { SchedulePreview } from 'scheduling'
import { Card, CardBlock } from 'card'
import { generateRandomId } from 'utils'
import { injectState, provideState } from '@julien-f/freactal'
import { isEqual } from 'lodash'
import { FormFeedback, FormGroup, getRandomId, Number } from './utils'
import { FormFeedback, FormGroup, Number } from './utils'
export default [
injectState,
@@ -27,7 +28,7 @@ export default [
cron,
exportRetention,
copyRetention,
formId: getRandomId(),
formId: generateRandomId(),
snapshotRetention,
timezone,
}),

View File

@@ -2,64 +2,13 @@ import _ from 'intl'
import ActionButton from 'action-button'
import React from 'react'
import SortedTable from 'sorted-table'
import StateButton from 'state-button'
import { Card, CardBlock, CardHeader } from 'card'
import { injectState, provideState } from '@julien-f/freactal'
import { isEmpty, find, findKey, size } from 'lodash'
import { isEmpty, find, size } from 'lodash'
import NewSchedule from './new-schedule'
import { FormFeedback, FormGroup } from './utils'
// ===================================================================
const rowTransform = (schedule, { settings }) => {
const { exportRetention, copyRetention, snapshotRetention } =
settings[schedule.id] || {}
return {
...schedule,
exportRetention,
copyRetention,
snapshotRetention,
}
}
const SAVED_SCHEDULES_INDIVIDUAL_ACTIONS = [
{
handler: (schedule, { editSchedule }) => editSchedule(schedule),
label: _('scheduleEdit'),
icon: 'edit',
disabled: (_, { disabledEdition }) => disabledEdition,
level: 'primary',
},
{
handler: (schedule, { deleteSchedule }) => deleteSchedule(schedule.id),
label: _('scheduleDelete'),
disabled: (_, { disabledDeletion }) => disabledDeletion,
icon: 'delete',
level: 'danger',
},
]
const NEW_SCHEDULES_INDIVIDUAL_ACTIONS = [
{
handler: (schedule, { editNewSchedule, newSchedules }) =>
editNewSchedule({
id: findKey(newSchedules, schedule),
...schedule,
}),
label: _('scheduleEdit'),
disabled: (_, { disabledEdition }) => disabledEdition,
icon: 'edit',
level: 'primary',
},
{
handler: (schedule, { deleteNewSchedule, newSchedules }) =>
deleteNewSchedule(findKey(newSchedules, schedule)),
label: _('scheduleDelete'),
icon: 'delete',
level: 'danger',
},
]
import { FormFeedback } from './utils'
// ===================================================================
@@ -74,14 +23,64 @@ export default [
injectState,
provideState({
computed: {
disabledDeletion: state =>
state.schedules.length + size(state.newSchedules) <= 1,
disabledDeletion: state => size(state.schedules) <= 1,
disabledEdition: state =>
state.editionMode !== undefined ||
(!state.exportMode && !state.copyMode && !state.snapshotMode),
error: state => find(FEEDBACK_ERRORS, error => state[error]),
schedulesColumns: state => {
individualActions: (
{ disabledDeletion, disabledEdition },
{ effects: { deleteSchedule, editSchedule } }
) => [
{
disabled: disabledEdition,
handler: editSchedule,
icon: 'edit',
label: _('scheduleEdit'),
level: 'primary',
},
{
disabled: disabledDeletion,
handler: deleteSchedule,
icon: 'delete',
label: _('scheduleDelete'),
level: 'danger',
},
],
rowTransform: ({ settings }) => schedule => {
const { exportRetention, copyRetention, snapshotRetention } =
settings[schedule.id] || {}
return {
...schedule,
exportRetention,
copyRetention,
snapshotRetention,
}
},
schedulesColumns: (state, { effects: { toggleScheduleState } }) => {
const columns = [
{
itemRenderer: _ => _.name,
sortCriteria: 'name',
name: _('scheduleName'),
default: true,
},
{
itemRenderer: schedule => (
<StateButton
disabledLabel={_('stateDisabled')}
disabledTooltip={_('logIndicationToEnable')}
enabledLabel={_('stateEnabled')}
enabledTooltip={_('logIndicationToDisable')}
handler={toggleScheduleState}
handlerParam={schedule.id}
state={schedule.enabled}
/>
),
sortCriteria: 'enabled',
name: _('state'),
},
{
itemRenderer: _ => _.cron,
sortCriteria: 'cron',
@@ -119,15 +118,6 @@ export default [
}
return columns
},
savedSchedulesColumns: state => [
{
itemRenderer: _ => _.name,
sortCriteria: 'name',
name: _('scheduleName'),
default: true,
},
...state.schedulesColumns,
],
},
}),
injectState,
@@ -150,43 +140,15 @@ export default [
/>
</CardHeader>
<CardBlock>
{isEmpty(state.schedules) &&
isEmpty(state.newSchedules) && (
<p className='text-md-center'>{_('noSchedules')}</p>
)}
{!isEmpty(state.schedules) && (
<FormGroup>
<label>
<strong>{_('backupSavedSchedules')}</strong>
</label>
<SortedTable
collection={state.schedules}
columns={state.savedSchedulesColumns}
data-deleteSchedule={effects.deleteSchedule}
data-disabledDeletion={state.disabledDeletion}
data-disabledEdition={state.disabledEdition}
data-editSchedule={effects.editSchedule}
data-settings={state.settings}
individualActions={SAVED_SCHEDULES_INDIVIDUAL_ACTIONS}
rowTransform={rowTransform}
/>
</FormGroup>
)}
{!isEmpty(state.newSchedules) && (
<FormGroup>
<label>
<strong>{_('backupNewSchedules')}</strong>
</label>
<SortedTable
collection={state.newSchedules}
columns={state.schedulesColumns}
data-deleteNewSchedule={effects.deleteNewSchedule}
data-disabledEdition={state.disabledEdition}
data-editNewSchedule={effects.editNewSchedule}
data-newSchedules={state.newSchedules}
individualActions={NEW_SCHEDULES_INDIVIDUAL_ACTIONS}
/>
</FormGroup>
{isEmpty(state.schedules) ? (
<p className='text-md-center'>{_('noSchedules')}</p>
) : (
<SortedTable
collection={state.schedules}
columns={state.schedulesColumns}
individualActions={state.individualActions}
rowTransform={state.rowTransform}
/>
)}
</CardBlock>
</FormFeedback>

View File

@@ -8,11 +8,6 @@ export const Input = props => <input {...props} className='form-control' />
export const Ul = props => <ul {...props} className='list-group' />
export const Li = props => <li {...props} className='list-group-item' />
export const getRandomId = () =>
Math.random()
.toString(36)
.slice(2)
export const Number = [
provideState({
effects: {

View File

@@ -13,8 +13,8 @@ import Restore from './restore'
import FileRestore from './file-restore'
const DeprecatedMsg = () => (
<div>
<em>{_('backupDeprecatedMessage')}</em>
<div className='alert alert-warning'>
{_('backupDeprecatedMessage')}
<br />
<Link to='/backup-ng/new'>{_('backupNgNewPage')}</Link>
</div>

View File

@@ -75,13 +75,13 @@ const JOB_COLUMNS = [
sortCriteria: ({ schedule }) => schedule.timezone,
},
{
name: _('jobState'),
name: _('state'),
itemRenderer: ({ schedule }) => (
<StateButton
disabledLabel={_('jobStateDisabled')}
disabledLabel={_('stateDisabled')}
disabledHandler={enableSchedule}
disabledTooltip={_('logIndicationToEnable')}
enabledLabel={_('jobStateEnabled')}
enabledLabel={_('stateEnabled')}
enabledHandler={disableSchedule}
enabledTooltip={_('logIndicationToDisable')}
handlerParam={schedule.id}
@@ -239,14 +239,28 @@ export default class Overview extends Component {
<CardBlock>
<NoObjects
collection={schedules}
emptyMessage={_('noScheduledJobs')}
emptyMessage={
<span>
{_('noScheduledJobs')}{' '}
<Link to='/backup-ng/health'>{_('legacySnapshotsLink')}</Link>
</span>
}
>
{() => (
<SortedTable
columns={JOB_COLUMNS}
collection={this._getScheduleCollection()}
userData={isScheduleUserMissing}
/>
<div>
<div className='alert alert-warning'>
{_('backupDeprecatedMessage')}
<br />
<a href='https://xen-orchestra.com/blog/migrate-backup-to-backup-ng/'>
{_('backupMigrationLink')}
</a>
</div>
<SortedTable
columns={JOB_COLUMNS}
collection={this._getScheduleCollection()}
userData={isScheduleUserMissing}
/>
</div>
)}
</NoObjects>
</CardBlock>

View File

@@ -17,13 +17,7 @@ import { Container, Row, Col } from 'grid'
import { Card, CardHeader, CardBlock } from 'card'
import { FormattedRelative, FormattedTime } from 'react-intl'
import { flatten, get, includes, isEmpty, map, mapValues } from 'lodash'
import {
addSubscriptions,
connectStore,
formatSize,
noop,
resolveIds,
} from 'utils'
import { connectStore, formatSize, noop, resolveIds } from 'utils'
import {
deleteMessage,
deleteOrphanedVdis,
@@ -32,7 +26,6 @@ import {
deleteVdi,
deleteVm,
isSrWritable,
subscribeSchedules,
} from 'xo'
import {
areObjectsFetched,
@@ -112,12 +105,12 @@ const SR_COLUMNS = [
sr.size > 1 && (
<Tooltip
content={_('spaceLeftTooltip', {
used: Math.round(sr.physical_usage / sr.size * 100),
used: Math.round((sr.physical_usage / sr.size) * 100),
free: formatSize(sr.size - sr.physical_usage),
})}
>
<meter
value={sr.physical_usage / sr.size * 100}
value={(sr.physical_usage / sr.size) * 100}
min='0'
max='100'
optimum='40'
@@ -390,9 +383,6 @@ const ALARM_COLUMNS = [
},
]
@addSubscriptions({
schedules: subscribeSchedules,
})
@connectStore(() => {
const getOrphanVdiSnapshots = createGetObjectsOfType('VDI-snapshot')
.filter([_ => !_.$snapshot_of && _.$VBDs.length === 0])
@@ -400,15 +390,6 @@ const ALARM_COLUMNS = [
const getOrphanVmSnapshots = createGetObjectsOfType('VM-snapshot')
.filter([snapshot => !snapshot.$snapshot_of])
.sort()
const getLoneBackupSnapshots = createGetObjectsOfType('VM-snapshot').filter(
createSelector(
createCollectionWrapper((_, props) => map(props.schedules, 'id')),
scheduleIds => _ => {
const scheduleId = _.other['xo:backup:schedule']
return scheduleId !== undefined && !includes(scheduleIds, scheduleId)
}
)
)
const getUserSrs = createGetObjectsOfType('SR').filter([isSrWritable])
const getVdiSrs = createGetObjectsOfType('SR').pick(
createSelector(getOrphanVdiSnapshots, snapshots => map(snapshots, '$SR'))
@@ -424,7 +405,6 @@ const ALARM_COLUMNS = [
vdiOrphaned: getOrphanVdiSnapshots,
vdiSr: getVdiSrs,
vmOrphaned: getOrphanVmSnapshots,
vmBackupSnapshots: getLoneBackupSnapshots,
}
})
export default class Health extends Component {
@@ -510,11 +490,6 @@ export default class Health extends Component {
this._getPoolPredicate
)
_getVmBackupSnapshots = createFilter(
() => this.props.vmBackupSnapshots,
this._getPoolPredicate
)
_getAlertMessages = createFilter(
() => this.props.alertMessages,
this._getPoolPredicate
@@ -635,24 +610,6 @@ export default class Health extends Component {
</Card>
</Col>
</Row>
<Row className='snapshot-vms'>
<Col>
<Card>
<CardHeader>
<Icon icon='vm' /> {_('vmSnapshotsRelatedToNonExistentBackups')}
</CardHeader>
<CardBlock>
<NoObjects
collection={this._getVmBackupSnapshots()}
columns={VM_COLUMNS}
component={SortedTable}
emptyMessage={_('noSnapshots')}
shortcutsTarget='.snapshot-vms'
/>
</CardBlock>
</Card>
</Col>
</Row>
<Row>
<Col>
<Card>

View File

@@ -186,7 +186,7 @@ export default class Jobs extends Component {
'vm.clone',
'vm.convert',
'vm.copy',
'vm.creatInterface',
'vm.createInterface',
'vm.delete',
'vm.migrate',
'vm.migrate',

View File

@@ -76,17 +76,17 @@ const SCHEDULES_COLUMNS = [
{
itemRenderer: schedule => (
<StateButton
disabledLabel={_('jobStateDisabled')}
disabledLabel={_('stateDisabled')}
disabledHandler={enableSchedule}
disabledTooltip={_('logIndicationToEnable')}
enabledLabel={_('jobStateEnabled')}
enabledLabel={_('stateEnabled')}
enabledHandler={disableSchedule}
enabledTooltip={_('logIndicationToDisable')}
handlerParam={schedule.id}
state={schedule.enabled}
/>
),
name: _('jobState'),
name: _('state'),
},
]

View File

@@ -1,9 +1,14 @@
import _, { FormattedDuration } from 'intl'
import addSubscriptions from 'add-subscriptions'
import Button from 'button'
import ButtonGroup from 'button-group'
import CopyToClipboard from 'react-copy-to-clipboard'
import Icon from 'icon'
import NoObjects from 'no-objects'
import React from 'react'
import ReportBugButton, { CAN_REPORT_BUG } from 'report-bug-button'
import SortedTable from 'sorted-table'
import Tooltip from 'tooltip'
import { alert } from 'modal'
import { Card, CardHeader, CardBlock } from 'card'
import { keyBy } from 'lodash'
@@ -46,11 +51,6 @@ const LOG_COLUMNS = [
itemRenderer: log => log.jobId.slice(4, 8),
sortCriteria: log => log.jobId,
},
{
name: _('jobMode'),
itemRenderer: log => get(() => log.data.mode),
sortCriteria: log => get(() => log.data.mode),
},
{
name: _('jobName'),
itemRenderer: (log, { jobs }) => get(() => jobs[log.jobId].name),
@@ -107,16 +107,37 @@ const LOG_COLUMNS = [
},
]
const showTasks = log =>
const showTasks = (log, { jobs }) => {
const formattedLog = JSON.stringify(log, null, 2)
alert(
<span>
{_('jobModalTitle', { job: log.jobId.slice(4, 8) })}{' '}
{get(() => jobs[log.jobId].name) || 'Job'} ({log.jobId.slice(4, 8)}){' '}
<span style={{ fontSize: '0.5em' }} className='text-muted'>
{log.id}
</span>
</span>{' '}
{log.status !== 'success' &&
log.status !== 'pending' && (
<ButtonGroup>
<Tooltip content={_('copyToClipboard')}>
<CopyToClipboard text={formattedLog}>
<Button size='small'>
<Icon icon='clipboard' />
</Button>
</CopyToClipboard>
</Tooltip>
{CAN_REPORT_BUG && (
<ReportBugButton
message={`\`\`\`json\n${formattedLog}\n\`\`\``}
size='small'
title='Backup job failed'
/>
)}
</ButtonGroup>
)}
</span>,
<LogAlertBody id={log.id} />
)
}
const LOG_INDIVIDUAL_ACTIONS = [
{

View File

@@ -1,6 +1,5 @@
import _, { FormattedDuration } from 'intl'
import ActionButton from 'action-button'
import Copiable from 'copiable'
import Icon from 'icon'
import React from 'react'
import renderXoItem, { renderXoItemFromId } from 'render-xo-item'
@@ -148,9 +147,7 @@ export default [
return (status === 'failure' || status === 'skipped') &&
result !== undefined ? (
<span className={status === 'skipped' ? 'text-info' : 'text-danger'}>
<Copiable tagName='p' data={JSON.stringify(result, null, 2)}>
<Icon icon='alarm' /> {result.message}
</Copiable>
<Icon icon='alarm' /> {result.message}
</span>
) : (
<div>
@@ -258,41 +255,32 @@ export default [
/>
)}
<br />
{operationLog.status === 'failure' ? (
<Copiable
tagName='p'
data={JSON.stringify(
operationLog.result,
null,
2
)}
>
{_.keyValue(
{operationLog.status === 'failure'
? _.keyValue(
_('taskError'),
<span className='text-danger'>
{operationLog.result.message}
</span>
)
: operationLog.result.size > 0 && (
<div>
{_.keyValue(
_('operationSize'),
formatSize(
operationLog.result.size
)
)}
<br />
{_.keyValue(
_('operationSpeed'),
formatSpeed(
operationLog.result.size,
operationLog.end -
operationLog.start
)
)}
</div>
)}
</Copiable>
) : (
operationLog.result.size > 0 && (
<div>
{_.keyValue(
_('operationSize'),
formatSize(operationLog.result.size)
)}
<br />
{_.keyValue(
_('operationSpeed'),
formatSpeed(
operationLog.result.size,
operationLog.end -
operationLog.start
)
)}
</div>
)
)}
</div>
)}
</li>
@@ -313,22 +301,12 @@ export default [
)}
<br />
{subTaskLog.status === 'failure' &&
subTaskLog.result !== undefined && (
<Copiable
tagName='p'
data={JSON.stringify(
subTaskLog.result,
null,
2
)}
>
{_.keyValue(
_('taskError'),
<span className='text-danger'>
{subTaskLog.result.message}
</span>
)}
</Copiable>
subTaskLog.result !== undefined &&
_.keyValue(
_('taskError'),
<span className='text-danger'>
{subTaskLog.result.message}
</span>
)}
</div>
)}
@@ -362,25 +340,20 @@ export default [
</a>
</Tooltip>
) : (
<Copiable
tagName='p'
data={JSON.stringify(taskLog.result, null, 2)}
>
{_.keyValue(
taskLog.status === 'skipped'
? _('taskReason')
: _('taskError'),
<span
className={
taskLog.status === 'skipped'
? 'text-info'
: 'text-danger'
}
>
{taskLog.result.message}
</span>
)}
</Copiable>
_.keyValue(
taskLog.status === 'skipped'
? _('taskReason')
: _('taskError'),
<span
className={
taskLog.status === 'skipped'
? 'text-info'
: 'text-danger'
}
>
{taskLog.result.message}
</span>
)
)
) : (
<div>

View File

@@ -232,6 +232,11 @@ export default class Menu extends Component {
icon: 'menu-backup-file-restore',
label: 'backupFileRestorePage',
},
{
to: '/backup-ng/health',
icon: 'menu-dashboard-health',
label: 'overviewHealthDashboardPage',
},
],
},
isAdmin && {
@@ -285,6 +290,11 @@ export default class Menu extends Component {
label: 'settingsLogsPage',
},
{ to: '/settings/ips', icon: 'ip', label: 'settingsIpsPage' },
{
to: '/settings/cloud-configs',
icon: 'template',
label: 'settingsCloudConfigsPage',
},
{
to: '/settings/config',
icon: 'menu-settings-config',

View File

@@ -52,18 +52,7 @@
justify-content: space-around;
}
.configDrive {
display: flex;
background-color: #eee;
padding: 1em;
margin-bottom: 0.5em;
}
.configDriveToggle {
margin: auto;
}
.refreshNames, .availableTemplateVars {
.refreshNames {
cursor: pointer;
}

View File

@@ -13,7 +13,10 @@ import store from 'store'
import Tags from 'tags'
import Tooltip from 'tooltip'
import Wizard, { Section } from 'wizard'
import { alert } from 'modal'
import {
AvailableTemplateVars,
DEFAULT_CLOUD_CONFIG_TEMPLATE,
} from 'cloud-config'
import { Container, Row, Col } from 'grid'
import { injectIntl } from 'react-intl'
import {
@@ -43,12 +46,12 @@ import {
getCloudInitConfig,
subscribeCurrentUser,
subscribeIpPools,
subscribePermissions,
subscribeResourceSets,
XEN_DEFAULT_CPU_CAP,
XEN_DEFAULT_CPU_WEIGHT,
} from 'xo'
import {
SelectCloudConfig,
SelectHost,
SelectIp,
SelectNetwork,
@@ -90,32 +93,6 @@ import styles from './index.css'
const NB_VMS_MIN = 2
const NB_VMS_MAX = 100
const AVAILABLE_TEMPLATE_VARS = {
'{name}': 'templateNameInfo',
'%': 'templateIndexInfo',
}
const showAvailableTemplateVars = () =>
alert(
_('availableTemplateVarsTitle'),
<ul>
{map(AVAILABLE_TEMPLATE_VARS, (value, key) => (
<li key={key}>{_.keyValue(key, _(value))}</li>
))}
</ul>
)
const AvailableTemplateVarsInfo = () => (
<Tooltip content={_('availableTemplateVarsInfo')}>
<a
className={classNames('text-info', styles.availableTemplateVars)}
onClick={showAvailableTemplateVars}
>
<Icon icon='info' />
</a>
</Tooltip>
)
/* eslint-disable camelcase */
const getObject = createGetObject((_, id) => id)
@@ -235,7 +212,6 @@ class Vif extends BaseComponent {
@addSubscriptions({
resourceSets: subscribeResourceSets,
permissions: subscribePermissions,
user: subscribeCurrentUser,
})
@connectStore(() => ({
@@ -315,12 +291,12 @@ export default class NewVm extends BaseComponent {
_reset = () => {
this._replaceState({
bootAfterCreate: true,
configDrive: false,
CPUs: '',
cpuCap: '',
cpuWeight: '',
existingDisks: {},
fastClone: true,
installMethod: 'noConfigDrive',
multipleVms: false,
name_label: '',
name_description: '',
@@ -364,7 +340,7 @@ export default class NewVm extends BaseComponent {
let cloudConfig
let cloudConfigs
if (state.configDrive) {
if (state.installMethod !== 'noConfigDrive') {
const hostname = state.name_label
.replace(/^\s+|\s+$/g, '')
.replace(/\s+/g, '-')
@@ -378,7 +354,9 @@ export default class NewVm extends BaseComponent {
''
)}`
} else {
const replacer = this._buildTemplate(state.customConfig)
const replacer = this._buildTemplate(
defined(state.customConfig, DEFAULT_CLOUD_CONFIG_TEMPLATE)
)
cloudConfig = replacer(this.state.state, 0)
if (state.multipleVms) {
const seqStart = state.seqStart
@@ -521,10 +499,8 @@ export default class NewVm extends BaseComponent {
// installation
installMethod:
(template.install_methods != null && template.install_methods[0]) ||
'SSH',
'noConfigDrive',
sshKeys: this.props.userSshKeys && this.props.userSshKeys.length && [0],
customConfig:
'#cloud-config\n#hostname: {name}%\n#ssh_authorized_keys:\n# - ssh-rsa <myKey>\n#packages:\n# - htop\n',
// interfaces
VIFs,
// disks
@@ -1019,6 +995,12 @@ export default class NewVm extends BaseComponent {
// INSTALL SETTINGS ------------------------------------------------------------
_onChangeCloudConfig = cloudConfig => {
this._setState({
customConfig: get(() => cloudConfig.template),
})
}
_renderInstallSettings = () => {
const { template } = this.state.state
if (!template) {
@@ -1026,7 +1008,6 @@ export default class NewVm extends BaseComponent {
}
const {
cloudConfig,
configDrive,
customConfig,
installIso,
installMethod,
@@ -1045,36 +1026,36 @@ export default class NewVm extends BaseComponent {
{this._isDiskTemplate ? (
<SectionContent key='diskTemplate' column>
<LineItem>
<div className={styles.configDrive}>
<span className={styles.configDriveToggle}>
{_('newVmConfigDrive')}
</span>
<label>
<input
checked={installMethod === 'noConfigDrive'}
name='installMethod'
onChange={this._linkState('installMethod')}
type='radio'
value='noConfigDrive'
/>
&nbsp;
<span className={styles.configDriveToggle}>
<Toggle
value={configDrive}
onChange={this._linkState('configDrive')}
/>
</span>
</div>
{_('noConfigDrive')}
</label>
</LineItem>
<br />
<LineItem>
<span>
<label>
<input
checked={installMethod === 'SSH'}
disabled={!configDrive}
name='installMethod'
onChange={this._linkState('installMethod')}
type='radio'
value='SSH'
/>{' '}
<span>{_('newVmSshKey')}</span>
</span>
/>
&nbsp;
{_('newVmSshKey')}
</label>
&nbsp;
<span className={classNames('input-group', styles.fixedWidth)}>
<DebounceInput
className='form-control'
disabled={!configDrive || installMethod !== 'SSH'}
disabled={installMethod !== 'SSH'}
onChange={this._linkState('newSshKey')}
value={newSshKey}
/>
@@ -1088,7 +1069,7 @@ export default class NewVm extends BaseComponent {
this.props.userSshKeys.length > 0 && (
<span className={styles.fixedWidth}>
<SelectSshKey
disabled={!configDrive || installMethod !== 'SSH'}
disabled={installMethod !== 'SSH'}
onChange={this._onChangeSshKeys}
multi
value={sshKeys || []}
@@ -1096,29 +1077,37 @@ export default class NewVm extends BaseComponent {
</span>
)}
</LineItem>
<br />
<LineItem>
<input
checked={installMethod === 'customConfig'}
disabled={!configDrive}
name='installMethod'
onChange={this._linkState('installMethod')}
type='radio'
value='customConfig'
/>
&nbsp;
<span>
{_('newVmCustomConfig')}
<label>
<input
checked={installMethod === 'customConfig'}
name='installMethod'
onChange={this._linkState('installMethod')}
type='radio'
value='customConfig'
/>
&nbsp;
<AvailableTemplateVarsInfo />
</span>
{_('newVmCustomConfig')}
</label>
&nbsp;
<DebounceTextarea
className={classNames('form-control', styles.customConfig)}
disabled={!configDrive || installMethod !== 'customConfig'}
onChange={this._linkState('customConfig')}
value={customConfig}
/>
<AvailableTemplateVars />
&nbsp;
<span className={styles.inlineSelect}>
<SelectCloudConfig
disabled={installMethod !== 'customConfig'}
onChange={this._onChangeCloudConfig}
/>
</span>
</LineItem>
<br />
<DebounceTextarea
className='form-control'
disabled={installMethod !== 'customConfig'}
onChange={this._linkState('customConfig')}
rows={7}
value={defined(customConfig, DEFAULT_CLOUD_CONFIG_TEMPLATE)}
/>
</SectionContent>
) : (
<SectionContent>
@@ -1214,7 +1203,6 @@ export default class NewVm extends BaseComponent {
}
_isInstallSettingsDone = () => {
const {
configDrive,
customConfig,
installIso,
installMethod,
@@ -1224,7 +1212,11 @@ export default class NewVm extends BaseComponent {
} = this.state.state
switch (installMethod) {
case 'customConfig':
return customConfig || !configDrive
return (
customConfig === undefined ||
customConfig.trim() !== '' ||
installMethod === 'noConfigDrive'
)
case 'ISO':
return installIso
case 'network':
@@ -1232,9 +1224,11 @@ export default class NewVm extends BaseComponent {
case 'PXE':
return true
case 'SSH':
return !isEmpty(sshKeys) || !configDrive
return !isEmpty(sshKeys) || installMethod === 'noConfigDrive'
default:
return template && this._isDiskTemplate && !configDrive
return (
template && this._isDiskTemplate && installMethod === 'noConfigDrive'
)
}
}
@@ -1285,7 +1279,7 @@ export default class NewVm extends BaseComponent {
_renderDisks = () => {
const {
state: { configDrive, existingDisks, VDIs },
state: { installMethod, existingDisks, VDIs },
} = this.state
const { pool } = this.props
let i = 0
@@ -1348,7 +1342,7 @@ export default class NewVm extends BaseComponent {
<SizeInput
className={styles.sizeInput}
onChange={this._linkState(`existingDisks.${index}.size`)}
readOnly={!configDrive}
readOnly={installMethod === 'noConfigDrive'}
value={defined(disk.size, null)}
/>
</Item>
@@ -1569,7 +1563,7 @@ export default class NewVm extends BaseComponent {
value={namePattern}
/>
&nbsp;
<AvailableTemplateVarsInfo />
<AvailableTemplateVars />
</Item>
<Item label={_('newVmFirstIndex')}>
<DebounceInput

View File

@@ -617,7 +617,7 @@ class ResourceSet extends Component {
const available = ipPoolLimits && ipPoolLimits.available
const total = ipPoolLimits && ipPoolLimits.total
return (
<span className='mr-1'>
<span className='mr-1' key={pool}>
{renderXoItem({
name: resolvedIpPool && resolvedIpPool.name,
type: 'ipPool',
@@ -743,12 +743,12 @@ export default class Self extends Component {
? isEmpty(resourceSets)
? _('noResourceSets')
: map(resourceSets, resourceSet => (
<ResourceSet
autoExpand={location.query.resourceSet === resourceSet.id}
key={resourceSet.id}
resourceSet={resourceSet}
/>
))
<ResourceSet
autoExpand={location.query.resourceSet === resourceSet.id}
key={resourceSet.id}
resourceSet={resourceSet}
/>
))
: _('loadingResourceSets')}
</div>
) : (

View File

@@ -0,0 +1,192 @@
import _ from 'intl'
import ActionButton from 'action-button'
import defined from 'xo-defined'
import React from 'react'
import SortedTable from 'sorted-table'
import { addSubscriptions, generateRandomId } from 'utils'
import {
AvailableTemplateVars,
DEFAULT_CLOUD_CONFIG_TEMPLATE,
} from 'cloud-config'
import { Container, Col } from 'grid'
import { find } from 'lodash'
import { injectState, provideState } from '@julien-f/freactal'
import { Text } from 'editable'
import { Textarea as DebounceTextarea } from 'debounce-input-decorator'
import {
createCloudConfig,
deleteCloudConfigs,
editCloudConfig,
subscribeCloudConfigs,
} from 'xo'
// ===================================================================
const COLUMNS = [
{
itemRenderer: _ => _.id.slice(4, 8),
name: _('formId'),
sortCriteria: _ => _.id.slice(4, 8),
},
{
itemRenderer: ({ id, name }) => (
<Text value={name} onChange={name => editCloudConfig(id, { name })} />
),
sortCriteria: 'name',
name: _('formName'),
default: true,
},
]
const ACTIONS = [
{
handler: deleteCloudConfigs,
icon: 'delete',
individualLabel: _('deleteCloudConfig'),
label: _('deleteSelectedCloudConfigs'),
level: 'danger',
},
]
const INDIVIDUAL_ACTIONS = [
{
handler: (cloudConfig, { populateForm }) => populateForm(cloudConfig),
icon: 'edit',
label: _('editCloudConfig'),
level: 'primary',
},
]
const initialParams = {
cloudConfigToEditId: undefined,
name: '',
template: undefined,
}
export default [
addSubscriptions({
cloudConfigs: subscribeCloudConfigs,
}),
provideState({
initialState: () => ({
formId: generateRandomId(),
inputNameId: generateRandomId(),
inputTemplateId: generateRandomId(),
...initialParams,
}),
effects: {
setInputValue: (_, { target: { name, value } }) => state => ({
...state,
[name]: value,
}),
reset: () => state => ({
...state,
...initialParams,
}),
createCloudConfig: ({ reset }) => async ({
name,
template = DEFAULT_CLOUD_CONFIG_TEMPLATE,
}) => {
await createCloudConfig({ name, template })
reset()
},
editCloudConfig: ({ reset }) => async (
{ name, template, cloudConfigToEditId },
{ cloudConfigs }
) => {
const oldCloudConfig = find(cloudConfigs, { id: cloudConfigToEditId })
if (
oldCloudConfig.name !== name ||
oldCloudConfig.template !== template
) {
await editCloudConfig(cloudConfigToEditId, { name, template })
}
reset()
},
populateForm: (_, { id, name, template }) => state => ({
...state,
name,
cloudConfigToEditId: id,
template,
}),
},
computed: {
isInvalid: ({ name, template }) =>
name.trim() === '' ||
(template !== undefined && template.trim() === ''),
},
}),
injectState,
({ state, effects, cloudConfigs }) => (
<Container>
<Col mediumSize={6}>
<form id={state.formId}>
<div className='form-group'>
<label htmlFor={state.inputNameId}>
<strong>{_('formName')}</strong>{' '}
</label>
<input
className='form-control'
id={state.inputNameId}
name='name'
onChange={effects.setInputValue}
type='text'
value={state.name}
/>
</div>{' '}
<div className='form-group'>
<label htmlFor={state.inputTemplateId}>
<strong>{_('settingsCloudConfigTemplate')}</strong>{' '}
</label>{' '}
<AvailableTemplateVars />
<DebounceTextarea
className='form-control'
id={state.inputTemplateId}
name='template'
onChange={effects.setInputValue}
rows={12}
value={defined(state.template, DEFAULT_CLOUD_CONFIG_TEMPLATE)}
/>
</div>{' '}
{state.cloudConfigToEditId !== undefined ? (
<ActionButton
btnStyle='primary'
disabled={state.isInvalid}
form={state.formId}
handler={effects.editCloudConfig}
icon='edit'
>
{_('formEdit')}
</ActionButton>
) : (
<ActionButton
btnStyle='success'
disabled={state.isInvalid}
form={state.formId}
handler={effects.createCloudConfig}
icon='add'
>
{_('formCreate')}
</ActionButton>
)}
<ActionButton
className='pull-right'
handler={effects.reset}
icon='cancel'
>
{_('formCancel')}
</ActionButton>
</form>
</Col>
<Col mediumSize={6}>
<SortedTable
actions={ACTIONS}
collection={cloudConfigs}
columns={COLUMNS}
data-populateForm={effects.populateForm}
individualActions={INDIVIDUAL_ACTIONS}
/>
</Col>
</Container>
),
].reduceRight((value, decorator) => decorator(value))

View File

@@ -7,6 +7,7 @@ import { Container, Row, Col } from 'grid'
import { NavLink, NavTabs } from 'nav'
import Acls from './acls'
import CloudConfigs from './cloud-configs'
import Config from './config'
import Groups from './groups'
import Ips from './ips'
@@ -50,6 +51,9 @@ const HEADER = (
<NavLink to='/settings/ips'>
<Icon icon='ip' /> {_('settingsIpsPage')}
</NavLink>
<NavLink to='/settings/cloud-configs'>
<Icon icon='template' /> {_('settingsCloudConfigsPage')}
</NavLink>
<NavLink to='/settings/config'>
<Icon icon='menu-settings-config' /> {_('settingsConfigPage')}
</NavLink>
@@ -61,6 +65,7 @@ const HEADER = (
const Settings = routes('servers', {
acls: Acls,
'cloud-configs': CloudConfigs,
config: Config,
groups: Groups,
ips: Ips,

View File

@@ -8,6 +8,7 @@ import BaseComponent from 'base-component'
import ButtonGroup from 'button-group'
import Copiable from 'copiable'
import NoObjects from 'no-objects'
import ReportBugButton, { CAN_REPORT_BUG } from 'report-bug-button'
import SortedTable from 'sorted-table'
import styles from './index.css'
import TabButton from 'tab-button'
@@ -16,27 +17,12 @@ import { alert, confirm } from 'modal'
import { createSelector } from 'selectors'
import { subscribeApiLogs, subscribeUsers, deleteApiLog } from 'xo'
const CAN_REPORT_BUG = process.env.XOA_PLAN > 1
const reportBug = log => {
const title = encodeURIComponent(`Error on ${log.data.method}`)
const message = encodeURIComponent(
`\`\`\`\n${log.data.method}\n${JSON.stringify(
log.data.params,
null,
2
)}\n${JSON.stringify(log.data.error, null, 2).replace(
/\\n/g,
'\n'
)}\n\`\`\``
)
window.open(
process.env.XOA_PLAN < 5
? `https://xen-orchestra.com/#!/member/support?title=${title}&message=${message}`
: `https://github.com/vatesfr/xen-orchestra/issues/new?title=${title}&body=${message}`
)
}
const formatMessage = data =>
`\`\`\`\n${data.method}\n${JSON.stringify(
data.params,
null,
2
)}\n${JSON.stringify(data.error, null, 2).replace(/\\n/g, '\n')}\n\`\`\``
const COLUMNS = [
{
@@ -102,10 +88,11 @@ const COLUMNS = [
tooltip={_('logDelete')}
/>
{CAN_REPORT_BUG && (
<ActionRowButton
handler={() => reportBug(log)}
icon='bug'
tooltip={_('reportBug')}
<ReportBugButton
message={log.data}
formatMessage={formatMessage}
rowButton
title={`Error on ${log.data.method}`}
/>
)}
</ButtonGroup>

View File

@@ -216,26 +216,26 @@ const INDIVIDUAL_ACTIONS = [
answer =>
answer.success
? alert(
<span>
<Icon icon='success' />{' '}
{_('remoteTestSuccess', { name: remote.name })}
</span>,
_('remoteTestSuccessMessage')
)
<span>
<Icon icon='success' />{' '}
{_('remoteTestSuccess', { name: remote.name })}
</span>,
_('remoteTestSuccessMessage')
)
: alert(
<span>
<Icon icon='error' />{' '}
{_('remoteTestFailure', { name: remote.name })}
</span>,
<p>
<dl className='dl-horizontal'>
<dt>{_('remoteTestError')}</dt>
<dd>{answer.error}</dd>
<dt>{_('remoteTestStep')}</dt>
<dd>{answer.step}</dd>
</dl>
</p>
)
<span>
<Icon icon='error' />{' '}
{_('remoteTestFailure', { name: remote.name })}
</span>,
<p>
<dl className='dl-horizontal'>
<dt>{_('remoteTestError')}</dt>
<dd>{answer.error}</dd>
<dt>{_('remoteTestStep')}</dt>
<dd>{answer.step}</dd>
</dl>
</p>
)
),
icon: 'diagnosis',
label: _('remoteTestTip'),
@@ -272,7 +272,7 @@ export default class Remotes extends Component {
constructor (props) {
super(props)
this.state = {
type: 'file',
type: 'nfs',
}
}
@@ -283,11 +283,11 @@ export default class Remotes extends Component {
some(values, ['name', this.refs.name.value])
)
? alert(
<span>
<Icon icon='error' /> {_('remoteTestName')}
</span>,
<p>{_('remoteTestNameFailure')}</p>
)
<span>
<Icon icon='error' /> {_('remoteTestName')}
</span>,
<p>{_('remoteTestNameFailure')}</p>
)
: this._createRemote()
_createRemote = async () => {
@@ -306,7 +306,7 @@ export default class Remotes extends Component {
const url = format(urlParams)
return createRemote(name && name.value, url).then(
() => {
this.setState({ type: 'file' })
this.setState({ type: 'nfs' })
path && (path.value = '')
username && (username.value = '')
password && (password.value = '')

View File

@@ -10,7 +10,7 @@ import { SelectPool } from 'select-objects'
import { connectStore, resolveIds } from 'utils'
import { Card, CardBlock, CardHeader } from 'card'
import { Col, Container, Row } from 'grid'
import { flatMap, flatten, isEmpty, keys, toArray } from 'lodash'
import { flatMap, flatten, isEmpty, keys, some, toArray } from 'lodash'
import {
createGetObject,
createGetObjectsOfType,
@@ -94,14 +94,19 @@ const COLUMNS = [
},
]
const isNotCancelable = task => !task.allowedOperations.includes('cancel')
const isNotDestroyable = task => !task.allowedOperations.includes('destroy')
const INDIVIDUAL_ACTIONS = [
{
disabled: isNotCancelable,
handler: cancelTask,
icon: 'task-cancel',
label: _('cancelTask'),
level: 'danger',
},
{
disabled: isNotDestroyable,
handler: destroyTask,
icon: 'task-destroy',
label: _('destroyTask'),
@@ -111,12 +116,14 @@ const INDIVIDUAL_ACTIONS = [
const GROUPED_ACTIONS = [
{
disabled: tasks => some(tasks, isNotCancelable),
handler: cancelTasks,
icon: 'task-cancel',
label: _('cancelTasks'),
level: 'danger',
},
{
disabled: tasks => some(tasks, isNotDestroyable),
handler: destroyTasks,
icon: 'task-destroy',
label: _('destroyTasks'),

View File

@@ -83,7 +83,7 @@ const GROUPED_ACTIONS = [
const INDIVIDUAL_ACTIONS = [
{
handler: copyVm,
handler: snapshot => copyVm(snapshot),
icon: 'vm-copy',
label: _('copySnapshot'),
},