Compare commits

..

2 Commits

Author SHA1 Message Date
mathieuRA
c172a75f10 finish the PR and test it 2024-02-21 09:12:41 +01:00
mathieuRA
7f21e7aeeb feat(xo-web/license): ability to rebind licenses 2024-02-19 16:51:59 +01:00
19 changed files with 159 additions and 395 deletions

View File

@@ -6,12 +6,11 @@ import { extractIdsFromSimplePattern } from '../extractIdsFromSimplePattern.mjs'
import { Task } from '../Task.mjs'
import createStreamThrottle from './_createStreamThrottle.mjs'
import { DEFAULT_SETTINGS, Abstract } from './_Abstract.mjs'
import { runTask } from './_runTask.mjs'
import { getAdaptersByRemote } from './_getAdaptersByRemote.mjs'
import { FullRemote } from './_vmRunners/FullRemote.mjs'
import { IncrementalRemote } from './_vmRunners/IncrementalRemote.mjs'
const noop = Function.prototype
const DEFAULT_REMOTE_VM_SETTINGS = {
concurrency: 2,
copyRetention: 0,
@@ -21,7 +20,6 @@ const DEFAULT_REMOTE_VM_SETTINGS = {
healthCheckVmsWithTags: [],
maxExportRate: 0,
maxMergedDeltasPerRun: Infinity,
nRetriesVmBackupFailures: 0,
timeout: 0,
validateVhdStreams: false,
vmTimeout: 0,
@@ -43,7 +41,6 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
const throttleStream = createStreamThrottle(settings.maxExportRate)
const config = this._config
await Disposable.use(
() => this._getAdapter(job.sourceRemote),
() => (settings.healthCheckSr !== undefined ? this._getRecord('SR', settings.healthCheckSr) : undefined),
@@ -65,19 +62,8 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
const allSettings = this._job.settings
const baseSettings = this._baseSettings
const queue = new Set(vmsUuids)
const taskByVmId = {}
const nTriesByVmId = {}
const handleVm = vmUuid => {
if (nTriesByVmId[vmUuid] === undefined) {
nTriesByVmId[vmUuid] = 0
}
nTriesByVmId[vmUuid]++
const taskStart = { name: 'backup VM', data: { type: 'VM', id: vmUuid } }
const vmSettings = { ...settings, ...allSettings[vmUuid] }
const isLastRun = nTriesByVmId[vmUuid] === vmSettings.nRetriesVmBackupFailures + 1
const opts = {
baseSettings,
@@ -86,7 +72,7 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
healthCheckSr,
remoteAdapters,
schedule,
settings: vmSettings,
settings: { ...settings, ...allSettings[vmUuid] },
sourceRemoteAdapter,
throttleStream,
vmUuid,
@@ -100,39 +86,10 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
throw new Error(`Job mode ${job.mode} not implemented for mirror backup`)
}
if (taskByVmId[vmUuid] === undefined) {
taskByVmId[vmUuid] = new Task(taskStart)
}
const task = taskByVmId[vmUuid]
return task
.run(async () => {
try {
const result = await vmBackup.run()
task.success(result)
return result
} catch (error) {
if (isLastRun) {
throw error
} else {
Task.warning(`Retry the VM mirror backup due to an error`, {
attempt: nTriesByVmId[vmUuid],
error: error.message,
})
queue.add(vmUuid)
}
}
})
.catch(noop)
return runTask(taskStart, () => vmBackup.run())
}
const { concurrency } = settings
const _handleVm = !concurrency ? handleVm : limitConcurrency(concurrency)(handleVm)
while (queue.size > 0) {
const vmIds = Array.from(queue)
queue.clear()
await asyncMapSettled(vmIds, _handleVm)
}
await asyncMapSettled(vmsUuids, !concurrency ? handleVm : limitConcurrency(concurrency)(handleVm))
}
)
}

View File

