Compare commits
47 Commits
updateChan
...
token-last
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7da0146d3e | ||
|
|
f3bbcbde08 | ||
|
|
0559fe8649 | ||
|
|
9e70397240 | ||
|
|
5f69b0e9a0 | ||
|
|
2a9bff1607 | ||
|
|
9e621d7de8 | ||
|
|
3e5c73528d | ||
|
|
397b5cd56d | ||
|
|
55cb6042e8 | ||
|
|
339d920b78 | ||
|
|
f14f716f3d | ||
|
|
fb83d1fc98 | ||
|
|
62208e7847 | ||
|
|
df91772f5c | ||
|
|
cf8a9d40be | ||
|
|
93d1c6c3fc | ||
|
|
f1fa811e5c | ||
|
|
5a9812c492 | ||
|
|
b53d613a64 | ||
|
|
225a67ae3b | ||
|
|
c7eb7db463 | ||
|
|
edfa729672 | ||
|
|
77d9798319 | ||
|
|
680f1e2f07 | ||
|
|
7c009b0fc0 | ||
|
|
eb7de4f2dd | ||
|
|
2378399981 | ||
|
|
37b2113763 | ||
|
|
5048485a85 | ||
|
|
9e667533e9 | ||
|
|
1fac7922b4 | ||
|
|
1a0e5eb6fc | ||
|
|
321e322492 | ||
|
|
8834af65f7 | ||
|
|
1a1dd0531d | ||
|
|
8752487280 | ||
|
|
4b12a6d31d | ||
|
|
2924f82754 | ||
|
|
9b236a6191 | ||
|
|
a3b8553cec | ||
|
|
00a1778a6d | ||
|
|
3b6bc629bc | ||
|
|
04dfd9a02c | ||
|
|
fb52868074 | ||
|
|
77d53d2abf | ||
|
|
6afb87def1 |
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -105,6 +105,10 @@ watchEffect(() => {
|
||||
onBeforeUnmount(() => {
|
||||
clearVncClient();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
sendCtrlAltDel: () => vncClient?.sendCtrlAltDel(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
37
@xen-orchestra/lite/src/components/TextLogo.vue
Normal file
37
@xen-orchestra/lite/src/components/TextLogo.vue
Normal 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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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-->
|
||||
|
||||
34
README.md
34
README.md
@@ -1,11 +1,35 @@
|
||||
# Xen Orchestra [](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>
|
||||
|
||||

|
||||

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

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## License
|
||||
|
||||
AGPL3 © [Vates SAS](http://vates.fr)
|
||||
AGPL3 © [Vates](http://vates.tech)
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
# Xen Orchestra
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
|
||||

|
||||
Log in to your account and use the deploy form available from [Vates website](https://vates.tech/deploy/)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ ignoreVmSnapshotResources = false
|
||||
restartHostTimeout = '20 minutes'
|
||||
maxUncoalescedVdis = 1
|
||||
vdiExportConcurrency = 12
|
||||
vmEvacuationConcurrency = 3
|
||||
vmExportConcurrency = 2
|
||||
vmSnapshotConcurrency = 2
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
248
packages/xo-server/src/api/xostor.mjs
Normal file
248
packages/xo-server/src/api/xostor.mjs
Normal file
@@ -0,0 +1,248 @@
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { defer } from 'golike-defer'
|
||||
|
||||
const ENUM_PROVISIONING = {
|
||||
Thin: 'thin',
|
||||
Thick: 'thick',
|
||||
}
|
||||
const LV_NAME = 'thin_device'
|
||||
const PROVISIONING = Object.values(ENUM_PROVISIONING)
|
||||
const VG_NAME = 'linstor_group'
|
||||
const _XOSTOR_DEPENDENCIES = ['xcp-ng-release-linstor', 'xcp-ng-linstor']
|
||||
const XOSTOR_DEPENDENCIES = _XOSTOR_DEPENDENCIES.join(',')
|
||||
|
||||
const log = createLogger('xo:api:pool')
|
||||
|
||||
function pluginCall(xapi, host, plugin, fnName, args) {
|
||||
return xapi.call('host.call_plugin', host._xapiRef, plugin, fnName, args)
|
||||
}
|
||||
|
||||
async function destroyVolumeGroup(xapi, host, force) {
|
||||
log.info(`Trying to delete the ${VG_NAME} volume group.`, { hostId: host.id })
|
||||
return pluginCall(xapi, host, 'lvm.py', 'destroy_volume_group', {
|
||||
vg_name: VG_NAME,
|
||||
force: String(force),
|
||||
})
|
||||
}
|
||||
|
||||
async function installOrUpdateDependencies(host, method = 'install') {
|
||||
if (method !== 'install' && method !== 'update') {
|
||||
throw new Error('Invalid method')
|
||||
}
|
||||
|
||||
const xapi = this.getXapi(host)
|
||||
log.info(`Trying to ${method} XOSTOR dependencies (${XOSTOR_DEPENDENCIES})`, { hostId: host.id })
|
||||
for (const _package of _XOSTOR_DEPENDENCIES) {
|
||||
await pluginCall(xapi, host, 'updater.py', method, {
|
||||
packages: _package,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function installDependencies({ host }) {
|
||||
return installOrUpdateDependencies.call(this, host)
|
||||
}
|
||||
installDependencies.description = 'Install XOSTOR dependencies'
|
||||
installDependencies.permission = 'admin'
|
||||
installDependencies.params = {
|
||||
host: { type: 'string' },
|
||||
}
|
||||
installDependencies.resolve = {
|
||||
host: ['host', 'host', 'administrate'],
|
||||
}
|
||||
|
||||
export function updateDependencies({ host }) {
|
||||
return installOrUpdateDependencies.call(this, host, 'update')
|
||||
}
|
||||
updateDependencies.description = 'Update XOSTOR dependencies'
|
||||
updateDependencies.permission = 'admin'
|
||||
updateDependencies.params = {
|
||||
host: { type: 'string' },
|
||||
}
|
||||
updateDependencies.resolve = {
|
||||
host: ['host', 'host', 'administrate'],
|
||||
}
|
||||
|
||||
export async function formatDisks({ disks, force, host, ignoreFileSystems, provisioning }) {
|
||||
const rawDisks = disks.join(',')
|
||||
const xapi = this.getXapi(host)
|
||||
|
||||
const lvmPlugin = (fnName, args) => pluginCall(xapi, host, 'lvm.py', fnName, args)
|
||||
log.info(`Format disks (${rawDisks}) with force: ${force}`, { hostId: host.id })
|
||||
|
||||
if (force) {
|
||||
await destroyVolumeGroup(xapi, host, force)
|
||||
}
|
||||
|
||||
// ATM we are unable to correctly identify errors (error.code can be used for multiple errors.)
|
||||
// so we are just adding some suggestion of "why there is this error"
|
||||
// Error handling will be improved as errors are discovered and understood
|
||||
try {
|
||||
await lvmPlugin('create_physical_volume', {
|
||||
devices: rawDisks,
|
||||
ignore_existing_filesystems: String(ignoreFileSystems),
|
||||
force: String(force),
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.code === 'LVM_ERROR(5)') {
|
||||
error.params = error.params.concat([
|
||||
"[XO] This error can be triggered if one of the disks is a 'tapdevs' disk.",
|
||||
'[XO] This error can be triggered if one of the disks have children',
|
||||
])
|
||||
}
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
await lvmPlugin('create_volume_group', {
|
||||
devices: rawDisks,
|
||||
vg_name: VG_NAME,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.code === 'LVM_ERROR(5)') {
|
||||
error.params = error.params.concat([
|
||||
"[XO] This error can be triggered if a VG 'linstor_group' is already present on the host.",
|
||||
])
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (provisioning === ENUM_PROVISIONING.Thin) {
|
||||
await lvmPlugin('create_thin_pool', {
|
||||
lv_name: LV_NAME,
|
||||
vg_name: VG_NAME,
|
||||
})
|
||||
}
|
||||
}
|
||||
formatDisks.description = 'Format disks for a XOSTOR use'
|
||||
formatDisks.permission = 'admin'
|
||||
formatDisks.params = {
|
||||
disks: { type: 'array', items: { type: 'string' } },
|
||||
force: { type: 'boolean', optional: true, default: false },
|
||||
host: { type: 'string' },
|
||||
ignoreFileSystems: { type: 'boolean', optional: true, default: false },
|
||||
provisioning: { enum: PROVISIONING },
|
||||
}
|
||||
formatDisks.resolve = {
|
||||
host: ['host', 'host', 'administrate'],
|
||||
}
|
||||
|
||||
export const create = defer(async function (
|
||||
$defer,
|
||||
{ description, disksByHost, force, ignoreFileSystems, name, provisioning, replication }
|
||||
) {
|
||||
const hostIds = Object.keys(disksByHost)
|
||||
|
||||
const tmpBoundObjectId = `tmp_${hostIds.join(',')}_${Math.random().toString(32).slice(2)}`
|
||||
|
||||
const xostorLicenses = await this.getLicenses({ productType: 'xostor' })
|
||||
|
||||
const now = Date.now()
|
||||
const availableLicenses = xostorLicenses.filter(
|
||||
({ boundObjectId, expires }) => boundObjectId === undefined && (expires === undefined || expires > now)
|
||||
)
|
||||
|
||||
let license = availableLicenses.find(license => license.productId === 'xostor')
|
||||
|
||||
if (license === undefined) {
|
||||
license = availableLicenses.find(license => license.productId === 'xostor.trial')
|
||||
}
|
||||
|
||||
if (license === undefined) {
|
||||
license = await this.createBoundXostorTrialLicense({
|
||||
boundObjectId: tmpBoundObjectId,
|
||||
})
|
||||
} else {
|
||||
await this.bindLicense({
|
||||
licenseId: license.id,
|
||||
boundObjectId: tmpBoundObjectId,
|
||||
})
|
||||
}
|
||||
$defer.onFailure(() =>
|
||||
this.unbindLicense({
|
||||
licenseId: license.id,
|
||||
productId: license.productId,
|
||||
boundObjectId: tmpBoundObjectId,
|
||||
})
|
||||
)
|
||||
|
||||
const hosts = hostIds.map(hostId => this.getObject(hostId, 'host'))
|
||||
if (!hosts.every(host => host.$pool === hosts[0].$pool)) {
|
||||
// we need to do this test to ensure it won't create a partial LV group with only the host of the pool of the first master
|
||||
throw new Error('All hosts must be in the same pool')
|
||||
}
|
||||
|
||||
const boundInstallDependencies = installDependencies.bind(this)
|
||||
await asyncEach(hosts, host => boundInstallDependencies({ host }), { stopOnError: false })
|
||||
const boundFormatDisks = formatDisks.bind(this)
|
||||
await asyncEach(
|
||||
hosts,
|
||||
host => boundFormatDisks({ disks: disksByHost[host.id], host, force, ignoreFileSystems, provisioning }),
|
||||
{
|
||||
stopOnError: false,
|
||||
}
|
||||
)
|
||||
|
||||
const host = hosts[0]
|
||||
const xapi = this.getXapi(host)
|
||||
|
||||
log.info(`Create XOSTOR (${name}) with provisioning: ${provisioning}`)
|
||||
const srRef = await xapi.SR_create({
|
||||
device_config: {
|
||||
'group-name': 'linstor_group/' + LV_NAME,
|
||||
redundancy: String(replication),
|
||||
provisioning,
|
||||
},
|
||||
host: host.id,
|
||||
name_description: description,
|
||||
name_label: name,
|
||||
shared: true,
|
||||
type: 'linstor',
|
||||
})
|
||||
const srUuid = await xapi.getField('SR', srRef, 'uuid')
|
||||
|
||||
await this.rebindLicense({
|
||||
licenseId: license.id,
|
||||
oldBoundObjectId: tmpBoundObjectId,
|
||||
newBoundObjectId: srUuid,
|
||||
})
|
||||
|
||||
return srUuid
|
||||
})
|
||||
|
||||
create.description = 'Create a XOSTOR storage'
|
||||
create.permission = 'admin'
|
||||
create.params = {
|
||||
description: { type: 'string', optional: true, default: 'From XO-server' },
|
||||
disksByHost: { type: 'object' },
|
||||
force: { type: 'boolean', optional: true, default: false },
|
||||
ignoreFileSystems: { type: 'boolean', optional: true, default: false },
|
||||
name: { type: 'string' },
|
||||
provisioning: { enum: PROVISIONING },
|
||||
replication: { type: 'number' },
|
||||
}
|
||||
|
||||
// Also called by sr.destroy if sr.SR_type === 'linstor'
|
||||
export async function destroy({ sr }) {
|
||||
if (sr.SR_type !== 'linstor') {
|
||||
throw new Error('Not a XOSTOR storage')
|
||||
}
|
||||
const xapi = this.getXapi(sr)
|
||||
const hosts = Object.values(xapi.objects.indexes.type.host).map(host => this.getObject(host.uuid, 'host'))
|
||||
|
||||
await xapi.destroySr(sr._xapiId)
|
||||
const license = (await this.getLicenses({ productType: 'xostor' })).find(license => license.boundObjectId === sr.uuid)
|
||||
await this.unbindLicense({
|
||||
boundObjectId: license.boundObjectId,
|
||||
productId: license.productId,
|
||||
})
|
||||
return asyncEach(hosts, host => destroyVolumeGroup(xapi, host, true), { stopOnError: false })
|
||||
}
|
||||
destroy.description = 'Destroy a XOSTOR storage'
|
||||
destroy.permission = 'admin'
|
||||
destroy.params = {
|
||||
sr: { type: 'string' },
|
||||
}
|
||||
destroy.resolve = {
|
||||
sr: ['sr', 'SR', 'administrate'],
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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!',
|
||||
|
||||
|
||||
@@ -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 !',
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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!',
|
||||
|
||||
|
||||
@@ -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!',
|
||||
|
||||
|
||||
@@ -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!',
|
||||
|
||||
|
||||
@@ -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!',
|
||||
|
||||
|
||||
@@ -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" поддержка не предоставляется!',
|
||||
|
||||
|
||||
@@ -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!',
|
||||
|
||||
|
||||
@@ -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: '不提供专业支持!',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -1019,7 +1019,7 @@
|
||||
@extend .fa-file-archive-o;
|
||||
}
|
||||
}
|
||||
&-menu-xosan {
|
||||
&-menu-xostor {
|
||||
@extend .fa;
|
||||
@extend .fa-database;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -46,7 +46,7 @@ import User from './user'
|
||||
import Vm from './vm'
|
||||
import Xoa from './xoa'
|
||||
import XoaUpdates from './xoa/update'
|
||||
import Xosan from './xosan'
|
||||
import Xostor from './xostor'
|
||||
import Import from './import'
|
||||
|
||||
import keymap, { help } from '../keymap'
|
||||
@@ -128,7 +128,7 @@ export const ICON_POOL_LICENSE = {
|
||||
'vms/new': NewVm,
|
||||
'vms/:id': Vm,
|
||||
xoa: Xoa,
|
||||
xosan: Xosan,
|
||||
xostor: Xostor,
|
||||
import: Import,
|
||||
hub: Hub,
|
||||
proxies: Proxies,
|
||||
|
||||
@@ -478,7 +478,11 @@ export default class Menu extends Component {
|
||||
label: 'taskMenu',
|
||||
pill: nResolvedTasks,
|
||||
},
|
||||
isAdmin && { to: '/xosan', icon: 'menu-xosan', label: 'xosan' },
|
||||
isAdmin && {
|
||||
to: '/xostor',
|
||||
label: 'xostor',
|
||||
icon: 'menu-xostor',
|
||||
},
|
||||
!noOperatablePools && {
|
||||
to: '/import/vm',
|
||||
icon: 'menu-new-import',
|
||||
|
||||
@@ -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 */}
|
||||
{/*
|
||||
<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 && (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { get } from '@xen-orchestra/defined'
|
||||
import { getLicenses, selfBindLicense, subscribePlugins, subscribeProxies, subscribeSelfLicenses } from 'xo'
|
||||
|
||||
import Proxies from './proxies'
|
||||
import Xosan from './xosan'
|
||||
import Xostor from './xostor'
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -196,7 +196,7 @@ export default class Licenses extends Component {
|
||||
|
||||
return getLicenses()
|
||||
.then(licenses => {
|
||||
const { proxy, xcpng, xoa, xosan } = groupBy(licenses, license => {
|
||||
const { proxy, xcpng, xoa, xosan, xostor } = groupBy(licenses, license => {
|
||||
for (const productType of license.productTypes) {
|
||||
if (productType === 'xo') {
|
||||
return 'xoa'
|
||||
@@ -210,6 +210,9 @@ export default class Licenses extends Component {
|
||||
if (productType === 'xcpng') {
|
||||
return 'xcpng'
|
||||
}
|
||||
if (productType === 'xostor') {
|
||||
return 'xostor'
|
||||
}
|
||||
}
|
||||
return 'other'
|
||||
})
|
||||
@@ -219,6 +222,7 @@ export default class Licenses extends Component {
|
||||
xcpng,
|
||||
xoa,
|
||||
xosan,
|
||||
xostor,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -300,6 +304,21 @@ export default class Licenses extends Component {
|
||||
}
|
||||
})
|
||||
|
||||
// --- XOSTOR ---
|
||||
forEach(licenses.xostor, license => {
|
||||
// When `expires` is undefined, the license isn't expired
|
||||
if (!(license.expires < now)) {
|
||||
products.push({
|
||||
buyer: license.buyer,
|
||||
expires: license.expires,
|
||||
id: license.id,
|
||||
product: 'XOSTOR',
|
||||
type: 'xostor',
|
||||
srId: license.boundObjectId,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return products
|
||||
}
|
||||
)
|
||||
@@ -377,18 +396,8 @@ export default class Licenses extends Component {
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
<h2>
|
||||
XOSAN
|
||||
<a
|
||||
className='btn btn-secondary ml-1'
|
||||
href='https://xen-orchestra.com/#!/xosan-home'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Icon icon='bug' /> {_('productSupport')}
|
||||
</a>
|
||||
</h2>
|
||||
<Xosan xosanLicenses={this.state.licenses.xosan} updateLicenses={this._updateLicenses} />
|
||||
<h2>{_('xostor')}</h2>
|
||||
<Xostor xostorLicenses={this.state.licenses.xostor} updateLicenses={this._updateLicenses} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import _ from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import Component from 'base-component'
|
||||
import Link from 'link'
|
||||
import React from 'react'
|
||||
import renderXoItem, { Pool } from 'render-xo-item'
|
||||
import SelectLicense from 'select-license'
|
||||
import SortedTable from 'sorted-table'
|
||||
import { connectStore } from 'utils'
|
||||
import { createSelector, createGetObjectsOfType } from 'selectors'
|
||||
import { filter, forEach, includes, map } from 'lodash'
|
||||
import { unlockXosan } from 'xo'
|
||||
|
||||
class XosanLicensesForm extends Component {
|
||||
state = {
|
||||
licenseId: 'none',
|
||||
}
|
||||
|
||||
onChangeLicense = event => {
|
||||
this.setState({ licenseId: event.target.value })
|
||||
}
|
||||
|
||||
unlockXosan = () => {
|
||||
const { item, userData } = this.props
|
||||
return unlockXosan(this.state.licenseId, item.id).then(userData.updateLicenses)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { item, userData } = this.props
|
||||
const { licenseId } = this.state
|
||||
|
||||
const license = userData.licensesByXosan[item.id]
|
||||
if (license === null) {
|
||||
return (
|
||||
<span className='text-danger'>
|
||||
{_('xosanMultipleLicenses')} <a href='https://xen-orchestra.com/'>{_('contactUs')}</a>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return license?.productId === 'xosan' ? (
|
||||
<span>{license.id.slice(-4)}</span>
|
||||
) : (
|
||||
<form className='form-inline'>
|
||||
<SelectLicense onChange={this.onChangeLicense} productType='xosan' />
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
className='ml-1'
|
||||
disabled={licenseId === 'none'}
|
||||
handler={this.unlockXosan}
|
||||
handlerParam={licenseId}
|
||||
icon='connect'
|
||||
>
|
||||
{_('bindLicense')}
|
||||
</ActionButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const XOSAN_COLUMNS = [
|
||||
{
|
||||
name: _('xosanName'),
|
||||
itemRenderer: sr => <Link to={`srs/${sr.id}`}>{renderXoItem(sr)}</Link>,
|
||||
sortCriteria: 'name_label',
|
||||
},
|
||||
{
|
||||
name: _('xosanPool'),
|
||||
itemRenderer: sr => <Pool id={sr.$pool} link />,
|
||||
},
|
||||
{
|
||||
name: _('license'),
|
||||
component: XosanLicensesForm,
|
||||
},
|
||||
]
|
||||
|
||||
const XOSAN_INDIVIDUAL_ACTIONS = [
|
||||
{
|
||||
label: _('productSupport'),
|
||||
icon: 'support',
|
||||
handler: () => window.open('https://xen-orchestra.com'),
|
||||
},
|
||||
]
|
||||
|
||||
@connectStore(() => ({
|
||||
xosanSrs: createGetObjectsOfType('SR').filter([
|
||||
({ SR_type }) => SR_type === 'xosan', // eslint-disable-line camelcase
|
||||
]),
|
||||
}))
|
||||
export default class Xosan extends Component {
|
||||
_getLicensesByXosan = createSelector(
|
||||
() => this.props.xosanLicenses,
|
||||
licenses => {
|
||||
const licensesByXosan = {}
|
||||
forEach(licenses, license => {
|
||||
let xosanId
|
||||
if ((xosanId = license.boundObjectId) === undefined) {
|
||||
return
|
||||
}
|
||||
licensesByXosan[xosanId] =
|
||||
licensesByXosan[xosanId] !== undefined
|
||||
? null // XOSAN bound to multiple licenses!
|
||||
: license
|
||||
})
|
||||
|
||||
return licensesByXosan
|
||||
}
|
||||
)
|
||||
|
||||
_getKnownXosans = createSelector(
|
||||
createSelector(
|
||||
() => this.props.xosanLicenses,
|
||||
(licenses = []) => filter(map(licenses, 'boundObjectId'))
|
||||
),
|
||||
() => this.props.xosanSrs,
|
||||
(knownXosanIds, xosanSrs) => filter(xosanSrs, ({ id }) => includes(knownXosanIds, id))
|
||||
)
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SortedTable
|
||||
collection={this._getKnownXosans()}
|
||||
columns={XOSAN_COLUMNS}
|
||||
individualActions={XOSAN_INDIVIDUAL_ACTIONS}
|
||||
stateUrlParam='s_xosan'
|
||||
userData={{
|
||||
licensesByXosan: this._getLicensesByXosan(),
|
||||
xosanSrs: this.props.xosanSrs,
|
||||
updateLicenses: this.props.updateLicenses,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
104
packages/xo-web/src/xo-app/xoa/licenses/xostor.js
Normal file
104
packages/xo-web/src/xo-app/xoa/licenses/xostor.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import _ from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import Component from 'base-component'
|
||||
import decorate from 'apply-decorators'
|
||||
import Icon from 'icon'
|
||||
import React from 'react'
|
||||
import SelectLicense from 'select-license'
|
||||
import SortedTable from 'sorted-table'
|
||||
import Tooltip from 'tooltip'
|
||||
import { bindLicense } from 'xo'
|
||||
import { connectStore } from 'utils'
|
||||
import { createGetObjectsOfType } from 'selectors'
|
||||
import { groupBy } from 'lodash'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { Pool, Sr } from 'render-xo-item'
|
||||
|
||||
class XostorLicensesForm extends Component {
|
||||
state = {
|
||||
licenseId: 'none',
|
||||
}
|
||||
|
||||
bind = () => {
|
||||
const { item, userData } = this.props
|
||||
return bindLicense(this.state.licenseId, item.uuid).then(userData.updateLicenses)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { item, userData } = this.props
|
||||
const { licenseId } = this.state
|
||||
const licenses = userData.licensesByXostorUuid[item.id]
|
||||
|
||||
// Xostor bound to multiple licenses
|
||||
if (licenses?.length > 1) {
|
||||
return (
|
||||
<div>
|
||||
<span>{licenses.map(license => license.id.slice(-4)).join(',')}</span>{' '}
|
||||
<Tooltip content={_('xostorMultipleLicenses')}>
|
||||
<Icon color='text-danger' icon='alarm' />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const license = licenses?.[0]
|
||||
return license !== undefined ? (
|
||||
<span>{license.id.slice(-4)}</span>
|
||||
) : (
|
||||
<form className='form-inline'>
|
||||
<SelectLicense onChange={this.linkState('licenseId')} productType='xostor' />
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
className='ml-1'
|
||||
disabled={licenseId === 'none'}
|
||||
handler={this.bind}
|
||||
handlerParam={licenseId}
|
||||
icon='connect'
|
||||
>
|
||||
{_('bindLicense')}
|
||||
</ActionButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const INDIVIDUAL_ACTIONS = [
|
||||
{
|
||||
label: _('productSupport'),
|
||||
icon: 'support',
|
||||
handler: () => window.open('https://xen-orchestra.com'),
|
||||
},
|
||||
]
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
default: true,
|
||||
name: _('name'),
|
||||
itemRenderer: sr => <Sr id={sr.id} link container={false} />,
|
||||
sortCriteria: 'name_label',
|
||||
},
|
||||
{ name: _('pool'), itemRenderer: sr => <Pool id={sr.$pool} link /> },
|
||||
{ name: _('license'), component: XostorLicensesForm },
|
||||
]
|
||||
const Xostor = decorate([
|
||||
connectStore(() => ({
|
||||
xostorSrs: createGetObjectsOfType('SR').filter([({ SR_type }) => SR_type === 'linstor']),
|
||||
})),
|
||||
provideState({
|
||||
computed: {
|
||||
licensesByXostorUuid: (state, { xostorLicenses }) => groupBy(xostorLicenses, 'boundObjectId'),
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ state, xostorSrs, updateLicenses }) => (
|
||||
<SortedTable
|
||||
collection={xostorSrs}
|
||||
columns={COLUMNS}
|
||||
data-updateLicenses={updateLicenses}
|
||||
data-licensesByXostorUuid={state.licensesByXostorUuid}
|
||||
individualActions={INDIVIDUAL_ACTIONS}
|
||||
/>
|
||||
),
|
||||
])
|
||||
|
||||
export default Xostor
|
||||
@@ -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>
|
||||
|
||||
4
packages/xo-web/src/xo-app/xostor/index.css
Normal file
4
packages/xo-web/src/xo-app/xostor/index.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.disksSelectors {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
71
packages/xo-web/src/xo-app/xostor/index.js
Normal file
71
packages/xo-web/src/xo-app/xostor/index.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import _ from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import decorate from 'apply-decorators'
|
||||
import Icon from 'icon'
|
||||
import React from 'react'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { Container, Col, Row } from 'grid'
|
||||
import { TryXoa } from 'utils'
|
||||
import { getXoaPlan, SOURCES } from 'xoa-plans'
|
||||
|
||||
import NewXostorForm from './new-xostor-form'
|
||||
import XostorList from './xostor-list'
|
||||
|
||||
import Page from '../page'
|
||||
|
||||
const HEADER = (
|
||||
<Container>
|
||||
<h2>
|
||||
<Icon icon='menu-xostor' /> {_('xostor')}
|
||||
</h2>
|
||||
</Container>
|
||||
)
|
||||
|
||||
const Xostor = decorate([
|
||||
provideState({
|
||||
initialState: () => ({ showNewXostorForm: false }),
|
||||
effects: {
|
||||
_toggleShowNewXostorForm() {
|
||||
this.state.showNewXostorForm = !this.state.showNewXostorForm
|
||||
},
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ effects, state }) => (
|
||||
<Page header={HEADER}>
|
||||
{getXoaPlan() === SOURCES ? (
|
||||
<Container>
|
||||
<h2 className='text-info'>{_('xostorAvailableInXoa')}</h2>
|
||||
<p>
|
||||
<TryXoa page='xosan' />
|
||||
</p>
|
||||
</Container>
|
||||
) : (
|
||||
<Container>
|
||||
<div className='alert alert-warning'>
|
||||
<p className='mb-0'>
|
||||
<strong>
|
||||
<Icon icon='alarm' /> {_('xostorIsInBetaStage')}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<XostorList />
|
||||
<Row className='mb-1'>
|
||||
<Col>
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
handler={effects._toggleShowNewXostorForm}
|
||||
icon={state.showNewXostorForm ? 'minus' : 'plus'}
|
||||
>
|
||||
{_('new')}
|
||||
</ActionButton>
|
||||
</Col>
|
||||
</Row>
|
||||
{state.showNewXostorForm && <NewXostorForm />}
|
||||
</Container>
|
||||
)}
|
||||
</Page>
|
||||
),
|
||||
])
|
||||
|
||||
export default Xostor
|
||||
593
packages/xo-web/src/xo-app/xostor/new-xostor-form.js
Normal file
593
packages/xo-web/src/xo-app/xostor/new-xostor-form.js
Normal file
@@ -0,0 +1,593 @@
|
||||
import _ from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import Collapse from 'collapse'
|
||||
import decorate from 'apply-decorators'
|
||||
import Icon from 'icon'
|
||||
import React from 'react'
|
||||
import Select from 'form/select'
|
||||
import semver from 'semver'
|
||||
import { Card, CardBlock, CardHeader } from 'card'
|
||||
import { connectStore, formatSize } from 'utils'
|
||||
import { Container, Col, Row } from 'grid'
|
||||
import { createGetObjectsOfType } from 'selectors'
|
||||
import { find, first, map, mapValues, remove, size, some } from 'lodash'
|
||||
import { createXostorSr, getBlockdevices } from 'xo'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { Input as DebounceInput } from 'debounce-input-decorator'
|
||||
import { Pool as PoolRenderItem, Network as NetworkRenderItem } from 'render-xo-item'
|
||||
import { SelectHost, SelectPool, SelectNetwork } from 'select-objects'
|
||||
import { toggleState, linkState } from 'reaclette-utils'
|
||||
|
||||
import styles from './index.css'
|
||||
|
||||
const MINIMAL_POOL_VERSION_FOR_XOSTOR = '8.2.1'
|
||||
|
||||
const N_HOSTS_MIN = 3
|
||||
const N_HOSTS_MAX = 7
|
||||
|
||||
const PROVISIONING_OPTIONS = [
|
||||
{ value: 'thin', label: _('thin') },
|
||||
{ value: 'thick', label: _('thick') },
|
||||
]
|
||||
|
||||
const REPLICATION_OPTIONS = [
|
||||
{ value: 1, label: '1' },
|
||||
{ value: 2, label: '2' },
|
||||
{ value: 3, label: '3' },
|
||||
]
|
||||
|
||||
const hasXostor = srs => some(srs, sr => sr.SR_type === 'linstor')
|
||||
const formatDiskName = name => '/dev/' + name
|
||||
const diskHasChildren = disk => Array.isArray(disk.children) && disk.children.length > 0
|
||||
const isDiskRecommendedType = disk => disk.type === 'disk' || disk.type.startsWith('raid')
|
||||
const isDiskMounted = disk => disk.mountpoint !== ''
|
||||
const isDiskRo = disk => disk.ro === '1'
|
||||
const isTapdevsDisk = disk => disk.name.startsWith('td')
|
||||
const isWithinRecommendedHostRange = hosts => size(hosts) >= N_HOSTS_MIN && size(hosts) <= N_HOSTS_MAX
|
||||
const isXcpngHost = host => host?.productBrand === 'XCP-ng'
|
||||
const isHostRecentEnough = host => semver.satisfies(host?.version, `>=${MINIMAL_POOL_VERSION_FOR_XOSTOR}`)
|
||||
const diskSelectRenderer = disk => (
|
||||
<span>
|
||||
<Icon icon='disk' /> {formatDiskName(disk.name)} {formatSize(Number(disk.size))}
|
||||
</span>
|
||||
)
|
||||
const xostorDiskPredicate = disk =>
|
||||
isDiskRecommendedType(disk) &&
|
||||
!isDiskRo(disk) &&
|
||||
!isDiskMounted(disk) &&
|
||||
!diskHasChildren(disk) &&
|
||||
!isTapdevsDisk(disk)
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const StorageCard = decorate([
|
||||
injectState,
|
||||
({ effects, state }) => (
|
||||
<Card>
|
||||
<CardHeader>{_('storage')}</CardHeader>
|
||||
<CardBlock>
|
||||
<Row>
|
||||
<Col>
|
||||
{_('name')}
|
||||
<DebounceInput className='form-control' name='srName' onChange={effects.linkState} value={state.srName} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className='mt-1'>
|
||||
<Col>
|
||||
{_('description')}
|
||||
<DebounceInput
|
||||
className='form-control'
|
||||
name='srDescription'
|
||||
onChange={effects.linkState}
|
||||
value={state.srDescription}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
),
|
||||
])
|
||||
|
||||
const SettingsCard = decorate([
|
||||
provideState({
|
||||
computed: {
|
||||
showWarningReplication: state => state.replication?.value === 1,
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ effects, state }) => (
|
||||
<Card>
|
||||
<CardHeader>{_('settings')}</CardHeader>
|
||||
<CardBlock>
|
||||
<Row>
|
||||
<Col>
|
||||
{_('replication')}
|
||||
<Select options={REPLICATION_OPTIONS} onChange={effects.onReplicationChange} value={state.replication} />
|
||||
{state.showWarningReplication && (
|
||||
<p className='text-warning'>
|
||||
<Icon icon='alarm' /> {_('xostorReplicationWarning')}
|
||||
</p>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className='form-group mt-1'>
|
||||
<Col>
|
||||
{_('provisioning')}
|
||||
<Select onChange={effects.onProvisioningChange} options={PROVISIONING_OPTIONS} value={state.provisioning} />
|
||||
</Col>
|
||||
</Row>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
),
|
||||
])
|
||||
|
||||
const PoolCard = decorate([
|
||||
connectStore({
|
||||
srs: createGetObjectsOfType('SR').groupBy('$pool'),
|
||||
}),
|
||||
provideState({
|
||||
initialState: () => ({ onlyShowXostorPools: true }),
|
||||
effects: {
|
||||
toggleState,
|
||||
},
|
||||
computed: {
|
||||
poolPredicate: (state, props) => {
|
||||
if (!state.onlyShowXostorPools) {
|
||||
return undefined
|
||||
}
|
||||
return pool => {
|
||||
const poolHosts = props.hostsByPoolId?.[pool.id]
|
||||
const host = first(poolHosts)
|
||||
return (
|
||||
isWithinRecommendedHostRange(poolHosts) &&
|
||||
isXcpngHost(host) &&
|
||||
!hasXostor(props.srs[pool.id]) &&
|
||||
isHostRecentEnough(host)
|
||||
)
|
||||
}
|
||||
},
|
||||
poolIsWithinRecommendedHostRange: state => isWithinRecommendedHostRange(state.poolHosts),
|
||||
poolHasXostor: (state, props) => hasXostor(props.srs[state.poolId]),
|
||||
isPoolRecentEnough: state => isHostRecentEnough(first(state.poolHosts)),
|
||||
isPoolXostorCompatible: state =>
|
||||
state.isXcpngHost && state.poolIsWithinRecommendedHostRange && !state.poolHasXostor && state.isPoolRecentEnough,
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ effects, state }) => (
|
||||
<Card>
|
||||
<CardHeader>{_('pool')}</CardHeader>
|
||||
<CardBlock>
|
||||
<div>
|
||||
<label>
|
||||
<input
|
||||
checked={state.onlyShowXostorPools}
|
||||
name='onlyShowXostorPools'
|
||||
onChange={effects.toggleState}
|
||||
type='checkbox'
|
||||
/>{' '}
|
||||
{_('onlyShowXostorRequirements', { type: _('pools') })}
|
||||
</label>
|
||||
<SelectPool onChange={effects.onPoolChange} predicate={state.poolPredicate} value={state.poolId} />
|
||||
{state.poolHosts !== undefined && !state.isPoolXostorCompatible && (
|
||||
<div className='text-danger'>
|
||||
{/* FIXME: add link of the documentation when ready */}
|
||||
<a href='#' rel='noreferrer' target='_blank'>
|
||||
{_('objectDoesNotMeetXostorRequirements', { object: <PoolRenderItem id={state.poolId} /> })}
|
||||
</a>
|
||||
<ul>
|
||||
{!state.isXcpngHost && <li>{_('notXcpPool')}</li>}
|
||||
{!state.poolIsWithinRecommendedHostRange && <li>{_('wrongNumberOfHosts')}</li>}
|
||||
{state.poolHasXostor && <li>{_('poolAlreadyHasXostor')}</li>}
|
||||
{!state.isPoolRecentEnough && (
|
||||
<li>{_('poolNotRecentEnough', { version: first(state.poolHosts).version })}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<em>
|
||||
<Icon icon='info' /> {_('xostorPackagesWillBeInstalled')}
|
||||
</em>
|
||||
</div>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
),
|
||||
])
|
||||
|
||||
const NetworkCard = decorate([
|
||||
provideState({
|
||||
initialState: () => ({ onlyShowXostorNetworks: true }),
|
||||
effects: {
|
||||
toggleState,
|
||||
},
|
||||
computed: {
|
||||
networksPredicate: (state, props) => network => {
|
||||
const isOnPool = network.$pool === state.poolId
|
||||
const pifs = network.PIFs
|
||||
return state.onlyShowXostorNetworks
|
||||
? isOnPool && pifs.length > 0 && pifs.every(pifId => props.pifs[pifId].ip !== '')
|
||||
: isOnPool
|
||||
},
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ effects, state }) => (
|
||||
<Card>
|
||||
<CardHeader>{_('network')}</CardHeader>
|
||||
<CardBlock>
|
||||
<label>
|
||||
<input
|
||||
checked={state.onlyShowXostorNetworks}
|
||||
name='onlyShowXostorNetworks'
|
||||
onChange={effects.toggleState}
|
||||
type='checkbox'
|
||||
/>{' '}
|
||||
{_('onlyShowXostorRequirements', { type: _('networks') })}
|
||||
</label>
|
||||
<SelectNetwork
|
||||
disabled={!state.isPoolSelected}
|
||||
onChange={effects.onNetworkChange}
|
||||
predicate={state.networksPredicate}
|
||||
value={state.networkId}
|
||||
/>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
),
|
||||
])
|
||||
|
||||
const DisksCard = decorate([
|
||||
provideState({
|
||||
initialState: () => ({
|
||||
onlyShowXostorDisks: true,
|
||||
}),
|
||||
effects: {
|
||||
toggleState,
|
||||
_onDiskChange(_, disk) {
|
||||
this.effects.onDiskChange(disk, this.state.hostId)
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
_blockdevices: async state =>
|
||||
state.isHostSelected && state.isXcpngHost ? (await getBlockdevices(state.hostId)).blockdevices : undefined,
|
||||
_disks: state =>
|
||||
state.onlyShowXostorDisks ? state._blockdevices?.filter(xostorDiskPredicate) : state._blockdevices,
|
||||
predicate: state => host => host.$pool === state.poolId,
|
||||
isHostSelected: state => state.hostId !== undefined,
|
||||
selectableDisks: state =>
|
||||
state._disks
|
||||
?.filter(disk => !state.disksByHost[state.hostId]?.some(_disk => _disk.name === disk.name))
|
||||
.sort((prev, next) => Number(next.size) - Number(prev.size)),
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ effects, state }) => (
|
||||
<Card>
|
||||
<CardHeader>{_('disks')}</CardHeader>
|
||||
<CardBlock>
|
||||
<Row>
|
||||
<Col size={8}>
|
||||
<Row className={styles.disksSelectors}>
|
||||
<Col size={6}>
|
||||
<SelectHost
|
||||
disabled={!state.isPoolSelected}
|
||||
onChange={effects.onHostChange}
|
||||
predicate={state.predicate}
|
||||
value={state.hostId}
|
||||
/>
|
||||
</Col>
|
||||
<Col size={6}>
|
||||
<label>
|
||||
<input
|
||||
checked={state.onlyShowXostorDisks}
|
||||
onChange={effects.toggleState}
|
||||
name='onlyShowXostorDisks'
|
||||
type='checkbox'
|
||||
/>{' '}
|
||||
{_('onlyShowXostorRequirements', { type: _('disks') })}
|
||||
</label>
|
||||
{state.isPoolSelected && !state.isXcpngHost && (
|
||||
<p className='text-danger mb-0'>
|
||||
<Icon icon='alarm' /> {_('cantFetchDisksFromNonXcpngHost')}
|
||||
</p>
|
||||
)}
|
||||
<Select
|
||||
disabled={!state.isHostSelected || !state.isPoolSelected || !state.isXcpngHost}
|
||||
onChange={effects._onDiskChange}
|
||||
optionRenderer={diskSelectRenderer}
|
||||
options={state.isHostSelected ? state.selectableDisks : []}
|
||||
placeholder={_('selectDisks')}
|
||||
value={null}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className='mt-1'>
|
||||
<Col>
|
||||
<SelectedDisks hostId={state.hostId} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col size={4}>
|
||||
{map(state.poolHosts, host => (
|
||||
<Collapse
|
||||
buttonText={_('xostorDisksDropdownLabel', {
|
||||
nDisks: state.disksByHost[host.id]?.length ?? 0,
|
||||
hostname: host.hostname,
|
||||
})}
|
||||
defaultOpen
|
||||
key={host.id}
|
||||
size='small'
|
||||
>
|
||||
<SelectedDisks hostId={host.id} fromDropdown />
|
||||
</Collapse>
|
||||
))}
|
||||
</Col>
|
||||
</Row>
|
||||
</CardBlock>
|
||||
</Card>
|
||||
),
|
||||
])
|
||||
|
||||
const SelectedDisks = decorate([
|
||||
provideState({
|
||||
effects: {
|
||||
_onDiskRemove(_, disk) {
|
||||
this.effects.onDiskRemove(disk, this.props.hostId)
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
disksHost: (state, props) => state.disksByHost[props.hostId],
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ effects, state, fromDropdown }) =>
|
||||
state.isHostSelected || fromDropdown ? (
|
||||
state.disksHost === undefined || state.disksHost.length < 1 ? (
|
||||
<p>{_('noDisks')}</p>
|
||||
) : (
|
||||
<ul className='list-group'>
|
||||
{state.disksHost.map(disk => (
|
||||
<ItemSelectedDisks disk={disk} key={disk.name} onDiskRemove={effects._onDiskRemove} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
) : null,
|
||||
])
|
||||
|
||||
const ItemSelectedDisks = ({ disk, onDiskRemove }) => {
|
||||
const _isDiskRecommendedType = isDiskRecommendedType(disk)
|
||||
const _isDiskRo = isDiskRo(disk)
|
||||
const _isDiskMounted = isDiskMounted(disk)
|
||||
const _diskHasChildren = diskHasChildren(disk)
|
||||
const _isTapdevsDisk = isTapdevsDisk(disk)
|
||||
const isDiskValid = _isDiskRecommendedType && !_isDiskRo && !_isDiskMounted && !_diskHasChildren && !_isTapdevsDisk
|
||||
|
||||
return (
|
||||
<li className='list-group-item'>
|
||||
<Icon icon='disk' /> {formatDiskName(disk.name)} {formatSize(Number(disk.size))}
|
||||
<ActionButton
|
||||
btnStyle='danger'
|
||||
className='pull-right'
|
||||
handler={onDiskRemove}
|
||||
handlerParam={disk}
|
||||
icon='delete'
|
||||
size='small'
|
||||
/>
|
||||
{!isDiskValid && (
|
||||
<div className='text-danger'>
|
||||
<Icon icon='error' /> {_('diskIncompatibleXostor')}
|
||||
<ul>
|
||||
{!_isDiskRecommendedType && <li>{_('selectedDiskTypeIncompatibleXostor', { type: disk.type })}</li>}
|
||||
{_isDiskRo && <li>{_('diskIsReadOnly')}</li>}
|
||||
{_isDiskMounted && <li>{_('diskAlreadyMounted', { mountpoint: disk.mountpoint })}</li>}
|
||||
{_diskHasChildren && <li>{_('diskHasChildren')}</li>}
|
||||
{_isTapdevsDisk && <li>{_('isTapdevsDisk')}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const SummaryCard = decorate([
|
||||
provideState({
|
||||
computed: {
|
||||
areHostsDisksConsistent: state =>
|
||||
state._disksByHostValues.every(disks => disks.length === state._disksByHostValues[0]?.length),
|
||||
finalSize: state => {
|
||||
const totalSize = state._disksByHostValues.reduce((minSize, disks) => {
|
||||
const size = disks.reduce((acc, disk) => acc + Number(disk.size), 0)
|
||||
return minSize === 0 || size < minSize ? size : minSize
|
||||
}, 0)
|
||||
|
||||
return (totalSize * state.numberOfHostsWithDisks) / state.replication.value
|
||||
},
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ state }) => {
|
||||
const srDescription = state.srDescription.trim()
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>{_('summary')}</CardHeader>
|
||||
<CardBlock>
|
||||
{state.isFormInvalid ? (
|
||||
<div className='text-danger'>
|
||||
<p>{_('fieldsMissing')}</p>
|
||||
<ul>
|
||||
{state.isReplicationMissing && <li>{_('fieldRequired', { field: _('replication') })}</li>}
|
||||
{state.isProvisioningMissing && <li>{_('fieldRequired', { field: _('provisioning') })}</li>}
|
||||
{state.isNameMissing && <li>{_('fieldRequired', { field: _('name') })}</li>}
|
||||
{state.isDisksMissing && <li>{_('xostorDiskRequired')}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{!state.areHostsDisksConsistent && (
|
||||
<p className='text-warning'>
|
||||
<Icon icon='alarm' /> {_('hostsNotSameNumberOfDisks')}
|
||||
</p>
|
||||
)}
|
||||
<Row>
|
||||
<Col size={6}>{_('keyValue', { key: _('name'), value: state.srName })}</Col>
|
||||
<Col size={6}>
|
||||
{_('keyValue', {
|
||||
key: _('description'),
|
||||
value: srDescription === '' ? _('noValue') : srDescription,
|
||||
})}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col size={6}>{_('keyValue', { key: _('replication'), value: state.replication.label })}</Col>
|
||||
<Col size={6}>{_('keyValue', { key: _('provisioning'), value: state.provisioning.label })}</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col size={12}>{_('keyValue', { key: _('pool'), value: <PoolRenderItem id={state.poolId} /> })}</Col>
|
||||
{/* FIXME: XOSTOR network management is not yet implemented at XOSTOR level */}
|
||||
{/* <Col size={6}>
|
||||
{_('keyValue', { key: _('network'), value: <NetworkRenderItem id={state.networkId} /> })}
|
||||
</Col> */}
|
||||
</Row>
|
||||
<Row>
|
||||
<Col size={6}>{_('keyValue', { key: _('numberOfHosts'), value: state.numberOfHostsWithDisks })}</Col>
|
||||
<Col size={6}>
|
||||
{_('keyValue', { key: _('approximateFinalSize'), value: formatSize(state.finalSize) })}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</CardBlock>
|
||||
</Card>
|
||||
)
|
||||
},
|
||||
])
|
||||
|
||||
const NewXostorForm = decorate([
|
||||
connectStore({
|
||||
hostsByPoolId: createGetObjectsOfType('host').sort().groupBy('$pool'),
|
||||
networks: createGetObjectsOfType('network'),
|
||||
pifs: createGetObjectsOfType('PIF'),
|
||||
}),
|
||||
provideState({
|
||||
initialState: () => ({
|
||||
_networkId: undefined,
|
||||
_createdSrUuid: undefined, // used for redirection when the storage has been created
|
||||
disksByHost: {},
|
||||
provisioning: PROVISIONING_OPTIONS[0], // default value 'thin'
|
||||
poolId: undefined,
|
||||
hostId: undefined,
|
||||
replication: REPLICATION_OPTIONS[1], // default value 2
|
||||
srDescription: '',
|
||||
srName: '',
|
||||
}),
|
||||
effects: {
|
||||
linkState,
|
||||
onHostChange(_, host) {
|
||||
this.state.hostId = host?.id
|
||||
},
|
||||
onPoolChange(_, pool) {
|
||||
this.state.disksByHost = {}
|
||||
this.state.poolId = pool?.id
|
||||
},
|
||||
onReplicationChange(_, replication) {
|
||||
this.state.replication = replication
|
||||
},
|
||||
onProvisioningChange(_, provisioning) {
|
||||
this.state.provisioning = provisioning
|
||||
},
|
||||
onNetworkChange(_, network) {
|
||||
this.state._networkId = network?.id ?? null
|
||||
},
|
||||
onDiskChange(_, disk, hostId) {
|
||||
const { disksByHost } = this.state
|
||||
if (disksByHost[hostId] === undefined) {
|
||||
disksByHost[hostId] = []
|
||||
}
|
||||
disksByHost[hostId].push(disk)
|
||||
this.state.disksByHost = { ...disksByHost }
|
||||
},
|
||||
onDiskRemove(_, disk, hostId) {
|
||||
const disks = this.state.disksByHost[hostId]
|
||||
remove(disks, _disk => _disk.name === disk.name)
|
||||
this.state.disksByHost = {
|
||||
...this.state.disksByHost,
|
||||
[hostId]: disks,
|
||||
}
|
||||
},
|
||||
async createXostorSr() {
|
||||
const { disksByHost, srDescription, srName, provisioning, replication } = this.state
|
||||
|
||||
this.state._createdSrUuid = await createXostorSr({
|
||||
description: srDescription.trim() === '' ? undefined : srDescription.trim(),
|
||||
disksByHost: mapValues(disksByHost, disks => disks.map(disk => formatDiskName(disk.name))),
|
||||
name: srName.trim() === '' ? undefined : srName.trim(),
|
||||
provisioning: provisioning.value,
|
||||
replication: replication.value,
|
||||
})
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
// Private ==========
|
||||
_disksByHostValues: state => Object.values(state.disksByHost).filter(disks => disks.length > 0),
|
||||
_defaultNetworkId: (state, props) => props.networks?.[state._pifManagement?.$network]?.id,
|
||||
_pifManagement: (state, props) => find(props.pifs, pif => pif.$pool === state.poolId && pif.management),
|
||||
// Utils ============
|
||||
poolHosts: (state, props) => props.hostsByPoolId?.[state.poolId],
|
||||
isPoolSelected: state => state.poolId !== undefined,
|
||||
numberOfHostsWithDisks: state => state._disksByHostValues.length,
|
||||
isReplicationMissing: state => state.replication === null,
|
||||
isProvisioningMissing: state => state.provisioning === null,
|
||||
isNameMissing: state => state.srName.trim() === '',
|
||||
isDisksMissing: state => state.numberOfHostsWithDisks === 0,
|
||||
isFormInvalid: state =>
|
||||
state.isReplicationMissing || state.isProvisioningMissing || state.isNameMissing || state.isDisksMissing,
|
||||
isXcpngHost: state => isXcpngHost(first(state.poolHosts)),
|
||||
getSrPath: state => () => `/srs/${state._createdSrUuid}`,
|
||||
// State ============
|
||||
networkId: state => (state._networkId === undefined ? state._defaultNetworkId : state._networkId),
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ effects, resetState, state, hostsByPoolId, networks, pifs }) => (
|
||||
<Container>
|
||||
<Row>
|
||||
<Col size={6}>
|
||||
<StorageCard />
|
||||
</Col>
|
||||
<Col size={6}>
|
||||
<SettingsCard />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col size={12}>
|
||||
<PoolCard hostsByPoolId={hostsByPoolId} />
|
||||
</Col>
|
||||
{/* FIXME: XOSTOR network management is not yet implemented at XOSTOR level */}
|
||||
{/* <Col size={6}>
|
||||
<NetworkCard networks={networks} pifs={pifs} />
|
||||
</Col> */}
|
||||
</Row>
|
||||
<Row>
|
||||
<DisksCard />
|
||||
</Row>
|
||||
<Row>
|
||||
<SummaryCard />
|
||||
</Row>
|
||||
<Row>
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
disabled={state.isFormInvalid}
|
||||
handler={effects.createXostorSr}
|
||||
icon='add'
|
||||
redirectOnSuccess={state.getSrPath}
|
||||
>
|
||||
{_('create')}
|
||||
</ActionButton>
|
||||
<ActionButton className='ml-1' handler={resetState} icon='reset'>
|
||||
{_('formReset')}
|
||||
</ActionButton>
|
||||
</Row>
|
||||
</Container>
|
||||
),
|
||||
])
|
||||
|
||||
export default NewXostorForm
|
||||
79
packages/xo-web/src/xo-app/xostor/xostor-list.js
Normal file
79
packages/xo-web/src/xo-app/xostor/xostor-list.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import _ from 'intl'
|
||||
import decorate from 'apply-decorators'
|
||||
import React from 'react'
|
||||
import SortedTable from 'sorted-table'
|
||||
import Tooltip from 'tooltip'
|
||||
import { connectStore, formatSize } from 'utils'
|
||||
import { createGetObjectsOfType, createSelector } from 'selectors'
|
||||
import { deleteSr } from 'xo'
|
||||
import { map } from 'lodash'
|
||||
import { Pool } from 'render-xo-item'
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
name: _('srPool'),
|
||||
itemRenderer: sr => <Pool id={sr.pool.id} link />,
|
||||
sortCriteria: 'pool.name_label',
|
||||
},
|
||||
{
|
||||
name: _('name'),
|
||||
itemRenderer: sr => sr.name_label,
|
||||
sortCriteria: 'name_label',
|
||||
},
|
||||
{
|
||||
name: _('provisioning'),
|
||||
itemRenderer: sr => sr.allocationStrategy,
|
||||
sortCriteria: 'allocationStrategy',
|
||||
},
|
||||
{
|
||||
name: _('size'),
|
||||
itemRenderer: sr => formatSize(sr.size),
|
||||
sortCriteria: 'size',
|
||||
},
|
||||
{
|
||||
name: _('usedSpace'),
|
||||
itemRenderer: sr => {
|
||||
const used = (sr.physical_usage * 100) / sr.size
|
||||
return (
|
||||
<Tooltip
|
||||
content={_('spaceLeftTooltip', {
|
||||
used: String(Math.round(used)),
|
||||
free: formatSize(sr.size - sr.physical_usage),
|
||||
})}
|
||||
>
|
||||
<progress className='progress' max='100' value={used} />
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
sortCriteria: sr => (sr.physical_usage * 100) / sr.size,
|
||||
},
|
||||
]
|
||||
|
||||
const INDIVIDUAL_ACTIONS = [
|
||||
{
|
||||
handler: deleteSr,
|
||||
icon: 'delete',
|
||||
label: _('delete'),
|
||||
level: 'danger',
|
||||
},
|
||||
]
|
||||
|
||||
const XostorList = decorate([
|
||||
connectStore(() => ({
|
||||
xostorSrs: createSelector(
|
||||
createGetObjectsOfType('SR').filter([sr => sr.SR_type === 'linstor']),
|
||||
createGetObjectsOfType('pool').groupBy('id'),
|
||||
(srs, poolByIds) => {
|
||||
return map(srs, sr => ({
|
||||
...sr,
|
||||
pool: poolByIds[sr.$pool][0],
|
||||
}))
|
||||
}
|
||||
),
|
||||
})),
|
||||
({ xostorSrs }) => (
|
||||
<SortedTable collection={xostorSrs} columns={COLUMNS} individualActions={INDIVIDUAL_ACTIONS} stateUrlParam='s' />
|
||||
),
|
||||
])
|
||||
|
||||
export default XostorList
|
||||
Reference in New Issue
Block a user