Compare commits
91 Commits
feat_s3_st
...
improveFor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d70da2a960 | ||
|
|
637eb1d2d7 | ||
|
|
86b86c5c99 | ||
|
|
0b8525febe | ||
|
|
a3ea70c61c | ||
|
|
ae0f3b4fe0 | ||
|
|
2552ef37d2 | ||
|
|
9803e8c6cb | ||
|
|
3410cbc3b9 | ||
|
|
93fce0d4bf | ||
|
|
dbdc5f3e3b | ||
|
|
581b42fa9d | ||
|
|
e07e2d3ccd | ||
|
|
ad928ec23d | ||
|
|
1d7559ded2 | ||
|
|
9099b58557 | ||
|
|
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 | ||
|
|
8bfe293414 | ||
|
|
2e634a9d1c | ||
|
|
bea771ca90 | ||
|
|
99e3622f31 | ||
|
|
a16522241e | ||
|
|
b86cb12649 | ||
|
|
2af74008b2 | ||
|
|
2e689592f1 | ||
|
|
3f8436b58b | ||
|
|
e3dd59d684 | ||
|
|
549d9b70a9 | ||
|
|
3bf6aae103 | ||
|
|
afb110c473 | ||
|
|
8727c3cf96 | ||
|
|
b13302ddeb | ||
|
|
e89ed06314 | ||
|
|
e3f57998f7 | ||
|
|
8cdb5ee31b | ||
|
|
5b734db656 | ||
|
|
e853f9d04f | ||
|
|
2a5e09719e | ||
|
|
3c0477e0da | ||
|
|
060d1c5297 | ||
|
|
55dd7bfb9c | ||
|
|
b00cf13029 | ||
|
|
73755e4ccf | ||
|
|
a1bd96da6a | ||
|
|
0e934c1413 | ||
|
|
eb69234a8e | ||
|
|
7659d9c0be | ||
|
|
2ba81d55f8 |
@@ -22,7 +22,7 @@
|
||||
"fuse-native": "^2.2.6",
|
||||
"lru-cache": "^7.14.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"vhd-lib": "^4.6.0"
|
||||
"vhd-lib": "^4.6.1"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.42.1",
|
||||
"@xen-orchestra/fs": "^4.1.0",
|
||||
"@xen-orchestra/backups": "^0.43.2",
|
||||
"@xen-orchestra/fs": "^4.1.1",
|
||||
"filenamify": "^6.0.0",
|
||||
"getopts": "^2.2.5",
|
||||
"lodash": "^4.17.15",
|
||||
@@ -27,7 +27,7 @@
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"version": "1.0.12",
|
||||
"version": "1.0.13",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -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,25 @@ 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('VDI', vdiRef)
|
||||
|
||||
// the size a of fully allocated vdi will be virtual_size exaclty, it's a gross over evaluation
|
||||
// of the real stream size in general, since a disk is never completly full
|
||||
// vdi.physical_size seems to underevaluate a lot the real disk usage of a VDI, as of 2023-10-30
|
||||
maxStreamLength += vdi.virtual_size
|
||||
}
|
||||
|
||||
const sizeContainer = watchStreamSize(stream)
|
||||
|
||||
const timestamp = Date.now()
|
||||
|
||||
await this._callWriters(
|
||||
writer =>
|
||||
writer.run({
|
||||
maxStreamLength,
|
||||
sizeContainer,
|
||||
stream: forkStreamUnpipe(stream),
|
||||
timestamp,
|
||||
|
||||
@@ -31,6 +31,11 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
throw new Error('cannot backup a VM created by this very job')
|
||||
}
|
||||
|
||||
const currentOperations = Object.values(vm.current_operations)
|
||||
if (currentOperations.some(_ => _ === 'migrate_send' || _ === 'pool_migrate')) {
|
||||
throw new Error('cannot backup a VM currently being migrated')
|
||||
}
|
||||
|
||||
this.config = config
|
||||
this.job = job
|
||||
this.remoteAdapters = remoteAdapters
|
||||
@@ -256,7 +261,15 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
}
|
||||
|
||||
if (this._writers.size !== 0) {
|
||||
await this._copy()
|
||||
const { pool_migrate = null, migrate_send = null } = this._exportedVm.blocked_operations
|
||||
|
||||
const reason = 'VM migration is blocked during backup'
|
||||
await this._exportedVm.update_blocked_operations({ pool_migrate: reason, migrate_send: reason })
|
||||
try {
|
||||
await this._copy()
|
||||
} finally {
|
||||
await this._exportedVm.update_blocked_operations({ pool_migrate, migrate_send })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (startAfter) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.42.1",
|
||||
"version": "0.43.2",
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
},
|
||||
@@ -28,7 +28,7 @@
|
||||
"@vates/nbd-client": "^2.0.0",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^4.1.0",
|
||||
"@xen-orchestra/fs": "^4.1.1",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"app-conf": "^2.3.0",
|
||||
@@ -44,7 +44,7 @@
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"tar": "^6.1.15",
|
||||
"uuid": "^9.0.0",
|
||||
"vhd-lib": "^4.6.0",
|
||||
"vhd-lib": "^4.6.1",
|
||||
"xen-api": "^1.3.6",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
@@ -56,7 +56,7 @@
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xen-orchestra/xapi": "^3.1.0"
|
||||
"@xen-orchestra/xapi": "^3.3.0"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/fs",
|
||||
"version": "4.1.0",
|
||||
"version": "4.1.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "The File System for Xen Orchestra backups.",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
|
||||
|
||||
@@ -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) {
|
||||
@@ -624,14 +626,18 @@ export default class RemoteHandlerAbstract {
|
||||
|
||||
const files = await this._list(dir)
|
||||
await asyncEach(files, file =>
|
||||
this._unlink(`${dir}/${file}`).catch(error => {
|
||||
// Unlink dir behavior is not consistent across platforms
|
||||
// https://github.com/nodejs/node-v0.x-archive/issues/5791
|
||||
if (error.code === 'EISDIR' || error.code === 'EPERM') {
|
||||
return this._rmtree(`${dir}/${file}`)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
this._unlink(`${dir}/${file}`).catch(
|
||||
error => {
|
||||
// Unlink dir behavior is not consistent across platforms
|
||||
// https://github.com/nodejs/node-v0.x-archive/issues/5791
|
||||
if (error.code === 'EISDIR' || error.code === 'EPERM') {
|
||||
return this._rmtree(`${dir}/${file}`)
|
||||
}
|
||||
throw error
|
||||
},
|
||||
// real unlink concurrency will be 2**max directory depth
|
||||
{ concurrency: 2 }
|
||||
)
|
||||
)
|
||||
return this._rmtree(dir)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,9 +2,16 @@
|
||||
|
||||
## **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)
|
||||
|
||||
- Ability to migrate selected VMs to another host (PR [#7040](https://github.com/vatesfr/xen-orchestra/pull/7040))
|
||||
- Ability to snapshot selected VMs (PR [#7021](https://github.com/vatesfr/xen-orchestra/pull/7021))
|
||||
- Add Patches to Pool Dashboard (PR [#6709](https://github.com/vatesfr/xen-orchestra/pull/6709))
|
||||
- Add remember me checkbox on the login page (PR [#7030](https://github.com/vatesfr/xen-orchestra/pull/7030))
|
||||
|
||||
## **0.1.3** (2023-09-01)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/lite",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"scripts": {
|
||||
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
|
||||
"build": "run-p type-check build-only",
|
||||
@@ -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 {
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
required
|
||||
/>
|
||||
</FormInputWrapper>
|
||||
<label class="remember-me-label">
|
||||
<FormCheckbox v-model="rememberMe" />
|
||||
<p>{{ $t("keep-me-logged") }}</p>
|
||||
</label>
|
||||
<UiButton type="submit" :busy="isConnecting">
|
||||
{{ $t("login") }}
|
||||
</UiButton>
|
||||
@@ -28,6 +32,9 @@ import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
|
||||
import FormCheckbox from "@/components/form/FormCheckbox.vue";
|
||||
import FormInput from "@/components/form/FormInput.vue";
|
||||
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
@@ -42,12 +49,16 @@ const password = ref("");
|
||||
const error = ref<string>();
|
||||
const passwordRef = ref<InstanceType<typeof FormInput>>();
|
||||
const isInvalidPassword = ref(false);
|
||||
const rememberMe = useLocalStorage("rememberMe", false);
|
||||
|
||||
const focusPasswordInput = () => passwordRef.value?.focus();
|
||||
|
||||
onMounted(() => {
|
||||
xenApiStore.reconnect();
|
||||
focusPasswordInput();
|
||||
if (rememberMe.value) {
|
||||
xenApiStore.reconnect();
|
||||
} else {
|
||||
focusPasswordInput();
|
||||
}
|
||||
});
|
||||
|
||||
watch(password, () => {
|
||||
@@ -72,6 +83,19 @@ async function handleSubmit() {
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.remember-me-label {
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
& .form-checkbox {
|
||||
margin: 1rem 1rem 1rem 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
& p {
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.form-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -87,7 +111,6 @@ form {
|
||||
font-size: 2rem;
|
||||
min-width: 30em;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
@@ -104,7 +127,7 @@ h1 {
|
||||
|
||||
img {
|
||||
width: 40rem;
|
||||
margin-bottom: 5rem;
|
||||
margin: auto auto 5rem auto;
|
||||
}
|
||||
|
||||
input {
|
||||
@@ -118,6 +141,6 @@ input {
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 2rem;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<LinearChart
|
||||
title="Chart title"
|
||||
subtitle="Chart subtitle"
|
||||
:data="data"
|
||||
:value-formatter="customValueFormatter"
|
||||
/>
|
||||
<LinearChart :data="data" :value-formatter="customValueFormatter" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<template>
|
||||
<UiCard class="linear-chart">
|
||||
<VueCharts :option="option" autoresize class="chart" />
|
||||
<slot name="summary" />
|
||||
</UiCard>
|
||||
<VueCharts :option="option" autoresize class="chart" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import type { LinearChartData, ValueFormatter } from "@/types/chart";
|
||||
import { IK_CHART_VALUE_FORMATTER } from "@/types/injection-keys";
|
||||
import { utcFormat } from "d3-time-format";
|
||||
@@ -15,7 +11,6 @@ import { LineChart } from "echarts/charts";
|
||||
import {
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
} from "echarts/components";
|
||||
import { use } from "echarts/core";
|
||||
@@ -26,8 +21,6 @@ import VueCharts from "vue-echarts";
|
||||
const Y_AXIS_MAX_VALUE = 200;
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
data: LinearChartData;
|
||||
valueFormatter?: ValueFormatter;
|
||||
maxValue?: number;
|
||||
@@ -52,15 +45,10 @@ use([
|
||||
LineChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
TitleComponent,
|
||||
LegendComponent,
|
||||
]);
|
||||
|
||||
const option = computed<EChartsOption>(() => ({
|
||||
title: {
|
||||
text: props.title,
|
||||
subtext: props.subtitle,
|
||||
},
|
||||
legend: {
|
||||
data: props.data.map((series) => series.label),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,33 +1,44 @@
|
||||
<template>
|
||||
<!-- TODO: add a loader when data is not fully loaded or undefined -->
|
||||
<!-- TODO: add small loader with tooltips when stats can be expired -->
|
||||
<!-- TODO: display the NoData component in case of a data recovery error -->
|
||||
<LinearChart
|
||||
:data="data"
|
||||
:max-value="customMaxValue"
|
||||
:subtitle="$t('last-week')"
|
||||
:title="$t('network-throughput')"
|
||||
:value-formatter="customValueFormatter"
|
||||
/>
|
||||
<UiCard class="linear-chart" :color="hasError ? 'error' : undefined">
|
||||
<UiCardTitle>{{ $t("network-throughput") }}</UiCardTitle>
|
||||
<UiCardTitle :level="UiCardTitleLevel.Subtitle">
|
||||
{{ $t("last-week") }}
|
||||
</UiCardTitle>
|
||||
<NoDataError v-if="hasError" />
|
||||
<UiCardSpinner v-else-if="isLoading" />
|
||||
<LinearChart
|
||||
v-else
|
||||
:data="data"
|
||||
:max-value="customMaxValue"
|
||||
:value-formatter="customValueFormatter"
|
||||
/>
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
|
||||
import { computed, defineAsyncComponent, inject } from "vue";
|
||||
import { map } from "lodash-es";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { formatSize } from "@/libs/utils";
|
||||
import type { HostStats } from "@/libs/xapi-stats";
|
||||
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
|
||||
import type { LinearChartData } from "@/types/chart";
|
||||
import { map } from "lodash-es";
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
|
||||
import { UiCardTitleLevel } from "@/types/enums";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const LinearChart = defineAsyncComponent(
|
||||
() => import("@/components/charts/LinearChart.vue")
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
|
||||
const { hasError, isFetching } = useHostCollection();
|
||||
|
||||
const data = computed<LinearChartData>(() => {
|
||||
const stats = hostLastWeekStats?.stats?.value;
|
||||
@@ -82,6 +93,25 @@ const data = computed<LinearChartData>(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const isStatFetched = computed(() => {
|
||||
const stats = hostLastWeekStats?.stats?.value;
|
||||
if (stats === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return stats.every((host) => {
|
||||
const hostStats = host.stats;
|
||||
return (
|
||||
hostStats != null &&
|
||||
Object.values(hostStats.pifs["rx"])[0].length +
|
||||
Object.values(hostStats.pifs["tx"])[0].length ===
|
||||
data.value[0].data.length + data.value[1].data.length
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const isLoading = computed(() => isFetching.value || !isStatFetched.value);
|
||||
|
||||
// TODO: improve the way to get the max value of graph
|
||||
// See: https://github.com/vatesfr/xen-orchestra/pull/6610/files#r1072237279
|
||||
const customMaxValue = computed(
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
<template>
|
||||
<UiCardTitle
|
||||
:level="UiCardTitleLevel.SubtitleWithUnderline"
|
||||
:left="$t('hosts')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
subtitle
|
||||
/>
|
||||
<NoDataError v-if="hasError" />
|
||||
<UsageBar v-else :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import { computed, inject, type ComputedRef } from "vue";
|
||||
import { getAvgCpuUsage } from "@/libs/utils";
|
||||
import { IK_HOST_STATS } from "@/types/injection-keys";
|
||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||
import { computed, type ComputedRef, inject } from "vue";
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import { UiCardTitleLevel } from "@/types/enums";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
|
||||
const { hasError } = useHostCollection();
|
||||
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
<template>
|
||||
<!-- TODO: add a loader when data is not fully loaded or undefined -->
|
||||
<!-- TODO: add small loader with tooltips when stats can be expired -->
|
||||
<!-- TODO: Display the NoDataError component in case of a data recovery error -->
|
||||
<LinearChart
|
||||
:data="data"
|
||||
:max-value="customMaxValue"
|
||||
:subtitle="$t('last-week')"
|
||||
:title="$t('pool-cpu-usage')"
|
||||
:value-formatter="customValueFormatter"
|
||||
/>
|
||||
<UiCard class="linear-chart" :color="hasError ? 'error' : undefined">
|
||||
<UiCardTitle>{{ $t("pool-cpu-usage") }}</UiCardTitle>
|
||||
<UiCardTitle :level="UiCardTitleLevel.Subtitle">
|
||||
{{ $t("last-week") }}
|
||||
</UiCardTitle>
|
||||
<NoDataError v-if="hasError" />
|
||||
<UiCardSpinner v-else-if="isLoading" />
|
||||
<LinearChart
|
||||
v-else
|
||||
:data="data"
|
||||
:max-value="customMaxValue"
|
||||
:value-formatter="customValueFormatter"
|
||||
/>
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import type { HostStats } from "@/libs/xapi-stats";
|
||||
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
|
||||
import type { LinearChartData, ValueFormatter } from "@/types/chart";
|
||||
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
|
||||
import { sumBy } from "lodash-es";
|
||||
import { computed, defineAsyncComponent, inject } from "vue";
|
||||
import type { HostStats } from "@/libs/xapi-stats";
|
||||
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
|
||||
import type { LinearChartData, ValueFormatter } from "@/types/chart";
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
|
||||
import { sumBy } from "lodash-es";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
|
||||
import { UiCardTitleLevel } from "@/types/enums";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const LinearChart = defineAsyncComponent(
|
||||
@@ -29,8 +38,7 @@ const { t } = useI18n();
|
||||
|
||||
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
|
||||
|
||||
const { records: hosts } = useHostCollection();
|
||||
|
||||
const { records: hosts, isFetching, hasError } = useHostCollection();
|
||||
const customMaxValue = computed(
|
||||
() => 100 * sumBy(hosts.value, (host) => +host.cpu_info.cpu_count)
|
||||
);
|
||||
@@ -79,6 +87,22 @@ const data = computed<LinearChartData>(() => {
|
||||
},
|
||||
];
|
||||
});
|
||||
const isStatFetched = computed(() => {
|
||||
const stats = hostLastWeekStats?.stats?.value;
|
||||
if (stats === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return stats.every((host) => {
|
||||
const hostStats = host.stats;
|
||||
return (
|
||||
hostStats != null &&
|
||||
Object.values(hostStats.cpus)[0].length === data.value[0].data.length
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const isLoading = computed(() => isFetching.value || !isStatFetched.value);
|
||||
|
||||
const customValueFormatter: ValueFormatter = (value) => `${value}%`;
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<UiCardTitle
|
||||
subtitle
|
||||
:level="UiCardTitleLevel.SubtitleWithUnderline"
|
||||
:left="$t('vms')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
/>
|
||||
@@ -9,6 +9,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type ComputedRef, computed, inject } from "vue";
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
@@ -16,7 +17,7 @@ import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { getAvgCpuUsage } from "@/libs/utils";
|
||||
import { IK_VM_STATS } from "@/types/injection-keys";
|
||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||
import { computed, type ComputedRef, inject } from "vue";
|
||||
import { UiCardTitleLevel } from "@/types/enums";
|
||||
|
||||
const { hasError } = useVmCollection();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<UiCardTitle
|
||||
subtitle
|
||||
:level="UiCardTitleLevel.SubtitleWithUnderline"
|
||||
:left="$t('hosts')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
/>
|
||||
@@ -13,6 +13,7 @@ import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import { IK_HOST_STATS } from "@/types/injection-keys";
|
||||
import { type ComputedRef, computed, inject } from "vue";
|
||||
import { UiCardTitleLevel } from "@/types/enums";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import { formatSize, parseRamUsage } from "@/libs/utils";
|
||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
<template>
|
||||
<!-- TODO: add a loader when data is not fully loaded or undefined -->
|
||||
<!-- TODO: add small loader with tooltips when stats can be expired -->
|
||||
<!-- TODO: display the NoDataError component in case of a data recovery error -->
|
||||
<LinearChart
|
||||
:data="data"
|
||||
:max-value="customMaxValue"
|
||||
:subtitle="$t('last-week')"
|
||||
:title="$t('pool-ram-usage')"
|
||||
:value-formatter="customValueFormatter"
|
||||
>
|
||||
<template #summary>
|
||||
<SizeStatsSummary :size="currentData.size" :usage="currentData.usage" />
|
||||
</template>
|
||||
</LinearChart>
|
||||
<UiCard class="linear-chart" :color="hasError ? 'error' : undefined">
|
||||
<UiCardTitle>{{ $t("pool-ram-usage") }}</UiCardTitle>
|
||||
<UiCardTitle :level="UiCardTitleLevel.Subtitle">
|
||||
{{ $t("last-week") }}
|
||||
</UiCardTitle>
|
||||
<NoDataError v-if="hasError" />
|
||||
<UiCardSpinner v-else-if="isLoading" />
|
||||
<LinearChart
|
||||
v-else
|
||||
:data="data"
|
||||
:max-value="customMaxValue"
|
||||
:value-formatter="customValueFormatter"
|
||||
/>
|
||||
<SizeStatsSummary :size="currentData.size" :usage="currentData.usage" />
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, inject } from "vue";
|
||||
import { formatSize } from "@/libs/utils";
|
||||
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
|
||||
import type { LinearChartData } from "@/types/chart";
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
|
||||
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
|
||||
import { sumBy } from "lodash-es";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
|
||||
import { UiCardTitleLevel } from "@/types/enums";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import { useHostMetricsCollection } from "@/stores/xen-api/host-metrics.store";
|
||||
import { formatSize } from "@/libs/utils";
|
||||
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
|
||||
import type { LinearChartData, ValueFormatter } from "@/types/chart";
|
||||
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
|
||||
import { sumBy } from "lodash-es";
|
||||
import { computed, defineAsyncComponent, inject } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const LinearChart = defineAsyncComponent(
|
||||
() => import("@/components/charts/LinearChart.vue")
|
||||
);
|
||||
|
||||
const { runningHosts } = useHostCollection();
|
||||
const { runningHosts, isFetching, hasError } = useHostCollection();
|
||||
const { getHostMemory } = useHostMetricsCollection();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -92,6 +98,23 @@ const data = computed<LinearChartData>(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const customValueFormatter: ValueFormatter = (value) =>
|
||||
String(formatSize(value));
|
||||
const isStatFetched = computed(() => {
|
||||
const stats = hostLastWeekStats?.stats?.value;
|
||||
if (stats === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return stats.every((host) => {
|
||||
const hostStats = host.stats;
|
||||
return (
|
||||
hostStats != null && hostStats.memory.length === data.value[0].data.length
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const isLoading = computed(
|
||||
() => (isFetching.value && !hasError.value) || !isStatFetched.value
|
||||
);
|
||||
|
||||
const customValueFormatter = (value: number) => String(formatSize(value));
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<UiCardTitle
|
||||
subtitle
|
||||
:level="UiCardTitleLevel.SubtitleWithUnderline"
|
||||
:left="$t('vms')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
/>
|
||||
@@ -9,14 +9,15 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { computed, inject, type ComputedRef } from "vue";
|
||||
import { formatSize, parseRamUsage } from "@/libs/utils";
|
||||
import { IK_VM_STATS } from "@/types/injection-keys";
|
||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||
import { computed, type ComputedRef, inject } from "vue";
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import { UiCardTitleLevel } from "@/types/enums";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
|
||||
const { hasError } = useVmCollection();
|
||||
|
||||
|
||||
@@ -1,35 +1,40 @@
|
||||
<template>
|
||||
<div :class="{ subtitle }" class="ui-section-title">
|
||||
<component
|
||||
:is="subtitle ? 'h5' : 'h4'"
|
||||
v-if="$slots.default || left"
|
||||
class="left"
|
||||
>
|
||||
<div :class="['ui-section-title', tags.left]">
|
||||
<component :is="tags.left" v-if="$slots.default || left" class="left">
|
||||
<slot>{{ left }}</slot>
|
||||
<UiCounter class="count" v-if="count > 0" :value="count" color="info" />
|
||||
</component>
|
||||
<component
|
||||
:is="subtitle ? 'h6' : 'h5'"
|
||||
v-if="$slots.right || right"
|
||||
class="right"
|
||||
>
|
||||
<component :is="tags.right" v-if="$slots.right || right" class="right">
|
||||
<slot name="right">{{ right }}</slot>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import UiCounter from "@/components/ui/UiCounter.vue";
|
||||
import { UiCardTitleLevel } from "@/types/enums";
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
subtitle?: boolean;
|
||||
count?: number;
|
||||
level?: UiCardTitleLevel;
|
||||
left?: string;
|
||||
right?: string;
|
||||
count?: number;
|
||||
}>(),
|
||||
{ count: 0 }
|
||||
{ count: 0, level: UiCardTitleLevel.Title }
|
||||
);
|
||||
|
||||
const tags = computed(() => {
|
||||
switch (props.level) {
|
||||
case UiCardTitleLevel.Subtitle:
|
||||
return { left: "h6", right: "h6" };
|
||||
case UiCardTitleLevel.SubtitleWithUnderline:
|
||||
return { left: "h5", right: "h6" };
|
||||
default:
|
||||
return { left: "h4", right: "h5" };
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
@@ -37,7 +42,6 @@ withDefaults(
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
--section-title-left-size: 2rem;
|
||||
--section-title-left-color: var(--color-blue-scale-100);
|
||||
@@ -46,9 +50,17 @@ withDefaults(
|
||||
--section-title-right-color: var(--color-extra-blue-base);
|
||||
--section-title-right-weight: 700;
|
||||
|
||||
&.subtitle {
|
||||
border-bottom: 1px solid var(--color-extra-blue-base);
|
||||
&.h6 {
|
||||
margin-bottom: 1rem;
|
||||
--section-title-left-size: 1.5rem;
|
||||
--section-title-left-color: var(--color-blue-scale-300);
|
||||
--section-title-left-weight: 400;
|
||||
}
|
||||
|
||||
&.h5 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-extra-blue-base);
|
||||
--section-title-left-size: 1.6rem;
|
||||
--section-title-left-color: var(--color-extra-blue-base);
|
||||
--section-title-left-weight: 700;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<MenuItem
|
||||
v-tooltip="!areAllSelectedVmsHalted && $t('selected-vms-in-execution')"
|
||||
v-tooltip="
|
||||
!areAllSelectedVmsHalted &&
|
||||
$t(isSingleAction ? 'vm-is-running' : 'selected-vms-in-execution')
|
||||
"
|
||||
:busy="areSomeSelectedVmsCloning"
|
||||
:disabled="isDisabled"
|
||||
:icon="faCopy"
|
||||
@@ -22,6 +25,7 @@ import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
selectedRefs: XenApiVm["$ref"][];
|
||||
isSingleAction?: boolean;
|
||||
}>();
|
||||
|
||||
const { getByOpaqueRef, isOperationPending } = useVmCollection();
|
||||
|
||||
@@ -11,6 +11,23 @@
|
||||
</template>
|
||||
<VmActionPowerStateItems :vm-refs="[vm.$ref]" />
|
||||
</AppMenu>
|
||||
<AppMenu v-if="vm !== undefined" placement="bottom-end" shadow>
|
||||
<template #trigger="{ open, isOpen }">
|
||||
<UiButton
|
||||
:active="isOpen"
|
||||
:icon="faEllipsisVertical"
|
||||
@click="open"
|
||||
transparent
|
||||
class="more-actions-button"
|
||||
v-tooltip="{
|
||||
placement: 'left',
|
||||
content: $t('more-actions'),
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
<VmActionCopyItem :selected-refs="[vm.$ref]" is-single-action />
|
||||
<VmActionSnapshotItem :vm-refs="[vm.$ref]" />
|
||||
</AppMenu>
|
||||
</template>
|
||||
</TitleBar>
|
||||
</template>
|
||||
@@ -21,11 +38,15 @@ import TitleBar from "@/components/TitleBar.vue";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
|
||||
import VmActionSnapshotItem from "@/components/vm/VmActionItems/VmActionSnapshotItem.vue";
|
||||
import VmActionCopyItem from "@/components/vm/VmActionItems/VmActionCopyItem.vue";
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
|
||||
import {
|
||||
faAngleDown,
|
||||
faDisplay,
|
||||
faEllipsisVertical,
|
||||
faPowerOff,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed } from "vue";
|
||||
@@ -40,3 +61,9 @@ const vm = computed(() =>
|
||||
|
||||
const name = computed(() => vm.value?.name_label);
|
||||
</script>
|
||||
|
||||
<style lang="postcss">
|
||||
.more-actions-button {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,8 +10,7 @@ export const useChartTheme = () => {
|
||||
|
||||
const getColors = () => ({
|
||||
background: style.getPropertyValue("--background-color-primary"),
|
||||
title: style.getPropertyValue("--color-blue-scale-100"),
|
||||
subtitle: style.getPropertyValue("--color-blue-scale-300"),
|
||||
text: style.getPropertyValue("--color-blue-scale-300"),
|
||||
splitLine: style.getPropertyValue("--color-blue-scale-400"),
|
||||
primary: style.getPropertyValue("--color-extra-blue-base"),
|
||||
secondary: style.getPropertyValue("--color-orange-world-base"),
|
||||
@@ -28,24 +27,10 @@ export const useChartTheme = () => {
|
||||
backgroundColor: colors.value.background,
|
||||
textStyle: {},
|
||||
grid: {
|
||||
top: 80,
|
||||
top: 40,
|
||||
left: 80,
|
||||
right: 20,
|
||||
},
|
||||
title: {
|
||||
textStyle: {
|
||||
color: colors.value.title,
|
||||
fontFamily: "Poppins, sans-serif",
|
||||
fontWeight: 500,
|
||||
fontSize: 20,
|
||||
},
|
||||
subtextStyle: {
|
||||
color: colors.value.subtitle,
|
||||
fontFamily: "Poppins, sans-serif",
|
||||
fontWeight: 400,
|
||||
fontSize: 14,
|
||||
},
|
||||
},
|
||||
line: {
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
@@ -235,7 +220,7 @@ export const useChartTheme = () => {
|
||||
},
|
||||
axisLabel: {
|
||||
show: true,
|
||||
color: colors.value.subtitle,
|
||||
color: colors.value.text,
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
@@ -295,7 +280,7 @@ export const useChartTheme = () => {
|
||||
},
|
||||
axisLabel: {
|
||||
show: true,
|
||||
color: colors.value.subtitle,
|
||||
color: colors.value.text,
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
@@ -325,7 +310,7 @@ export const useChartTheme = () => {
|
||||
left: "right",
|
||||
top: "bottom",
|
||||
textStyle: {
|
||||
color: colors.value.subtitle,
|
||||
color: colors.value.text,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
|
||||
@@ -75,9 +75,12 @@
|
||||
"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",
|
||||
"keep-me-logged": "Keep me logged in",
|
||||
"language": "Language",
|
||||
"last-week": "Last week",
|
||||
"learn-more": "Learn more",
|
||||
@@ -85,6 +88,7 @@
|
||||
"loading-hosts": "Loading hosts…",
|
||||
"log-out": "Log out",
|
||||
"login": "Login",
|
||||
"more-actions": "More actions",
|
||||
"migrate": "Migrate",
|
||||
"migrate-n-vms": "Migrate 1 VM | Migrate {n} VMs",
|
||||
"n-hosts-awaiting-patch": "{n} host is awaiting this patch | {n} hosts are awaiting this patch",
|
||||
@@ -104,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",
|
||||
@@ -135,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",
|
||||
@@ -172,6 +177,7 @@
|
||||
"vcpus": "vCPUs",
|
||||
"vcpus-used": "vCPUs used",
|
||||
"version": "Version",
|
||||
"vm-is-running": "The VM is running",
|
||||
"vms": "VMs",
|
||||
"xo-lite-under-construction": "XOLite is under construction"
|
||||
}
|
||||
|
||||
@@ -75,9 +75,12 @@
|
||||
"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",
|
||||
"keep-me-logged": "Rester connecté",
|
||||
"language": "Langue",
|
||||
"last-week": "Semaine dernière",
|
||||
"learn-more": "En savoir plus",
|
||||
@@ -85,6 +88,7 @@
|
||||
"loading-hosts": "Chargement des hôtes…",
|
||||
"log-out": "Se déconnecter",
|
||||
"login": "Connexion",
|
||||
"more-actions": "Plus d'actions",
|
||||
"migrate": "Migrer",
|
||||
"migrate-n-vms": "Migrer 1 VM | Migrer {n} VMs",
|
||||
"n-hosts-awaiting-patch": "{n} hôte attend ce patch | {n} hôtes attendent ce patch",
|
||||
@@ -104,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",
|
||||
@@ -135,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",
|
||||
@@ -172,6 +177,7 @@
|
||||
"vcpus": "vCPUs",
|
||||
"vcpus-used": "vCPUs utilisés",
|
||||
"version": "Version",
|
||||
"vm-is-running": "La VM est en cours d'exécution",
|
||||
"vms": "VMs",
|
||||
"xo-lite-under-construction": "XOLite est en construction"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,7 +2,7 @@ import XapiStats from "@/libs/xapi-stats";
|
||||
import XenApi from "@/libs/xen-api/xen-api";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, ref, watchEffect } from "vue";
|
||||
|
||||
const HOST_URL = import.meta.env.PROD
|
||||
? window.origin
|
||||
@@ -17,16 +17,24 @@ enum STATUS {
|
||||
export const useXenApiStore = defineStore("xen-api", () => {
|
||||
const xenApi = new XenApi(HOST_URL);
|
||||
const xapiStats = new XapiStats(xenApi);
|
||||
const currentSessionId = useLocalStorage<string | undefined>(
|
||||
const storedSessionId = useLocalStorage<string | undefined>(
|
||||
"sessionId",
|
||||
undefined
|
||||
);
|
||||
const currentSessionId = ref(storedSessionId.value);
|
||||
const rememberMe = useLocalStorage("rememberMe", false);
|
||||
const status = ref(STATUS.DISCONNECTED);
|
||||
const isConnected = computed(() => status.value === STATUS.CONNECTED);
|
||||
const isConnecting = computed(() => status.value === STATUS.CONNECTING);
|
||||
const getXapi = () => xenApi;
|
||||
const getXapiStats = () => xapiStats;
|
||||
|
||||
watchEffect(() => {
|
||||
storedSessionId.value = rememberMe.value
|
||||
? currentSessionId.value
|
||||
: undefined;
|
||||
});
|
||||
|
||||
const connect = async (username: string, password: string) => {
|
||||
status.value = STATUS.CONNECTING;
|
||||
|
||||
@@ -63,7 +71,7 @@ export const useXenApiStore = defineStore("xen-api", () => {
|
||||
|
||||
async function disconnect() {
|
||||
await xenApi.disconnect();
|
||||
currentSessionId.value = null;
|
||||
currentSessionId.value = undefined;
|
||||
status.value = STATUS.DISCONNECTED;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ type LinearChartData = {
|
||||
|
||||
```vue-template
|
||||
<LinearChart
|
||||
title="Chart title"
|
||||
subtitle="Chart subtitle"
|
||||
:data="data"
|
||||
/>
|
||||
```
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<template>
|
||||
<ComponentStory
|
||||
:params="[
|
||||
prop('title').preset('Chart title').widget(),
|
||||
prop('subtitle').preset('Here is a subtitle').widget(),
|
||||
prop('data')
|
||||
.preset(data)
|
||||
.required()
|
||||
@@ -58,8 +56,6 @@ const data: LinearChartData = [
|
||||
const presets = {
|
||||
"Network bandwidth": {
|
||||
props: {
|
||||
title: "Network bandwidth",
|
||||
subtitle: "Last week",
|
||||
"value-formatter": byteFormatter,
|
||||
"max-value": 500000000,
|
||||
data: [
|
||||
|
||||
5
@xen-orchestra/lite/src/types/enums.ts
Normal file
5
@xen-orchestra/lite/src/types/enums.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum UiCardTitleLevel {
|
||||
Title,
|
||||
Subtitle,
|
||||
SubtitleWithUnderline,
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -24,6 +24,8 @@ const serializeError = error => ({
|
||||
})
|
||||
|
||||
export default class Tasks extends EventEmitter {
|
||||
#logsToClearOnSuccess = new Set()
|
||||
|
||||
// contains consolidated logs of all live and finished tasks
|
||||
#store
|
||||
|
||||
@@ -36,6 +38,22 @@ export default class Tasks extends EventEmitter {
|
||||
this.#tasks.delete(id)
|
||||
},
|
||||
onTaskUpdate: async taskLog => {
|
||||
const { id, status } = taskLog
|
||||
if (status !== 'pending') {
|
||||
if (this.#logsToClearOnSuccess.has(id)) {
|
||||
this.#logsToClearOnSuccess.delete(id)
|
||||
|
||||
if (status === 'success') {
|
||||
try {
|
||||
await this.#store.del(id)
|
||||
} catch (error) {
|
||||
warn('failure on deleting task log from store', { error, taskLog })
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error objects are not JSON-ifiable by default
|
||||
const { result } = taskLog
|
||||
if (result instanceof Error && result.toJSON === undefined) {
|
||||
@@ -135,10 +153,13 @@ export default class Tasks extends EventEmitter {
|
||||
*
|
||||
* @returns {Task}
|
||||
*/
|
||||
create({ name, objectId, userId = this.#app.apiContext?.user?.id, type }) {
|
||||
create(
|
||||
{ name, objectId, userId = this.#app.apiContext?.user?.id, type, ...props },
|
||||
{ clearLogOnSuccess = false } = {}
|
||||
) {
|
||||
const tasks = this.#tasks
|
||||
|
||||
const task = new Task({ properties: { name, objectId, userId, type }, onProgress: this.#onProgress })
|
||||
const task = new Task({ properties: { ...props, name, objectId, userId, type }, onProgress: this.#onProgress })
|
||||
|
||||
// Use a compact, sortable, string representation of the creation date
|
||||
//
|
||||
@@ -152,6 +173,9 @@ export default class Tasks extends EventEmitter {
|
||||
task.id = id
|
||||
|
||||
tasks.set(id, task)
|
||||
if (clearLogOnSuccess) {
|
||||
this.#logsToClearOnSuccess.add(id)
|
||||
}
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "0.12.0",
|
||||
"version": "0.14.0",
|
||||
"engines": {
|
||||
"node": ">=15.6"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@xen-orchestra/proxy",
|
||||
"version": "0.26.34",
|
||||
"version": "0.26.37",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "XO Proxy used to remotely execute backup jobs",
|
||||
"keywords": [
|
||||
@@ -32,13 +32,13 @@
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/disposable": "^0.1.4",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.42.1",
|
||||
"@xen-orchestra/fs": "^4.1.0",
|
||||
"@xen-orchestra/backups": "^0.43.2",
|
||||
"@xen-orchestra/fs": "^4.1.1",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/mixin": "^0.1.0",
|
||||
"@xen-orchestra/mixins": "^0.12.0",
|
||||
"@xen-orchestra/mixins": "^0.14.0",
|
||||
"@xen-orchestra/self-signed": "^0.1.3",
|
||||
"@xen-orchestra/xapi": "^3.1.0",
|
||||
"@xen-orchestra/xapi": "^3.3.0",
|
||||
"ajv": "^8.0.3",
|
||||
"app-conf": "^2.3.0",
|
||||
"async-iterator-to-stream": "^1.1.0",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"node-fetch": "^3.3.0",
|
||||
"vhd-lib": "^4.6.0"
|
||||
"vhd-lib": "^4.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
|
||||
@@ -5,3 +5,4 @@ export { default as VBD } from './vbd.mjs'
|
||||
export { default as VDI } from './vdi.mjs'
|
||||
export { default as VIF } from './vif.mjs'
|
||||
export { default as VM } from './vm.mjs'
|
||||
export { default as VTPM } from './vtpm.mjs'
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import { asyncMap } from '@xen-orchestra/async-map'
|
||||
import { decorateClass } from '@vates/decorate-with'
|
||||
import { defer } from 'golike-defer'
|
||||
import { incorrectState, operationFailed } from 'xo-common/api-errors.js'
|
||||
|
||||
import { getCurrentVmUuid } from './_XenStore.mjs'
|
||||
|
||||
@@ -31,7 +33,38 @@ class Host {
|
||||
*
|
||||
* @param {string} ref - Opaque reference of the host
|
||||
*/
|
||||
async smartReboot($defer, ref) {
|
||||
async smartReboot($defer, ref, bypassBlockedSuspend = false, bypassCurrentVmCheck = false) {
|
||||
let currentVmRef
|
||||
try {
|
||||
currentVmRef = await this.call('VM.get_by_uuid', await getCurrentVmUuid())
|
||||
} catch (error) {}
|
||||
|
||||
const residentVmRefs = await this.getField('host', ref, 'resident_VMs')
|
||||
const vmsWithSuspendBlocked = await asyncMap(residentVmRefs, ref => this.getRecord('VM', ref)).filter(
|
||||
vm =>
|
||||
vm.$ref !== currentVmRef &&
|
||||
!vm.is_control_domain &&
|
||||
vm.power_state !== 'Halted' &&
|
||||
vm.power_state !== 'Suspended' &&
|
||||
vm.blocked_operations.suspend !== undefined
|
||||
)
|
||||
|
||||
if (!bypassBlockedSuspend && vmsWithSuspendBlocked.length > 0) {
|
||||
throw incorrectState({ actual: vmsWithSuspendBlocked.map(vm => vm.uuid), expected: [], object: 'suspendBlocked' })
|
||||
}
|
||||
|
||||
if (!bypassCurrentVmCheck && residentVmRefs.includes(currentVmRef)) {
|
||||
throw operationFailed({
|
||||
objectId: await this.getField('VM', currentVmRef, 'uuid'),
|
||||
code: 'xoaOnHost',
|
||||
})
|
||||
}
|
||||
|
||||
await asyncEach(vmsWithSuspendBlocked, vm => {
|
||||
$defer(() => vm.update_blocked_operations('suspend', vm.blocked_operations.suspend ?? null))
|
||||
return vm.update_blocked_operations('suspend', null)
|
||||
})
|
||||
|
||||
const suspendedVms = []
|
||||
if (await this.getField('host', ref, 'enabled')) {
|
||||
await this.callAsync('host.disable', ref)
|
||||
@@ -42,13 +75,8 @@ class Host {
|
||||
})
|
||||
}
|
||||
|
||||
let currentVmRef
|
||||
try {
|
||||
currentVmRef = await this.call('VM.get_by_uuid', await getCurrentVmUuid())
|
||||
} catch (error) {}
|
||||
|
||||
await asyncEach(
|
||||
await this.getField('host', ref, 'resident_VMs'),
|
||||
residentVmRefs,
|
||||
async vmRef => {
|
||||
if (vmRef === currentVmRef) {
|
||||
return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/xapi",
|
||||
"version": "3.1.0",
|
||||
"version": "3.3.0",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/xapi",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
@@ -34,7 +34,7 @@
|
||||
"json-rpc-protocol": "^0.13.2",
|
||||
"lodash": "^4.17.15",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"vhd-lib": "^4.6.0",
|
||||
"vhd-lib": "^4.6.1",
|
||||
"xo-common": "^0.8.0"
|
||||
},
|
||||
"private": false,
|
||||
|
||||
37
@xen-orchestra/xapi/vtpm.mjs
Normal file
37
@xen-orchestra/xapi/vtpm.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import upperFirst from 'lodash/upperFirst.js'
|
||||
import { incorrectState } from 'xo-common/api-errors.js'
|
||||
|
||||
export default class Vtpm {
|
||||
async create({ is_unique = false, VM }) {
|
||||
const pool = this.pool
|
||||
|
||||
// If VTPM.create is called on a pool that doesn't support VTPM, the errors aren't explicit.
|
||||
// See https://github.com/xapi-project/xen-api/issues/5186
|
||||
if (pool.restrictions.restrict_vtpm !== 'false') {
|
||||
throw incorrectState({
|
||||
actual: pool.restrictions.restrict_vtpm,
|
||||
expected: 'false',
|
||||
object: pool.uuid,
|
||||
property: 'restrictions.restrict_vtpm',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.call('VTPM.create', VM, is_unique)
|
||||
} catch (error) {
|
||||
const { code, params } = error
|
||||
if (code === 'VM_BAD_POWER_STATE') {
|
||||
const [, expected, actual] = params
|
||||
// In `VM_BAD_POWER_STATE` errors, the power state is lowercased
|
||||
throw incorrectState({
|
||||
actual: upperFirst(actual),
|
||||
expected: upperFirst(expected),
|
||||
object: await this.getField('VM', VM, 'uuid'),
|
||||
property: 'power_state',
|
||||
})
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
96
CHANGELOG.md
96
CHANGELOG.md
@@ -1,17 +1,80 @@
|
||||
# ChangeLog
|
||||
|
||||
## **next**
|
||||
## **5.88.0** (2023-10-31)
|
||||
|
||||
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
|
||||
|
||||
### Highlights
|
||||
|
||||
- [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))
|
||||
- [Self] Show number of VMs that belong to each Resource Set (PR [#7114](https://github.com/vatesfr/xen-orchestra/pull/7114))
|
||||
- [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))
|
||||
- [XOSTOR] Ability to create a XOSTOR storage (PR [#6983](https://github.com/vatesfr/xen-orchestra/pull/6983))
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [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))
|
||||
- [Dashboard/Health] Displays number of VDIs to coalesce (PR [#7111](https://github.com/vatesfr/xen-orchestra/pull/7111))
|
||||
- [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))
|
||||
- [New network] Remove bonded PIFs from selector when creating network (PR [#7136](https://github.com/vatesfr/xen-orchestra/pull/7136))
|
||||
- Try to preserve current page across reauthentication (PR [#7013](https://github.com/vatesfr/xen-orchestra/pull/7013))
|
||||
- [XO-WEB/Forget SR] Changed the modal message and added a confirmation text to be sure the action is understood by the user (PR [#7154](https://github.com/vatesfr/xen-orchestra/pull/7154))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [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))
|
||||
- [Netbox] Fix "The selected cluster is not assigned to this site" error [Forum#7887](https://xcp-ng.org/forum/topic/7887) (PR [#7124](https://github.com/vatesfr/xen-orchestra/pull/7124))
|
||||
- [Backups] Fix `MESSAGE_METHOD_UNKNOWN` during full backup [Forum#7894](https://xcp-ng.org/forum/topic/7894)(PR [#7139](https://github.com/vatesfr/xen-orchestra/pull/7139))
|
||||
- [Resource Set] Fix error displayed after successful VM addition to resource set PR ([#7144](https://github.com/vatesfr/xen-orchestra/pull/7144))
|
||||
|
||||
### Released packages
|
||||
|
||||
- @xen-orchestra/fs 4.1.1
|
||||
- @xen-orchestra/xapi 3.3.0
|
||||
- @xen-orchestra/mixins 0.14.0
|
||||
- xo-server-backup-reports 0.18.0
|
||||
- xo-server-transport-xmpp 0.1.3
|
||||
- xo-server-usage-report 0.10.5
|
||||
- @xen-orchestra/backups 0.43.2
|
||||
- @xen-orchestra/proxy 0.26.37
|
||||
- xo-cli 0.21.0
|
||||
- xo-server 5.125.1
|
||||
- xo-server-netbox 1.3.2
|
||||
- xo-web 5.127.1
|
||||
|
||||
## **5.87.0** (2023-09-29)
|
||||
|
||||
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
|
||||
|
||||
### Highlights
|
||||
|
||||
- [Patches] Support new XenServer Updates system. See [our documentation](https://xen-orchestra.com/docs/updater.html#xenserver-updates). (PR [#7044](https://github.com/vatesfr/xen-orchestra/pull/7044))
|
||||
- [Host/Advanced] New button to download system logs [#3968](https://github.com/vatesfr/xen-orchestra/issues/3968) (PR [#7048](https://github.com/vatesfr/xen-orchestra/pull/7048))
|
||||
- [Home/Hosts, Pools] Display host brand and version (PR [#7027](https://github.com/vatesfr/xen-orchestra/pull/7027))
|
||||
- [SR] Ability to reclaim space [#1204](https://github.com/vatesfr/xen-orchestra/issues/1204) (PR [#7054](https://github.com/vatesfr/xen-orchestra/pull/7054))
|
||||
- [XOA] New button to restart XO Server directly from the UI (PR [#7056](https://github.com/vatesfr/xen-orchestra/pull/7056))
|
||||
- [Host/Advanced] Display system disks health based on the _smartctl_ plugin. [#4458](https://github.com/vatesfr/xen-orchestra/issues/4458) (PR [#7060](https://github.com/vatesfr/xen-orchestra/pull/7060))
|
||||
- [Authentication] Failed attempts are now logged as XO tasks (PR [#7061](https://github.com/vatesfr/xen-orchestra/pull/7061))
|
||||
- [Backup] Prevent VMs from being migrated while they are backed up (PR [#7024](https://github.com/vatesfr/xen-orchestra/pull/7024))
|
||||
- [Backup] Prevent VMs from being backed up while they are migrated (PR [#7024](https://github.com/vatesfr/xen-orchestra/pull/7024))
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [Netbox] Don't delete VMs that have been created manually in XO-synced cluster [Forum#7639](https://xcp-ng.org/forum/topic/7639) (PR [#7008](https://github.com/vatesfr/xen-orchestra/pull/7008))
|
||||
- [Kubernetes] _Search domains_ field is now optional [#7028](https://github.com/vatesfr/xen-orchestra/pull/7028)
|
||||
- [Patches] Support new XenServer Updates system. See [our documentation](https://xen-orchestra.com/docs/updater.html#xenserver-updates). (PR [#7044](https://github.com/vatesfr/xen-orchestra/pull/7044))
|
||||
- [REST API] Hosts' audit and system logs can be downloaded [#3968](https://github.com/vatesfr/xen-orchestra/issues/3968) (PR [#7048](https://github.com/vatesfr/xen-orchestra/pull/7048))
|
||||
- [Host/Advanced] New button to download system logs [#3968](https://github.com/vatesfr/xen-orchestra/issues/3968) (PR [#7048](https://github.com/vatesfr/xen-orchestra/pull/7048))
|
||||
- [Home/Hosts, Pools] Display host brand and version (PR [#7027](https://github.com/vatesfr/xen-orchestra/pull/7027))
|
||||
- [SR] Ability to reclaim space [#1204](https://github.com/vatesfr/xen-orchestra/issues/1204) (PR [#7054](https://github.com/vatesfr/xen-orchestra/pull/7054))
|
||||
- [XOA] New button to restart XO Server directly from the UI (PR [#7056](https://github.com/vatesfr/xen-orchestra/pull/7056))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
@@ -22,23 +85,28 @@
|
||||
- [Backup] Fix `VHDFile implementation is not compatible with encrypted remote` when using VHD directory with encryption (PR [#7045](https://github.com/vatesfr/xen-orchestra/pull/7045))
|
||||
- [Backup/Mirror] Fix `xo:fs:local WARN lock compromised` when mirroring a Backup Repository to a local/NFS/SMB repository ([#7043](https://github.com/vatesfr/xen-orchestra/pull/7043))
|
||||
- [Ova import] Fix importing VM with collision in disk position (PR [#7051](https://github.com/vatesfr/xen-orchestra/pull/7051)) (issue [7046](https://github.com/vatesfr/xen-orchestra/issues/7046))
|
||||
- [Backup/Mirror] Fix backup report not being sent (PR [#7049](https://github.com/vatesfr/xen-orchestra/pull/7049))
|
||||
- [New VM] Only add MBR to cloud-init drive on Windows VMs to avoid booting issues (e.g. with Talos) (PR [#7050](https://github.com/vatesfr/xen-orchestra/pull/7050))
|
||||
- [VDI Import] Add the SR name to the corresponding XAPI task (PR [#6979](https://github.com/vatesfr/xen-orchestra/pull/6979))
|
||||
|
||||
### Released packages
|
||||
|
||||
- vhd-lib 4.6.0
|
||||
- @xen-orchestra/backups 0.42.1
|
||||
- @xen-orchestra/proxy 0.26.34
|
||||
- xo-vmdk-to-vhd 2.5.6
|
||||
- xo-server 5.123.0
|
||||
- xo-server-auth-github 0.3.1
|
||||
- xo-server-auth-google 0.3.1
|
||||
- xo-server-netbox 1.3.0
|
||||
- xo-web 5.125.0
|
||||
- vhd-lib 4.6.1
|
||||
- @xen-orchestra/xapi 3.2.0
|
||||
- @xen-orchestra/backups 0.43.0
|
||||
- @xen-orchestra/backups-cli 1.0.13
|
||||
- @xen-orchestra/mixins 0.13.0
|
||||
- @xen-orchestra/proxy 0.26.35
|
||||
- xo-server 5.124.0
|
||||
- xo-server-backup-reports 0.17.4
|
||||
- xo-web 5.126.0
|
||||
|
||||
## **5.86.1** (2023-09-07)
|
||||
|
||||
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [User] _Forget all connection tokens_ button should not delete other users' tokens, even when current user is an administrator (PR [#7014](https://github.com/vatesfr/xen-orchestra/pull/7014))
|
||||
@@ -100,8 +168,6 @@
|
||||
|
||||
## **5.85.0** (2023-07-31)
|
||||
|
||||
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
|
||||
|
||||
### Highlights
|
||||
|
||||
- [Import/From VMWare] Support ESXi 6.5+ with snapshot (PR [#6909](https://github.com/vatesfr/xen-orchestra/pull/6909))
|
||||
|
||||
@@ -11,9 +11,7 @@
|
||||
|
||||
> Users must be able to say: “I had this issue, happy to know it's fixed”
|
||||
|
||||
- [Backup/Mirror] Fix backup report not being sent (PR [#7049](https://github.com/vatesfr/xen-orchestra/pull/7049))
|
||||
- [New VM] Only add MBR to cloud-init drive on Windows VMs to avoid booting issues (e.g. with Talos) (PR [#7050](https://github.com/vatesfr/xen-orchestra/pull/7050))
|
||||
- [VDI Import] Add the SR name to the corresponding XAPI task (PR [#6979](https://github.com/vatesfr/xen-orchestra/pull/6979))
|
||||
- [Netbox] Fix VMs' `site` property being unnecessarily updated on some versions of Netbox (PR [#7145](https://github.com/vatesfr/xen-orchestra/pull/7145))
|
||||
|
||||
### Packages to release
|
||||
|
||||
@@ -31,9 +29,6 @@
|
||||
|
||||
<!--packages-start-->
|
||||
|
||||
- @xen-orchestra/xapi minor
|
||||
- xo-server-backup-reports patch
|
||||
- xo-server patch
|
||||
- xo-web patch
|
||||
- xo-server-netbox patch
|
||||
|
||||
<!--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/)
|
||||
|
||||
@@ -362,7 +362,7 @@ XO will try to find the right prefix for each IP address. If it can't find a pre
|
||||
- Assign it to object types:
|
||||
- Virtualization > cluster
|
||||
- Virtualization > virtual machine
|
||||
- Virtualization > interface`
|
||||
- Virtualization > interface
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -94,9 +94,9 @@ uri = 'tcp://db:password@hostname:port'
|
||||
|
||||
## Proxy for updates and patches
|
||||
|
||||
To check if your hosts are up-to-date, we need to access `http://updates.xensource.com/XenServer/updates.xml`.
|
||||
To check if your hosts are up-to-date, we need to access `https://updates.ops.xenserver.com/xenserver/updates.xml`.
|
||||
|
||||
And to download the patches, we need access to `http://support.citrix.com/supportkc/filedownload?`.
|
||||
And to download the patches, we need access to `https://fileservice.citrix.com/direct/v2/download/secured/support/article/*/downloads/*.zip`.
|
||||
|
||||
To do that behind a corporate proxy, just add the `httpProxy` variable to match your current proxy configuration.
|
||||
|
||||
|
||||
@@ -82,13 +82,13 @@ As you may have seen in other parts of the documentation, XO is composed of two
|
||||
|
||||
#### NodeJS
|
||||
|
||||
XO needs Node.js. **Please always use latest Node LTS**.
|
||||
XO requires Node.js 18.
|
||||
|
||||
We'll consider at this point that you've got a working node on your box. E.g:
|
||||
|
||||
```console
|
||||
$ node -v
|
||||
v16.14.0
|
||||
v18.18.0
|
||||
```
|
||||
|
||||
If not, see [this page](https://nodejs.org/en/download/package-manager/) for instructions on how to install Node.
|
||||
@@ -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:
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"node": ">=10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/fs": "^4.1.0",
|
||||
"@xen-orchestra/fs": "^4.1.1",
|
||||
"cli-progress": "^3.1.0",
|
||||
"exec-promise": "^0.7.0",
|
||||
"getopts": "^2.2.3",
|
||||
@@ -31,7 +31,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"uuid": "^9.0.0",
|
||||
"vhd-lib": "^4.6.0"
|
||||
"vhd-lib": "^4.6.1"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "vhd-lib",
|
||||
"version": "4.6.0",
|
||||
"version": "4.6.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Primitives for VHD file handling",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/vhd-lib",
|
||||
@@ -20,7 +20,7 @@
|
||||
"@vates/read-chunk": "^1.2.0",
|
||||
"@vates/stream-reader": "^0.1.0",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^4.1.0",
|
||||
"@xen-orchestra/fs": "^4.1.1",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"async-iterator-to-stream": "^1.0.2",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@xen-orchestra/fs": "^4.1.0",
|
||||
"@xen-orchestra/fs": "^4.1.1",
|
||||
"execa": "^5.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"rimraf": "^5.0.1",
|
||||
|
||||
@@ -25,8 +25,16 @@ async function checkFile(vhdName) {
|
||||
// Since the qemu-img check command isn't compatible with vhd format, we use
|
||||
// the convert command to do a check by conversion. Indeed, the conversion will
|
||||
// fail if the source file isn't a proper vhd format.
|
||||
await execa('qemu-img', ['convert', '-fvpc', '-Oqcow2', vhdName, 'outputFile.qcow2'])
|
||||
await fsPromise.unlink('./outputFile.qcow2')
|
||||
const target = vhdName + '.qcow2'
|
||||
try {
|
||||
await execa('qemu-img', ['convert', '-fvpc', '-Oqcow2', vhdName, target])
|
||||
} finally {
|
||||
try {
|
||||
await fsPromise.unlink(target)
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.checkFile = checkFile
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"promise-toolbox": "^0.19.2",
|
||||
"readable-stream": "^3.1.1",
|
||||
"throttle": "^1.0.3",
|
||||
"vhd-lib": "^4.6.0"
|
||||
"vhd-lib": "^4.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,6 @@ async function main(createClient) {
|
||||
}
|
||||
export default main
|
||||
|
||||
if (!module.parent) {
|
||||
if (module.parent === null) {
|
||||
main(require('./').createClient).catch(console.error.bind(console, 'FATAL'))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "xo-cli",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Basic CLI for Xen-Orchestra",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server-backup-reports",
|
||||
"version": "0.17.3",
|
||||
"version": "0.18.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Backup reports plugin for XO-Server",
|
||||
"keywords": [
|
||||
|
||||
@@ -90,6 +90,8 @@ const formatSpeed = (bytes, milliseconds) =>
|
||||
})
|
||||
: 'N/A'
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
const NO_VMS_MATCH_THIS_PATTERN = 'no VMs match this pattern'
|
||||
const NO_SUCH_OBJECT_ERROR = 'no such object'
|
||||
const UNHEALTHY_VDI_CHAIN_ERROR = 'unhealthy VDI chain'
|
||||
@@ -193,13 +195,17 @@ const toMarkdown = parts => {
|
||||
class BackupReportsXoPlugin {
|
||||
constructor(xo) {
|
||||
this._xo = xo
|
||||
this._eventListener = async (...args) => {
|
||||
try {
|
||||
await this._report(...args)
|
||||
} catch (error) {
|
||||
logger.warn(error)
|
||||
}
|
||||
}
|
||||
|
||||
const report = this._report
|
||||
this._report = (...args) =>
|
||||
xo.tasks
|
||||
.create(
|
||||
{ type: 'xo:xo-server-backup-reports:sendReport', name: 'Sending backup report', runId: args[0] },
|
||||
{ clearLogOnSuccess: true }
|
||||
)
|
||||
.run(() => report.call(this, ...args))
|
||||
|
||||
this._eventListener = (...args) => this._report(...args).catch(noop)
|
||||
}
|
||||
|
||||
configure({ toMails, toXmpp }) {
|
||||
@@ -595,24 +601,28 @@ class BackupReportsXoPlugin {
|
||||
})
|
||||
}
|
||||
|
||||
_sendReport({ mailReceivers, markdown, subject, success }) {
|
||||
async _sendReport({ mailReceivers, markdown, subject, success }) {
|
||||
if (mailReceivers === undefined || mailReceivers.length === 0) {
|
||||
mailReceivers = this._mailsReceivers
|
||||
}
|
||||
|
||||
const xo = this._xo
|
||||
return Promise.all([
|
||||
xo.sendEmail !== undefined &&
|
||||
xo.sendEmail({
|
||||
to: mailReceivers,
|
||||
subject,
|
||||
markdown,
|
||||
}),
|
||||
xo.sendToXmppClient !== undefined &&
|
||||
xo.sendToXmppClient({
|
||||
to: this._xmppReceivers,
|
||||
message: markdown,
|
||||
}),
|
||||
const promises = [
|
||||
mailReceivers !== undefined &&
|
||||
(xo.sendEmail === undefined
|
||||
? Promise.reject(new Error('transport-email plugin not enabled'))
|
||||
: xo.sendEmail({
|
||||
to: mailReceivers,
|
||||
subject,
|
||||
markdown,
|
||||
})),
|
||||
this._xmppReceivers !== undefined &&
|
||||
(xo.sendEmail === undefined
|
||||
? Promise.reject(new Error('transport-xmpp plugin not enabled'))
|
||||
: xo.sendToXmppClient({
|
||||
to: this._xmppReceivers,
|
||||
message: markdown,
|
||||
})),
|
||||
xo.sendSlackMessage !== undefined &&
|
||||
xo.sendSlackMessage({
|
||||
message: markdown,
|
||||
@@ -622,7 +632,22 @@ class BackupReportsXoPlugin {
|
||||
status: success ? 'OK' : 'CRITICAL',
|
||||
message: markdown,
|
||||
}),
|
||||
])
|
||||
]
|
||||
|
||||
const errors = []
|
||||
const pushError = errors.push.bind(errors)
|
||||
|
||||
await Promise.all(promises.filter(Boolean).map(_ => _.catch(pushError)))
|
||||
|
||||
if (errors.length !== 0) {
|
||||
throw new AggregateError(
|
||||
errors,
|
||||
errors
|
||||
.map(_ => _.message)
|
||||
.filter(_ => _ != null && _.length !== 0)
|
||||
.join(', ')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
_legacyVmHandler(status) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server-netbox",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Synchronizes pools managed by Xen Orchestra with Netbox",
|
||||
"keywords": [
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -144,7 +144,9 @@ class Netbox {
|
||||
const httpRequest = async () => {
|
||||
try {
|
||||
const response = await this.#xo.httpRequest(url, options)
|
||||
this.#netboxApiVersion = response.headers['api-version']
|
||||
// API version only follows minor version, which is less precise and is not semver-valid
|
||||
// See https://github.com/netbox-community/netbox/issues/12879#issuecomment-1589190236
|
||||
this.#netboxApiVersion = semver.coerce(response.headers['api-version'])?.version ?? undefined
|
||||
const body = await response.text()
|
||||
if (body.length > 0) {
|
||||
return JSON.parse(body)
|
||||
@@ -336,6 +338,14 @@ class Netbox {
|
||||
tags: [],
|
||||
}
|
||||
|
||||
// Prior to Netbox v3.3.0: no "site" field on VMs
|
||||
// v3.3.0: "site" is REQUIRED and MUST be the same as cluster's site
|
||||
// v3.3.5: "site" is OPTIONAL (auto-assigned in UI, not in API). `null` and cluster's site are accepted.
|
||||
// v3.4.8: "site" is OPTIONAL and AUTO-ASSIGNED with cluster's site. If passed: ignored except if site is different from cluster's, then error.
|
||||
if (this.#netboxApiVersion === undefined || semver.satisfies(this.#netboxApiVersion, '3.3.0 - 3.4.7')) {
|
||||
nbVm.site = find(nbClusters, { id: nbCluster.id })?.site?.id ?? null
|
||||
}
|
||||
|
||||
const distro = xoVm.os_version?.distro
|
||||
if (distro != null) {
|
||||
const slug = slugify(distro)
|
||||
@@ -379,10 +389,7 @@ class Netbox {
|
||||
nbVm.tags = nbVmTags.sort(({ id: id1 }, { id: id2 }) => (id1 < id2 ? -1 : 1))
|
||||
|
||||
// https://netbox.readthedocs.io/en/stable/release-notes/version-2.7/#api-choice-fields-now-use-string-values-3569
|
||||
if (
|
||||
this.#netboxApiVersion !== undefined &&
|
||||
!semver.satisfies(semver.coerce(this.#netboxApiVersion).version, '>=2.7.0')
|
||||
) {
|
||||
if (this.#netboxApiVersion !== undefined && !semver.satisfies(this.#netboxApiVersion, '>=2.7.0')) {
|
||||
nbVm.status = xoVm.power_state === 'Running' ? 1 : 0
|
||||
}
|
||||
|
||||
@@ -395,6 +402,9 @@ class Netbox {
|
||||
cluster: nbVm.cluster?.id ?? null,
|
||||
status: nbVm.status?.value ?? null,
|
||||
platform: nbVm.platform?.id ?? null,
|
||||
// If site is not supported by Netbox, its value is undefined
|
||||
// If site is supported by Netbox but empty, its value is null
|
||||
site: nbVm.site == null ? nbVm.site : nbVm.site.id,
|
||||
// Sort them so that they can be compared by diff()
|
||||
tags: nbVm.tags.map(nbTag => ({ id: nbTag.id })).sort(({ id: id1 }, { id: id2 }) => (id1 < id2 ? -1 : 1)),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server-transport-xmpp",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Transport Xmpp plugin for XO-Server",
|
||||
"keywords": [
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server-usage-report",
|
||||
"version": "0.10.4",
|
||||
"version": "0.10.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Report resources usage with their evolution",
|
||||
"keywords": [
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "xo-server",
|
||||
"version": "5.123.0",
|
||||
"version": "5.125.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Server part of Xen-Orchestra",
|
||||
"keywords": [
|
||||
@@ -41,18 +41,18 @@
|
||||
"@vates/predicates": "^1.1.0",
|
||||
"@vates/read-chunk": "^1.2.0",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.42.1",
|
||||
"@xen-orchestra/backups": "^0.43.2",
|
||||
"@xen-orchestra/cron": "^1.0.6",
|
||||
"@xen-orchestra/defined": "^0.0.1",
|
||||
"@xen-orchestra/emit-async": "^1.0.0",
|
||||
"@xen-orchestra/fs": "^4.1.0",
|
||||
"@xen-orchestra/fs": "^4.1.1",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/mixin": "^0.1.0",
|
||||
"@xen-orchestra/mixins": "^0.12.0",
|
||||
"@xen-orchestra/mixins": "^0.14.0",
|
||||
"@xen-orchestra/self-signed": "^0.1.3",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"@xen-orchestra/vmware-explorer": "^0.3.0",
|
||||
"@xen-orchestra/xapi": "^3.1.0",
|
||||
"@xen-orchestra/xapi": "^3.3.0",
|
||||
"ajv": "^8.0.3",
|
||||
"app-conf": "^2.3.0",
|
||||
"async-iterator-to-stream": "^1.0.1",
|
||||
@@ -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",
|
||||
@@ -128,7 +127,7 @@
|
||||
"unzipper": "^0.10.5",
|
||||
"uuid": "^9.0.0",
|
||||
"value-matcher": "^0.2.0",
|
||||
"vhd-lib": "^4.6.0",
|
||||
"vhd-lib": "^4.6.1",
|
||||
"ws": "^8.2.3",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"xen-api": "^1.3.6",
|
||||
|
||||
@@ -69,3 +69,14 @@ html
|
||||
button.btn.btn-block.btn-info
|
||||
i.fa.fa-sign-in
|
||||
| Sign in
|
||||
script.
|
||||
(function () {
|
||||
var d = document
|
||||
var h = d.location.hash
|
||||
d.querySelectorAll('a').forEach(a => {
|
||||
a.href += h
|
||||
})
|
||||
d.querySelectorAll('form').forEach(form => {
|
||||
form.action += h
|
||||
})
|
||||
})()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -119,7 +120,15 @@ set.resolve = {
|
||||
|
||||
// FIXME: set force to false per default when correctly implemented in
|
||||
// UI.
|
||||
export async function restart({ bypassBackupCheck = false, host, force = false, suspendResidentVms }) {
|
||||
export async function restart({
|
||||
bypassBackupCheck = false,
|
||||
host,
|
||||
force = false,
|
||||
suspendResidentVms,
|
||||
|
||||
bypassBlockedSuspend = force,
|
||||
bypassCurrentVmCheck = force,
|
||||
}) {
|
||||
if (bypassBackupCheck) {
|
||||
log.warn('host.restart with argument "bypassBackupCheck" set to true', { hostId: host.id })
|
||||
} else {
|
||||
@@ -127,7 +136,9 @@ export async function restart({ bypassBackupCheck = false, host, force = false,
|
||||
}
|
||||
|
||||
const xapi = this.getXapi(host)
|
||||
return suspendResidentVms ? xapi.host_smartReboot(host._xapiRef) : xapi.rebootHost(host._xapiId, force)
|
||||
return suspendResidentVms
|
||||
? xapi.host_smartReboot(host._xapiRef, bypassBlockedSuspend, bypassCurrentVmCheck)
|
||||
: xapi.rebootHost(host._xapiId, force)
|
||||
}
|
||||
|
||||
restart.description = 'restart the host'
|
||||
@@ -137,6 +148,14 @@ restart.params = {
|
||||
type: 'boolean',
|
||||
optional: true,
|
||||
},
|
||||
bypassBlockedSuspend: {
|
||||
type: 'boolean',
|
||||
optional: true,
|
||||
},
|
||||
bypassCurrentVmCheck: {
|
||||
type: 'boolean',
|
||||
optional: true,
|
||||
},
|
||||
id: { type: 'string' },
|
||||
force: {
|
||||
type: 'boolean',
|
||||
@@ -456,3 +475,73 @@ setControlDomainMemory.params = {
|
||||
setControlDomainMemory.resolve = {
|
||||
host: ['id', 'host', 'administrate'],
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
/**
|
||||
*
|
||||
* @param {{host:HOST}} params
|
||||
* @returns null if plugin is not installed or don't have the method
|
||||
* an object device: status on success
|
||||
*/
|
||||
export function getSmartctlHealth({ host }) {
|
||||
return this.getXapi(host).getSmartctlHealth(host._xapiId)
|
||||
}
|
||||
|
||||
getSmartctlHealth.description = 'get smartctl health status'
|
||||
|
||||
getSmartctlHealth.params = {
|
||||
id: { type: 'string' },
|
||||
}
|
||||
|
||||
getSmartctlHealth.resolve = {
|
||||
host: ['id', 'host', 'view'],
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{host:HOST}} params
|
||||
* @returns null if plugin is not installed or don't have the method
|
||||
* an object device: full device information on success
|
||||
*/
|
||||
export function getSmartctlInformation({ host, deviceNames }) {
|
||||
return this.getXapi(host).getSmartctlInformation(host._xapiId, deviceNames)
|
||||
}
|
||||
|
||||
getSmartctlInformation.description = 'get smartctl information'
|
||||
|
||||
getSmartctlInformation.params = {
|
||||
id: { type: 'string' },
|
||||
|
||||
deviceNames: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
optional: true,
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
29
packages/xo-server/src/api/vtpm.mjs
Normal file
29
packages/xo-server/src/api/vtpm.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
export async function create({ vm }) {
|
||||
const xapi = this.getXapi(vm)
|
||||
const vtpmRef = await xapi.VTPM_create({ VM: vm._xapiRef })
|
||||
return xapi.getField('VTPM', vtpmRef, 'uuid')
|
||||
}
|
||||
|
||||
create.description = 'create a VTPM'
|
||||
|
||||
create.params = {
|
||||
id: { type: 'string' },
|
||||
}
|
||||
|
||||
create.resolve = {
|
||||
vm: ['id', 'VM', 'administrate'],
|
||||
}
|
||||
|
||||
export async function destroy({ vtpm }) {
|
||||
await this.getXapi(vtpm).call('VTPM.destroy', vtpm._xapiRef)
|
||||
}
|
||||
|
||||
destroy.description = 'destroy a VTPM'
|
||||
|
||||
destroy.params = {
|
||||
id: { type: 'string' },
|
||||
}
|
||||
|
||||
destroy.resolve = {
|
||||
vtpm: ['id', 'VTPM', 'administrate'],
|
||||
}
|
||||
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 } = 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ const TRANSFORMS = {
|
||||
},
|
||||
suspendSr: link(obj, 'suspend_image_SR'),
|
||||
zstdSupported: obj.restrictions.restrict_zstd_export === 'false',
|
||||
vtpmSupported: obj.restrictions.restrict_vtpm === 'false',
|
||||
|
||||
// TODO
|
||||
// - ? networks = networksByPool.items[pool.id] (network.$pool.id)
|
||||
@@ -413,6 +414,7 @@ const TRANSFORMS = {
|
||||
suspendSr: link(obj, 'suspend_SR'),
|
||||
tags: obj.tags,
|
||||
VIFs: link(obj, 'VIFs'),
|
||||
VTPMs: link(obj, 'VTPMs'),
|
||||
virtualizationMode: domainType,
|
||||
|
||||
// deprecated, use pvDriversVersion instead
|
||||
@@ -509,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,
|
||||
@@ -841,6 +844,14 @@ const TRANSFORMS = {
|
||||
vgpus: link(obj, 'VGPUs'),
|
||||
}
|
||||
},
|
||||
|
||||
vtpm(obj) {
|
||||
return {
|
||||
type: 'VTPM',
|
||||
|
||||
vm: link(obj, 'VM'),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -12,6 +12,7 @@ import mixin from '@xen-orchestra/mixin/legacy.js'
|
||||
import ms from 'ms'
|
||||
import noop from 'lodash/noop.js'
|
||||
import once from 'lodash/once.js'
|
||||
import pick from 'lodash/pick.js'
|
||||
import tarStream from 'tar-stream'
|
||||
import uniq from 'lodash/uniq.js'
|
||||
import { asyncMap } from '@xen-orchestra/async-map'
|
||||
@@ -65,6 +66,7 @@ export default class Xapi extends XapiBase {
|
||||
maxUncoalescedVdis,
|
||||
restartHostTimeout,
|
||||
vdiExportConcurrency,
|
||||
vmEvacuationConcurrency,
|
||||
vmExportConcurrency,
|
||||
vmMigrationConcurrency = 3,
|
||||
vmSnapshotConcurrency,
|
||||
@@ -75,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'])
|
||||
@@ -191,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)
|
||||
@@ -1428,4 +1445,34 @@ export default class Xapi extends XapiBase {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getSmartctlHealth(hostId) {
|
||||
try {
|
||||
return JSON.parse(await this.call('host.call_plugin', this.getObject(hostId).$ref, 'smartctl.py', 'health', {}))
|
||||
} catch (error) {
|
||||
if (error.code === 'XENAPI_MISSING_PLUGIN' || error.code === 'UNKNOWN_XENAPI_PLUGIN_FUNCTION') {
|
||||
return null
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getSmartctlInformation(hostId, deviceNames) {
|
||||
try {
|
||||
const informations = JSON.parse(
|
||||
await this.call('host.call_plugin', this.getObject(hostId).$ref, 'smartctl.py', 'information', {})
|
||||
)
|
||||
if (deviceNames === undefined) {
|
||||
return informations
|
||||
}
|
||||
return pick(informations, deviceNames)
|
||||
} catch (error) {
|
||||
if (error.code === 'XENAPI_MISSING_PLUGIN' || error.code === 'UNKNOWN_XENAPI_PLUGIN_FUNCTION') {
|
||||
return null
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ const listMissingPatches = debounceWithKey(_listMissingPatches, LISTING_DEBOUNCE
|
||||
// =============================================================================
|
||||
|
||||
export default {
|
||||
// raw { uuid: patch } map translated from updates.xensource.com/XenServer/updates.xml
|
||||
// raw { uuid: patch } map translated from updates.ops.xenserver.com/xenserver/updates.xml
|
||||
// FIXME: should be static
|
||||
@decorateWith(debounceWithKey, 24 * 60 * 60 * 1000, function () {
|
||||
return this
|
||||
@@ -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
|
||||
@@ -629,7 +629,13 @@ export default {
|
||||
continue
|
||||
}
|
||||
|
||||
const residentVms = host.$resident_VMs.map(vm => vm.uuid)
|
||||
|
||||
for (const vmId of vmIds) {
|
||||
if (residentVms.includes(vmId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await this.migrateVm(vmId, this, hostId)
|
||||
} catch (err) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { parseDuration } from '@vates/parse-duration'
|
||||
import patch from '../patch.mjs'
|
||||
import { Tokens } from '../models/token.mjs'
|
||||
import { forEach, generateToken } from '../utils.mjs'
|
||||
import { replace } from '../sensitive-values.mjs'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -14,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 => {
|
||||
@@ -85,7 +79,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(
|
||||
@@ -136,41 +130,57 @@ export default class {
|
||||
}
|
||||
|
||||
async authenticateUser(credentials, userData) {
|
||||
// don't even attempt to authenticate with empty password
|
||||
const { password } = credentials
|
||||
if (password === '') {
|
||||
throw new Error('empty password')
|
||||
}
|
||||
const { tasks } = this._app
|
||||
const task = await tasks.create(
|
||||
{
|
||||
type: 'xo:authentication:authenticateUser',
|
||||
name: 'XO user authentication',
|
||||
credentials: replace(credentials),
|
||||
userData,
|
||||
},
|
||||
{
|
||||
// only keep trace of failed attempts
|
||||
clearLogOnSuccess: true,
|
||||
}
|
||||
)
|
||||
|
||||
// TODO: remove when email has been replaced by username.
|
||||
if (credentials.email) {
|
||||
credentials.username = credentials.email
|
||||
} else if (credentials.username) {
|
||||
credentials.email = credentials.username
|
||||
}
|
||||
return task.run(async () => {
|
||||
// don't even attempt to authenticate with empty password
|
||||
const { password } = credentials
|
||||
if (password === '') {
|
||||
throw new Error('empty password')
|
||||
}
|
||||
|
||||
const failures = this._failures
|
||||
// TODO: remove when email has been replaced by username.
|
||||
if (credentials.email) {
|
||||
credentials.username = credentials.email
|
||||
} else if (credentials.username) {
|
||||
credentials.email = credentials.username
|
||||
}
|
||||
|
||||
const { username } = credentials
|
||||
const now = Date.now()
|
||||
let lastFailure
|
||||
if (username && (lastFailure = failures[username]) && lastFailure + this._throttlingDelay > now) {
|
||||
throw new Error('too fast authentication tries')
|
||||
}
|
||||
const failures = this._failures
|
||||
|
||||
const result = await this._authenticateUser(credentials, userData)
|
||||
if (result === undefined) {
|
||||
failures[username] = now
|
||||
throw invalidCredentials()
|
||||
}
|
||||
const { username } = credentials
|
||||
const now = Date.now()
|
||||
let lastFailure
|
||||
if (username && (lastFailure = failures[username]) && lastFailure + this._throttlingDelay > now) {
|
||||
throw new Error('too fast authentication tries')
|
||||
}
|
||||
|
||||
delete failures[username]
|
||||
return result
|
||||
const result = await this._authenticateUser(credentials, userData)
|
||||
if (result === undefined) {
|
||||
failures[username] = now
|
||||
throw invalidCredentials()
|
||||
}
|
||||
|
||||
delete failures[username]
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
async createAuthenticationToken({ description, expiresIn, userId }) {
|
||||
async createAuthenticationToken({ client, description, expiresIn, userId }) {
|
||||
let duration = this._defaultTokenValidity
|
||||
if (expiresIn !== undefined) {
|
||||
duration = parseDuration(expiresIn)
|
||||
@@ -181,8 +191,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(),
|
||||
@@ -217,8 +246,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
|
||||
}
|
||||
@@ -244,8 +271,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,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user