@@ -11,8 +11,6 @@ import { getAdaptersByRemote } from './_getAdaptersByRemote.mjs'
import { IncrementalXapi } from './_vmRunners/IncrementalXapi.mjs'
import { FullXapi } from './_vmRunners/FullXapi.mjs'
const noop = Function.prototype
const DEFAULT_XAPI_VM_SETTINGS = {
bypassVdiChainsCheck: false,
checkpointSnapshot: false,
@@ -26,7 +24,6 @@ const DEFAULT_XAPI_VM_SETTINGS = {
healthCheckVmsWithTags: [],
maxExportRate: 0,
maxMergedDeltasPerRun: Infinity,
nRetriesVmBackupFailures: 0,
offlineBackup: false,
offlineSnapshot: false,
snapshotRetention: 0,
@@ -56,7 +53,6 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
const throttleStream = createStreamThrottle(settings.maxExportRate)
const config = this._config
await Disposable.use(
Disposable.all(
extractIdsFromSimplePattern(job.srs).map(id =>
@@ -93,98 +89,48 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
const allSettings = this._job.settings
const baseSettings = this._baseSettings
const queue = new Set(vmIds)
const taskByVmId = {}
const nTriesByVmId = {}
const handleVm = vmUuid => {
const getVmTask = () => {
if (taskByVmId[vmUuid] === undefined) {
taskByVmId[vmUuid] = new Task(taskStart)
}
return taskByVmId[vmUuid]
}
const vmBackupFailed = error => {
if (isLastRun) {
throw error
} else {
Task.warning(`Retry the VM backup due to an error`, {
attempt: nTriesByVmId[vmUuid],
error: error.message,
})
queue.add(vmUuid)
}
}
if (nTriesByVmId[vmUuid] === undefined) {
nTriesByVmId[vmUuid] = 0
}
nTriesByVmId[vmUuid]++
const vmSettings = { ...settings, ...allSettings[vmUuid] }
const taskStart = { name: 'backup VM', data: { type: 'VM', id: vmUuid } }
const isLastRun = nTriesByVmId[vmUuid] === vmSettings.nRetriesVmBackupFailures + 1
return this._getRecord('VM', vmUuid).then(
disposableVm =>
Disposable.use(disposableVm, async vm => {
if (taskStart.data.name_label === undefined) {
taskStart.data.name_label = vm.name_label
}
const task = getVmTask()
return task
.run(async () => {
const opts = {
baseSettings,
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
schedule,
settings: vmSettings,
srs,
throttleStream,
vm,
}
let vmBackup
if (job.mode === 'delta') {
vmBackup = new IncrementalXapi(opts)
Disposable.use(disposableVm, vm => {
taskStart.data.name_label = vm.name_label
return runTask(taskStart, () => {
const opts = {
baseSettings,
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
schedule,
settings: { ...settings, ...allSettings[vm.uuid] },
srs,
throttleStream,
vm,
}
let vmBackup
if (job.mode === 'delta') {
vmBackup = new IncrementalXapi(opts)
} else {
if (job.mode === 'full') {
vmBackup = new FullXapi(opts)
} else {
if (job.mode === 'full') {
vmBackup = new FullXapi(opts)
} else {
throw new Error(`Job mode ${job.mode} not implemented`)
}
throw new Error(`Job mode ${job.mode} not implemented`)
}
try {
const result = await vmBackup.run()
task.success(result)
return result
} catch (error) {
vmBackupFailed(error)
}
})
.catch(noop) // errors are handled by logs
}
return vmBackup.run()
})
}),
error =>
getVmTask().run(() => {
vmBackupFailed(error)
runTask(taskStart, () => {
throw error
})
)
}
const { concurrency } = settings
const _handleVm = concurrency === 0 ? handleVm : limitConcurrency(concurrency)(handleVm)
while (queue.size > 0) {
const vmIds = Array.from(queue)
queue.clear()
await asyncMapSettled(vmIds, _handleVm)
}
await asyncMapSettled(vmIds, concurrency === 0 ? handleVm : limitConcurrency(concurrency)(handleVm))
}
)
}

View File

@@ -50,17 +50,7 @@ const RRD_POINTS_PER_STEP: { [key in RRD_STEP]: number } = {
// Utils
// -------------------------------------------------------------------
function parseNumber(value: number | string) {
// Starting from XAPI 23.31, numbers in the JSON payload are encoded as
// strings to support NaN, Infinity and -Infinity
if (typeof value === 'string') {
const asNumber = +value
if (isNaN(asNumber) && value !== 'NaN') {
throw new Error('cannot parse number: ' + value)
}
value = asNumber
}
function convertNanToNull(value: number) {
return isNaN(value) ? null : value
}
@@ -69,7 +59,7 @@ function parseNumber(value: number | string) {
// -------------------------------------------------------------------
const computeValues = (dataRow: any, legendIndex: number, transformValue = identity) =>
map(dataRow, ({ values }) => transformValue(parseNumber(values[legendIndex])))
map(dataRow, ({ values }) => transformValue(convertNanToNull(values[legendIndex])))
const createGetProperty = (obj: object, property: string, defaultValue: unknown) =>
defaults(obj, { [property]: defaultValue })[property] as any
@@ -329,14 +319,8 @@ export default class XapiStats {
},
abortSignal,
})
const text = await resp.text()
try {
// starting from XAPI 23.31, the response is valid JSON
return JSON.parse(text)
} catch (error) {
// eslint-disable-next-line import/no-named-as-default-member -- https://github.com/json5/json5/issues/287
return JSON5.parse(text)
}
// eslint-disable-next-line import/no-named-as-default-member -- https://github.com/json5/json5/issues/287
return JSON5.parse(await resp.text())
}
// To avoid multiple requests, we keep a cache for the stats and
@@ -399,10 +383,7 @@ export default class XapiStats {
abortSignal,
})
const actualStep = parseNumber(json.meta.step)
if (actualStep !== step) {
throw new FaultyGranularity(`Unable to get the true granularity: ${actualStep}`)
}
const actualStep = json.meta.step as number
if (json.data.length > 0) {
// fetched data is organized from the newest to the oldest
@@ -426,15 +407,14 @@ export default class XapiStats {
let stepStats = xoObjectStats[actualStep]
let cacheStepStats = cacheXoObjectStats[actualStep]
const endTimestamp = parseNumber(json.meta.end)
if (stepStats === undefined || stepStats.endTimestamp !== endTimestamp) {
if (stepStats === undefined || stepStats.endTimestamp !== json.meta.end) {
stepStats = xoObjectStats[actualStep] = {
endTimestamp,
endTimestamp: json.meta.end,
interval: actualStep,
canBeExpired: false,
}
cacheStepStats = cacheXoObjectStats[actualStep] = {
endTimestamp,
endTimestamp: json.meta.end,
interval: actualStep,
canBeExpired: true,
}
@@ -458,6 +438,10 @@ export default class XapiStats {
})
})
}
if (actualStep !== step) {
throw new FaultyGranularity(`Unable to get the true granularity: ${actualStep}`)
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return

View File

@@ -9,10 +9,6 @@
- Disable search engine indexing via a `robots.txt`
- [Stats] Support format used by XAPI 23.31
- [REST API] Export host [SMT](https://en.wikipedia.org/wiki/Simultaneous_multithreading) status at `/hosts/:id/smt` [Forum#71374](https://xcp-ng.org/forum/post/71374)
- [Home & REST API] `$container` field of an halted VM now points to a host if a VDI is on a local storage [Forum#71769](https://xcp-ng.org/forum/post/71769)
- [Size Input] Ability to select two new units in the dropdown (`TiB`, `PiB`) (PR [#7382](https://github.com/vatesfr/xen-orchestra/pull/7382))
- [Backup] Ability to set a number of retries for VM backup failures [#2139](https://github.com/vatesfr/xen-orchestra/issues/2139) (PR [#7308](https://github.com/vatesfr/xen-orchestra/pull/7308))
### Bug fixes
@@ -21,7 +17,6 @@
- [Settings/XO Config] Sort backups from newest to oldest
- [Plugins/audit] Don't log `tag.getAllConfigured` calls
- [Remotes] Correctly clear error when the remote is tested with success
- [Import/VMWare] Fix importing last snapshot (PR [#7370](https://github.com/vatesfr/xen-orchestra/pull/7370))
### Packages to release
@@ -45,6 +40,6 @@
- vhd-lib patch
- xo-server minor
- xo-server-audit patch
- xo-web minor
- xo-web patch
<!--packages-end-->

View File

@@ -93,21 +93,6 @@ Follow the instructions:
You can also download XOA from xen-orchestra.com in an XVA file. Once you've got the XVA file, you can import it with `xe vm-import filename=xoa_unified.xva` or via XenCenter.
If you want to use static IP address for your appliance:
```sh
xe vm-param-set uuid="$uuid" \
xenstore-data:vm-data/ip="$ip" \
xenstore-data:vm-data/netmask="$netmask" \
xenstore-data:vm-data/gateway="$gateway"
```
If you want to replace the default DNS server:
```sh
xe vm-param-set uuid="$uuid" xenstore-data:vm-data/dns="$dns"
```
After the VM is imported, you just need to start it with `xe vm-start vm="XOA"` or with XenCenter.
## First console connection

View File

@@ -27,11 +27,6 @@ const SCHEMA_SETTINGS = {
minimum: 1,
optional: true,
},
nRetriesVmBackupFailures: {
minimum: 0,
optional: true,
type: 'number',
},
preferNbd: {
type: 'boolean',
optional: true,

View File

@@ -328,34 +328,6 @@ const TRANSFORMS = {
const { creation } = xoData.extract(obj) ?? {}
let $container
if (obj.resident_on !== 'OpaqueRef:NULL') {
// resident_on is set when the VM is running (or paused or suspended on a host)
$container = link(obj, 'resident_on')
} else {
// if the VM is halted, the $container is the pool
$container = link(obj, 'pool')
// unless one of its VDI is on a non shared SR
//
// linked objects may not be there when this code run, and it will only be
// refreshed when the VM XAPI record change, this value is not guaranteed
// to be up-to-date, but it practice it appears to work fine thanks to
// `VBDs` and `current_operations` changing when a VDI is
// added/removed/migrated
for (const vbd of obj.$VBDs) {
const sr = vbd?.$VDI?.$SR
if (sr !== undefined && !sr.shared) {
const pbd = sr.$PBDs[0]
const hostId = pbd && link(pbd, 'host')
if (hostId !== undefined) {
$container = hostId
break
}
}
}
}
const vm = {
// type is redefined after for controllers/, templates &
// snapshots.
@@ -450,7 +422,8 @@ const TRANSFORMS = {
xenTools,
...getVmGuestToolsProps(obj),
$container,
// TODO: handle local VMs (`VM.get_possible_hosts()`).
$container: isRunning ? link(obj, 'resident_on') : link(obj, 'pool'),
$VBDs: link(obj, 'VBDs'),
// TODO: dedupe

View File

@@ -280,7 +280,7 @@ export default class MigrateVm {
const stream = vhd.stream()
await vdi.$importContent(stream, { format: VDI_FORMAT_VHD })
}
return { vdi, vhd }
return vhd
})
)
)

View File

@@ -1,6 +1,5 @@
import asyncMapSettled from '@xen-orchestra/async-map/legacy.js'
import { basename } from 'path'
import { createLogger } from '@xen-orchestra/log'
import { format, parse } from 'xo-remote-parser'
import {
DEFAULT_ENCRYPTION_ALGORITHM,
@@ -18,35 +17,17 @@ import { Remotes } from '../models/remote.mjs'
// ===================================================================
const { warn } = createLogger('xo:mixins:remotes')
const obfuscateRemote = ({ url, ...remote }) => {
const parsedUrl = parse(url)
remote.url = format(sensitiveValues.obfuscate(parsedUrl))
return remote
}
// these properties should be defined on the remote object itself and not as
// part of the remote URL
//
// there is a bug somewhere that keep putting them into the URL, this list
// is here to help track it
const INVALID_URL_PARAMS = ['benchmarks', 'id', 'info', 'name', 'proxy', 'enabled', 'error', 'url']
function validateUrl(url) {
const parsedUrl = parse(url)
const { path } = parsedUrl
function validatePath(url) {
const { path } = parse(url)
if (path !== undefined && basename(path) === 'xo-vm-backups') {
throw invalidParameters('remote url should not end with xo-vm-backups')
}
for (const param of INVALID_URL_PARAMS) {
if (Object.hasOwn(parsedUrl, param)) {
// log with stack trace
warn(new Error('invalid remote URL param ' + param))
}
}
}
export default class {
@@ -201,22 +182,6 @@ export default class {
if (remote === undefined) {
throw noSuchObject(id, 'remote')
}
const parsedUrl = parse(remote.url)
let fixed = false
for (const param of INVALID_URL_PARAMS) {
if (Object.hasOwn(parsedUrl, param)) {
// delete the value to trace its real origin when it's added back
// with `updateRemote()`
delete parsedUrl[param]
fixed = true
}
}
if (fixed) {
remote.url = format(parsedUrl)
this._remotes.update(remote).catch(warn)
}
return remote
}
@@ -237,7 +202,7 @@ export default class {
}
async createRemote({ name, options, proxy, url }) {
validateUrl(url)
validatePath(url)
const params = {
enabled: false,
@@ -254,10 +219,6 @@ export default class {
}
updateRemote(id, { enabled, name, options, proxy, url }) {
if (url !== undefined) {
validateUrl(url)
}
const handlers = this._handlers
const handler = handlers[id]
if (handler !== undefined) {
@@ -277,7 +238,7 @@ export default class {
@synchronized()
async _updateRemote(id, { url, ...props }) {
if (url !== undefined) {
validateUrl(url)
validatePath(url)
}
const remote = await this._getRemote(id)

View File

@@ -253,10 +253,6 @@ export default class RestApi {
const host = req.xapiObject
res.json(await host.$xapi.listMissingPatches(host))
},
async smt({ xapiObject }, res) {
res.json({ enabled: await xapiObject.$xapi.isHyperThreadingEnabled(xapiObject.$id) })
},
}
collections.pools.routes = {

View File

@@ -138,7 +138,7 @@ export class Range extends Component {
export Toggle from './toggle'
const UNITS = ['kiB', 'MiB', 'GiB', 'TiB', 'PiB']
const UNITS = ['kiB', 'MiB', 'GiB']
const DEFAULT_UNIT = 'GiB'
export class SizeInput extends BaseComponent {

View File

@@ -141,7 +141,6 @@ const messages = {
removeColor: 'Remove color',
xcpNg: 'XCP-ng',
noFileSelected: 'No file selected',
nRetriesVmBackupFailures: 'Number of retries if VM backup fails',
// ----- Modals -----
alertOk: 'OK',

View File

@@ -3621,6 +3621,9 @@ export const unlockXosan = (licenseId, srId) => _call('xosan.unlock', { licenseI
export const bindLicense = (licenseId, boundObjectId) => _call('xoa.licenses.bind', { licenseId, boundObjectId })
export const rebindObjectLicense = (boundObjectId, licenseId, productId) =>
_call('xoa.licenses.rebindObject', { boundObjectId, licenseId, productId })
export const bindXcpngLicense = (licenseId, boundObjectId) =>
bindLicense(licenseId, boundObjectId)::tap(subscribeXcpngLicenses.forceRefresh)

View File

@@ -189,7 +189,6 @@ const getInitialState = ({ preSelectedVmIds, setHomeVmIdsSelection, suggestedExc
drMode: false,
name: '',
nbdConcurrency: 1,
nRetriesVmBackupFailures: 0,
preferNbd: false,
remotes: [],
schedules: {},
@@ -636,11 +635,6 @@ const New = decorate([
nbdConcurrency,
})
},
setNRetriesVmBackupFailures({ setGlobalSettings }, nRetries) {
setGlobalSettings({
nRetriesVmBackupFailures: nRetries,
})
},
},
computed: {
compressionId: generateId,
@@ -650,7 +644,6 @@ const New = decorate([
inputMaxExportRate: generateId,
inputPreferNbd: generateId,
inputNbdConcurrency: generateId,
inputNRetriesVmBackupFailures: generateId,
inputTimeoutId: generateId,
// In order to keep the user preference, the offline backup is kept in the DB
@@ -763,7 +756,6 @@ const New = decorate([
fullInterval,
maxExportRate,
nbdConcurrency = 1,
nRetriesVmBackupFailures = 0,
offlineBackup,
offlineSnapshot,
preferNbd,
@@ -998,17 +990,6 @@ const New = decorate([
value={concurrency}
/>
</FormGroup>
<FormGroup>
<label htmlFor={state.inputNRetriesVmBackupFailures}>
<strong>{_('nRetriesVmBackupFailures')}</strong>
</label>
<Number
id={state.inputNRetriesVmBackupFailures}
min={0}
onChange={effects.setNRetriesVmBackupFailures}
value={nRetriesVmBackupFailures}
/>
</FormGroup>
<FormGroup>
<label htmlFor={state.inputTimeoutId}>
<strong>{_('timeout')}</strong>

View File

@@ -124,8 +124,6 @@ const NewMirrorBackup = decorate([
setAdvancedSettings({ timeout: timeout !== undefined ? timeout * 3600e3 : undefined }),
setMaxExportRate: ({ setAdvancedSettings }, rate) =>
setAdvancedSettings({ maxExportRate: rate !== undefined ? rate * (1024 * 1024) : undefined }),
setNRetriesVmBackupFailures: ({ setAdvancedSettings }, nRetriesVmBackupFailures) =>
setAdvancedSettings({ nRetriesVmBackupFailures }),
setSourceRemote: (_, obj) => () => ({
sourceRemote: obj === null ? {} : obj.value,
}),
@@ -206,7 +204,6 @@ const NewMirrorBackup = decorate([
inputConcurrencyId: generateId,
inputTimeoutId: generateId,
inputMaxExportRateId: generateId,
inputNRetriesVmBackupFailures: generateId,
isBackupInvalid: state =>
state.isMissingName || state.isMissingBackupMode || state.isMissingSchedules || state.isMissingRetention,
isFull: state => state.mode === 'full',
@@ -234,7 +231,7 @@ const NewMirrorBackup = decorate([
}),
injectState,
({ state, effects, intl: { formatMessage } }) => {
const { concurrency, timeout, maxExportRate, nRetriesVmBackupFailures = 0 } = state.advancedSettings
const { concurrency, timeout, maxExportRate } = state.advancedSettings
return (
<form id={state.formId}>
<Container>
@@ -317,17 +314,6 @@ const NewMirrorBackup = decorate([
value={concurrency}
/>
</FormGroup>
<FormGroup>
<label htmlFor={state.inputNRetriesVmBackupFailures}>
<strong>{_('nRetriesVmBackupFailures')}</strong>
</label>
<Number
id={state.inputNRetriesVmBackupFailures}
min={0}
onChange={effects.setNRetriesVmBackupFailures}
value={nRetriesVmBackupFailures}
/>
</FormGroup>
<FormGroup>
<label htmlFor={state.inputTimeoutId}>
<strong>{_('timeout')}</strong>

View File

@@ -319,7 +319,6 @@ class JobsTable extends React.Component {
compression,
concurrency,
fullInterval,
nRetriesVmBackupFailures,
offlineBackup,
offlineSnapshot,
proxyId,
@@ -350,9 +349,6 @@ class JobsTable extends React.Component {
{compression !== undefined && (
<Li>{_.keyValue(_('compression'), compression === 'native' ? 'GZIP' : compression)}</Li>
)}
{nRetriesVmBackupFailures > 0 && (
<Li>{_.keyValue(_('nRetriesVmBackupFailures'), nRetriesVmBackupFailures)}</Li>
)}
</Ul>
)
},

View File

@@ -0,0 +1,53 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import React from 'react'
import SelectLicense from 'select-license'
import { bindLicense, rebindObjectLicense } from 'xo'
import BulkIcons from '../../../common/bulk-icons'
export default class LicenseForm extends Component {
state = {
licenseId: 'none',
}
bind = async () => {
const { userData, item, itemUuidPath = 'uuid', license } = this.props
if (license !== undefined) {
await rebindObjectLicense(item[itemUuidPath], this.state.licenseId, license.productId)
} else {
await bindLicense(this.state.licenseId, item[itemUuidPath])
}
userData.updateLicenses()
this.setState({ licenseId: 'none' })
}
render() {
const { license } = this.props
return (
<div className='d-flex'>
<div>
{license !== undefined && license.id.slice(-4)}
<BulkIcons alerts={this.props.alerts} />
</div>
<form className='form-inline ml-1'>
<SelectLicense
onChange={this.linkState('licenseId')}
productType={this.props.productType}
value={this.state.licenseId}
/>
<ActionButton
btnStyle='primary'
className='ml-1'
disabled={this.state.licenseId === 'none'}
handler={this.bind}
icon='connect'
>
{_(license === undefined ? 'bindLicense' : 'update')}
</ActionButton>
</form>
</div>
)
}
}

View File

@@ -1,74 +1,68 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import decorate from 'apply-decorators'
import Icon from 'icon'
import React from 'react'
import SelectLicense from 'select-license'
import SortedTable from 'sorted-table'
import Tooltip from 'tooltip'
import { addSubscriptions } from 'utils'
import groupBy from 'lodash/groupBy.js'
import { createSelector } from 'selectors'
import { injectState, provideState } from 'reaclette'
import { Proxy, Vm } from 'render-xo-item'
import { subscribeProxies, bindLicense } from 'xo'
import { subscribeProxies } from 'xo'
import LicenseForm from './license-form'
class ProxyLicensesForm extends Component {
state = {
licenseId: 'none',
}
getAlerts = createSelector(
() => this.props.item,
() => this.props.userData,
(proxy, userData) => {
const alerts = []
const licenses = userData.licensesByVmUuid[proxy.vmUuid]
onChangeLicense = event => {
this.setState({ licenseId: event.target.value })
}
if (proxy.vmUuid === undefined) {
alerts.push({
level: 'danger',
render: (
<p>
{_('proxyUnknownVm')} <a href='https://xen-orchestra.com/'>{_('contactUs')}</a>
</p>
),
})
}
bind = () => {
const { item, userData } = this.props
return bindLicense(this.state.licenseId, item.vmUuid).then(userData.updateLicenses)
}
// Proxy bound to multiple licenses
if (licenses?.length > 1) {
alerts.push({
level: 'danger',
render: (
<p>
{_('proxyMultipleLicenses')}
<br />
{licenses.map(license => license.id.slice(-4)).join(',')}
</p>
),
})
}
return alerts
}
)
render() {
const alerts = this.getAlerts()
const { item, userData } = this.props
const { licenseId } = this.state
const licenses = userData.licensesByVmUuid[item.vmUuid]
if (item.vmUuid === undefined) {
return (
<span className='text-danger'>
{_('proxyUnknownVm')} <a href='https://xen-orchestra.com/'>{_('contactUs')}</a>
</span>
)
}
// Proxy bound to multiple licenses
if (licenses?.length > 1) {
return (
<div>
<span>{licenses.map(license => license.id.slice(-4)).join(',')}</span>{' '}
<Tooltip content={_('proxyMultipleLicenses')}>
<Icon color='text-danger' icon='alarm' />
</Tooltip>
</div>
)
}
const license = licenses?.[0]
return license !== undefined ? (
<span>{license.id.slice(-4)}</span>
) : (
<form className='form-inline'>
<SelectLicense onChange={this.onChangeLicense} productType='xoproxy' />
<ActionButton
btnStyle='primary'
className='ml-1'
disabled={licenseId === 'none'}
handler={this.bind}
handlerParam={licenseId}
icon='connect'
>
{_('bindLicense')}
</ActionButton>
</form>
return (
<LicenseForm
alerts={alerts}
item={item}
itemUuidPath='vmUuid'
license={license}
productType='xoproxy'
userData={userData}
/>
)
}
}

View File

@@ -1,30 +1,17 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import decorate from 'apply-decorators'
import Icon from 'icon'
import React from 'react'
import SelectLicense from 'select-license'
import SortedTable from 'sorted-table'
import { bindLicense } from 'xo'
import { connectStore } from 'utils'
import { createGetObjectsOfType, createSelector } from 'selectors'
import { groupBy } from 'lodash'
import { injectState, provideState } from 'reaclette'
import { Pool, Sr } from 'render-xo-item'
import BulkIcons from '../../../common/bulk-icons'
import LicenseForm from './license-form'
class XostorLicensesForm extends Component {
state = {
licenseId: 'none',
}
bind = () => {
const { item, userData } = this.props
return bindLicense(this.state.licenseId, item.uuid).then(userData.updateLicenses)
}
getAlerts = createSelector(
() => this.props.item,
() => this.props.userData,
@@ -59,39 +46,12 @@ class XostorLicensesForm extends Component {
render() {
const alerts = this.getAlerts()
if (alerts.length > 0) {
return <BulkIcons alerts={alerts} />
}
const { item, userData } = this.props
const { licenseId } = this.state
const licenses = userData.licensesByXostorUuid[item.id]
const license = licenses?.[0]
return license !== undefined ? (
<span>{license?.id.slice(-4)}</span>
) : (
<div>
{license !== undefined && (
<div className='text-danger mb-1'>
<Icon icon='alarm' /> {_('licenseHasExpired')}
</div>
)}
<form className='form-inline'>
<SelectLicense onChange={this.linkState('licenseId')} productType='xostor' />
<ActionButton
btnStyle='primary'
className='ml-1'
disabled={licenseId === 'none'}
handler={this.bind}
handlerParam={licenseId}
icon='connect'
>
{_('bindLicense')}
</ActionButton>
</form>
</div>
)
return <LicenseForm alerts={alerts} item={item} license={license} productType='xostor' userData={userData} />
}
}