Compare commits

...

47 Commits

Author SHA1 Message Date
Julien Fontanet
7da0146d3e feat(xo-server/token): savelog last use info 2023-10-27 11:37:44 +02:00
Julien Fontanet
f3bbcbde08 feat(xo-cli): only create a single token per instance (and user) 2023-10-27 11:28:39 +02:00
Julien Fontanet
0559fe8649 feat(xo-server/token): client info support 2023-10-27 11:28:39 +02:00
Julien Fontanet
9e70397240 fix(xo-server/redis): fix indexes handling
Introduced by 225a67ae3
2023-10-27 11:27:25 +02:00
Thierry Goettelmann
5f69b0e9a0 feat(lite/console): new console toolbar (#7088) 2023-10-27 10:27:51 +02:00
Julien Fontanet
2a9bff1607 chore(xo-server/importConfig): don't use deptree 2023-10-27 10:14:02 +02:00
Pierre Donias
9e621d7de8 feat(lite/header): replace logo with "XO LITE" (#7118) 2023-10-27 09:16:28 +02:00
Mathieu
3e5c73528d feat(xo-server,xo-web/XOSTOR): XOSTOR implementation (#6983)
See https://xcp-ng.org/forum/topic/5361
2023-10-26 16:58:59 +02:00
Pierre Donias
397b5cd56d fix(xo-server/snapshot): allow self user that is member of a group to snapshot (#7129)
Introduced by a88798cc22
See Zammad#18478
2023-10-26 16:08:43 +02:00
Julien Fontanet
55cb6042e8 chore(yarn.lock): update dev deps 2023-10-26 11:00:14 +02:00
Pierre Donias
339d920b78 feat(xo-web/proxy): ability to open support tunnel on XO Proxy (#7127)
Requires #7126
2023-10-25 17:26:06 +02:00
Julien Fontanet
f14f716f3d feat(xo-server/api): proxy.openSupportTunnel (#7126)
The goal is to provide an easier way for the support team to open a tunnel on a proxy appliance.

This is the server side of this feature.
2023-10-25 17:12:17 +02:00
Julien Fontanet
fb83d1fc98 feat(xo-server/api): ignorable parameters (#7125) 2023-10-25 15:49:41 +02:00
Julien Fontanet
62208e7847 fix(xo-server-transport-xmpp): fix loading (#7082)
Fixes https://xcp-ng.org/forum/post/66402

Introduced by d6fc86b6b
2023-10-25 14:36:40 +02:00
Julien Fontanet
df91772f5c chore(xo-server/server): use builtin (un)serialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
cf8a9d40be chore(xo-server/remote): use builtin (un)serialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
93d1c6c3fc chore(xo-server/plugin-metadata): use builtin (un)serialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
f1fa811e5c chore(xo-server/user): use builtin (un)serialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
5a9812c492 chore(xo-server/group): use builtin (un)serialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
b53d613a64 chore(xo-server/token): use builtin unserialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
225a67ae3b chore(xo-server/redis): proper (un)serialization support 2023-10-25 11:48:53 +02:00
Mathieu
c7eb7db463 feat(xo-web/about): display if XO from source is up to date (#7091)
Fixes #5934
2023-10-24 17:14:01 +02:00
Pierre Donias
edfa729672 chore(lite/assets): remove darkreader properties in SVG files (#7121) 2023-10-24 16:40:37 +02:00
Mathieu
77d9798319 fix(xo-web/vtpm): fix various an error has occured (#7122)
Introduced by 8834af65f7
Introduced by 1a1dd0531d

Fix `an error has occurred` in the VM advanced tab and on the VM creation form
if the user does not have pool permission.
2023-10-24 16:26:36 +02:00
Pierre Donias
680f1e2f07 chore(lite): serve Poppins font internally (#7117) 2023-10-24 15:19:54 +02:00
Julien Fontanet
7c009b0fc0 feat(xo-server): support reading JSON records in Redis
This allows forward compatibility with future versions which will use JSON records in the future.
2023-10-23 15:13:28 +02:00
Pierre Donias
eb7de4f2dd feat(xo-web/self): show # of VMs that belong to each Resource Set (#7114)
See Zammad#17568
2023-10-23 15:03:30 +02:00
Olivier Lambert
2378399981 docs: update project's README (#7116) 2023-10-23 14:25:03 +02:00
Florent BEAUCHAMP
37b2113763 feat(fs/s3): compute sensible chunk size for uploads 2023-10-23 10:23:50 +02:00
Florent BEAUCHAMP
5048485a85 feat(fs/s3): object lock mode need content md5
and the middleware consume addiitionnal memory
2023-10-23 10:23:50 +02:00
Florent BEAUCHAMP
9e667533e9 fix(fs/s3): throw error if upload >50GB 2023-10-23 10:23:50 +02:00
MlssFrncJrg
1fac7922b4 feat(xo-web/dashboard/health): VDIs to coalesce warning contains the number (#7111)
Fixes Zammad#17577
2023-10-20 15:53:24 +02:00
Julien Fontanet
1a0e5eb6fc chore: format with Prettier 2023-10-20 15:52:10 +02:00
Pierre Donias
321e322492 feat(xo-server/clearHost): pass optional batch size arg (#7107)
Fixes #7105
See https://github.com/xapi-project/xen-api/issues/5202
See https://github.com/xapi-project/xen-api/pull/5203

`host.evacuate`: try passing optional batch size argument.
If not supported: remove it and try again.
2023-10-19 17:03:14 +02:00
Mathieu
8834af65f7 feat(xo-server/xo-web/VM/new): VTPM creation (#7077)
See #7066
See #6802
See #7085
2023-10-19 16:48:56 +02:00
Mathieu
1a1dd0531d feat(xo-web/VM/advanced): VTPM management (#7085)
See #7066
See #6802
See #7074
2023-10-19 15:46:03 +02:00
Pierre Donias
8752487280 docs(installation): add nfs-common dependency for Debian/Ubuntu (#7108) 2023-10-18 22:50:29 +02:00
Pierre Donias
4b12a6d31d fix(xo-server-usage-report): handle null and nested stats (#7092)
Introduced by 083483645e

Fixes Zammad#18120
Fixes Zammad#18266

- Always assume that data can be `null`
- Handle edge cases where all values are `null`
- Properly handle nested RRD collections: collections have different depths (`memory`: 1, `cpus[0]`: 2, `pifs.rx[0]`: 3, ...). This PR replaces `getLastDays` which wouldn't handle those depths properly, with `getDeepLastValues` which is run on the whole stat object and doesn't assume the depth of the collections. It finds any Array at any depth and slices it to only keep the last N values.
2023-10-18 22:50:08 +02:00
Julien Fontanet
2924f82754 fix(xo-web): don't sign out on connection error (#7103)
May fix zammad#17717

Introduced by 005ab47d9
2023-10-18 18:07:16 +02:00
Pierre Donias
9b236a6191 fix(netbox/test): test custom fields first (#7104)
More atomic and it makes more sense for users to check that the Netbox
configuration is correct before doing any write operations
2023-10-18 11:56:10 +02:00
Julien Fontanet
a3b8553cec fix(xo-server,xo-web): fix total number of VDIs to coalesce (#7098)
Fixes #7016

Summing all chains does take not common chains into account, the total must be computed on the server side.
2023-10-18 11:52:43 +02:00
Pierre Donias
00a1778a6d feat(lite): set color-scheme CSS property to "dark" in dark mode (#7101) 2023-10-17 16:50:13 +02:00
MlssFrncJrg
3b6bc629bc fix(xo-web/home): fix misaligned descriptions (#7090) 2023-10-16 15:53:35 +02:00
Pierre Donias
04dfd9a02c fix(xo-server-usage-report): use @xen-orchestra/log to log errors (#7096)
Fixes Zammad#14579
Fixes Zammad#18183

Better handles error objects with a circular structure and avoids "Converting
circular structure to JSON" error on stringify
2023-10-16 10:07:57 +02:00
Pierre Donias
fb52868074 fix(xo-server/patching): always check that XS credentials are configured on XS (#7093)
Introduced by a30d962b1d
2023-10-13 16:49:04 +02:00
Pierre Donias
77d53d2abf fix(xo-server/patching): always pass xsCredentials to installPatches on XS (#7089)
Fixes Zammad#18284

Introduced by a30d962b1d
2023-10-13 11:45:17 +02:00
Julien Fontanet
6afb87def1 feat(xo-server/vm.set): support xenStoreData
Fixes #7055
2023-10-13 11:26:48 +02:00
90 changed files with 3592 additions and 1854 deletions

View File

@@ -681,11 +681,13 @@ export class RemoteAdapter {
}
}
async outputStream(path, input, { checksum = true, validator = noop } = {}) {
async outputStream(path, input, { checksum = true, maxStreamLength, streamLength, validator = noop } = {}) {
const container = watchStreamSize(input)
await this._handler.outputStream(path, input, {
checksum,
dirMode: this._dirMode,
maxStreamLength,
streamLength,
async validator() {
await input.task
return validator.apply(this, arguments)

View File

@@ -29,6 +29,8 @@ export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote
writer =>
writer.run({
stream: forkStreamUnpipe(stream),
// stream will be forked and transformed, it's not safe to attach additionnal properties to it
streamLength: stream.length,
timestamp: metadata.timestamp,
vm: metadata.vm,
vmSnapshot: metadata.vmSnapshot,

View File

@@ -35,13 +35,23 @@ export const FullXapi = class FullXapiVmBackupRunner extends AbstractXapi {
useSnapshot: false,
})
)
const vdis = await exportedVm.$getDisks()
let maxStreamLength = 1024 * 1024 // Ovf file and tar headers are a few KB, let's stay safe
for (const vdiRef of vdis) {
const vdi = await this._xapi.getRecord(vdiRef)
// at most the xva will take the physical usage of the disk
// the resulting stream can be smaller due to the smaller block size for xva than vhd, and compression of xcp-ng
maxStreamLength += vdi.physical_utilisation
}
const sizeContainer = watchStreamSize(stream)
const timestamp = Date.now()
await this._callWriters(
writer =>
writer.run({
maxStreamLength,
sizeContainer,
stream: forkStreamUnpipe(stream),
timestamp,

View File

@@ -24,7 +24,7 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
)
}
async _run({ timestamp, sizeContainer, stream, vm, vmSnapshot }) {
async _run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
const settings = this._settings
const job = this._job
const scheduleId = this._scheduleId
@@ -65,6 +65,8 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
await Task.run({ name: 'transfer' }, async () => {
await adapter.outputStream(dataFilename, stream, {
maxStreamLength,
streamLength,
validator: tmpPath => adapter.isValidXva(tmpPath),
})
return { size: sizeContainer.size }

View File

@@ -1,9 +1,9 @@
import { AbstractWriter } from './_AbstractWriter.mjs'
export class AbstractFullWriter extends AbstractWriter {
async run({ timestamp, sizeContainer, stream, vm, vmSnapshot }) {
async run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
try {
return await this._run({ timestamp, sizeContainer, stream, vm, vmSnapshot })
return await this._run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot })
} finally {
// ensure stream is properly closed
stream.destroy()

View File

@@ -189,7 +189,7 @@ export default class RemoteHandlerAbstract {
* @param {number} [options.dirMode]
* @param {(this: RemoteHandlerAbstract, path: string) => Promise<undefined>} [options.validator] Function that will be called before the data is commited to the remote, if it fails, file should not exist
*/
async outputStream(path, input, { checksum = true, dirMode, validator } = {}) {
async outputStream(path, input, { checksum = true, dirMode, maxStreamLength, streamLength, validator } = {}) {
path = normalizePath(path)
let checksumStream
@@ -201,6 +201,8 @@ export default class RemoteHandlerAbstract {
}
await this._outputStream(path, input, {
dirMode,
maxStreamLength,
streamLength,
validator,
})
if (checksum) {
@@ -633,7 +635,7 @@ export default class RemoteHandlerAbstract {
}
throw error
},
// real unlink concurrency will be 2**max directory depth
// real unlink concurrency will be 2**max directory depth
{ concurrency: 2 }
)
)

View File

@@ -5,6 +5,7 @@ import {
CreateMultipartUploadCommand,
DeleteObjectCommand,
GetObjectCommand,
GetObjectLockConfigurationCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
@@ -17,7 +18,7 @@ import { getApplyMd5BodyChecksumPlugin } from '@aws-sdk/middleware-apply-body-ch
import { Agent as HttpAgent } from 'http'
import { Agent as HttpsAgent } from 'https'
import { createLogger } from '@xen-orchestra/log'
import { PassThrough, pipeline } from 'stream'
import { PassThrough, Transform, pipeline } from 'stream'
import { parse } from 'xo-remote-parser'
import copyStreamToBuffer from './_copyStreamToBuffer.js'
import guessAwsRegion from './_guessAwsRegion.js'
@@ -30,6 +31,8 @@ import { pRetry } from 'promise-toolbox'
// limits: https://docs.aws.amazon.com/AmazonS3/latest/dev/qfacts.html
const MAX_PART_SIZE = 1024 * 1024 * 1024 * 5 // 5GB
const MAX_PART_NUMBER = 10000
const MIN_PART_SIZE = 5 * 1024 * 1024
const { warn } = createLogger('xo:fs:s3')
export default class S3Handler extends RemoteHandlerAbstract {
@@ -71,9 +74,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
}),
})
// Workaround for https://github.com/aws/aws-sdk-js-v3/issues/2673
this.#s3.middlewareStack.use(getApplyMd5BodyChecksumPlugin(this.#s3.config))
const parts = split(path)
this.#bucket = parts.shift()
this.#dir = join(...parts)
@@ -223,11 +223,35 @@ export default class S3Handler extends RemoteHandlerAbstract {
}
}
async _outputStream(path, input, { validator }) {
async _outputStream(path, input, { streamLength, maxStreamLength = streamLength, validator }) {
// S3 storage is limited to 10K part, each part is limited to 5GB. And the total upload must be smaller than 5TB
// a bigger partSize increase the memory consumption of aws/lib-storage exponentially
let partSize
if (maxStreamLength === undefined) {
warn(`Writing ${path} to a S3 remote without a max size set will cut it to 50GB`, { path })
partSize = MIN_PART_SIZE // min size for S3
} else {
partSize = Math.min(Math.max(Math.ceil(maxStreamLength / MAX_PART_NUMBER), MIN_PART_SIZE), MAX_PART_SIZE)
}
// ensure we don't try to upload a stream to big for this partSize
let readCounter = 0
const MAX_SIZE = MAX_PART_NUMBER * partSize
const streamCutter = new Transform({
transform(chunk, encoding, callback) {
readCounter += chunk.length
if (readCounter > MAX_SIZE) {
callback(new Error(`read ${readCounter} bytes, maximum size allowed is ${MAX_SIZE} `))
} else {
callback(null, chunk)
}
},
})
// Workaround for "ReferenceError: ReadableStream is not defined"
// https://github.com/aws/aws-sdk-js-v3/issues/2522
const Body = new PassThrough()
pipeline(input, Body, () => {})
pipeline(input, streamCutter, Body, () => {})
const upload = new Upload({
client: this.#s3,
@@ -235,6 +259,8 @@ export default class S3Handler extends RemoteHandlerAbstract {
...this.#createParams(path),
Body,
},
partSize,
leavePartsOnError: false,
})
await upload.done()
@@ -418,6 +444,24 @@ export default class S3Handler extends RemoteHandlerAbstract {
async _closeFile(fd) {}
async _sync() {
await super._sync()
try {
// if Object Lock is enabled, each upload must come with a contentMD5 header
// the computation of this md5 is memory-intensive, especially when uploading a stream
const res = await this.#s3.send(new GetObjectLockConfigurationCommand({ Bucket: this.#bucket }))
if (res.ObjectLockConfiguration?.ObjectLockEnabled === 'Enabled') {
// Workaround for https://github.com/aws/aws-sdk-js-v3/issues/2673
// will automatically add the contentMD5 header to any upload to S3
this.#s3.middlewareStack.use(getApplyMd5BodyChecksumPlugin(this.#s3.config))
}
} catch (error) {
if (error.Code !== 'ObjectLockConfigurationNotFoundError') {
throw error
}
}
}
useVhdDirectory() {
return true
}

View File

@@ -3,6 +3,8 @@
## **next**
- Ability to snapshot/copy a VM from its view (PR [#7087](https://github.com/vatesfr/xen-orchestra/pull/7087))
- [Header] Replace logo with "XO LITE" (PR [#7118](https://github.com/vatesfr/xen-orchestra/pull/7118))
- New VM console toolbar + Ability to send Ctrl+Alt+Del (PR [#7088](https://github.com/vatesfr/xen-orchestra/pull/7088))
## **0.1.4** (2023-10-03)

View File

@@ -11,6 +11,7 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@fontsource/poppins": "^5.0.8",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.2.0",
"@fortawesome/free-solid-svg-icons": "^6.2.0",

View File

@@ -1,7 +1,11 @@
@import "reset.css";
@import "theme.css";
/* TODO Serve fonts locally */
@import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;0,900;1,400;1,500;1,600;1,700;1,900&display=swap");
@import "@fontsource/poppins/400.css";
@import "@fontsource/poppins/500.css";
@import "@fontsource/poppins/600.css";
@import "@fontsource/poppins/700.css";
@import "@fontsource/poppins/900.css";
@import "@fontsource/poppins/400-italic.css";
body {
min-height: 100vh;

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,4 +1,6 @@
:root {
--color-logo: #282467;
--color-blue-scale-000: #000000;
--color-blue-scale-100: #1a1b38;
--color-blue-scale-200: #595a6f;
@@ -59,6 +61,10 @@
}
:root.dark {
color-scheme: dark;
--color-logo: #e5e5e7;
--color-blue-scale-000: #ffffff;
--color-blue-scale-100: #e5e5e7;
--color-blue-scale-200: #9899a5;

View File

@@ -7,7 +7,8 @@
class="toggle-navigation"
/>
<RouterLink :to="{ name: 'home' }">
<img alt="XO Lite" src="../assets/logo.svg" />
<img v-if="isMobile" alt="XO Lite" src="../assets/logo.svg" />
<TextLogo v-else />
</RouterLink>
<slot />
<div class="right">
@@ -18,6 +19,7 @@
<script lang="ts" setup>
import AccountButton from "@/components/AccountButton.vue";
import TextLogo from "@/components/TextLogo.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { useNavigationStore } from "@/stores/navigation.store";
import { useUiStore } from "@/stores/ui.store";
@@ -44,6 +46,10 @@ const { trigger: navigationTrigger } = storeToRefs(navigationStore);
img {
width: 4rem;
}
.text-logo {
margin: 1rem;
}
}
.right {

View File

@@ -105,6 +105,10 @@ watchEffect(() => {
onBeforeUnmount(() => {
clearVncClient();
});
defineExpose({
sendCtrlAltDel: () => vncClient?.sendCtrlAltDel(),
});
</script>
<style lang="postcss" scoped>

View File

@@ -0,0 +1,37 @@
<template>
<svg
class="text-logo"
viewBox="300.85 622.73 318.32 63.27"
xmlns="http://www.w3.org/2000/svg"
width="100"
height="22"
>
<g>
<polygon
points="355.94 684.92 341.54 684.92 327.84 664.14 315.68 684.92 301.81 684.92 317.59 659.25 338.96 659.25 355.94 684.92"
/>
<path
d="M406.2,627.17c4.62,2.64,8.27,6.33,10.94,11.07,2.67,4.74,4.01,10.1,4.01,16.07s-1.34,11.35-4.01,16.12c-2.67,4.77-6.32,8.48-10.94,11.12-4.63,2.64-9.78,3.97-15.47,3.97s-10.85-1.32-15.47-3.97c-4.63-2.64-8.27-6.35-10.95-11.12-2.67-4.77-4.01-10.14-4.01-16.12s1.34-11.33,4.01-16.07c2.67-4.74,6.32-8.43,10.95-11.07,4.62-2.64,9.78-3.97,15.47-3.97s10.84,1.32,15.47,3.97Zm-24.86,9.65c-2.7,1.61-4.81,3.92-6.33,6.94-1.52,3.02-2.28,6.54-2.28,10.56s.76,7.54,2.28,10.56c1.52,3.02,3.63,5.33,6.33,6.94,2.7,1.61,5.83,2.41,9.39,2.41s6.69-.8,9.39-2.41c2.7-1.61,4.81-3.92,6.33-6.94,1.52-3.02,2.28-6.53,2.28-10.56s-.76-7.54-2.28-10.56-3.63-5.33-6.33-6.94c-2.7-1.61-5.83-2.41-9.39-2.41s-6.69,.8-9.39,2.41Z"
/>
<polygon
points="354.99 624.06 339.53 649.22 317.49 649.22 300.86 624.06 315.26 624.06 328.96 644.84 341.12 624.06 354.99 624.06"
/>
<g>
<path d="M476.32,675.94h20.81v10.04h-33.47v-63.14h12.66v53.1Z" />
<path d="M517.84,622.84v63.14h-12.66v-63.14h12.66Z" />
<path
d="M573.29,622.84v10.22h-16.82v52.92h-12.66v-52.92h-16.83v-10.22h46.31Z"
/>
<path
d="M595.18,633.06v15.83h21.26v10.04h-21.26v16.73h23.97v10.31h-36.64v-63.23h36.64v10.31h-23.97Z"
/>
</g>
</g>
</svg>
</template>
<style lang="postcss" scoped>
.text-logo {
fill: var(--color-logo);
}
</style>

View File

@@ -25,10 +25,11 @@ defineProps<{
align-items: center;
height: 4.4rem;
padding-right: 1.5rem;
padding-left: 1rem;
padding-left: 1.5rem;
white-space: nowrap;
border-radius: 0.8rem;
gap: 1rem;
background-color: var(--color-blue-scale-500);
&.disabled {
color: var(--color-blue-scale-400);

View File

@@ -75,6 +75,8 @@
"following-hosts-unreachable": "The following hosts are unreachable",
"force-reboot": "Force reboot",
"force-shutdown": "Force shutdown",
"fullscreen": "Fullscreen",
"fullscreen-leave": "Leave fullscreen",
"go-back": "Go back",
"here": "Here",
"hosts": "Hosts",
@@ -106,7 +108,7 @@
"object": "Object",
"object-not-found": "Object {id} can't be found…",
"on-object": "on {object}",
"open-in-new-window": "Open in new window",
"open-console-in-new-tab": "Open console in new tab",
"or": "Or",
"page-not-found": "This page is not to be found…",
"password": "Password",
@@ -137,6 +139,7 @@
"save": "Save",
"select-destination-host": "Select a destination host",
"selected-vms-in-execution": "Some selected VMs are running",
"send-ctrl-alt-del": "Send Ctrl+Alt+Del",
"send-us-feedback": "Send us feedback",
"settings": "Settings",
"shutdown": "Shutdown",

View File

@@ -75,6 +75,8 @@
"following-hosts-unreachable": "Les hôtes suivants sont inaccessibles",
"force-reboot": "Forcer le redémarrage",
"force-shutdown": "Forcer l'arrêt",
"fullscreen": "Plein écran",
"fullscreen-leave": "Quitter plein écran",
"go-back": "Revenir en arrière",
"here": "Ici",
"hosts": "Hôtes",
@@ -106,7 +108,7 @@
"object": "Objet",
"object-not-found": "L'objet {id} est introuvable…",
"on-object": "sur {object}",
"open-in-new-window": "Ouvrir dans une nouvelle fenêtre",
"open-console-in-new-tab": "Ouvrir la console dans un nouvel onglet",
"or": "Ou",
"page-not-found": "Cette page est introuvable…",
"password": "Mot de passe",
@@ -137,6 +139,7 @@
"save": "Enregistrer",
"select-destination-host": "Sélectionnez un hôte de destination",
"selected-vms-in-execution": "Certaines VMs sélectionnées sont en cours d'exécution",
"send-ctrl-alt-del": "Envoyer Ctrl+Alt+Suppr",
"send-us-feedback": "Envoyez-nous vos commentaires",
"settings": "Paramètres",
"shutdown": "Arrêter",

View File

@@ -1,7 +1,7 @@
import { useBreakpoints, useColorMode } from "@vueuse/core";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { useRoute } from "vue-router";
import { useRoute, useRouter } from "vue-router";
export const useUiStore = defineStore("ui", () => {
const currentHostOpaqueRef = ref();
@@ -14,8 +14,15 @@ export const useUiStore = defineStore("ui", () => {
const isMobile = computed(() => !isDesktop.value);
const router = useRouter();
const route = useRoute();
const hasUi = computed(() => route.query.ui !== "0");
const hasUi = computed<boolean>({
get: () => route.query.ui !== "0",
set: (value: boolean) => {
void router.replace({ query: { ui: value ? undefined : "0" } });
},
});
return {
colorMode,

View File

@@ -7,40 +7,62 @@
{{ $t("power-on-for-console") }}
</div>
<template v-else-if="vm && vmConsole">
<AppMenu horizontal>
<MenuItem
:icon="faArrowUpRightFromSquare"
@click="openInNewTab"
v-if="uiStore.hasUi"
>
{{ $t("open-console-in-new-tab") }}
</MenuItem>
<MenuItem
:icon="
uiStore.hasUi
? faUpRightAndDownLeftFromCenter
: faDownLeftAndUpRightToCenter
"
@click="toggleFullScreen"
>
{{ $t(uiStore.hasUi ? "fullscreen" : "fullscreen-leave") }}
</MenuItem>
<MenuItem
:disabled="!consoleElement"
:icon="faKeyboard"
@click="sendCtrlAltDel"
>
{{ $t("send-ctrl-alt-del") }}
</MenuItem>
</AppMenu>
<RemoteConsole
ref="consoleElement"
:is-console-available="isConsoleAvailable"
:location="vmConsole.location"
class="remote-console"
/>
<div class="open-in-new-window">
<RouterLink
v-if="uiStore.hasUi"
:to="{ query: { ui: '0' } }"
class="link"
target="_blank"
>
<UiIcon :icon="faArrowUpRightFromSquare" />
{{ $t("open-in-new-window") }}
</RouterLink>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import RemoteConsole from "@/components/RemoteConsole.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import { useConsoleCollection } from "@/stores/xen-api/console.store";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { VM_OPERATION, VM_POWER_STATE } from "@/libs/xen-api/xen-api.enums";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { VM_POWER_STATE, VM_OPERATION } from "@/libs/xen-api/xen-api.enums";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useUiStore } from "@/stores/ui.store";
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
import { useConsoleCollection } from "@/stores/xen-api/console.store";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import {
faArrowUpRightFromSquare,
faDownLeftAndUpRightToCenter,
faKeyboard,
faUpRightAndDownLeftFromCenter,
} from "@fortawesome/free-solid-svg-icons";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import { useRoute, useRouter } from "vue-router";
const STOP_OPERATIONS = [
VM_OPERATION.SHUTDOWN,
@@ -54,6 +76,7 @@ const STOP_OPERATIONS = [
usePageTitleStore().setTitle(useI18n().t("console"));
const router = useRouter();
const route = useRoute();
const uiStore = useUiStore();
@@ -95,14 +118,26 @@ const isConsoleAvailable = computed(() =>
? !isOperationPending(vm.value, STOP_OPERATIONS)
: false
);
const consoleElement = ref();
const sendCtrlAltDel = () => consoleElement.value?.sendCtrlAltDel();
const toggleFullScreen = () => {
uiStore.hasUi = !uiStore.hasUi;
};
const openInNewTab = () => {
const routeData = router.resolve({ query: { ui: "0" } });
window.open(routeData.href, "_blank");
};
</script>
<style lang="postcss" scoped>
.vm-console-view {
display: flex;
align-items: center;
justify-content: center;
height: calc(100% - 14.5rem);
flex-direction: column;
&.no-ui {
height: 100%;
@@ -160,4 +195,9 @@ const isConsoleAvailable = computed(() =>
}
}
}
.vm-console-view:deep(.app-menu) {
background-color: transparent;
align-self: center;
}
</style>

View File

@@ -10,12 +10,28 @@
- [Host/Advanced] Allow to force _Smart reboot_ if some resident VMs have the suspend operation blocked [Forum#7136](https://xcp-ng.org/forum/topic/7136/suspending-vms-during-host-reboot/23) (PR [#7025](https://github.com/vatesfr/xen-orchestra/pull/7025))
- [Plugin/backup-report] Errors are now listed in XO tasks
- [PIF] Show network name in PIF selectors (PR [#7081](https://github.com/vatesfr/xen-orchestra/pull/7081))
- [VM/Advanced] Possibility to create/delete VTPM [#7066](https://github.com/vatesfr/xen-orchestra/issues/7066) [Forum#6578](https://xcp-ng.org/forum/topic/6578/xcp-ng-8-3-public-alpha/109) (PR [#7085](https://github.com/vatesfr/xen-orchestra/pull/7085))
- [VM/New] Possibility to create and attach a _VTPM_ to a VM [#7066](https://github.com/vatesfr/xen-orchestra/issues/7066) [Forum#6578](https://xcp-ng.org/forum/topic/6578/xcp-ng-8-3-public-alpha/109) (PR [#7077](https://github.com/vatesfr/xen-orchestra/pull/7077))
- [Dashboard/Health] Displays number of VDIs to coalesce (PR [#7111](https://github.com/vatesfr/xen-orchestra/pull/7111))
- [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
> Users must be able to say: “I had this issue, happy to know it's fixed”
- [Rolling Pool Update] After the update, when migrating VMs back to their host, do not migrate VMs that are already on the right host [Forum#7802](https://xcp-ng.org/forum/topic/7802) (PR [#7071](https://github.com/vatesfr/xen-orchestra/pull/7071))
- [RPU] Fix "XenServer credentials not found" when running a Rolling Pool Update on a XenServer pool (PR [#7089](https://github.com/vatesfr/xen-orchestra/pull/7089))
- [Usage report] Fix "Converting circular structure to JSON" error
- [Home] Fix OS icons alignment (PR [#7090](https://github.com/vatesfr/xen-orchestra/pull/7090))
- [SR/Advanced] Fix the total number of VDIs to coalesce by taking into account common chains [#7016](https://github.com/vatesfr/xen-orchestra/issues/7016) (PR [#7098](https://github.com/vatesfr/xen-orchestra/pull/7098))
- Don't require to sign in again in XO after losing connection to XO Server (e.g. when restarting or upgrading XO) (PR [#7103](https://github.com/vatesfr/xen-orchestra/pull/7103))
- [Usage report] Fix "Converting circular structure to JSON" error (PR [#7096](https://github.com/vatesfr/xen-orchestra/pull/7096))
- [Usage report] Fix "Cannot convert undefined or null to object" error (PR [#7092](https://github.com/vatesfr/xen-orchestra/pull/7092))
- [Plugin/transport-xmpp] Fix plugin load
- [Self Service] Fix Self users not being able to snapshot VMs when they're members of a user group (PR [#7129](https://github.com/vatesfr/xen-orchestra/pull/7129))
### Packages to release
@@ -33,10 +49,16 @@
<!--packages-start-->
- @xen-orchestra/backups patch
- @xen-orchestra/fs patch
- @xen-orchestra/mixins minor
- @xen-orchestra/xapi minor
- xo-cli minor
- xo-server minor
- xo-server-backup-reports minor
- xo-server-netbox patch
- xo-server-transport-xmpp patch
- xo-server-usage-report patch
- xo-web minor
<!--packages-end-->

View File

@@ -1,11 +1,35 @@
# Xen Orchestra [![Build Status](https://travis-ci.org/vatesfr/xen-orchestra.png?branch=master)](https://travis-ci.org/vatesfr/xen-orchestra)
<h3 align="center"><b>Xen Orchestra</b></h3>
<p align="center"><b>Manage, Backup and Cloudify your XCP-ng/XenServer infrastructure</b></p>
![](http://i.imgur.com/tRffA5y.png)
![](https://repository-images.githubusercontent.com/8077957/6dcf71fd-bad9-4bfa-933f-b466c52d513d)
## Installation
XO (Xen Orchestra) is a complete solution to visualize, manage, backup and delegate your XCP-ng (or XenServer) infrastructure. **No agent** is required for it to work.
XOA or manual install procedure is [available here](https://xen-orchestra.com/docs/installation.html)
It provides a web UI, a CLI and a REST API, while also getting a Terraform provider among other connectors/plugins.
## ⚡️ Quick start
Log in to your account and use the deploy form available from the [Vates website](https://vates.tech/deploy/).
## 📚 Documentation
The official documentation is available at https://xen-orchestra.com/docs
## 🚀 Features
- **Centralized interface**: one Xen Orchestra to rule your entire infrastructure, even across datacenters at various locations
- **Administration and management:** VM creation, management, migration, metrics and statistics, XO proxies for remote sites… XO will become your best friend!
- **Backup & Disaster Recovery:** The backup is an essential component for the security of your infrastructure. With Xen Orchestra, select the backup mode that suits you best and protect your VMs and your business. Rolling snapshot, Full backup & replication, incremental backup & replication, mirror backup, S3 support among many other possibilities!
- **Cloud Enabler:** Xen Orchestra is your cloud initiator for XCP-ng (and XenServer). Group management, resource delegation and easy group administration. The Cloud is yours!
## 📸 Screenshots
![](https://vates.tech/assets/img/illustrations/xen-orchestra-screen-1.png.avif)
![](https://vates.tech/assets/img/illustrations/xen-orchestra-screen-3.png.avif)
![](https://vates.tech/assets/img/illustrations/xen-orchestra-screen-4.png.avif)
## License
AGPL3 © [Vates SAS](http://vates.fr)
AGPL3 © [Vates](http://vates.tech)

View File

@@ -1,17 +1,13 @@
# Xen Orchestra
![](https://repository-images.githubusercontent.com/8077957/6dcf71fd-bad9-4bfa-933f-b466c52d513d)
## Introduction
Welcome to the official Xen Orchestra (XO) documentation.
XO (Xen Orchestra) is a complete solution to visualize, manage, backup and delegate your XCP-ng (or XenServer) infrastructure. **No agent** is required for it to work.
XO is a web interface to visualize and administer your XenServer (or XAPI enabled) hosts. **No agent** is required for it to work.
It aims to be easy to use on any device supporting modern web technologies (HTML 5, CSS 3, JavaScript), such as your desktop computer or your smartphone.
It provides a web UI, a CLI and a REST API, while also getting a Terraform provider among other connectors/plugins.
## Quick start
Log in to your account and use the deploy form available on [Xen Orchestra website](https://xen-orchestra.com/#!/xoa).
More details available on the [installation section](installation.md#xoa).
![Xen Orchestra logo](./assets/logo.png)
Log in to your account and use the deploy form available from [Vates website](https://vates.tech/deploy/)

View File

@@ -106,7 +106,7 @@ XO needs the following packages to be installed. Redis is used as a database by
For example, on Debian/Ubuntu:
```sh
apt-get install build-essential redis-server libpng-dev git python3-minimal libvhdi-utils lvm2 cifs-utils
apt-get install build-essential redis-server libpng-dev git python3-minimal libvhdi-utils lvm2 cifs-utils nfs-common
```
On Fedora/CentOS like:

View File

@@ -13,6 +13,7 @@ import humanFormat from 'human-format'
import identity from 'lodash/identity.js'
import isObject from 'lodash/isObject.js'
import micromatch from 'micromatch'
import os from 'os'
import pairs from 'lodash/toPairs.js'
import pick from 'lodash/pick.js'
import prettyMs from 'pretty-ms'
@@ -47,7 +48,7 @@ async function connect() {
return xo
}
async function parseRegisterArgs(args, tokenDescription, acceptToken = false) {
async function parseRegisterArgs(args, tokenDescription, client, acceptToken = false) {
const {
allowUnauthorized,
expiresIn,
@@ -84,21 +85,21 @@ async function parseRegisterArgs(args, tokenDescription, acceptToken = false) {
pw(resolve)
}),
] = opts
result.token = await _createToken({ ...result, description: tokenDescription, email, password })
result.token = await _createToken({ ...result, client, description: tokenDescription, email, password })
}
return result
}
async function _createToken({ allowUnauthorized, description, email, expiresIn, password, url }) {
async function _createToken({ allowUnauthorized, client, description, email, expiresIn, password, url }) {
const xo = new Xo({ rejectUnauthorized: !allowUnauthorized, url })
await xo.open()
try {
await xo.signIn({ email, password })
console.warn('Successfully logged with', xo.user.email)
return await xo.call('token.create', { description, expiresIn }).catch(error => {
// if invalid parameter error, retry without description for backward compatibility
return await xo.call('token.create', { client, description, expiresIn }).catch(error => {
// if invalid parameter error, retry without client and description for backward compatibility
if (error.code === 10) {
return xo.call('token.create', { expiresIn })
}
@@ -219,6 +220,8 @@ function wrap(val) {
// ===================================================================
const PACKAGE_JSON = JSON.parse(readFileSync(new URL('package.json', import.meta.url)))
const help = wrap(
(function (pkg) {
return `Usage:
@@ -355,7 +358,7 @@ $name v$version`.replace(/<([^>]+)>|\$(\w+)/g, function (_, arg, key) {
return pkg[key]
})
})(JSON.parse(readFileSync(new URL('package.json', import.meta.url))))
})(PACKAGE_JSON)
)
// -------------------------------------------------------------------
@@ -422,9 +425,18 @@ async function createToken(args) {
COMMANDS.createToken = createToken
async function register(args) {
const opts = await parseRegisterArgs(args, 'xo-cli --register', true)
let { clientId } = await config.load()
if (clientId === undefined) {
clientId = Math.random().toString(36).slice(2)
}
const { name, version } = PACKAGE_JSON
const label = `${name}@${version} - ${os.hostname()} - ${os.type()} ${os.machine()}`
const opts = await parseRegisterArgs(args, label, { id: clientId }, true)
await config.set({
allowUnauthorized: opts.allowUnauthorized,
clientId,
server: opts.url,
token: opts.token,
})

View File

@@ -103,6 +103,8 @@ class Netbox {
}
async test() {
await this.#checkCustomFields()
const randomSuffix = Math.random().toString(36).slice(2, 11)
const name = '[TMP] Xen Orchestra Netbox plugin test - ' + randomSuffix
await this.#request('/virtualization/cluster-types/', 'POST', {
@@ -113,8 +115,6 @@ class Netbox {
})
const nbClusterTypes = await this.#request(`/virtualization/cluster-types/?name=${encodeURIComponent(name)}`)
await this.#checkCustomFields()
if (nbClusterTypes.length !== 1) {
throw new Error('Could not properly write and read Netbox')
}

View File

@@ -29,8 +29,7 @@
"node": ">=10"
},
"dependencies": {
"@xmpp/client": "^0.13.1",
"promise-toolbox": "^0.21.0"
"@xmpp/client": "^0.13.1"
},
"devDependencies": {
"@babel/cli": "^7.0.0",

View File

@@ -1,4 +1,3 @@
import fromEvent from 'promise-toolbox/fromEvent'
import { client, xml } from '@xmpp/client'
// ===================================================================
@@ -56,10 +55,7 @@ class TransportXmppPlugin {
async load() {
this._client = client(this._conf)
this._client.on('error', () => {})
await fromEvent(this._client.connection.socket, 'data')
await fromEvent(this._client, 'online')
await this._client.start()
this._unset = this._set('sendToXmppClient', this._sendToXmppClient)
}

View File

@@ -12,9 +12,9 @@ import {
filter,
find,
forEach,
get,
isFinite,
map,
mapValues,
orderBy,
round,
values,
@@ -204,6 +204,11 @@ function computeMean(values) {
}
})
// No values to work with, return null
if (n === 0) {
return null
}
return sum / n
}
@@ -226,7 +231,7 @@ function getTop(objects, options) {
object => {
const value = object[opt]
return isNaN(value) ? -Infinity : value
return isNaN(value) || value === null ? -Infinity : value
},
'desc'
).slice(0, 3),
@@ -244,7 +249,9 @@ function computePercentage(curr, prev, options) {
return zipObject(
options,
map(options, opt =>
prev[opt] === 0 || prev[opt] === null ? 'NONE' : `${((curr[opt] - prev[opt]) * 100) / prev[opt]}`
prev[opt] === 0 || prev[opt] === null || curr[opt] === null
? 'NONE'
: `${((curr[opt] - prev[opt]) * 100) / prev[opt]}`
)
)
}
@@ -257,7 +264,15 @@ function getDiff(oldElements, newElements) {
}
function getMemoryUsedMetric({ memory, memoryFree = memory }) {
return map(memory, (value, key) => value - memoryFree[key])
return map(memory, (value, key) => {
const tMemory = value
const tMemoryFree = memoryFree[key]
if (tMemory == null || tMemoryFree == null) {
return null
}
return tMemory - tMemoryFree
})
}
const METRICS_MEAN = {
@@ -274,51 +289,61 @@ const DAYS_TO_KEEP = {
weekly: 7,
monthly: 30,
}
function getLastDays(data, periodicity) {
const daysToKeep = DAYS_TO_KEEP[periodicity]
const expectedData = {}
for (const [key, value] of Object.entries(data)) {
if (Array.isArray(value)) {
// slice only applies to array
expectedData[key] = value.slice(-daysToKeep)
} else {
expectedData[key] = value
}
function getDeepLastValues(data, nValues) {
if (data == null) {
return {}
}
return expectedData
if (Array.isArray(data)) {
return data.slice(-nValues)
}
if (typeof data !== 'object') {
throw new Error('data must be an object or an array')
}
return mapValues(data, value => getDeepLastValues(value, nValues))
}
// ===================================================================
async function getVmsStats({ runningVms, periodicity, xo }) {
const lastNValues = DAYS_TO_KEEP[periodicity]
return orderBy(
await Promise.all(
map(runningVms, async vm => {
const { stats } = await xo.getXapiVmStats(vm, GRANULARITY).catch(error => {
log.warn('Error on fetching VM stats', {
error,
vmId: vm.id,
})
return {
stats: {},
}
})
const stats = getDeepLastValues(
(
await xo.getXapiVmStats(vm, GRANULARITY).catch(error => {
log.warn('Error on fetching VM stats', {
error,
vmId: vm.id,
})
return {
stats: {},
}
})
).stats,
lastNValues
)
const iopsRead = METRICS_MEAN.iops(getLastDays(get(stats.iops, 'r'), periodicity))
const iopsWrite = METRICS_MEAN.iops(getLastDays(get(stats.iops, 'w'), periodicity))
const iopsRead = METRICS_MEAN.iops(stats.iops?.r)
const iopsWrite = METRICS_MEAN.iops(stats.iops?.w)
return {
uuid: vm.uuid,
name: vm.name_label,
addresses: Object.values(vm.addresses),
cpu: METRICS_MEAN.cpu(getLastDays(stats.cpus, periodicity)),
ram: METRICS_MEAN.ram(getLastDays(getMemoryUsedMetric(stats), periodicity)),
diskRead: METRICS_MEAN.disk(getLastDays(get(stats.xvds, 'r'), periodicity)),
diskWrite: METRICS_MEAN.disk(getLastDays(get(stats.xvds, 'w'), periodicity)),
cpu: METRICS_MEAN.cpu(stats.cpus),
ram: METRICS_MEAN.ram(getMemoryUsedMetric(stats)),
diskRead: METRICS_MEAN.disk(stats.xvds?.r),
diskWrite: METRICS_MEAN.disk(stats.xvds?.w),
iopsRead,
iopsWrite,
iopsTotal: iopsRead + iopsWrite,
netReception: METRICS_MEAN.net(getLastDays(get(stats.vifs, 'rx'), periodicity)),
netTransmission: METRICS_MEAN.net(getLastDays(get(stats.vifs, 'tx'), periodicity)),
netReception: METRICS_MEAN.net(stats.vifs?.rx),
netTransmission: METRICS_MEAN.net(stats.vifs?.tx),
}
})
),
@@ -328,27 +353,34 @@ async function getVmsStats({ runningVms, periodicity, xo }) {
}
async function getHostsStats({ runningHosts, periodicity, xo }) {
const lastNValues = DAYS_TO_KEEP[periodicity]
return orderBy(
await Promise.all(
map(runningHosts, async host => {
const { stats } = await xo.getXapiHostStats(host, GRANULARITY).catch(error => {
log.warn('Error on fetching host stats', {
error,
hostId: host.id,
})
return {
stats: {},
}
})
const stats = getDeepLastValues(
(
await xo.getXapiHostStats(host, GRANULARITY).catch(error => {
log.warn('Error on fetching host stats', {
error,
hostId: host.id,
})
return {
stats: {},
}
})
).stats,
lastNValues
)
return {
uuid: host.uuid,
name: host.name_label,
cpu: METRICS_MEAN.cpu(getLastDays(stats.cpus, periodicity)),
ram: METRICS_MEAN.ram(getLastDays(getMemoryUsedMetric(stats), periodicity)),
load: METRICS_MEAN.load(getLastDays(stats.load, periodicity)),
netReception: METRICS_MEAN.net(getLastDays(get(stats.pifs, 'rx'), periodicity)),
netTransmission: METRICS_MEAN.net(getLastDays(get(stats.pifs, 'tx'), periodicity)),
cpu: METRICS_MEAN.cpu(stats.cpus),
ram: METRICS_MEAN.ram(getMemoryUsedMetric(stats)),
load: METRICS_MEAN.load(stats.load),
netReception: METRICS_MEAN.net(stats.pifs?.rx),
netTransmission: METRICS_MEAN.net(stats.pifs?.tx),
}
})
),
@@ -358,6 +390,8 @@ async function getHostsStats({ runningHosts, periodicity, xo }) {
}
async function getSrsStats({ periodicity, xo, xoObjects }) {
const lastNValues = DAYS_TO_KEEP[periodicity]
return orderBy(
await asyncMapSettled(
filter(xoObjects, obj => obj.type === 'SR' && obj.size > 0 && obj.$PBDs.length > 0),
@@ -371,18 +405,23 @@ async function getSrsStats({ periodicity, xo, xoObjects }) {
name += ` (${container.name_label})`
}
const { stats } = await xo.getXapiSrStats(sr.id, GRANULARITY).catch(error => {
log.warn('Error on fetching SR stats', {
error,
srId: sr.id,
})
return {
stats: {},
}
})
const stats = getDeepLastValues(
(
await xo.getXapiSrStats(sr.id, GRANULARITY).catch(error => {
log.warn('Error on fetching SR stats', {
error,
srId: sr.id,
})
return {
stats: {},
}
})
).stats,
lastNValues
)
const iopsRead = computeMean(getLastDays(get(stats.iops, 'r'), periodicity))
const iopsWrite = computeMean(getLastDays(get(stats.iops, 'w'), periodicity))
const iopsRead = computeMean(stats.iops?.r)
const iopsWrite = computeMean(stats.iops?.w)
return {
uuid: sr.uuid,
@@ -477,7 +516,7 @@ async function getHostsMissingPatches({ runningHosts, xo }) {
.getXapi(host)
.listMissingPatches(host._xapiId)
.catch(error => {
console.error('[WARN] error on fetching hosts missing patches:', JSON.stringify(error))
log.warn('Error on fetching hosts missing patches', { error })
return []
})
@@ -741,7 +780,7 @@ class UsageReportPlugin {
try {
await this._sendReport(true)
} catch (error) {
console.error('[WARN] scheduled function:', (error && error.stack) || error)
log.warn('Scheduled usage report error', { error })
}
})

View File

@@ -172,6 +172,7 @@ ignoreVmSnapshotResources = false
restartHostTimeout = '20 minutes'
maxUncoalescedVdis = 1
vdiExportConcurrency = 12
vmEvacuationConcurrency = 3
vmExportConcurrency = 2
vmSnapshotConcurrency = 2

View File

@@ -68,7 +68,6 @@
"cookie-parser": "^1.4.3",
"d3-time-format": "^4.1.0",
"decorator-synchronized": "^0.6.0",
"deptree": "^1.0.0",
"exec-promise": "^0.7.0",
"execa": "^7.0.0",
"express": "^4.16.2",

View File

@@ -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'],
}

View File

@@ -202,6 +202,26 @@ checkHealth.params = {
},
}
export async function openSupportTunnel({ id }) {
await this.callProxyMethod(id, 'appliance.supportTunnel.open')
for (let i = 0; i < 10; ++i) {
const { open, stdout } = await this.callProxyMethod(id, 'appliance.supportTunnel.getState')
if (open && stdout.length !== 0) {
return stdout
}
await new Promise(resolve => setTimeout(resolve, 1e3))
}
throw new Error('could not open support tunnel')
}
openSupportTunnel.permission = 'admin'
openSupportTunnel.params = {
id: { type: 'string' },
}
export function updateApplianceSettings({ id, ...props }) {
return this.updateProxyAppliance(id, props)
}

View File

@@ -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

View File

@@ -1,8 +1,9 @@
// TODO: Prevent token connections from creating tokens.
// TODO: Token permission.
export async function create({ description, expiresIn }) {
export async function create({ client, description, expiresIn }) {
return (
await this.createAuthenticationToken({
client,
description,
expiresIn,
userId: this.apiContext.user.id,
@@ -17,6 +18,15 @@ create.params = {
optional: true,
type: 'string',
},
client: {
description:
'client this authentication token belongs to, if a previous token exists, it will be updated and returned',
optional: true,
type: 'object',
properties: {
id: { description: 'unique identifier of this client', type: 'string' },
},
},
expiresIn: {
optional: true,
type: ['number', 'string'],

View File

@@ -5,6 +5,7 @@ import asyncMapSettled from '@xen-orchestra/async-map/legacy.js'
import { Task } from '@xen-orchestra/mixins/Tasks.mjs'
import concat from 'lodash/concat.js'
import hrp from 'http-request-plus'
import mapKeys from 'lodash/mapKeys.js'
import { createLogger } from '@xen-orchestra/log'
import { defer } from 'golike-defer'
import { format } from 'json-rpc-peer'
@@ -237,6 +238,11 @@ export const create = defer(async function ($defer, params) {
await this.allocIpAddresses(vif.$id, concat(vif.ipv4_allowed, vif.ipv6_allowed)).catch(() => xapi.deleteVif(vif))
}
if (params.createVtpm) {
const vtpmRef = await xapi.VTPM_create({ VM: xapiVm.$ref })
$defer.onFailure(() => xapi.call('VTPM.destroy', vtpmRef))
}
if (params.bootAfterCreate) {
startVmAndDestroyCloudConfigVdi(xapi, xapiVm, cloudConfigVdiUuid, params)
}
@@ -257,6 +263,11 @@ create.params = {
optional: true,
},
createVtpm: {
type: 'boolean',
default: false,
},
networkConfig: {
type: 'string',
optional: true,
@@ -622,6 +633,8 @@ warmMigration.params = {
// -------------------------------------------------------------------
const autoPrefix = (pfx, str) => (str.startsWith(pfx) ? str : pfx + str)
export const set = defer(async function ($defer, params) {
const VM = extract(params, 'VM')
const xapi = this.getXapi(VM)
@@ -646,6 +659,11 @@ export const set = defer(async function ($defer, params) {
await xapi.call('VM.set_suspend_SR', VM._xapiRef, suspendSr === null ? Ref.EMPTY : suspendSr._xapiRef)
}
const xenStoreData = extract(params, 'xenStoreData')
if (xenStoreData !== undefined) {
await this.getXapiObject(VM).update_xenstore_data(mapKeys(xenStoreData, (v, k) => autoPrefix('vm-data/', k)))
}
return xapi.editVm(vmId, params, async (limits, vm) => {
const resourceSet = xapi.xo.getData(vm, 'resourceSet')
@@ -747,6 +765,15 @@ set.params = {
blockedOperations: { type: 'object', optional: true, properties: { '*': { type: ['boolean', 'null', 'string'] } } },
suspendSr: { type: ['string', 'null'], optional: true },
xenStoreData: {
description: 'properties that should be set or deleted (if null) in the VM XenStore',
optional: true,
type: 'object',
additionalProperties: {
type: ['null', 'string'],
},
},
}
set.resolve = {
@@ -946,7 +973,12 @@ export const snapshot = defer(async function (
}
}
if (resourceSet === undefined || !resourceSet.subjects.includes(user.id)) {
// Workaround: allow Resource Set members to snapshot a VM even though they
// don't have operate permissions on the SR(s)
if (
resourceSet === undefined ||
(!resourceSet.subjects.includes(user.id) && !user.groups.some(groupId => resourceSet.subjects.includes(groupId)))
) {
await checkPermissionOnSrs.call(this, vm)
}

View 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'],
}

View File

@@ -35,6 +35,16 @@ import Collection, { ModelAlreadyExists } from '../collection.mjs'
const VERSION = '20170905'
export default class Redis extends Collection {
// Prepare a record before storing in the database
//
// Input object can be mutated or a new one returned
_serialize(record) {}
// Clean a record after being fetched from the database
//
// Input object can be mutated or a new one returned
_unserialize(record) {}
constructor({ connection, indexes = [], namespace }) {
super()
@@ -85,8 +95,8 @@ export default class Redis extends Collection {
)
const idsIndex = `${prefix}_ids`
await asyncMapSettled(redis.sMembers(idsIndex), id =>
redis.hGetAll(`${prefix}:${id}`).then(values =>
await asyncMapSettled(redis.sMembers(idsIndex), id => {
return this.#get(`${prefix}:${id}`).then(values =>
values == null
? redis.sRem(idsIndex, id) // entry no longer exists
: asyncMapSettled(indexes, index => {
@@ -96,22 +106,23 @@ export default class Redis extends Collection {
}
})
)
)
})
}
_extract(ids) {
const prefix = this.prefix + ':'
const { redis } = this
const models = []
return Promise.all(
map(ids, id => {
return redis.hGetAll(prefix + id).then(model => {
return this.#get(prefix + id).then(model => {
// If empty, consider it a no match.
if (isEmpty(model)) {
return
}
model = this._unserialize(model) ?? model
// Mix the identifier in.
model.id = id
@@ -129,6 +140,12 @@ export default class Redis extends Collection {
return Promise.all(
map(models, async model => {
// don't mutate param
model = JSON.parse(JSON.stringify(model))
// allow specific serialization
model = this._serialize(model) ?? model
// Generate a new identifier if necessary.
if (model.id === undefined) {
model.id = generateUuid()
@@ -144,7 +161,7 @@ export default class Redis extends Collection {
// remove the previous values from indexes
if (indexes.length !== 0) {
const previous = await redis.hGetAll(`${prefix}:${id}`)
const previous = await this.#get(`${prefix}:${id}`)
await asyncMapSettled(indexes, index => {
const value = previous[index]
if (value !== undefined) {
@@ -184,6 +201,22 @@ export default class Redis extends Collection {
)
}
async #get(key) {
const { redis } = this
let model
try {
model = await redis.hGetAll(key)
} catch (error) {
if (!error.message.startsWith('WRONGTYPE')) {
throw error
}
model = await redis.get(key).then(JSON.parse)
}
return model
}
_get(properties) {
const { prefix, redis } = this
@@ -227,7 +260,7 @@ export default class Redis extends Collection {
promise = Promise.all([
promise,
asyncMapSettled(ids, id =>
redis.hGetAll(`${prefix}:${id}`).then(
this.#get(`${prefix}:${id}`).then(
values =>
values != null &&
asyncMapSettled(indexes, index => {

View File

@@ -2,33 +2,21 @@ import isEmpty from 'lodash/isEmpty.js'
import Collection from '../collection/redis.mjs'
import { forEach } from '../utils.mjs'
import { parseProp } from './utils.mjs'
// ===================================================================
export class Groups extends Collection {
_serialize(group) {
let tmp
group.users = isEmpty((tmp = group.users)) ? undefined : JSON.stringify(tmp)
}
_unserialize(group) {
group.users = parseProp('group', group, 'users', [])
}
create(name, provider, providerGroupId) {
return this.add({ name, provider, providerGroupId })
}
async save(group) {
// Serializes.
let tmp
group.users = isEmpty((tmp = group.users)) ? undefined : JSON.stringify(tmp)
return /* await */ this.update(group)
}
async get(properties) {
const groups = await super.get(properties)
// Deserializes.
forEach(groups, group => {
group.users = parseProp('group', group, 'users', [])
})
return groups
}
}

View File

@@ -1,18 +1,26 @@
import Collection from '../collection/redis.mjs'
import { createLogger } from '@xen-orchestra/log'
import { forEach } from '../utils.mjs'
const log = createLogger('xo:plugin-metadata')
// ===================================================================
export class PluginsMetadata extends Collection {
async save({ id, autoload, configuration }) {
return /* await */ this.update({
id,
autoload: autoload ? 'true' : 'false',
configuration: configuration && JSON.stringify(configuration),
})
_serialize(metadata) {
const { autoload, configuration } = metadata
metadata.autoload = JSON.stringify(autoload)
metadata.configuration = JSON.stringify(configuration)
}
_unserialize(metadata) {
const { autoload, configuration } = metadata
metadata.autoload = autoload === 'true'
try {
metadata.configuration = configuration && JSON.parse(configuration)
} catch (error) {
log.warn(`cannot parse pluginMetadata.configuration: ${configuration}`)
metadata.configuration = []
}
}
async merge(id, data) {
@@ -21,27 +29,9 @@ export class PluginsMetadata extends Collection {
throw new Error('no such plugin metadata')
}
return /* await */ this.save({
return /* await */ this.update({
...pluginMetadata,
...data,
})
}
async get(properties) {
const pluginsMetadata = await super.get(properties)
// Deserializes.
forEach(pluginsMetadata, pluginMetadata => {
const { autoload, configuration } = pluginMetadata
pluginMetadata.autoload = autoload === 'true'
try {
pluginMetadata.configuration = configuration && JSON.parse(configuration)
} catch (error) {
log.warn(`cannot parse pluginMetadata.configuration: ${configuration}`)
pluginMetadata.configuration = []
}
})
return pluginsMetadata
}
}

View File

@@ -1,36 +1,26 @@
import Collection from '../collection/redis.mjs'
import { forEach, serializeError } from '../utils.mjs'
import { serializeError } from '../utils.mjs'
import { parseProp } from './utils.mjs'
// ===================================================================
export class Remotes extends Collection {
async get(properties) {
const remotes = await super.get(properties)
forEach(remotes, remote => {
remote.benchmarks = parseProp('remote', remote, 'benchmarks')
remote.enabled = remote.enabled === 'true'
remote.error = parseProp('remote', remote, 'error', remote.error)
})
return remotes
_serialize(remote) {
const { benchmarks } = remote
if (benchmarks !== undefined) {
remote.benchmarks = JSON.stringify(benchmarks)
}
const { error } = remote
if (error !== undefined) {
remote.error = JSON.stringify(typeof error === 'object' ? serializeError(error) : error)
}
}
_update(remotes) {
return super._update(
remotes.map(remote => {
const { benchmarks } = remote
if (benchmarks !== undefined) {
remote.benchmarks = JSON.stringify(benchmarks)
}
const { error } = remote
if (error !== undefined) {
remote.error = JSON.stringify(typeof error === 'object' ? serializeError(error) : error)
}
return remote
})
)
_unserialize(remote) {
remote.benchmarks = parseProp('remote', remote, 'benchmarks')
remote.enabled = remote.enabled === 'true'
remote.error = parseProp('remote', remote, 'error', remote.error)
}
}

View File

@@ -1,11 +1,35 @@
import Collection from '../collection/redis.mjs'
import { forEach, serializeError } from '../utils.mjs'
import { serializeError } from '../utils.mjs'
import { parseProp } from './utils.mjs'
// ===================================================================
export class Servers extends Collection {
_serialize(server) {
server.allowUnauthorized = server.allowUnauthorized ? 'true' : undefined
server.enabled = server.enabled ? 'true' : undefined
const { error } = server
server.error = error != null ? JSON.stringify(serializeError(error)) : undefined
server.readOnly = server.readOnly ? 'true' : undefined
}
_unserialize(server) {
server.allowUnauthorized = server.allowUnauthorized === 'true'
server.enabled = server.enabled === 'true'
if (server.error) {
server.error = parseProp('server', server, 'error', '')
} else {
delete server.error
}
server.readOnly = server.readOnly === 'true'
// see https://github.com/vatesfr/xen-orchestra/issues/6656
if (server.httpProxy === '') {
delete server.httpProxy
}
}
async create(params) {
const { host } = params
@@ -15,38 +39,4 @@ export class Servers extends Collection {
return /* await */ this.add(params)
}
async get(properties) {
const servers = await super.get(properties)
// Deserializes
forEach(servers, server => {
server.allowUnauthorized = server.allowUnauthorized === 'true'
server.enabled = server.enabled === 'true'
if (server.error) {
server.error = parseProp('server', server, 'error', '')
} else {
delete server.error
}
server.readOnly = server.readOnly === 'true'
// see https://github.com/vatesfr/xen-orchestra/issues/6656
if (server.httpProxy === '') {
delete server.httpProxy
}
})
return servers
}
_update(servers) {
servers.forEach(server => {
server.allowUnauthorized = server.allowUnauthorized ? 'true' : undefined
server.enabled = server.enabled ? 'true' : undefined
const { error } = server
server.error = error != null ? JSON.stringify(serializeError(error)) : undefined
server.readOnly = server.readOnly ? 'true' : undefined
})
return super._update(servers)
}
}

View File

@@ -2,4 +2,29 @@ import Collection from '../collection/redis.mjs'
// ===================================================================
export class Tokens extends Collection {}
export class Tokens extends Collection {
_serialize(token) {
const { client, lastUse } = token
if (client !== undefined) {
const { id, ...rest } = client
token.client_id = id
token.client = JSON.stringify(rest)
}
}
_unserialize(token) {
const { client, client_id } = token
if (client !== undefined) {
token.client = {
...JSON.parse(client),
id: client_id,
}
delete token.client_id
}
if (token.created_at !== undefined) {
token.created_at = +token.created_at
}
token.expiration = +token.expiration
}
}

View File

@@ -6,25 +6,23 @@ import { parseProp } from './utils.mjs'
// ===================================================================
const serialize = user => {
let tmp
return {
...user,
authProviders: isEmpty((tmp = user.authProviders)) ? undefined : JSON.stringify(tmp),
groups: isEmpty((tmp = user.groups)) ? undefined : JSON.stringify(tmp),
preferences: isEmpty((tmp = user.preferences)) ? undefined : JSON.stringify(tmp),
}
}
const deserialize = user => ({
permission: 'none',
...user,
authProviders: parseProp('user', user, 'authProviders', undefined),
groups: parseProp('user', user, 'groups', []),
preferences: parseProp('user', user, 'preferences', {}),
})
export class Users extends Collection {
_serialize(user) {
let tmp
user.authProviders = isEmpty((tmp = user.authProviders)) ? undefined : JSON.stringify(tmp)
user.groups = isEmpty((tmp = user.groups)) ? undefined : JSON.stringify(tmp)
user.preferences = isEmpty((tmp = user.preferences)) ? undefined : JSON.stringify(tmp)
}
_unserialize(user) {
if (user.permission === undefined) {
user.permission = 'none'
}
user.authProviders = parseProp('user', user, 'authProviders', undefined)
user.groups = parseProp('user', user, 'groups', [])
user.preferences = parseProp('user', user, 'preferences', {})
}
async create(properties) {
const { email } = properties
@@ -34,14 +32,6 @@ export class Users extends Collection {
}
// Adds the user to the collection.
return /* await */ this.add(serialize(properties))
}
async save(user) {
return /* await */ this.update(serialize(user))
}
async get(properties) {
return (await super.get(properties)).map(deserialize)
return /* await */ this.add(properties)
}
}

View File

@@ -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,

View File

@@ -66,6 +66,7 @@ export default class Xapi extends XapiBase {
maxUncoalescedVdis,
restartHostTimeout,
vdiExportConcurrency,
vmEvacuationConcurrency,
vmExportConcurrency,
vmMigrationConcurrency = 3,
vmSnapshotConcurrency,
@@ -76,6 +77,7 @@ export default class Xapi extends XapiBase {
this._guessVhdSizeOnImport = guessVhdSizeOnImport
this._maxUncoalescedVdis = maxUncoalescedVdis
this._restartHostTimeout = parseDuration(restartHostTimeout)
this._vmEvacuationConcurrency = vmEvacuationConcurrency
// close event is emitted when the export is canceled via browser. See https://github.com/vatesfr/xen-orchestra/issues/5535
const waitStreamEnd = async stream => fromEvents(await stream, ['end', 'close'])
@@ -192,22 +194,36 @@ export default class Xapi extends XapiBase {
return network.$ref
}
})(pool.other_config['xo:migrationNetwork'])
try {
try {
await (migrationNetworkRef === undefined
? this.callAsync('host.evacuate', hostRef)
: this.callAsync('host.evacuate', hostRef, migrationNetworkRef))
} catch (error) {
if (error.code === 'MESSAGE_PARAMETER_COUNT_MISMATCH') {
log.warn(
'host.evacuate with a migration network is not supported on this host, falling back to evacuating without the migration network',
{ error }
)
await this.callAsync('host.evacuate', hostRef)
} else {
throw error
// host ref
// migration network: optional and might not be supported
// batch size: optional and might not be supported
const params = [hostRef, migrationNetworkRef ?? Ref.EMPTY, this._vmEvacuationConcurrency]
// Removes n params from the end and keeps removing until a non-empty param is found
const popParamsAndTrim = (n = 0) => {
let last
let i = 0
while (i < n || (last = params[params.length - 1]) === undefined || last === Ref.EMPTY) {
if (params.length <= 1) {
throw new Error('not enough params left')
}
params.pop()
i++
}
}
popParamsAndTrim()
try {
await pRetry(() => this.callAsync('host.evacuate', ...params), {
delay: 0,
when: { code: 'MESSAGE_PARAMETER_COUNT_MISMATCH' },
onRetry: error => {
log.warn(error)
popParamsAndTrim(1)
},
})
} catch (error) {
if (!force) {
await this.call('host.enable', hostRef)

View File

@@ -405,6 +405,11 @@ export default {
},
_poolWideInstall: deferrable(async function ($defer, patches, xsCredentials) {
// New XS patching system: https://support.citrix.com/article/CTX473972/upcoming-changes-in-xencenter
if (xsCredentials?.username === undefined || xsCredentials?.apikey === undefined) {
throw new Error('XenServer credentials not found. See https://xen-orchestra.com/docs/updater.html#xenserver-updates')
}
// Legacy XS patches
if (!useUpdateSystem(this.pool.$master)) {
// for each patch: pool_patch.pool_apply
@@ -420,11 +425,6 @@ export default {
}
// ----------
// New XS patching system: https://support.citrix.com/article/CTX473972/upcoming-changes-in-xencenter
if (xsCredentials?.username === undefined || xsCredentials?.apikey === undefined) {
throw new Error('XenServer credentials not found. See https://xen-orchestra.com/docs/updater.html#xenserver-updates')
}
// for each patch: pool_update.introduce → pool_update.pool_apply
for (const p of patches) {
const [vdi] = await Promise.all([this._uploadPatch($defer, p.uuid, xsCredentials), this._ejectToolsIsos()])
@@ -493,7 +493,7 @@ export default {
},
@decorateWith(deferrable)
async rollingPoolUpdate($defer) {
async rollingPoolUpdate($defer, { xsCredentials } = {}) {
const isXcp = _isXcp(this.pool.$master)
if (this.pool.ha_enabled) {
@@ -530,7 +530,7 @@ export default {
// On XS/CH, start by installing patches on all hosts
if (!isXcp) {
log.debug('Install patches')
await this.installPatches()
await this.installPatches({ xsCredentials })
}
// Remember on which hosts the running VMs are

View File

@@ -49,18 +49,19 @@ export default {
await this._unplugPbd(this.getObject(id))
},
_getVdiChainsInfo(uuid, childrenMap, cache) {
_getVdiChainsInfo(uuid, childrenMap, cache, resultContainer) {
let info = cache[uuid]
if (info === undefined) {
const children = childrenMap[uuid]
const unhealthyLength = children !== undefined && children.length === 1 ? 1 : 0
resultContainer.nUnhealthyVdis += unhealthyLength
const vdi = this.getObjectByUuid(uuid, undefined)
if (vdi === undefined) {
info = { unhealthyLength, missingParent: uuid }
} else {
const parent = vdi.sm_config['vhd-parent']
if (parent !== undefined) {
info = this._getVdiChainsInfo(parent, childrenMap, cache)
info = this._getVdiChainsInfo(parent, childrenMap, cache, resultContainer)
info.unhealthyLength += unhealthyLength
} else {
info = { unhealthyLength }
@@ -76,12 +77,13 @@ export default {
const unhealthyVdis = { __proto__: null }
const children = groupBy(vdis, 'sm_config.vhd-parent')
const vdisWithUnknownVhdParent = { __proto__: null }
const resultContainer = { nUnhealthyVdis: 0 }
const cache = { __proto__: null }
forEach(vdis, vdi => {
if (vdi.managed && !vdi.is_a_snapshot) {
const { uuid } = vdi
const { unhealthyLength, missingParent } = this._getVdiChainsInfo(uuid, children, cache)
const { unhealthyLength, missingParent } = this._getVdiChainsInfo(uuid, children, cache, resultContainer)
if (unhealthyLength !== 0) {
unhealthyVdis[uuid] = unhealthyLength
@@ -95,6 +97,7 @@ export default {
return {
vdisWithUnknownVhdParent,
unhealthyVdis,
...resultContainer,
}
},

View File

@@ -59,10 +59,38 @@ const hasPermission = (actual, expected) => PERMISSIONS[actual] >= PERMISSIONS[e
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true, useDefaults: true })
function checkParams(method, params) {
// Parameters suffixed by `?` are marked as ignorable by the client and
// ignored if unsupported by this version of the API
//
// This simplifies compatibility with older version of the API if support
// of the parameter is preferable but not necessary
const ignorableParams = new Set()
for (const key of Object.keys(params)) {
if (key.endsWith('?')) {
const rawKey = key.slice(0, -1)
if (Object.hasOwn(params, rawKey)) {
throw new Error(`conflicting keys: ${rawKey} and ${key}`)
}
params[rawKey] = params[key]
delete params[key]
ignorableParams.add(rawKey)
}
}
const { validate } = method
if (validate !== undefined) {
if (!validate(params)) {
throw errors.invalidParameters(validate.errors)
const vErrors = new Set(validate.errors)
for (const error of vErrors) {
if (error.schemaPath === '#/additionalProperties' && ignorableParams.has(error.params.additionalProperty)) {
delete params[error.params.additionalProperty]
vErrors.delete(error)
}
}
if (vErrors.size !== 0) {
throw errors.invalidParameters(Array.from(vErrors))
}
}
}
}

View File

@@ -15,13 +15,6 @@ const log = createLogger('xo:authentification')
const noSuchAuthenticationToken = id => noSuchObject(id, 'authenticationToken')
const unserialize = token => {
if (token.created_at !== undefined) {
token.created_at = +token.created_at
}
token.expiration = +token.expiration
}
export default class {
constructor(app) {
app.config.watch('authentication', config => {
@@ -56,13 +49,23 @@ export default class {
})
// Token authentication provider.
this.registerAuthenticationProvider(async ({ token: tokenId }) => {
this.registerAuthenticationProvider(async ({ token: tokenId }, { ip } = {}) => {
if (!tokenId) {
return
}
try {
const token = await app.getAuthenticationToken(tokenId)
this._tokens.update({
...token,
lastUse: {
ip,
timestamp: Date.now(),
},
})
return { expiration: token.expiration, userId: token.user_id }
} catch (error) {}
})
@@ -86,7 +89,7 @@ export default class {
const tokensDb = (this._tokens = new Tokens({
connection: app._redis,
namespace: 'token',
indexes: ['user_id'],
indexes: ['client_id', 'user_id'],
}))
app.addConfigManager(
@@ -187,7 +190,7 @@ export default class {
// -----------------------------------------------------------------
async createAuthenticationToken({ description, expiresIn, userId }) {
async createAuthenticationToken({ client, description, expiresIn, userId }) {
let duration = this._defaultTokenValidity
if (expiresIn !== undefined) {
duration = parseDuration(expiresIn)
@@ -198,8 +201,27 @@ export default class {
}
}
const tokens = this._tokens
const now = Date.now()
const clientId = client?.id
if (clientId !== undefined) {
const token = await tokens.first({ client_id: clientId, user_id: userId })
if (token !== undefined) {
if (token.expiration > now) {
token.description = description
token.expiration = now + duration
tokens.update(token)::ignoreErrors()
return token
}
tokens.remove(token.id)::ignoreErrors()
}
}
const token = {
client,
created_at: now,
description,
id: await generateToken(),
@@ -234,8 +256,6 @@ export default class {
async _getAuthenticationToken(id, properties) {
const token = await this._tokens.first(properties ?? id)
if (token !== undefined) {
unserialize(token)
if (token.expiration > Date.now()) {
return token
}
@@ -261,8 +281,6 @@ export default class {
const tokensDb = this._tokens
const toRemove = []
for (const token of await tokensDb.get({ user_id: userId })) {
unserialize(token)
const { expiration } = token
if (expiration < now) {
toRemove.push(token.id)

View File

@@ -1,5 +1,4 @@
import * as openpgp from 'openpgp'
import DepTree from 'deptree'
import fromCallback from 'promise-toolbox/fromCallback'
import { createLogger } from '@xen-orchestra/log'
import { gunzip, gzip } from 'node:zlib'
@@ -11,7 +10,6 @@ const log = createLogger('xo:config-management')
export default class ConfigManagement {
constructor(app) {
this._app = app
this._depTree = new DepTree()
this._managers = { __proto__: null }
}
@@ -21,7 +19,6 @@ export default class ConfigManagement {
throw new Error(`${id} is already taken`)
}
this._depTree.add(id, dependencies)
this._managers[id] = { dependencies, exporter, importer }
}
@@ -76,15 +73,27 @@ export default class ConfigManagement {
config = JSON.parse(config)
const managers = this._managers
for (const key of this._depTree.resolve()) {
const manager = managers[key]
const imported = new Set()
async function importEntry(id) {
if (!imported.has(id)) {
imported.add(id)
const data = config[key]
if (data !== undefined) {
log.debug(`importing ${key}`)
await manager.importer(data)
await importEntries(managers[id].dependencies)
const data = config[id]
if (data !== undefined) {
log.debug(`importing ${id}`)
await managers[id].importer(data)
}
}
}
async function importEntries(ids) {
for (const id of ids) {
await importEntry(id)
}
}
await importEntries(Object.keys(config))
await this._app.hooks.clean()
}
}

View File

@@ -33,7 +33,7 @@ export default class {
plugins =>
Promise.all(
plugins.map(async plugin => {
await this._pluginsMetadata.save(plugin)
await this._pluginsMetadata.update(plugin)
if (plugin.configuration !== undefined && this._plugins[plugin.id] !== undefined) {
await this.configurePlugin(plugin.id, plugin.configuration)
}
@@ -88,7 +88,7 @@ export default class {
;({ autoload, configuration } = metadata)
} else {
log.info(`[NOTICE] register plugin ${name} for the first time`)
await this._pluginsMetadata.save({
await this._pluginsMetadata.update({
id,
autoload,
})

View File

@@ -62,11 +62,14 @@ export default class Pools {
}
const patchesName = await Promise.all([targetXapi.findPatches(targetRequiredPatches), ...findPatchesPromises])
const { xsCredentials } = _app.apiContext.user.preferences
// Install patches in parallel.
const installPatchesPromises = []
installPatchesPromises.push(
targetXapi.installPatches({
patches: patchesName[0],
xsCredentials,
})
)
let i = 1
@@ -74,6 +77,7 @@ export default class Pools {
installPatchesPromises.push(
sourceXapis[sourceId].installPatches({
patches: patchesName[i++],
xsCredentials,
})
)
}

View File

@@ -39,7 +39,7 @@ export default class {
app.addConfigManager(
'groups',
() => groupsDb.get(),
groups => Promise.all(groups.map(group => groupsDb.save(group))),
groups => Promise.all(groups.map(group => groupsDb.update(group))),
['users']
)
app.addConfigManager(
@@ -53,7 +53,7 @@ export default class {
if (!isEmpty(conflictUsers)) {
await Promise.all(conflictUsers.map(({ id }) => id !== userId && this.deleteUser(id)))
}
return usersDb.save(user)
return usersDb.update(user)
})
)
)
@@ -196,7 +196,7 @@ export default class {
user.email = user.name
delete user.name
await this._users.save(user)
await this._users.update(user)
}
// Merge this method in getUser() when plain objects.
@@ -368,7 +368,7 @@ export default class {
if (name) group.name = name
await this._groups.save(group)
await this._groups.update(group)
}
async getGroup(id) {
@@ -390,17 +390,17 @@ export default class {
user.groups = addToArraySet(user.groups, groupId)
group.users = addToArraySet(group.users, userId)
await Promise.all([this._users.save(user), this._groups.save(group)])
await Promise.all([this._users.update(user), this._groups.update(group)])
}
async _removeUserFromGroup(userId, group) {
group.users = removeFromArraySet(group.users, userId)
return this._groups.save(group)
return this._groups.update(group)
}
async _removeGroupFromUser(groupId, user) {
user.groups = removeFromArraySet(user.groups, groupId)
return this._users.save(user)
return this._users.update(user)
}
async removeUserFromGroup(userId, groupId) {
@@ -438,11 +438,11 @@ export default class {
group.users = userIds
const saveUser = ::this._users.save
const updateUser = ::this._users.update
await Promise.all([
Promise.all(newUsers.map(saveUser)),
Promise.all(oldUsers.map(saveUser)),
this._groups.save(group),
Promise.all(newUsers.map(updateUser)),
Promise.all(oldUsers.map(updateUser)),
this._groups.update(group),
])
}
}

View File

@@ -686,7 +686,7 @@ export default class XenServers {
$defer(() => app.loadPlugin('load-balancer'))
}
await this.getXapi(pool).rollingPoolUpdate()
await this.getXapi(pool).rollingPoolUpdate({ xsCredentials: app.apiContext.user.preferences.xsCredentials })
}
}

View File

@@ -6,7 +6,7 @@ import React from 'react'
const Icon = ({ icon, size = 1, color, fixedWidth, ...props }) => {
props.className = classNames(
props.className,
icon !== undefined ? `xo-icon-${icon}` : 'fa', // Without icon prop, is a placeholder.
icon != null ? `xo-icon-${icon}` : 'fa', // Misaligned problem modification: if no icon or null, apply 'fa'
isInteger(size) ? `fa-${size}x` : `fa-${size}`,
color,
fixedWidth && 'fa-fw'

View File

@@ -3184,12 +3184,6 @@ export default {
// Original text: "Xen Orchestra"
xenOrchestra: 'Xen Orchestra',
// Original text: "Xen Orchestra server"
xenOrchestraServer: 'servidor',
// Original text: "Xen Orchestra web client"
xenOrchestraWeb: 'cliente web',
// Original text: "No pro support provided!"
noProSupport: '¡Sin soporte Pro!',

View File

@@ -3261,12 +3261,6 @@ export default {
// Original text: "Xen Orchestra"
xenOrchestra: 'Xen Orchestra',
// Original text: "Xen Orchestra server"
xenOrchestraServer: 'Serveur Xen Orchestra',
// Original text: "Xen Orchestra web client"
xenOrchestraWeb: 'Client web Xen Orchestra',
// Original text: "No pro support provided!"
noProSupport: 'Pas de support professionel fourni !',

View File

@@ -2720,12 +2720,6 @@ export default {
// Original text: 'Xen Orchestra'
xenOrchestra: undefined,
// Original text: 'server'
xenOrchestraServer: undefined,
// Original text: 'web client'
xenOrchestraWeb: undefined,
// Original text: 'No pro support provided!'
noProSupport: undefined,

View File

@@ -3003,12 +3003,6 @@ export default {
// Original text: "Xen Orchestra"
xenOrchestra: 'CLOUDXO',
// Original text: "Xen Orchestra server"
xenOrchestraServer: 'Cloudxo szerver',
// Original text: "Xen Orchestra web client"
xenOrchestraWeb: 'Cloudxo web kliens',
// Original text: "No pro support provided!"
noProSupport: 'Nincsen pro-szupport!',

View File

@@ -4697,12 +4697,6 @@ export default {
// Original text: 'No host selected to be added'
addHostNoHostMessage: 'Nessun host selezionato da aggiungere',
// Original text: 'Xen Orchestra server'
xenOrchestraServer: 'Server Xen Orchestra',
// Original text: 'Xen Orchestra web client'
xenOrchestraWeb: 'Client web Xen Orchestra',
// Original text: 'Professional support missing!'
noProSupport: 'Manca il supporto professionale!',

View File

@@ -2734,12 +2734,6 @@ export default {
// Original text: "Xen Orchestra"
xenOrchestra: 'Xen Orchestra',
// Original text: "server"
xenOrchestraServer: 'serwer',
// Original text: "web client"
xenOrchestraWeb: 'web klient',
// Original text: "No pro support provided!"
noProSupport: 'No pro support provided!',

View File

@@ -2729,12 +2729,6 @@ export default {
// Original text: "Xen Orchestra"
xenOrchestra: 'Xen Orchestra',
// Original text: "server"
xenOrchestraServer: 'servidor',
// Original text: "web client"
xenOrchestraWeb: 'cliente web',
// Original text: "No pro support provided!"
noProSupport: 'Nenhum suporte pro fornecido!',

View File

@@ -3187,12 +3187,6 @@ export default {
// Original text: "Xen Orchestra"
xenOrchestra: 'Xen Orchestra',
// Original text: "Xen Orchestra server"
xenOrchestraServer: 'Сервер Xen Orchestra',
// Original text: "Xen Orchestra web client"
xenOrchestraWeb: 'WEB-клиент Xen Orchestra',
// Original text: "No pro support provided!"
noProSupport: '"PRO" поддержка не предоставляется!',

View File

@@ -4011,12 +4011,6 @@ export default {
// Original text: "Xen Orchestra"
xenOrchestra: 'Xen Orchestra',
// Original text: "Xen Orchestra server"
xenOrchestraServer: 'Xen Orchestra sunucusu',
// Original text: "Xen Orchestra web client"
xenOrchestraWeb: 'Xen Orchestra web istemcisi',
// Original text: "No pro support provided!"
noProSupport: 'Hiçbir profesyonel destek verilmez!',

View File

@@ -2064,12 +2064,6 @@ export default {
// Original text: "Xen Orchestra"
xenOrchestra: 'Xen Orchestra',
// Original text: "server"
xenOrchestraServer: '服务器',
// Original text: "web client"
xenOrchestraWeb: 'Web客户端',
// Original text: "No pro support provided!"
noProSupport: '不提供专业支持!',

View File

@@ -25,8 +25,10 @@ const messages = {
esxiImportStopOnErrorDescription: 'Stop on the first error when importing VMs',
nImportVmsInParallel: 'Number of VMs to import in parallel',
stopOnError: 'Stop on error',
uuid: 'UUID',
vmSrUsage: 'Storage: {used} used of {total} ({free} free)',
new: 'New',
notDefined: 'Not defined',
status: 'Status',
statusConnecting: 'Connecting',
@@ -117,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',
@@ -1367,6 +1371,10 @@ const messages = {
logAction: 'Action',
// ----- VM advanced tab -----
createVtpm: 'Create a VTPM',
deleteVtpm: 'Delete the VTPM',
deleteVtpmWarning:
'If the VTPM is in use, removing it will result in a dangerous data loss. Are you sure you want to remove the VTPM?',
vmRemoveButton: 'Remove',
vmConvertToTemplateButton: 'Convert to template',
vmSwitchVirtualizationMode: 'Convert to {mode}',
@@ -1396,9 +1404,12 @@ const messages = {
srHaTooltip: 'SR used for High Availability',
nestedVirt: 'Nested virtualization',
vmAffinityHost: 'Affinity host',
vmNeedToBeHalted: 'The VM needs to be halted',
vmVga: 'VGA',
vmVideoram: 'Video RAM',
vmNicType: 'NIC type',
vtpm: 'VTPM',
vtpmRequireUefi: 'A UEFI boot firmware is necessary to use a VTPM',
noAffinityHost: 'None',
originalTemplate: 'Original template',
unknownOsName: 'Unknown',
@@ -1568,13 +1579,14 @@ const messages = {
unhealthyVdis: 'Unhealthy VDIs',
vdisToCoalesce: 'VDIs to coalesce',
vdisWithInvalidVhdParent: 'VDIs with invalid parent VHD',
srVdisToCoalesceWarning: 'This SR has more than {limitVdis, number} VDIs to coalesce',
srVdisToCoalesceWarning: 'This SR has {nVdis, number} VDI{nVdis, plural, one {} other {s}} to coalesce',
// ----- New VM -----
createVmModalTitle: 'Create VM',
createVmModalWarningMessage:
"You're about to use a large amount of resources available on the resource set. Are you sure you want to continue?",
copyHostBiosStrings: 'Copy host BIOS strings to VM',
enableVtpm: 'Enable VTPM',
newVmCreateNewVmOn: 'Create a new VM on {select}',
newVmCreateNewVmNoPermission: 'You have no permission to create a VM',
newVmInfoPanel: 'Info',
@@ -1644,8 +1656,11 @@ const messages = {
newVmNetworkConfigDoc: 'Network config documentation',
templateHasBiosStrings: 'The template already contains the BIOS strings',
secureBootLinkToDocumentationMessage: 'Click for more information about Guest UEFI Secure Boot.',
seeVtpmDocumentation: 'See VTPM documentation',
vmBootFirmwareIsUefi: 'The boot firmware is UEFI',
destroyCloudConfigVdiAfterBoot: 'Destroy cloud config drive after first boot',
vtpmNotSupported: 'VTPM is only supported on pools running XCP-ng/XS 8.3 or later.',
warningVtpmRequired: 'This template requires a VTPM, if you proceed, the VM will likely not be able to boot.',
// ----- Self -----
resourceSets: 'Resource sets',
@@ -1680,6 +1695,8 @@ const messages = {
resourceSetQuota: 'Used: {usage} (Total: {total})',
resourceSetNew: 'New',
shareVmsByDefault: 'Share VMs by default',
nVmsInResourceSet:
'{nVms, number} VM{nVms, plural, one {} other {s}} belong{nVms, plural, one {s} other {}} to this Resource Set',
// ---- VM import ---
fileType: 'File type:',
@@ -2092,8 +2109,7 @@ const messages = {
addHostNoHostMessage: 'No host selected to be added',
// ----- About View -----
xenOrchestraServer: 'Xen Orchestra server',
xenOrchestraWeb: 'Xen Orchestra web client',
failedToFetchLatestMasterCommit: 'Failed to fetch latest master commit',
noProSupport: 'Professional support missing!',
productionUse: 'Want to use in production?',
getSupport: 'Get pro support with the Xen Orchestra Appliance at {website}',
@@ -2111,6 +2127,9 @@ const messages = {
xoAccount: 'Access your XO Account',
openTicket: 'Report a problem',
openTicketText: 'Problem? Open a ticket!',
xoUpToDate: 'Your Xen Orchestra is up to date',
xoFromSourceNotUpToDate:
'You are not up to date with master. {nBehind} commit{nBehind, plural, one {} other {s}} behind {nAhead, plural, =0 {} other {and {nAhead, number} commit{nAhead, plural, one {} other {s}} ahead}}',
// ----- Upgrade Panel -----
upgradeNeeded: 'Upgrade needed',
@@ -2473,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',

View File

@@ -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>

View File

@@ -109,7 +109,13 @@ const xo = invoke(() => {
credentials: { token },
})
xo.on('authenticationFailure', signOut)
xo.on('authenticationFailure', error => {
console.warn('authenticationFailure', error)
if (error.name !== 'ConnectionError') {
signOut(error)
}
})
xo.on('scheduledAttempt', ({ delay }) => {
console.warn('next attempt in %s ms', delay)
})
@@ -1661,14 +1667,22 @@ export const migrateVms = vms =>
)
}, noop)
export const createVm = args => _call('vm.create', args)
export const createVm = async args => {
try {
return await _call('vm.create', args)
} catch (err) {
handlePoolDoesNotSupportVtpmError(err)
throw error
}
}
export const createVms = (args, nameLabels, cloudConfigs) =>
confirm({
export const createVms = async (args, nameLabels, cloudConfigs) => {
await confirm({
title: _('newVmCreateVms'),
body: _('newVmCreateVmsConfirm', { nbVms: nameLabels.length }),
}).then(() =>
Promise.all(
})
try {
return await Promise.all(
map(
nameLabels,
(
@@ -1682,7 +1696,11 @@ export const createVms = (args, nameLabels, cloudConfigs) =>
})
)
)
)
} catch (error) {
handlePoolDoesNotSupportVtpmError(error)
throw error
}
}
export const getCloudInitConfig = template => _call('vm.getCloudInitConfig', { template })
@@ -1922,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({
@@ -2149,6 +2169,29 @@ export const deleteAclRule = ({ protocol = undefined, port = undefined, ipRange
vifId: resolveId(vif),
})
// VTPM -----------------------------------------------------------
const handlePoolDoesNotSupportVtpmError = err => {
if (
incorrectState.is(err, {
property: 'restrictions.restrict_vtpm',
expected: 'false',
})
) {
console.error(err)
throw new Error('This pool does not support VTPM')
}
}
export const createVtpm = async vm => {
try {
return await _call('vtpm.create', { id: resolveId(vm) })
} catch (err) {
handlePoolDoesNotSupportVtpmError(err)
throw err
}
}
export const deleteVtpm = vtpm => _call('vtpm.destroy', { id: resolveId(vtpm) })
// Network -----------------------------------------------------------
export const editNetwork = (network, props) => _call('network.set', { ...props, id: resolveId(network) })
@@ -3444,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 })
@@ -3590,6 +3637,11 @@ export const destroyProxyAppliances = proxies =>
export const upgradeProxyAppliance = (proxy, props) =>
_call('proxy.upgradeAppliance', { id: resolveId(proxy), ...props })
export const openTunnelOnProxy = async proxy => {
const result = await _call('proxy.openSupportTunnel', { id: resolveId(proxy) }).catch(err => err.message)
await alert(_('supportTunnel'), <pre>{result}</pre>)
}
export const getProxyApplianceUpdaterState = id => _call('proxy.getApplianceUpdaterState', { id })
export const updateProxyApplianceSettings = (id, props) => _call('proxy.updateApplianceSettings', { id, ...props })
@@ -3682,3 +3734,20 @@ export const esxiListVms = (host, user, password, sslVerify) =>
_call('esxi.listVms', { host, user, password, sslVerify })
export const importVmsFromEsxi = params => _call('vm.importMultipleFromEsxi', params)
// Github API ---------------------------------------------------------------
const _callGithubApi = async (endpoint = '') => {
const url = new URL('https://api.github.com/repos/vatesfr/xen-orchestra')
url.pathname += endpoint
const resp = await fetch(url.toString())
const json = await resp.json()
if (resp.ok) {
return json
} else {
throw new Error(json.message)
}
}
export const getMasterCommit = () => _callGithubApi('/commits/master')
export const compareCommits = (base, head) => _callGithubApi(`/compare/${base}...${head}`)

View File

@@ -1019,7 +1019,7 @@
@extend .fa-file-archive-o;
}
}
&-menu-xosan {
&-menu-xostor {
@extend .fa;
@extend .fa-database;
}

View File

@@ -100,6 +100,14 @@ $select-input-height: 40px; // Bootstrap input height
color: #333;
}
.d-inline-flex {
display: inline-flex;
}
.align-self-center {
align-self: center;
}
// COLORS ======================================================================
.xo-status-running {

View File

@@ -1,17 +1,14 @@
import _ from 'intl'
import Component from 'base-component'
import Copiable from 'copiable'
import Icon from 'icon'
import Link from 'link'
import Page from '../page'
import React from 'react'
import { getUser } from 'selectors'
import { serverVersion } from 'xo'
import { compareCommits, getMasterCommit, serverVersion } from 'xo'
import { Container, Row, Col } from 'grid'
import { connectStore, getXoaPlan } from 'utils'
import pkg from '../../../package'
const COMMIT_ID = process.env.GIT_HEAD
const HEADER = (
@@ -30,13 +27,51 @@ const HEADER = (
user: getUser,
}))
export default class About extends Component {
componentWillMount() {
async componentWillMount() {
serverVersion.then(serverVersion => {
this.setState({ serverVersion })
})
if (process.env.XOA_PLAN > 4 && COMMIT_ID !== '') {
try {
const commit = await getMasterCommit()
const isOnLatest = commit.sha === COMMIT_ID
const diff = {
nAhead: 0,
nBehind: 0,
}
if (!isOnLatest) {
try {
const { ahead_by, behind_by } = await compareCommits(commit.sha, COMMIT_ID)
diff.nAhead = ahead_by
diff.nBehind = behind_by
} catch (err) {
console.error(err)
diff.nBehind = 'unknown'
}
}
this.setState({
commit: {
isOnLatest,
master: commit,
diffWithMaster: diff,
fetched: true,
},
})
} catch (err) {
console.error(err)
this.setState({
commit: {
fetched: false,
},
})
}
}
}
render() {
const { user } = this.props
const { commit } = this.state
const isAdmin = user && user.permission === 'admin'
return (
@@ -44,32 +79,53 @@ export default class About extends Component {
<Container className='text-xs-center'>
{isAdmin && [
process.env.XOA_PLAN > 4 && COMMIT_ID !== '' && (
<Row key='0'>
<Col>
<Icon icon='git' size={4} />
<h4>
Xen Orchestra, commit{' '}
<a href={'https://github.com/vatesfr/xen-orchestra/commit/' + COMMIT_ID}>{COMMIT_ID.slice(0, 5)}</a>
</h4>
</Col>
</Row>
<Col>
<Row key='0'>
<Col mediumSize={6}>
<Icon icon='git' size={4} />
<h4>
Xen Orchestra, commit{' '}
<a href={'https://github.com/vatesfr/xen-orchestra/commit/' + COMMIT_ID}>
{COMMIT_ID.slice(0, 5)}
</a>
</h4>
</Col>
<Col mediumSize={6} className={commit?.fetched === false ? 'text-warning' : ''}>
<Icon icon='git' size={4} />
<h4>
{commit === undefined ? (
_('statusLoading')
) : commit.fetched ? (
<span>
Master, commit <a href={commit.master.html_url}>{commit.master.sha.slice(0, 5)}</a>
</span>
) : (
_('failedToFetchLatestMasterCommit')
)}
</h4>
</Col>
</Row>
{commit?.fetched && (
<Row className={`mt-1 ${commit.isOnLatest ? '' : 'text-warning '}`}>
<h4>
{commit.isOnLatest ? (
<span>
{_('xoUpToDate')} <Icon icon='check' color='text-success' />
</span>
) : (
<span>
{_('xoFromSourceNotUpToDate', {
nBehind: commit.diffWithMaster.nBehind,
nAhead: commit.diffWithMaster.nAhead,
})}{' '}
<Icon icon='alarm' color='text-warning' />
</span>
)}
</h4>
</Row>
)}
</Col>
),
<Row key='1'>
<Col mediumSize={6}>
<Icon icon='host' size={4} />
<Copiable tagName='h4' data={`xo-server ${this.state.serverVersion}`}>
xo-server {this.state.serverVersion || 'unknown'}
</Copiable>
<p className='text-muted'>{_('xenOrchestraServer')}</p>
</Col>
<Col mediumSize={6}>
<Icon icon='vm' size={4} />
<Copiable tagName='h4' data={`xo-web ${pkg.version}`}>
xo-web {pkg.version}
</Copiable>
<p className='text-muted'>{_('xenOrchestraWeb')}</p>
</Col>
</Row>,
]}
{process.env.XOA_PLAN > 4 ? (
<div>

View File

@@ -11,7 +11,7 @@ import { connectStore } from 'utils'
import { Col, Row } from 'grid'
import { createGetObjectsOfType } from 'selectors'
import { injectState, provideState } from 'reaclette'
import { forEach, isEmpty, map, size } from 'lodash'
import { forEach, isEmpty, map } from 'lodash'
import { Sr, Vdi } from 'render-xo-item'
import { subscribeSrsUnhealthyVdiChainsLength, VDIS_TO_COALESCE_LIMIT } from 'xo'
@@ -20,8 +20,8 @@ const COLUMNS = [
itemRenderer: (srId, { vdisHealthBySr }) => (
<div>
<Sr id={srId} link />{' '}
{size(vdisHealthBySr[srId].unhealthyVdis) >= VDIS_TO_COALESCE_LIMIT && (
<Tooltip content={_('srVdisToCoalesceWarning', { limitVdis: VDIS_TO_COALESCE_LIMIT })}>
{vdisHealthBySr[srId].nUnhealthyVdis >= VDIS_TO_COALESCE_LIMIT && (
<Tooltip content={_('srVdisToCoalesceWarning', { nVdis: vdisHealthBySr[srId].nUnhealthyVdis })}>
<span className='text-warning'>
<Icon icon='alarm' />
</span>

View File

@@ -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,

View File

@@ -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',

View File

@@ -346,6 +346,7 @@ export default class NewVm extends BaseComponent {
seqStart: 1,
share: this._getResourceSet()?.shareByDefault ?? false,
tags: [],
createVtpm: this._templateNeedsVtpm(),
},
callback
)
@@ -493,6 +494,7 @@ export default class NewVm extends BaseComponent {
bootAfterCreate: state.bootAfterCreate,
copyHostBiosStrings:
state.hvmBootFirmware !== 'uefi' && !this._templateHasBiosStrings() && state.copyHostBiosStrings,
createVtpm: state.createVtpm,
destroyCloudConfigVdiAfterBoot: state.destroyCloudConfigVdiAfterBoot,
secureBoot: state.secureBoot,
share: state.share,
@@ -599,6 +601,7 @@ export default class NewVm extends BaseComponent {
}),
// settings
secureBoot: template.secureBoot,
createVtpm: this._templateNeedsVtpm(),
})
if (this._isCoreOs()) {
@@ -748,6 +751,8 @@ export default class NewVm extends BaseComponent {
template => template && template.virtualizationMode === 'hvm'
)
_templateNeedsVtpm = () => this.props.template?.platform?.vtpm === 'true'
// On change -------------------------------------------------------------------
_onChangeSshKeys = keys => this._setState({ sshKeys: map(keys, key => key.id) })
@@ -881,7 +886,12 @@ export default class NewVm extends BaseComponent {
_getRedirectionUrl = id => (this.state.state.multipleVms ? '/home' : `/vms/${id}`)
_handleBootFirmware = value => this._setState({ hvmBootFirmware: value, secureBoot: false })
_handleBootFirmware = value =>
this._setState({
hvmBootFirmware: value,
secureBoot: false,
createVtpm: value === 'uefi' ? this._templateNeedsVtpm() : false,
})
// MAIN ------------------------------------------------------------------------
@@ -1531,6 +1541,7 @@ export default class NewVm extends BaseComponent {
cpuCap,
cpusMax,
cpuWeight,
createVtpm,
destroyCloudConfigVdiAfterBoot,
hvmBootFirmware,
installMethod,
@@ -1565,6 +1576,8 @@ export default class NewVm extends BaseComponent {
</label>
) : null
const isVtpmSupported = pool?.vtpmSupported ?? true
return (
<Section icon='new-vm-advanced' title='newVmAdvancedPanel' done={this._isAdvancedDone()}>
<SectionContent column>
@@ -1769,6 +1782,23 @@ export default class NewVm extends BaseComponent {
<Item label={_('secureBoot')}>
<Toggle onChange={this._toggleState('secureBoot')} value={secureBoot} />
</Item>
<Item label={_('enableVtpm')} className='d-inline-flex'>
<Tooltip content={!isVtpmSupported ? _('vtpmNotSupported') : undefined}>
<Toggle onChange={this._toggleState('createVtpm')} value={createVtpm} disabled={!isVtpmSupported} />
</Tooltip>
{/* FIXME: link to VTPM documentation when ready */}
{/* &nbsp;
<Tooltip content={_('seeVtpmDocumentation')}>
<a className='text-info align-self-center' style={{ cursor: 'pointer' }} href='#'>
<Icon icon='info' />
</a>
</Tooltip> */}
{!createVtpm && this._templateNeedsVtpm() && (
<span className='align-self-center text-warning ml-1'>
<Icon icon='alarm' /> {_('warningVtpmRequired')}
</span>
)}
</Item>
</SectionContent>
),
isAdmin && isHvm && (

View File

@@ -23,6 +23,7 @@ import {
forgetProxyAppliances,
getLicenses,
getProxyApplianceUpdaterState,
openTunnelOnProxy,
registerProxy,
subscribeProxies,
upgradeProxyAppliance,
@@ -112,6 +113,13 @@ const INDIVIDUAL_ACTIONS = [
label: _('forceUpgrade'),
level: 'primary',
},
{
collapsed: true,
handler: proxy => openTunnelOnProxy(proxy),
icon: 'open-tunnel',
label: _('openTunnel'),
level: 'primary',
},
{
handler: ({ id }, { router }) =>
router.push({

View File

@@ -13,6 +13,7 @@ import intersection from 'lodash/intersection'
import isEmpty from 'lodash/isEmpty'
import keyBy from 'lodash/keyBy'
import keys from 'lodash/keys'
import Link from 'link'
import map from 'lodash/map'
import mapKeys from 'lodash/mapKeys'
import PropTypes from 'prop-types'
@@ -20,6 +21,7 @@ import React from 'react'
import remove from 'lodash/remove'
import renderXoItem from 'render-xo-item'
import ResourceSetQuotas from 'resource-set-quotas'
import size from 'lodash/size'
import some from 'lodash/some'
import Tags from 'tags'
import Upgrade from 'xoa-upgrade'
@@ -570,10 +572,13 @@ export class Edit extends Component {
@addSubscriptions({
ipPools: subscribeIpPools,
})
@connectStore({
vms: createGetObjectsOfType('VM').filter((state, props) => vm => vm.resourceSet === props.resourceSet.id),
})
@injectIntl
class ResourceSet extends Component {
_renderDisplay = () => {
const { resourceSet } = this.props
const { resourceSet, vms } = this.props
const resolvedIpPools = mapKeys(this.props.ipPools, 'id')
const { limits, ipPools, subjects, objectsByType, tags } = resourceSet
@@ -615,6 +620,9 @@ class ResourceSet extends Component {
</li>,
<li key='graphs' className='list-group-item'>
<ResourceSetQuotas limits={limits} />
<Link to={`/home?s=resourceSet:${resourceSet.id}&t=VM`}>
<Icon icon='preview' /> {_('nVmsInResourceSet', { nVms: size(vms) })}
</Link>
</li>,
<li key='actions' className='list-group-item text-xs-center'>
<div className='btn-toolbar'>

View File

@@ -10,7 +10,7 @@ import { CustomFields } from 'custom-fields'
import { createGetObjectsOfType } from 'selectors'
import { createSelector } from 'reselect'
import { createSrUnhealthyVdiChainsLengthSubscription, deleteSr, reclaimSrSpace, toggleSrMaintenanceMode } from 'xo'
import { flowRight, isEmpty, keys, sum, values } from 'lodash'
import { flowRight, isEmpty, keys } from 'lodash'
// ===================================================================
@@ -44,11 +44,11 @@ const UnhealthyVdiChains = flowRight(
connectStore(() => ({
vdis: createGetObjectsOfType('VDI').pick(createSelector((_, props) => props.chains?.unhealthyVdis, keys)),
}))
)(({ chains: { unhealthyVdis } = {}, vdis }) =>
)(({ chains: { nUnhealthyVdis, unhealthyVdis } = {}, vdis }) =>
isEmpty(vdis) ? null : (
<div>
<hr />
<h3>{_('srUnhealthyVdiTitle', { total: sum(values(unhealthyVdis)) })}</h3>
<h3>{_('srUnhealthyVdiTitle', { total: nUnhealthyVdis })}</h3>
<SortedTable collection={vdis} columns={COLUMNS} stateUrlParam='s_unhealthy_vdis' userData={unhealthyVdis} />
</div>
)

View File

@@ -2,6 +2,7 @@ import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import decorate from 'apply-decorators'
import Copiable from 'copiable'
import defined, { get } from '@xen-orchestra/defined'
import getEventValue from 'get-event-value'
import Icon from 'icon'
@@ -28,8 +29,10 @@ import {
cloneVm,
convertVmToTemplate,
createVgpu,
createVtpm,
deleteVgpu,
deleteVm,
deleteVtpm,
editVm,
getVmsHaValues,
isVmRunning,
@@ -450,9 +453,48 @@ export default class TabAdvanced extends Component {
_onNicTypeChange = value => editVm(this.props.vm, { nicType: value === '' ? null : value })
_getDisabledAddVtpmReason = createSelector(
() => this.props.vm,
() => this.props.pool,
(vm, pool) => {
if (pool?.vtpmSupported === false) {
return _('vtpmNotSupported')
}
if (vm.boot.firmware !== 'uefi') {
return _('vtpmRequireUefi')
}
if (vm.power_state !== 'Halted') {
return _('vmNeedToBeHalted')
}
}
)
_getDisabledDeleteVtpmReason = () => {
if (this.props.vm.power_state !== 'Halted') {
return _('vmNeedToBeHalted')
}
}
_handleDeleteVtpm = async vtpm => {
await confirm({
icon: 'delete',
title: _('deleteVtpm'),
body: <p>{_('deleteVtpmWarning')}</p>,
strongConfirm: {
messageId: 'deleteVtpm',
},
})
return deleteVtpm(vtpm)
}
render() {
const { container, isAdmin, vgpus, vm, vmPool } = this.props
const isWarmMigrationAvailable = getXoaPlan().value >= PREMIUM.value
const addVtpmTooltip = this._getDisabledAddVtpmReason()
const deleteVtpmTooltip = this._getDisabledDeleteVtpmReason()
const isAddVtpmAvailable = addVtpmTooltip === undefined
const isDeleteVtpmAvailable = deleteVtpmTooltip === undefined
const vtpmId = vm.VTPMs[0]
return (
<Container>
<Row>
@@ -798,6 +840,59 @@ export default class TabAdvanced extends Component {
</td>
</tr>
)}
<tr>
<th>{_('vtpm')}</th>
<td>
{/*
FIXME: add documentation link
<a
className='text-muted'
href='#'
rel='noopener noreferrer'
style={{ display: 'block' }}
target='_blank'
>
<Icon icon='info' /> {_('seeVtpmDocumentation')}
</a> */}
{vtpmId === undefined ? (
<Tooltip content={addVtpmTooltip}>
<ActionButton
btnStyle='primary'
disabled={!isAddVtpmAvailable}
handler={createVtpm}
handlerParam={vm}
icon='add'
>
{_('createVtpm')}
</ActionButton>
</Tooltip>
) : (
<div>
<Tooltip content={deleteVtpmTooltip}>
<ActionButton
btnStyle='danger'
disabled={!isDeleteVtpmAvailable}
handler={this._handleDeleteVtpm}
handlerParam={vtpmId}
icon='delete'
>
{_('deleteVtpm')}
</ActionButton>
</Tooltip>
<table className='table mt-1'>
<tbody>
<tr>
<th>{_('uuid')}</th>
<Copiable tagName='td' data={vtpmId}>
{vtpmId.slice(0, 4)}
</Copiable>
</tr>
</tbody>
</table>
</div>
)}
</td>
</tr>
{vm.boot.firmware === 'uefi' && (
<tr>
<th>{_('secureBoot')}</th>

View File

@@ -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>

View File

@@ -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,
}}
/>
)
}
}

View 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

View File

@@ -43,7 +43,13 @@ const Support = decorate([
<ActionButton btnStyle='primary' disabled={COMMUNITY} handler={reportOnSupportPanel} icon='ticket'>
{_('createSupportTicket')}
</ActionButton>
<ActionButton btnStyle='danger' disabled={COMMUNITY} handler={restartXoServer} icon='restart' className='ml-1'>
<ActionButton
btnStyle='danger'
disabled={COMMUNITY}
handler={restartXoServer}
icon='restart'
className='ml-1'
>
{_('restartXoServer')}
</ActionButton>
</Col>

View File

@@ -0,0 +1,4 @@
.disksSelectors {
display: flex;
align-items: flex-end;
}

View 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

View 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

View 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

2541
yarn.lock

File diff suppressed because it is too large Load Diff