feat(xo-server,xo-web/XOSTOR): XOSTOR implementation (#6983)
See https://xcp-ng.org/forum/topic/5361
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
- [Self] Show number of VMs that belong to each Resource Set (PR [#7114](https://github.com/vatesfr/xen-orchestra/pull/7114))
|
||||
- [About] For source users, display if their XO is up to date [#5934](https://github.com/vatesfr/xen-orchestra/issues/5934) (PR [#7091](https://github.com/vatesfr/xen-orchestra/pull/7091))
|
||||
- [Proxy] Ability to open support tunnel on XO Proxy (PRs [#7126](https://github.com/vatesfr/xen-orchestra/pull/7126) [#7127](https://github.com/vatesfr/xen-orchestra/pull/7127))
|
||||
- [XOSTOR] Ability to create a XOSTOR storage (PR [#6983](https://github.com/vatesfr/xen-orchestra/pull/6983))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import assert from 'assert'
|
||||
import { format } from 'json-rpc-peer'
|
||||
import { incorrectState } from 'xo-common/api-errors.js'
|
||||
|
||||
import backupGuard from './_backupGuard.mjs'
|
||||
|
||||
@@ -523,3 +524,24 @@ getSmartctlInformation.params = {
|
||||
getSmartctlInformation.resolve = {
|
||||
host: ['id', 'host', 'view'],
|
||||
}
|
||||
|
||||
export async function getBlockdevices({ host }) {
|
||||
const xapi = this.getXapi(host)
|
||||
if (host.productBrand !== 'XCP-ng') {
|
||||
throw incorrectState({
|
||||
actual: host.productBrand,
|
||||
expected: 'XCP-ng',
|
||||
object: host.id,
|
||||
property: 'productBrand',
|
||||
})
|
||||
}
|
||||
return JSON.parse(await xapi.call('host.call_plugin', host._xapiRef, 'lsblk.py', 'list_block_devices', {}))
|
||||
}
|
||||
|
||||
getBlockdevices.params = {
|
||||
id: { type: 'string' },
|
||||
}
|
||||
|
||||
getBlockdevices.resolve = {
|
||||
host: ['id', 'host', 'administrate'],
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import some from 'lodash/some.js'
|
||||
import ensureArray from '../_ensureArray.mjs'
|
||||
import { asInteger } from '../xapi/utils.mjs'
|
||||
import { debounceWithKey } from '../_pDebounceWithKey.mjs'
|
||||
import { destroy as destroyXostor } from './xostor.mjs'
|
||||
import { forEach, parseXml } from '../utils.mjs'
|
||||
|
||||
// ===================================================================
|
||||
@@ -56,6 +57,10 @@ const srIsBackingHa = sr => sr.$pool.ha_enabled && some(sr.$pool.$ha_statefiles,
|
||||
// TODO: find a way to call this "delete" and not destroy
|
||||
export async function destroy({ sr }) {
|
||||
const xapi = this.getXapi(sr)
|
||||
if (sr.SR_type === 'linstor') {
|
||||
await destroyXostor.call(this, { sr })
|
||||
return
|
||||
}
|
||||
if (sr.SR_type !== 'xosan') {
|
||||
await xapi.destroySr(sr._xapiId)
|
||||
return
|
||||
|
||||
248
packages/xo-server/src/api/xostor.mjs
Normal file
248
packages/xo-server/src/api/xostor.mjs
Normal file
@@ -0,0 +1,248 @@
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { defer } from 'golike-defer'
|
||||
|
||||
const ENUM_PROVISIONING = {
|
||||
Thin: 'thin',
|
||||
Thick: 'thick',
|
||||
}
|
||||
const LV_NAME = 'thin_device'
|
||||
const PROVISIONING = Object.values(ENUM_PROVISIONING)
|
||||
const VG_NAME = 'linstor_group'
|
||||
const _XOSTOR_DEPENDENCIES = ['xcp-ng-release-linstor', 'xcp-ng-linstor']
|
||||
const XOSTOR_DEPENDENCIES = _XOSTOR_DEPENDENCIES.join(',')
|
||||
|
||||
const log = createLogger('xo:api:pool')
|
||||
|
||||
function pluginCall(xapi, host, plugin, fnName, args) {
|
||||
return xapi.call('host.call_plugin', host._xapiRef, plugin, fnName, args)
|
||||
}
|
||||
|
||||
async function destroyVolumeGroup(xapi, host, force) {
|
||||
log.info(`Trying to delete the ${VG_NAME} volume group.`, { hostId: host.id })
|
||||
return pluginCall(xapi, host, 'lvm.py', 'destroy_volume_group', {
|
||||
vg_name: VG_NAME,
|
||||
force: String(force),
|
||||
})
|
||||
}
|
||||
|
||||
async function installOrUpdateDependencies(host, method = 'install') {
|
||||
if (method !== 'install' && method !== 'update') {
|
||||
throw new Error('Invalid method')
|
||||
}
|
||||
|
||||
const xapi = this.getXapi(host)
|
||||
log.info(`Trying to ${method} XOSTOR dependencies (${XOSTOR_DEPENDENCIES})`, { hostId: host.id })
|
||||
for (const _package of _XOSTOR_DEPENDENCIES) {
|
||||
await pluginCall(xapi, host, 'updater.py', method, {
|
||||
packages: _package,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function installDependencies({ host }) {
|
||||
return installOrUpdateDependencies.call(this, host)
|
||||
}
|
||||
installDependencies.description = 'Install XOSTOR dependencies'
|
||||
installDependencies.permission = 'admin'
|
||||
installDependencies.params = {
|
||||
host: { type: 'string' },
|
||||
}
|
||||
installDependencies.resolve = {
|
||||
host: ['host', 'host', 'administrate'],
|
||||
}
|
||||
|
||||
export function updateDependencies({ host }) {
|
||||
return installOrUpdateDependencies.call(this, host, 'update')
|
||||
}
|
||||
updateDependencies.description = 'Update XOSTOR dependencies'
|
||||
updateDependencies.permission = 'admin'
|
||||
updateDependencies.params = {
|
||||
host: { type: 'string' },
|
||||
}
|
||||
updateDependencies.resolve = {
|
||||
host: ['host', 'host', 'administrate'],
|
||||
}
|
||||
|
||||
export async function formatDisks({ disks, force, host, ignoreFileSystems, provisioning }) {
|
||||
const rawDisks = disks.join(',')
|
||||
const xapi = this.getXapi(host)
|
||||
|
||||
const lvmPlugin = (fnName, args) => pluginCall(xapi, host, 'lvm.py', fnName, args)
|
||||
log.info(`Format disks (${rawDisks}) with force: ${force}`, { hostId: host.id })
|
||||
|
||||
if (force) {
|
||||
await destroyVolumeGroup(xapi, host, force)
|
||||
}
|
||||
|
||||
// ATM we are unable to correctly identify errors (error.code can be used for multiple errors.)
|
||||
// so we are just adding some suggestion of "why there is this error"
|
||||
// Error handling will be improved as errors are discovered and understood
|
||||
try {
|
||||
await lvmPlugin('create_physical_volume', {
|
||||
devices: rawDisks,
|
||||
ignore_existing_filesystems: String(ignoreFileSystems),
|
||||
force: String(force),
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.code === 'LVM_ERROR(5)') {
|
||||
error.params = error.params.concat([
|
||||
"[XO] This error can be triggered if one of the disks is a 'tapdevs' disk.",
|
||||
'[XO] This error can be triggered if one of the disks have children',
|
||||
])
|
||||
}
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
await lvmPlugin('create_volume_group', {
|
||||
devices: rawDisks,
|
||||
vg_name: VG_NAME,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.code === 'LVM_ERROR(5)') {
|
||||
error.params = error.params.concat([
|
||||
"[XO] This error can be triggered if a VG 'linstor_group' is already present on the host.",
|
||||
])
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (provisioning === ENUM_PROVISIONING.Thin) {
|
||||
await lvmPlugin('create_thin_pool', {
|
||||
lv_name: LV_NAME,
|
||||
vg_name: VG_NAME,
|
||||
})
|
||||
}
|
||||
}
|
||||
formatDisks.description = 'Format disks for a XOSTOR use'
|
||||
formatDisks.permission = 'admin'
|
||||
formatDisks.params = {
|
||||
disks: { type: 'array', items: { type: 'string' } },
|
||||
force: { type: 'boolean', optional: true, default: false },
|
||||
host: { type: 'string' },
|
||||
ignoreFileSystems: { type: 'boolean', optional: true, default: false },
|
||||
provisioning: { enum: PROVISIONING },
|
||||
}
|
||||
formatDisks.resolve = {
|
||||
host: ['host', 'host', 'administrate'],
|
||||
}
|
||||
|
||||
export const create = defer(async function (
|
||||
$defer,
|
||||
{ description, disksByHost, force, ignoreFileSystems, name, provisioning, replication }
|
||||
) {
|
||||
const hostIds = Object.keys(disksByHost)
|
||||
|
||||
const tmpBoundObjectId = `tmp_${hostIds.join(',')}_${Math.random().toString(32).slice(2)}`
|
||||
|
||||
const xostorLicenses = await this.getLicenses({ productType: 'xostor' })
|
||||
|
||||
const now = Date.now()
|
||||
const availableLicenses = xostorLicenses.filter(
|
||||
({ boundObjectId, expires }) => boundObjectId === undefined && (expires === undefined || expires > now)
|
||||
)
|
||||
|
||||
let license = availableLicenses.find(license => license.productId === 'xostor')
|
||||
|
||||
if (license === undefined) {
|
||||
license = availableLicenses.find(license => license.productId === 'xostor.trial')
|
||||
}
|
||||
|
||||
if (license === undefined) {
|
||||
license = await this.createBoundXostorTrialLicense({
|
||||
boundObjectId: tmpBoundObjectId,
|
||||
})
|
||||
} else {
|
||||
await this.bindLicense({
|
||||
licenseId: license.id,
|
||||
boundObjectId: tmpBoundObjectId,
|
||||
})
|
||||
}
|
||||
$defer.onFailure(() =>
|
||||
this.unbindLicense({
|
||||
licenseId: license.id,
|
||||
productId: license.productId,
|
||||
boundObjectId: tmpBoundObjectId,
|
||||
})
|
||||
)
|
||||
|
||||
const hosts = hostIds.map(hostId => this.getObject(hostId, 'host'))
|
||||
if (!hosts.every(host => host.$pool === hosts[0].$pool)) {
|
||||
// we need to do this test to ensure it won't create a partial LV group with only the host of the pool of the first master
|
||||
throw new Error('All hosts must be in the same pool')
|
||||
}
|
||||
|
||||
const boundInstallDependencies = installDependencies.bind(this)
|
||||
await asyncEach(hosts, host => boundInstallDependencies({ host }), { stopOnError: false })
|
||||
const boundFormatDisks = formatDisks.bind(this)
|
||||
await asyncEach(
|
||||
hosts,
|
||||
host => boundFormatDisks({ disks: disksByHost[host.id], host, force, ignoreFileSystems, provisioning }),
|
||||
{
|
||||
stopOnError: false,
|
||||
}
|
||||
)
|
||||
|
||||
const host = hosts[0]
|
||||
const xapi = this.getXapi(host)
|
||||
|
||||
log.info(`Create XOSTOR (${name}) with provisioning: ${provisioning}`)
|
||||
const srRef = await xapi.SR_create({
|
||||
device_config: {
|
||||
'group-name': 'linstor_group/' + LV_NAME,
|
||||
redundancy: String(replication),
|
||||
provisioning,
|
||||
},
|
||||
host: host.id,
|
||||
name_description: description,
|
||||
name_label: name,
|
||||
shared: true,
|
||||
type: 'linstor',
|
||||
})
|
||||
const srUuid = await xapi.getField('SR', srRef, 'uuid')
|
||||
|
||||
await this.rebindLicense({
|
||||
licenseId: license.id,
|
||||
oldBoundObjectId: tmpBoundObjectId,
|
||||
newBoundObjectId: srUuid,
|
||||
})
|
||||
|
||||
return srUuid
|
||||
})
|
||||
|
||||
create.description = 'Create a XOSTOR storage'
|
||||
create.permission = 'admin'
|
||||
create.params = {
|
||||
description: { type: 'string', optional: true, default: 'From XO-server' },
|
||||
disksByHost: { type: 'object' },
|
||||
force: { type: 'boolean', optional: true, default: false },
|
||||
ignoreFileSystems: { type: 'boolean', optional: true, default: false },
|
||||
name: { type: 'string' },
|
||||
provisioning: { enum: PROVISIONING },
|
||||
replication: { type: 'number' },
|
||||
}
|
||||
|
||||
// Also called by sr.destroy if sr.SR_type === 'linstor'
|
||||
export async function destroy({ sr }) {
|
||||
if (sr.SR_type !== 'linstor') {
|
||||
throw new Error('Not a XOSTOR storage')
|
||||
}
|
||||
const xapi = this.getXapi(sr)
|
||||
const hosts = Object.values(xapi.objects.indexes.type.host).map(host => this.getObject(host.uuid, 'host'))
|
||||
|
||||
await xapi.destroySr(sr._xapiId)
|
||||
const license = (await this.getLicenses({ productType: 'xostor' })).find(license => license.boundObjectId === sr.uuid)
|
||||
await this.unbindLicense({
|
||||
boundObjectId: license.boundObjectId,
|
||||
productId: license.productId,
|
||||
})
|
||||
return asyncEach(hosts, host => destroyVolumeGroup(xapi, host, true), { stopOnError: false })
|
||||
}
|
||||
destroy.description = 'Destroy a XOSTOR storage'
|
||||
destroy.permission = 'admin'
|
||||
destroy.params = {
|
||||
sr: { type: 'string' },
|
||||
}
|
||||
destroy.resolve = {
|
||||
sr: ['sr', 'SR', 'administrate'],
|
||||
}
|
||||
@@ -511,7 +511,8 @@ const TRANSFORMS = {
|
||||
// TODO: Should it replace usage?
|
||||
physical_usage: +obj.physical_utilisation,
|
||||
|
||||
allocationStrategy: ALLOCATION_BY_TYPE[srType],
|
||||
allocationStrategy:
|
||||
srType === 'linstor' ? obj.$PBDs[0]?.device_config.provisioning ?? 'unknown' : ALLOCATION_BY_TYPE[srType],
|
||||
current_operations: obj.current_operations,
|
||||
inMaintenanceMode: obj.other_config['xo:maintenanceState'] !== undefined,
|
||||
name_description: obj.name_description,
|
||||
|
||||
@@ -28,6 +28,7 @@ const messages = {
|
||||
uuid: 'UUID',
|
||||
vmSrUsage: 'Storage: {used} used of {total} ({free} free)',
|
||||
|
||||
new: 'New',
|
||||
notDefined: 'Not defined',
|
||||
status: 'Status',
|
||||
statusConnecting: 'Connecting',
|
||||
@@ -118,6 +119,8 @@ const messages = {
|
||||
advancedSettings: 'Advanced settings',
|
||||
forceUpgrade: 'Force upgrade',
|
||||
txChecksumming: 'TX checksumming',
|
||||
thick: 'Thick',
|
||||
thin: 'Thin',
|
||||
unknownSize: 'Unknown size',
|
||||
installedCertificates: 'Installed certificates',
|
||||
expiry: 'Expiry',
|
||||
@@ -2489,6 +2492,40 @@ const messages = {
|
||||
xosanUnderlyingStorageUsage: 'Using {usage}',
|
||||
xosanCustomIpNetwork: 'Custom IP network (/24)',
|
||||
xosanIssueHostNotInNetwork: 'Will configure the host xosan network device with a static IP address and plug it in.',
|
||||
// ----- XOSTOR -----
|
||||
approximateFinalSize: 'Approximate final size',
|
||||
cantFetchDisksFromNonXcpngHost: 'Unable to fetch physical disks from non-XCP-ng host',
|
||||
diskAlreadyMounted: 'The disk is mounted on: {mountpoint}',
|
||||
diskHasChildren: 'The disk has children',
|
||||
diskIncompatibleXostor: 'Disk incompatible with XOSTOR',
|
||||
diskIsReadOnly: 'The disk is Read-Only',
|
||||
disks: 'Disks',
|
||||
fieldRequired: '{field} is required',
|
||||
fieldsMissing: 'Some fields are missing',
|
||||
hostsNotSameNumberOfDisks: 'Hosts do not have the same number of disks',
|
||||
isTapdevsDisk: 'This is "tapdevs" disk',
|
||||
networks: 'Networks',
|
||||
notXcpPool: 'Not an XCP-ng pool',
|
||||
noXostorFound: 'No XOSTOR found',
|
||||
numberOfHosts: 'Number of hosts',
|
||||
objectDoesNotMeetXostorRequirements: '{object} does not meet XOSTOR requirements. Refer to the documentation.',
|
||||
onlyShowXostorRequirements: 'Only show {type} that meet XOSTOR requirements',
|
||||
poolAlreadyHasXostor: 'Pool already has a XOSTOR',
|
||||
poolNotRecentEnough: 'Not recent enough. Current version: {version}',
|
||||
replication: 'Replication',
|
||||
selectDisks: 'Select disk(s)…',
|
||||
selectedDiskTypeIncompatibleXostor: 'Only disks of type "Disk" and "Raid" are accepted. Selected disk type: {type}.',
|
||||
storage: 'Storage',
|
||||
summary: 'Summary',
|
||||
wrongNumberOfHosts: 'Wrong number of hosts',
|
||||
xostor: 'XOSTOR',
|
||||
xostorAvailableInXoa: 'XOSTOR is available in XOA',
|
||||
xostorIsInBetaStage: 'XOSTOR is currently in its BETA stage. Do not use it in a production environment!',
|
||||
xostorDiskRequired: 'At least one disk is required',
|
||||
xostorDisksDropdownLabel: '({nDisks, number} disk{nDisks, plural, one {} other {s}}) {hostname}',
|
||||
xostorMultipleLicenses: 'This XOSTOR has more than 1 license!',
|
||||
xostorPackagesWillBeInstalled: '"xcp-ng-release-linstor" and "xcp-ng-linstor" will be installed on each host',
|
||||
xostorReplicationWarning: 'If a disk dies, you will lose data',
|
||||
|
||||
// Hub
|
||||
hubPage: 'Hub',
|
||||
|
||||
@@ -9,7 +9,15 @@ import map from 'lodash/map.js'
|
||||
import { renderXoItemFromId } from './render-xo-item'
|
||||
|
||||
const LicenseOptions = ({ license, formatDate }) => {
|
||||
const productId = license.productId.split('-')[1]
|
||||
/**
|
||||
* license.productId can be:
|
||||
* - xcpng-enterprise
|
||||
* - xcpng-standard
|
||||
* - xo-proxy
|
||||
* - xostor
|
||||
* - xostor.trial
|
||||
*/
|
||||
const productId = license.productId.startsWith('xostor') ? license.productId : license.productId.split('-')[1]
|
||||
return (
|
||||
<option value={license.id}>
|
||||
<span>
|
||||
|
||||
@@ -1940,6 +1940,8 @@ export const importDisks = (disks, sr) =>
|
||||
)
|
||||
)
|
||||
|
||||
export const getBlockdevices = host => _call('host.getBlockdevices', { id: resolveId(host) })
|
||||
|
||||
import ExportVmModalBody from './export-vm-modal' // eslint-disable-line import/first
|
||||
export const exportVm = async vm => {
|
||||
const { compression, format } = await confirm({
|
||||
@@ -3485,6 +3487,10 @@ export const updateXosanPacks = pool =>
|
||||
return downloadAndInstallXosanPack(pack, pool, { version: pack.version })
|
||||
})
|
||||
|
||||
// XOSTOR --------------------------------------------------------------------
|
||||
|
||||
export const createXostorSr = params => _call('xostor.create', params)
|
||||
|
||||
// Licenses --------------------------------------------------------------------
|
||||
|
||||
export const getLicenses = ({ productType } = {}) => _call('xoa.licenses.getAll', { productType })
|
||||
|
||||
@@ -1019,7 +1019,7 @@
|
||||
@extend .fa-file-archive-o;
|
||||
}
|
||||
}
|
||||
&-menu-xosan {
|
||||
&-menu-xostor {
|
||||
@extend .fa;
|
||||
@extend .fa-database;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ import User from './user'
|
||||
import Vm from './vm'
|
||||
import Xoa from './xoa'
|
||||
import XoaUpdates from './xoa/update'
|
||||
import Xosan from './xosan'
|
||||
import Xostor from './xostor'
|
||||
import Import from './import'
|
||||
|
||||
import keymap, { help } from '../keymap'
|
||||
@@ -128,7 +128,7 @@ export const ICON_POOL_LICENSE = {
|
||||
'vms/new': NewVm,
|
||||
'vms/:id': Vm,
|
||||
xoa: Xoa,
|
||||
xosan: Xosan,
|
||||
xostor: Xostor,
|
||||
import: Import,
|
||||
hub: Hub,
|
||||
proxies: Proxies,
|
||||
|
||||
@@ -478,7 +478,11 @@ export default class Menu extends Component {
|
||||
label: 'taskMenu',
|
||||
pill: nResolvedTasks,
|
||||
},
|
||||
isAdmin && { to: '/xosan', icon: 'menu-xosan', label: 'xosan' },
|
||||
isAdmin && {
|
||||
to: '/xostor',
|
||||
label: 'xostor',
|
||||
icon: 'menu-xostor',
|
||||
},
|
||||
!noOperatablePools && {
|
||||
to: '/import/vm',
|
||||
icon: 'menu-new-import',
|
||||
|
||||
@@ -18,7 +18,7 @@ import { get } from '@xen-orchestra/defined'
|
||||
import { getLicenses, selfBindLicense, subscribePlugins, subscribeProxies, subscribeSelfLicenses } from 'xo'
|
||||
|
||||
import Proxies from './proxies'
|
||||
import Xosan from './xosan'
|
||||
import Xostor from './xostor'
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -196,7 +196,7 @@ export default class Licenses extends Component {
|
||||
|
||||
return getLicenses()
|
||||
.then(licenses => {
|
||||
const { proxy, xcpng, xoa, xosan } = groupBy(licenses, license => {
|
||||
const { proxy, xcpng, xoa, xosan, xostor } = groupBy(licenses, license => {
|
||||
for (const productType of license.productTypes) {
|
||||
if (productType === 'xo') {
|
||||
return 'xoa'
|
||||
@@ -210,6 +210,9 @@ export default class Licenses extends Component {
|
||||
if (productType === 'xcpng') {
|
||||
return 'xcpng'
|
||||
}
|
||||
if (productType === 'xostor') {
|
||||
return 'xostor'
|
||||
}
|
||||
}
|
||||
return 'other'
|
||||
})
|
||||
@@ -219,6 +222,7 @@ export default class Licenses extends Component {
|
||||
xcpng,
|
||||
xoa,
|
||||
xosan,
|
||||
xostor,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -300,6 +304,21 @@ export default class Licenses extends Component {
|
||||
}
|
||||
})
|
||||
|
||||
// --- XOSTOR ---
|
||||
forEach(licenses.xostor, license => {
|
||||
// When `expires` is undefined, the license isn't expired
|
||||
if (!(license.expires < now)) {
|
||||
products.push({
|
||||
buyer: license.buyer,
|
||||
expires: license.expires,
|
||||
id: license.id,
|
||||
product: 'XOSTOR',
|
||||
type: 'xostor',
|
||||
srId: license.boundObjectId,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return products
|
||||
}
|
||||
)
|
||||
@@ -377,18 +396,8 @@ export default class Licenses extends Component {
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
<h2>
|
||||
XOSAN
|
||||
<a
|
||||
className='btn btn-secondary ml-1'
|
||||
href='https://xen-orchestra.com/#!/xosan-home'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Icon icon='bug' /> {_('productSupport')}
|
||||
</a>
|
||||
</h2>
|
||||
<Xosan xosanLicenses={this.state.licenses.xosan} updateLicenses={this._updateLicenses} />
|
||||
<h2>{_('xostor')}</h2>
|
||||
<Xostor xostorLicenses={this.state.licenses.xostor} updateLicenses={this._updateLicenses} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import _ from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import Component from 'base-component'
|
||||
import Link from 'link'
|
||||
import React from 'react'
|
||||
import renderXoItem, { Pool } from 'render-xo-item'
|
||||
import SelectLicense from 'select-license'
|
||||
import SortedTable from 'sorted-table'
|
||||
import { connectStore } from 'utils'
|
||||
import { createSelector, createGetObjectsOfType } from 'selectors'
|
||||
import { filter, forEach, includes, map } from 'lodash'
|
||||
import { unlockXosan } from 'xo'
|
||||
|
||||
class XosanLicensesForm extends Component {
|
||||
state = {
|
||||
licenseId: 'none',
|
||||
}
|
||||
|
||||
onChangeLicense = event => {
|
||||
this.setState({ licenseId: event.target.value })
|
||||
}
|
||||
|
||||
unlockXosan = () => {
|
||||
const { item, userData } = this.props
|
||||
return unlockXosan(this.state.licenseId, item.id).then(userData.updateLicenses)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { item, userData } = this.props
|
||||
const { licenseId } = this.state
|
||||
|
||||
const license = userData.licensesByXosan[item.id]
|
||||
if (license === null) {
|
||||
return (
|
||||
<span className='text-danger'>
|
||||
{_('xosanMultipleLicenses')} <a href='https://xen-orchestra.com/'>{_('contactUs')}</a>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return license?.productId === 'xosan' ? (
|
||||
<span>{license.id.slice(-4)}</span>
|
||||
) : (
|
||||
<form className='form-inline'>
|
||||
<SelectLicense onChange={this.onChangeLicense} productType='xosan' />
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
className='ml-1'
|
||||
disabled={licenseId === 'none'}
|
||||
handler={this.unlockXosan}
|
||||
handlerParam={licenseId}
|
||||
icon='connect'
|
||||
>
|
||||
{_('bindLicense')}
|
||||
</ActionButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const XOSAN_COLUMNS = [
|
||||
{
|
||||
name: _('xosanName'),
|
||||
itemRenderer: sr => <Link to={`srs/${sr.id}`}>{renderXoItem(sr)}</Link>,
|
||||
sortCriteria: 'name_label',
|
||||
},
|
||||
{
|
||||
name: _('xosanPool'),
|
||||
itemRenderer: sr => <Pool id={sr.$pool} link />,
|
||||
},
|
||||
{
|
||||
name: _('license'),
|
||||
component: XosanLicensesForm,
|
||||
},
|
||||
]
|
||||
|
||||
const XOSAN_INDIVIDUAL_ACTIONS = [
|
||||
{
|
||||
label: _('productSupport'),
|
||||
icon: 'support',
|
||||
handler: () => window.open('https://xen-orchestra.com'),
|
||||
},
|
||||
]
|
||||
|
||||
@connectStore(() => ({
|
||||
xosanSrs: createGetObjectsOfType('SR').filter([
|
||||
({ SR_type }) => SR_type === 'xosan', // eslint-disable-line camelcase
|
||||
]),
|
||||
}))
|
||||
export default class Xosan extends Component {
|
||||
_getLicensesByXosan = createSelector(
|
||||
() => this.props.xosanLicenses,
|
||||
licenses => {
|
||||
const licensesByXosan = {}
|
||||
forEach(licenses, license => {
|
||||
let xosanId
|
||||
if ((xosanId = license.boundObjectId) === undefined) {
|
||||
return
|
||||
}
|
||||
licensesByXosan[xosanId] =
|
||||
licensesByXosan[xosanId] !== undefined
|
||||
? null // XOSAN bound to multiple licenses!
|
||||
: license
|
||||
})
|
||||
|
||||
return licensesByXosan
|
||||
}
|
||||
)
|
||||
|
||||
_getKnownXosans = createSelector(
|
||||
createSelector(
|
||||
() => this.props.xosanLicenses,
|
||||
(licenses = []) => filter(map(licenses, 'boundObjectId'))
|
||||
),
|
||||
() => this.props.xosanSrs,
|
||||
(knownXosanIds, xosanSrs) => filter(xosanSrs, ({ id }) => includes(knownXosanIds, id))
|
||||
)
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SortedTable
|
||||
collection={this._getKnownXosans()}
|
||||
columns={XOSAN_COLUMNS}
|
||||
individualActions={XOSAN_INDIVIDUAL_ACTIONS}
|
||||
stateUrlParam='s_xosan'
|
||||
userData={{
|
||||
licensesByXosan: this._getLicensesByXosan(),
|
||||
xosanSrs: this.props.xosanSrs,
|
||||
updateLicenses: this.props.updateLicenses,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
104
packages/xo-web/src/xo-app/xoa/licenses/xostor.js
Normal file
104
packages/xo-web/src/xo-app/xoa/licenses/xostor.js
Normal file
@@ -0,0 +1,104 @@
|
||||
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 { bindLicense } from 'xo'
|
||||
import { connectStore } from 'utils'
|
||||
import { createGetObjectsOfType } from 'selectors'
|
||||
import { groupBy } from 'lodash'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { Pool, Sr } from 'render-xo-item'
|
||||
|
||||
class XostorLicensesForm extends Component {
|
||||
state = {
|
||||
licenseId: 'none',
|
||||
}
|
||||
|
||||
bind = () => {
|
||||
const { item, userData } = this.props
|
||||
return bindLicense(this.state.licenseId, item.uuid).then(userData.updateLicenses)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { item, userData } = this.props
|
||||
const { licenseId } = this.state
|
||||
const licenses = userData.licensesByXostorUuid[item.id]
|
||||
|
||||
// Xostor bound to multiple licenses
|
||||
if (licenses?.length > 1) {
|
||||
return (
|
||||
<div>
|
||||
<span>{licenses.map(license => license.id.slice(-4)).join(',')}</span>{' '}
|
||||
<Tooltip content={_('xostorMultipleLicenses')}>
|
||||
<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.linkState('licenseId')} productType='xostor' />
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
className='ml-1'
|
||||
disabled={licenseId === 'none'}
|
||||
handler={this.bind}
|
||||
handlerParam={licenseId}
|
||||
icon='connect'
|
||||
>
|
||||
{_('bindLicense')}
|
||||
</ActionButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const INDIVIDUAL_ACTIONS = [
|
||||
{
|
||||
label: _('productSupport'),
|
||||
icon: 'support',
|
||||
handler: () => window.open('https://xen-orchestra.com'),
|
||||
},
|
||||
]
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
default: true,
|
||||
name: _('name'),
|
||||
itemRenderer: sr => <Sr id={sr.id} link container={false} />,
|
||||
sortCriteria: 'name_label',
|
||||
},
|
||||
{ name: _('pool'), itemRenderer: sr => <Pool id={sr.$pool} link /> },
|
||||
{ name: _('license'), component: XostorLicensesForm },
|
||||
]
|
||||
const Xostor = decorate([
|
||||
connectStore(() => ({
|
||||
xostorSrs: createGetObjectsOfType('SR').filter([({ SR_type }) => SR_type === 'linstor']),
|
||||
})),
|
||||
provideState({
|
||||
computed: {
|
||||
licensesByXostorUuid: (state, { xostorLicenses }) => groupBy(xostorLicenses, 'boundObjectId'),
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ state, xostorSrs, updateLicenses }) => (
|
||||
<SortedTable
|
||||
collection={xostorSrs}
|
||||
columns={COLUMNS}
|
||||
data-updateLicenses={updateLicenses}
|
||||
data-licensesByXostorUuid={state.licensesByXostorUuid}
|
||||
individualActions={INDIVIDUAL_ACTIONS}
|
||||
/>
|
||||
),
|
||||
])
|
||||
|
||||
export default Xostor
|
||||
4
packages/xo-web/src/xo-app/xostor/index.css
Normal file
4
packages/xo-web/src/xo-app/xostor/index.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.disksSelectors {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
71
packages/xo-web/src/xo-app/xostor/index.js
Normal file
71
packages/xo-web/src/xo-app/xostor/index.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import _ from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import decorate from 'apply-decorators'
|
||||
import Icon from 'icon'
|
||||
import React from 'react'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { Container, Col, Row } from 'grid'
|
||||
import { TryXoa } from 'utils'
|
||||
import { getXoaPlan, SOURCES } from 'xoa-plans'
|
||||
|
||||
import NewXostorForm from './new-xostor-form'
|
||||
import XostorList from './xostor-list'
|
||||
|
||||
import Page from '../page'
|
||||
|
||||
const HEADER = (
|
||||
<Container>
|
||||
<h2>
|
||||
<Icon icon='menu-xostor' /> {_('xostor')}
|
||||
</h2>
|
||||
</Container>
|
||||
)
|
||||
|
||||
const Xostor = decorate([
|
||||
provideState({
|
||||
initialState: () => ({ showNewXostorForm: false }),
|
||||
effects: {
|
||||
_toggleShowNewXostorForm() {
|
||||
this.state.showNewXostorForm = !this.state.showNewXostorForm
|
||||
},
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ effects, state }) => (
|
||||
<Page header={HEADER}>
|
||||
{getXoaPlan() === SOURCES ? (
|
||||
<Container>
|
||||
<h2 className='text-info'>{_('xostorAvailableInXoa')}</h2>
|
||||
<p>
|
||||
<TryXoa page='xosan' />
|
||||
</p>
|
||||
</Container>
|
||||
) : (
|
||||
<Container>
|
||||
<div className='alert alert-warning'>
|
||||
<p className='mb-0'>
|
||||
<strong>
|
||||
<Icon icon='alarm' /> {_('xostorIsInBetaStage')}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<XostorList />
|
||||
<Row className='mb-1'>
|
||||
<Col>
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
handler={effects._toggleShowNewXostorForm}
|
||||
icon={state.showNewXostorForm ? 'minus' : 'plus'}
|
||||
>
|
||||
{_('new')}
|
||||
</ActionButton>
|
||||
</Col>
|
||||
</Row>
|
||||
{state.showNewXostorForm && <NewXostorForm />}
|
||||
</Container>
|
||||
)}
|
||||
</Page>
|
||||
),
|
||||
])
|
||||
|
||||
export default Xostor
|
||||
593
packages/xo-web/src/xo-app/xostor/new-xostor-form.js
Normal file
593
packages/xo-web/src/xo-app/xostor/new-xostor-form.js
Normal file
@@ -0,0 +1,593 @@
|
||||
import _ from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import Collapse from 'collapse'
|
||||
import decorate from 'apply-decorators'
|
||||
import Icon from 'icon'
|
||||
import React from 'react'
|
||||
import Select from 'form/select'
|
||||
import semver from 'semver'
|
||||
import { Card, CardBlock, CardHeader } from 'card'
|
||||
import { connectStore, formatSize } from 'utils'
|
||||
import { Container, Col, Row } from 'grid'
|
||||
import { createGetObjectsOfType } from 'selectors'
|
||||
import { find, first, map, mapValues, remove, size, some } from 'lodash'
|
||||
import { createXostorSr, getBlockdevices } from 'xo'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { Input as DebounceInput } from 'debounce-input-decorator'
|
||||
import { Pool as PoolRenderItem, Network as NetworkRenderItem } from 'render-xo-item'
|
||||
import { SelectHost, SelectPool, SelectNetwork } from 'select-objects'
|
||||
import { toggleState, linkState } from 'reaclette-utils'
|
||||
|
||||
import styles from './index.css'
|
||||
|
||||
const MINIMAL_POOL_VERSION_FOR_XOSTOR = '8.2.1'
|
||||
|
||||
const N_HOSTS_MIN = 3
|
||||
const N_HOSTS_MAX = 7
|
||||
|
||||
const PROVISIONING_OPTIONS = [
|
||||
{ value: 'thin', label: _('thin') },
|
||||
{ value: 'thick', label: _('thick') },
|
||||
]
|
||||
|
||||
const REPLICATION_OPTIONS = [
|
||||
{ value: 1, label: '1' },
|
||||
{ value: 2, label: '2' },
|
||||
{ value: 3, label: '3' },
|
||||
]
|
||||
|
||||
const hasXostor = srs => some(srs, sr => sr.SR_type === 'linstor')
|
||||
const formatDiskName = name => '/dev/' + name
|
||||
const diskHasChildren = disk => Array.isArray(disk.children) && disk.children.length > 0
|
||||
const isDiskRecommendedType = disk => disk.type === 'disk' || disk.type.startsWith('raid')
|
||||
const isDiskMounted = disk => disk.mountpoint !== ''
|
||||
const isDiskRo = disk => disk.ro === '1'
|
||||
const isTapdevsDisk = disk => disk.name.startsWith('td')
|
||||
const isWithinRecommendedHostRange = hosts => size(hosts) >= N_HOSTS_MIN && size(hosts) <= N_HOSTS_MAX
|
||||
const isXcpngHost = host => host?.productBrand === 'XCP-ng'
|
||||
const isHostRecentEnough = host => semver.satisfies(host?.version, `>=${MINIMAL_POOL_VERSION_FOR_XOSTOR}`)
|
||||
const diskSelectRenderer = disk => (
|
||||
<span>
|
||||
<Icon icon='disk' /> {formatDiskName(disk.name)} {formatSize(Number(disk.size))}
|
||||
</span>
|
||||
)
|
||||
const xostorDiskPredicate = disk =>
|
||||
isDiskRecommendedType(disk) &&
|
||||
!isDiskRo(disk) &&
|
||||
!isDiskMounted(disk) &&
|
||||
!diskHasChildren(disk) &&
|
||||
!isTapdevsDisk(disk)
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const StorageCard = decorate([
|
||||
injectState,
|
||||
({ effects, state }) => (
|
||||
<Card>
|
||||
<CardHeader>{_('storage')}</CardHeader>
|
||||
<CardBlock>
|
||||
<Row>
|
||||
<Col>
|
||||
{_('name')}
|
||||
<DebounceInput className='form-control' name='srName' onChange={effects.linkState} value={state.srName} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className='mt-1'>
|
||||
<Col>
|
||||
{_('description')}
|
||||
<DebounceInput
|
||||
className='form-control'
|
||||
name='srDescription'
|
||||
onChange={effects.linkState}
|
||||
value={state.srDescription}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
),
|
||||
])
|
||||
|
||||
const SettingsCard = decorate([
|
||||
provideState({
|
||||
computed: {
|
||||
showWarningReplication: state => state.replication?.value === 1,
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ effects, state }) => (
|
||||
<Card>
|
||||
<CardHeader>{_('settings')}</CardHeader>
|
||||
<CardBlock>
|
||||
<Row>
|
||||
<Col>
|
||||
{_('replication')}
|
||||
<Select options={REPLICATION_OPTIONS} onChange={effects.onReplicationChange} value={state.replication} />
|
||||
{state.showWarningReplication && (
|
||||
<p className='text-warning'>
|
||||
<Icon icon='alarm' /> {_('xostorReplicationWarning')}
|
||||
</p>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className='form-group mt-1'>
|
||||
<Col>
|
||||
{_('provisioning')}
|
||||
<Select onChange={effects.onProvisioningChange} options={PROVISIONING_OPTIONS} value={state.provisioning} />
|
||||
</Col>
|
||||
</Row>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
),
|
||||
])
|
||||
|
||||
const PoolCard = decorate([
|
||||
connectStore({
|
||||
srs: createGetObjectsOfType('SR').groupBy('$pool'),
|
||||
}),
|
||||
provideState({
|
||||
initialState: () => ({ onlyShowXostorPools: true }),
|
||||
effects: {
|
||||
toggleState,
|
||||
},
|
||||
computed: {
|
||||
poolPredicate: (state, props) => {
|
||||
if (!state.onlyShowXostorPools) {
|
||||
return undefined
|
||||
}
|
||||
return pool => {
|
||||
const poolHosts = props.hostsByPoolId?.[pool.id]
|
||||
const host = first(poolHosts)
|
||||
return (
|
||||
isWithinRecommendedHostRange(poolHosts) &&
|
||||
isXcpngHost(host) &&
|
||||
!hasXostor(props.srs[pool.id]) &&
|
||||
isHostRecentEnough(host)
|
||||
)
|
||||
}
|
||||
},
|
||||
poolIsWithinRecommendedHostRange: state => isWithinRecommendedHostRange(state.poolHosts),
|
||||
poolHasXostor: (state, props) => hasXostor(props.srs[state.poolId]),
|
||||
isPoolRecentEnough: state => isHostRecentEnough(first(state.poolHosts)),
|
||||
isPoolXostorCompatible: state =>
|
||||
state.isXcpngHost && state.poolIsWithinRecommendedHostRange && !state.poolHasXostor && state.isPoolRecentEnough,
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ effects, state }) => (
|
||||
<Card>
|
||||
<CardHeader>{_('pool')}</CardHeader>
|
||||
<CardBlock>
|
||||
<div>
|
||||
<label>
|
||||
<input
|
||||
checked={state.onlyShowXostorPools}
|
||||
name='onlyShowXostorPools'
|
||||
onChange={effects.toggleState}
|
||||
type='checkbox'
|
||||
/>{' '}
|
||||
{_('onlyShowXostorRequirements', { type: _('pools') })}
|
||||
</label>
|
||||
<SelectPool onChange={effects.onPoolChange} predicate={state.poolPredicate} value={state.poolId} />
|
||||
{state.poolHosts !== undefined && !state.isPoolXostorCompatible && (
|
||||
<div className='text-danger'>
|
||||
{/* FIXME: add link of the documentation when ready */}
|
||||
<a href='#' rel='noreferrer' target='_blank'>
|
||||
{_('objectDoesNotMeetXostorRequirements', { object: <PoolRenderItem id={state.poolId} /> })}
|
||||
</a>
|
||||
<ul>
|
||||
{!state.isXcpngHost && <li>{_('notXcpPool')}</li>}
|
||||
{!state.poolIsWithinRecommendedHostRange && <li>{_('wrongNumberOfHosts')}</li>}
|
||||
{state.poolHasXostor && <li>{_('poolAlreadyHasXostor')}</li>}
|
||||
{!state.isPoolRecentEnough && (
|
||||
<li>{_('poolNotRecentEnough', { version: first(state.poolHosts).version })}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<em>
|
||||
<Icon icon='info' /> {_('xostorPackagesWillBeInstalled')}
|
||||
</em>
|
||||
</div>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
),
|
||||
])
|
||||
|
||||
const NetworkCard = decorate([
|
||||
provideState({
|
||||
initialState: () => ({ onlyShowXostorNetworks: true }),
|
||||
effects: {
|
||||
toggleState,
|
||||
},
|
||||
computed: {
|
||||
networksPredicate: (state, props) => network => {
|
||||
const isOnPool = network.$pool === state.poolId
|
||||
const pifs = network.PIFs
|
||||
return state.onlyShowXostorNetworks
|
||||
? isOnPool && pifs.length > 0 && pifs.every(pifId => props.pifs[pifId].ip !== '')
|
||||
: isOnPool
|
||||
},
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ effects, state }) => (
|
||||
<Card>
|
||||
<CardHeader>{_('network')}</CardHeader>
|
||||
<CardBlock>
|
||||
<label>
|
||||
<input
|
||||
checked={state.onlyShowXostorNetworks}
|
||||
name='onlyShowXostorNetworks'
|
||||
onChange={effects.toggleState}
|
||||
type='checkbox'
|
||||
/>{' '}
|
||||
{_('onlyShowXostorRequirements', { type: _('networks') })}
|
||||
</label>
|
||||
<SelectNetwork
|
||||
disabled={!state.isPoolSelected}
|
||||
onChange={effects.onNetworkChange}
|
||||
predicate={state.networksPredicate}
|
||||
value={state.networkId}
|
||||
/>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
),
|
||||
])
|
||||
|
||||
const DisksCard = decorate([
|
||||
provideState({
|
||||
initialState: () => ({
|
||||
onlyShowXostorDisks: true,
|
||||
}),
|
||||
effects: {
|
||||
toggleState,
|
||||
_onDiskChange(_, disk) {
|
||||
this.effects.onDiskChange(disk, this.state.hostId)
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
_blockdevices: async state =>
|
||||
state.isHostSelected && state.isXcpngHost ? (await getBlockdevices(state.hostId)).blockdevices : undefined,
|
||||
_disks: state =>
|
||||
state.onlyShowXostorDisks ? state._blockdevices?.filter(xostorDiskPredicate) : state._blockdevices,
|
||||
predicate: state => host => host.$pool === state.poolId,
|
||||
isHostSelected: state => state.hostId !== undefined,
|
||||
selectableDisks: state =>
|
||||
state._disks
|
||||
?.filter(disk => !state.disksByHost[state.hostId]?.some(_disk => _disk.name === disk.name))
|
||||
.sort((prev, next) => Number(next.size) - Number(prev.size)),
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ effects, state }) => (
|
||||
<Card>
|
||||
<CardHeader>{_('disks')}</CardHeader>
|
||||
<CardBlock>
|
||||
<Row>
|
||||
<Col size={8}>
|
||||
<Row className={styles.disksSelectors}>
|
||||
<Col size={6}>
|
||||
<SelectHost
|
||||
disabled={!state.isPoolSelected}
|
||||
onChange={effects.onHostChange}
|
||||
predicate={state.predicate}
|
||||
value={state.hostId}
|
||||
/>
|
||||
</Col>
|
||||
<Col size={6}>
|
||||
<label>
|
||||
<input
|
||||
checked={state.onlyShowXostorDisks}
|
||||
onChange={effects.toggleState}
|
||||
name='onlyShowXostorDisks'
|
||||
type='checkbox'
|
||||
/>{' '}
|
||||
{_('onlyShowXostorRequirements', { type: _('disks') })}
|
||||
</label>
|
||||
{state.isPoolSelected && !state.isXcpngHost && (
|
||||
<p className='text-danger mb-0'>
|
||||
<Icon icon='alarm' /> {_('cantFetchDisksFromNonXcpngHost')}
|
||||
</p>
|
||||
)}
|
||||
<Select
|
||||
disabled={!state.isHostSelected || !state.isPoolSelected || !state.isXcpngHost}
|
||||
onChange={effects._onDiskChange}
|
||||
optionRenderer={diskSelectRenderer}
|
||||
options={state.isHostSelected ? state.selectableDisks : []}
|
||||
placeholder={_('selectDisks')}
|
||||
value={null}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className='mt-1'>
|
||||
<Col>
|
||||
<SelectedDisks hostId={state.hostId} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col size={4}>
|
||||
{map(state.poolHosts, host => (
|
||||
<Collapse
|
||||
buttonText={_('xostorDisksDropdownLabel', {
|
||||
nDisks: state.disksByHost[host.id]?.length ?? 0,
|
||||
hostname: host.hostname,
|
||||
})}
|
||||
defaultOpen
|
||||
key={host.id}
|
||||
size='small'
|
||||
>
|
||||
<SelectedDisks hostId={host.id} fromDropdown />
|
||||
</Collapse>
|
||||
))}
|
||||
</Col>
|
||||
</Row>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
),
|
||||
])
|
||||
|
||||
const SelectedDisks = decorate([
|
||||
provideState({
|
||||
effects: {
|
||||
_onDiskRemove(_, disk) {
|
||||
this.effects.onDiskRemove(disk, this.props.hostId)
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
disksHost: (state, props) => state.disksByHost[props.hostId],
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ effects, state, fromDropdown }) =>
|
||||
state.isHostSelected || fromDropdown ? (
|
||||
state.disksHost === undefined || state.disksHost.length < 1 ? (
|
||||
<p>{_('noDisks')}</p>
|
||||
) : (
|
||||
<ul className='list-group'>
|
||||
{state.disksHost.map(disk => (
|
||||
<ItemSelectedDisks disk={disk} key={disk.name} onDiskRemove={effects._onDiskRemove} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
) : null,
|
||||
])
|
||||
|
||||
const ItemSelectedDisks = ({ disk, onDiskRemove }) => {
|
||||
const _isDiskRecommendedType = isDiskRecommendedType(disk)
|
||||
const _isDiskRo = isDiskRo(disk)
|
||||
const _isDiskMounted = isDiskMounted(disk)
|
||||
const _diskHasChildren = diskHasChildren(disk)
|
||||
const _isTapdevsDisk = isTapdevsDisk(disk)
|
||||
const isDiskValid = _isDiskRecommendedType && !_isDiskRo && !_isDiskMounted && !_diskHasChildren && !_isTapdevsDisk
|
||||
|
||||
return (
|
||||
<li className='list-group-item'>
|
||||
<Icon icon='disk' /> {formatDiskName(disk.name)} {formatSize(Number(disk.size))}
|
||||
<ActionButton
|
||||
btnStyle='danger'
|
||||
className='pull-right'
|
||||
handler={onDiskRemove}
|
||||
handlerParam={disk}
|
||||
icon='delete'
|
||||
size='small'
|
||||
/>
|
||||
{!isDiskValid && (
|
||||
<div className='text-danger'>
|
||||
<Icon icon='error' /> {_('diskIncompatibleXostor')}
|
||||
<ul>
|
||||
{!_isDiskRecommendedType && <li>{_('selectedDiskTypeIncompatibleXostor', { type: disk.type })}</li>}
|
||||
{_isDiskRo && <li>{_('diskIsReadOnly')}</li>}
|
||||
{_isDiskMounted && <li>{_('diskAlreadyMounted', { mountpoint: disk.mountpoint })}</li>}
|
||||
{_diskHasChildren && <li>{_('diskHasChildren')}</li>}
|
||||
{_isTapdevsDisk && <li>{_('isTapdevsDisk')}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const SummaryCard = decorate([
|
||||
provideState({
|
||||
computed: {
|
||||
areHostsDisksConsistent: state =>
|
||||
state._disksByHostValues.every(disks => disks.length === state._disksByHostValues[0]?.length),
|
||||
finalSize: state => {
|
||||
const totalSize = state._disksByHostValues.reduce((minSize, disks) => {
|
||||
const size = disks.reduce((acc, disk) => acc + Number(disk.size), 0)
|
||||
return minSize === 0 || size < minSize ? size : minSize
|
||||
}, 0)
|
||||
|
||||
return (totalSize * state.numberOfHostsWithDisks) / state.replication.value
|
||||
},
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ state }) => {
|
||||
const srDescription = state.srDescription.trim()
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>{_('summary')}</CardHeader>
|
||||
<CardBlock>
|
||||
{state.isFormInvalid ? (
|
||||
<div className='text-danger'>
|
||||
<p>{_('fieldsMissing')}</p>
|
||||
<ul>
|
||||
{state.isReplicationMissing && <li>{_('fieldRequired', { field: _('replication') })}</li>}
|
||||
{state.isProvisioningMissing && <li>{_('fieldRequired', { field: _('provisioning') })}</li>}
|
||||
{state.isNameMissing && <li>{_('fieldRequired', { field: _('name') })}</li>}
|
||||
{state.isDisksMissing && <li>{_('xostorDiskRequired')}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{!state.areHostsDisksConsistent && (
|
||||
<p className='text-warning'>
|
||||
<Icon icon='alarm' /> {_('hostsNotSameNumberOfDisks')}
|
||||
</p>
|
||||
)}
|
||||
<Row>
|
||||
<Col size={6}>{_('keyValue', { key: _('name'), value: state.srName })}</Col>
|
||||
<Col size={6}>
|
||||
{_('keyValue', {
|
||||
key: _('description'),
|
||||
value: srDescription === '' ? _('noValue') : srDescription,
|
||||
})}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col size={6}>{_('keyValue', { key: _('replication'), value: state.replication.label })}</Col>
|
||||
<Col size={6}>{_('keyValue', { key: _('provisioning'), value: state.provisioning.label })}</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col size={12}>{_('keyValue', { key: _('pool'), value: <PoolRenderItem id={state.poolId} /> })}</Col>
|
||||
{/* FIXME: XOSTOR network management is not yet implemented at XOSTOR level */}
|
||||
{/* <Col size={6}>
|
||||
{_('keyValue', { key: _('network'), value: <NetworkRenderItem id={state.networkId} /> })}
|
||||
</Col> */}
|
||||
</Row>
|
||||
<Row>
|
||||
<Col size={6}>{_('keyValue', { key: _('numberOfHosts'), value: state.numberOfHostsWithDisks })}</Col>
|
||||
<Col size={6}>
|
||||
{_('keyValue', { key: _('approximateFinalSize'), value: formatSize(state.finalSize) })}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</CardBlock>
|
||||
</Card>
|
||||
)
|
||||
},
|
||||
])
|
||||
|
||||
const NewXostorForm = decorate([
|
||||
connectStore({
|
||||
hostsByPoolId: createGetObjectsOfType('host').sort().groupBy('$pool'),
|
||||
networks: createGetObjectsOfType('network'),
|
||||
pifs: createGetObjectsOfType('PIF'),
|
||||
}),
|
||||
provideState({
|
||||
initialState: () => ({
|
||||
_networkId: undefined,
|
||||
_createdSrUuid: undefined, // used for redirection when the storage has been created
|
||||
disksByHost: {},
|
||||
provisioning: PROVISIONING_OPTIONS[0], // default value 'thin'
|
||||
poolId: undefined,
|
||||
hostId: undefined,
|
||||
replication: REPLICATION_OPTIONS[1], // default value 2
|
||||
srDescription: '',
|
||||
srName: '',
|
||||
}),
|
||||
effects: {
|
||||
linkState,
|
||||
onHostChange(_, host) {
|
||||
this.state.hostId = host?.id
|
||||
},
|
||||
onPoolChange(_, pool) {
|
||||
this.state.disksByHost = {}
|
||||
this.state.poolId = pool?.id
|
||||
},
|
||||
onReplicationChange(_, replication) {
|
||||
this.state.replication = replication
|
||||
},
|
||||
onProvisioningChange(_, provisioning) {
|
||||
this.state.provisioning = provisioning
|
||||
},
|
||||
onNetworkChange(_, network) {
|
||||
this.state._networkId = network?.id ?? null
|
||||
},
|
||||
onDiskChange(_, disk, hostId) {
|
||||
const { disksByHost } = this.state
|
||||
if (disksByHost[hostId] === undefined) {
|
||||
disksByHost[hostId] = []
|
||||
}
|
||||
disksByHost[hostId].push(disk)
|
||||
this.state.disksByHost = { ...disksByHost }
|
||||
},
|
||||
onDiskRemove(_, disk, hostId) {
|
||||
const disks = this.state.disksByHost[hostId]
|
||||
remove(disks, _disk => _disk.name === disk.name)
|
||||
this.state.disksByHost = {
|
||||
...this.state.disksByHost,
|
||||
[hostId]: disks,
|
||||
}
|
||||
},
|
||||
async createXostorSr() {
|
||||
const { disksByHost, srDescription, srName, provisioning, replication } = this.state
|
||||
|
||||
this.state._createdSrUuid = await createXostorSr({
|
||||
description: srDescription.trim() === '' ? undefined : srDescription.trim(),
|
||||
disksByHost: mapValues(disksByHost, disks => disks.map(disk => formatDiskName(disk.name))),
|
||||
name: srName.trim() === '' ? undefined : srName.trim(),
|
||||
provisioning: provisioning.value,
|
||||
replication: replication.value,
|
||||
})
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
// Private ==========
|
||||
_disksByHostValues: state => Object.values(state.disksByHost).filter(disks => disks.length > 0),
|
||||
_defaultNetworkId: (state, props) => props.networks?.[state._pifManagement?.$network]?.id,
|
||||
_pifManagement: (state, props) => find(props.pifs, pif => pif.$pool === state.poolId && pif.management),
|
||||
// Utils ============
|
||||
poolHosts: (state, props) => props.hostsByPoolId?.[state.poolId],
|
||||
isPoolSelected: state => state.poolId !== undefined,
|
||||
numberOfHostsWithDisks: state => state._disksByHostValues.length,
|
||||
isReplicationMissing: state => state.replication === null,
|
||||
isProvisioningMissing: state => state.provisioning === null,
|
||||
isNameMissing: state => state.srName.trim() === '',
|
||||
isDisksMissing: state => state.numberOfHostsWithDisks === 0,
|
||||
isFormInvalid: state =>
|
||||
state.isReplicationMissing || state.isProvisioningMissing || state.isNameMissing || state.isDisksMissing,
|
||||
isXcpngHost: state => isXcpngHost(first(state.poolHosts)),
|
||||
getSrPath: state => () => `/srs/${state._createdSrUuid}`,
|
||||
// State ============
|
||||
networkId: state => (state._networkId === undefined ? state._defaultNetworkId : state._networkId),
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ effects, resetState, state, hostsByPoolId, networks, pifs }) => (
|
||||
<Container>
|
||||
<Row>
|
||||
<Col size={6}>
|
||||
<StorageCard />
|
||||
</Col>
|
||||
<Col size={6}>
|
||||
<SettingsCard />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col size={12}>
|
||||
<PoolCard hostsByPoolId={hostsByPoolId} />
|
||||
</Col>
|
||||
{/* FIXME: XOSTOR network management is not yet implemented at XOSTOR level */}
|
||||
{/* <Col size={6}>
|
||||
<NetworkCard networks={networks} pifs={pifs} />
|
||||
</Col> */}
|
||||
</Row>
|
||||
<Row>
|
||||
<DisksCard />
|
||||
</Row>
|
||||
<Row>
|
||||
<SummaryCard />
|
||||
</Row>
|
||||
<Row>
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
disabled={state.isFormInvalid}
|
||||
handler={effects.createXostorSr}
|
||||
icon='add'
|
||||
redirectOnSuccess={state.getSrPath}
|
||||
>
|
||||
{_('create')}
|
||||
</ActionButton>
|
||||
<ActionButton className='ml-1' handler={resetState} icon='reset'>
|
||||
{_('formReset')}
|
||||
</ActionButton>
|
||||
</Row>
|
||||
</Container>
|
||||
),
|
||||
])
|
||||
|
||||
export default NewXostorForm
|
||||
79
packages/xo-web/src/xo-app/xostor/xostor-list.js
Normal file
79
packages/xo-web/src/xo-app/xostor/xostor-list.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import _ from 'intl'
|
||||
import decorate from 'apply-decorators'
|
||||
import React from 'react'
|
||||
import SortedTable from 'sorted-table'
|
||||
import Tooltip from 'tooltip'
|
||||
import { connectStore, formatSize } from 'utils'
|
||||
import { createGetObjectsOfType, createSelector } from 'selectors'
|
||||
import { deleteSr } from 'xo'
|
||||
import { map } from 'lodash'
|
||||
import { Pool } from 'render-xo-item'
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
name: _('srPool'),
|
||||
itemRenderer: sr => <Pool id={sr.pool.id} link />,
|
||||
sortCriteria: 'pool.name_label',
|
||||
},
|
||||
{
|
||||
name: _('name'),
|
||||
itemRenderer: sr => sr.name_label,
|
||||
sortCriteria: 'name_label',
|
||||
},
|
||||
{
|
||||
name: _('provisioning'),
|
||||
itemRenderer: sr => sr.allocationStrategy,
|
||||
sortCriteria: 'allocationStrategy',
|
||||
},
|
||||
{
|
||||
name: _('size'),
|
||||
itemRenderer: sr => formatSize(sr.size),
|
||||
sortCriteria: 'size',
|
||||
},
|
||||
{
|
||||
name: _('usedSpace'),
|
||||
itemRenderer: sr => {
|
||||
const used = (sr.physical_usage * 100) / sr.size
|
||||
return (
|
||||
<Tooltip
|
||||
content={_('spaceLeftTooltip', {
|
||||
used: String(Math.round(used)),
|
||||
free: formatSize(sr.size - sr.physical_usage),
|
||||
})}
|
||||
>
|
||||
<progress className='progress' max='100' value={used} />
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
sortCriteria: sr => (sr.physical_usage * 100) / sr.size,
|
||||
},
|
||||
]
|
||||
|
||||
const INDIVIDUAL_ACTIONS = [
|
||||
{
|
||||
handler: deleteSr,
|
||||
icon: 'delete',
|
||||
label: _('delete'),
|
||||
level: 'danger',
|
||||
},
|
||||
]
|
||||
|
||||
const XostorList = decorate([
|
||||
connectStore(() => ({
|
||||
xostorSrs: createSelector(
|
||||
createGetObjectsOfType('SR').filter([sr => sr.SR_type === 'linstor']),
|
||||
createGetObjectsOfType('pool').groupBy('id'),
|
||||
(srs, poolByIds) => {
|
||||
return map(srs, sr => ({
|
||||
...sr,
|
||||
pool: poolByIds[sr.$pool][0],
|
||||
}))
|
||||
}
|
||||
),
|
||||
})),
|
||||
({ xostorSrs }) => (
|
||||
<SortedTable collection={xostorSrs} columns={COLUMNS} individualActions={INDIVIDUAL_ACTIONS} stateUrlParam='s' />
|
||||
),
|
||||
])
|
||||
|
||||
export default XostorList
|
||||
Reference in New Issue
Block a user