Compare commits
3 Commits
xo6/ui-tre
...
trustCerti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa57fbf15b | ||
|
|
f89ef1db40 | ||
|
|
fe4568bfe4 |
@@ -65,11 +65,10 @@ module.exports = {
|
||||
typescript: true,
|
||||
'eslint-import-resolver-custom-alias': {
|
||||
alias: {
|
||||
'@core': '../web-core/lib',
|
||||
'@': './src',
|
||||
},
|
||||
extensions: ['.ts'],
|
||||
packages: ['@xen-orchestra/lite', '@xen-orchestra/web'],
|
||||
packages: ['@xen-orchestra/lite'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -160,10 +160,10 @@ export class ImportVmBackup {
|
||||
// update the stream with the negative vhd stream
|
||||
stream = await negativeVhd.stream()
|
||||
vdis[vdiRef].baseVdi = snapshotCandidate
|
||||
} catch (error) {
|
||||
} catch (err) {
|
||||
// can be a broken VHD chain, a vhd chain with a key backup, ....
|
||||
// not an irrecuperable error, don't dispose parentVhd, and fallback to full restore
|
||||
warn(`can't use differential restore`, { error })
|
||||
warn(`can't use differential restore`, err)
|
||||
disposableDescendants?.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,14 +191,13 @@ export class RemoteAdapter {
|
||||
// check if we will be allowed to merge a a vhd created in this adapter
|
||||
// with the vhd at path `path`
|
||||
async isMergeableParent(packedParentUid, path) {
|
||||
return await Disposable.use(VhdSynthetic.fromVhdChain(this.handler, path), vhd => {
|
||||
return await Disposable.use(openVhd(this.handler, path), vhd => {
|
||||
// this baseUuid is not linked with this vhd
|
||||
if (!vhd.footer.uuid.equals(packedParentUid)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// check if all the chain is composed of vhd directory
|
||||
const isVhdDirectory = vhd.checkVhdsClass(VhdDirectory)
|
||||
const isVhdDirectory = vhd instanceof VhdDirectory
|
||||
return isVhdDirectory
|
||||
? this.useVhdDirectory() && this.#getCompressionType() === vhd.compressionType
|
||||
: !this.useVhdDirectory()
|
||||
|
||||
@@ -2,7 +2,6 @@ import { asyncEach } from '@vates/async-each'
|
||||
import { decorateMethodsWith } from '@vates/decorate-with'
|
||||
import { defer } from 'golike-defer'
|
||||
import assert from 'node:assert'
|
||||
import * as UUID from 'uuid'
|
||||
import isVhdDifferencingDisk from 'vhd-lib/isVhdDifferencingDisk.js'
|
||||
import mapValues from 'lodash/mapValues.js'
|
||||
|
||||
@@ -10,48 +9,11 @@ import { AbstractRemote } from './_AbstractRemote.mjs'
|
||||
import { forkDeltaExport } from './_forkDeltaExport.mjs'
|
||||
import { IncrementalRemoteWriter } from '../_writers/IncrementalRemoteWriter.mjs'
|
||||
import { Task } from '../../Task.mjs'
|
||||
import { Disposable } from 'promise-toolbox'
|
||||
import { openVhd } from 'vhd-lib'
|
||||
import { getVmBackupDir } from '../../_getVmBackupDir.mjs'
|
||||
|
||||
class IncrementalRemoteVmBackupRunner extends AbstractRemote {
|
||||
_getRemoteWriter() {
|
||||
return IncrementalRemoteWriter
|
||||
}
|
||||
async _selectBaseVm(metadata) {
|
||||
// for each disk , get the parent
|
||||
const baseUuidToSrcVdi = new Map()
|
||||
|
||||
// no previous backup for a base( =key) backup
|
||||
if (metadata.isBase) {
|
||||
return
|
||||
}
|
||||
await asyncEach(Object.entries(metadata.vdis), async ([id, vdi]) => {
|
||||
const isDifferencing = metadata.isVhdDifferencing[`${id}.vhd`]
|
||||
if (isDifferencing) {
|
||||
const vmDir = getVmBackupDir(metadata.vm.uuid)
|
||||
const path = `${vmDir}/${metadata.vhds[id]}`
|
||||
// don't catch error : we can't recover if the source vhd are missing
|
||||
await Disposable.use(openVhd(this._sourceRemoteAdapter._handler, path), vhd => {
|
||||
baseUuidToSrcVdi.set(UUID.stringify(vhd.header.parentUuid), vdi.$snapshot_of$uuid)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const presentBaseVdis = new Map(baseUuidToSrcVdi)
|
||||
await this._callWriters(
|
||||
writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis),
|
||||
'writer.checkBaseVdis()',
|
||||
false
|
||||
)
|
||||
// check if the parent vdi are present in all the remotes
|
||||
baseUuidToSrcVdi.forEach((srcVdiUuid, baseUuid) => {
|
||||
if (!presentBaseVdis.has(baseUuid)) {
|
||||
throw new Error(`Missing vdi ${baseUuid} which is a base for a delta`)
|
||||
}
|
||||
})
|
||||
// yeah , let's go
|
||||
}
|
||||
async _run($defer) {
|
||||
const transferList = await this._computeTransferList(({ mode }) => mode === 'delta')
|
||||
await this._callWriters(async writer => {
|
||||
@@ -64,7 +26,7 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
|
||||
if (transferList.length > 0) {
|
||||
for (const metadata of transferList) {
|
||||
assert.strictEqual(metadata.mode, 'delta')
|
||||
await this._selectBaseVm(metadata)
|
||||
|
||||
await this._callWriters(writer => writer.prepare({ isBase: metadata.isBase }), 'writer.prepare()')
|
||||
const incrementalExport = await this._sourceRemoteAdapter.readIncrementalVmBackup(metadata, undefined, {
|
||||
useChain: false,
|
||||
@@ -88,17 +50,6 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
|
||||
}),
|
||||
'writer.transfer()'
|
||||
)
|
||||
// this will update parent name with the needed alias
|
||||
await this._callWriters(
|
||||
writer =>
|
||||
writer.updateUuidAndChain({
|
||||
isVhdDifferencing,
|
||||
timestamp: metadata.timestamp,
|
||||
vdis: incrementalExport.vdis,
|
||||
}),
|
||||
'writer.updateUuidAndChain()'
|
||||
)
|
||||
|
||||
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
|
||||
// for healthcheck
|
||||
this._tags = metadata.vm.tags
|
||||
|
||||
@@ -78,18 +78,6 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
|
||||
'writer.transfer()'
|
||||
)
|
||||
|
||||
// we want to control the uuid of the vhd in the chain
|
||||
// and ensure they are correctly chained
|
||||
await this._callWriters(
|
||||
writer =>
|
||||
writer.updateUuidAndChain({
|
||||
isVhdDifferencing,
|
||||
timestamp,
|
||||
vdis: deltaExport.vdis,
|
||||
}),
|
||||
'writer.updateUuidAndChain()'
|
||||
)
|
||||
|
||||
this._baseVm = exportedVm
|
||||
|
||||
if (baseVm !== undefined) {
|
||||
@@ -145,7 +133,7 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
|
||||
])
|
||||
const srcVdi = srcVdis[snapshotOf]
|
||||
if (srcVdi !== undefined) {
|
||||
baseUuidToSrcVdi.set(baseUuid, srcVdi.uuid)
|
||||
baseUuidToSrcVdi.set(baseUuid, srcVdi)
|
||||
} else {
|
||||
debug('ignore snapshot VDI because no longer present on VM', {
|
||||
vdi: baseUuid,
|
||||
@@ -166,18 +154,18 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
|
||||
}
|
||||
|
||||
const fullVdisRequired = new Set()
|
||||
baseUuidToSrcVdi.forEach((srcVdiUuid, baseUuid) => {
|
||||
baseUuidToSrcVdi.forEach((srcVdi, baseUuid) => {
|
||||
if (presentBaseVdis.has(baseUuid)) {
|
||||
debug('found base VDI', {
|
||||
base: baseUuid,
|
||||
vdi: srcVdiUuid,
|
||||
vdi: srcVdi.uuid,
|
||||
})
|
||||
} else {
|
||||
debug('missing base VDI', {
|
||||
base: baseUuid,
|
||||
vdi: srcVdiUuid,
|
||||
vdi: srcVdi.uuid,
|
||||
})
|
||||
fullVdisRequired.add(srcVdiUuid)
|
||||
fullVdisRequired.add(srcVdi.uuid)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import assert from 'node:assert'
|
||||
import mapValues from 'lodash/mapValues.js'
|
||||
import ignoreErrors from 'promise-toolbox/ignoreErrors'
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import { asyncMap } from '@xen-orchestra/async-map'
|
||||
import { chainVhd, openVhd } from 'vhd-lib'
|
||||
import { chainVhd, checkVhdChain, openVhd, VhdAbstract } from 'vhd-lib'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { decorateClass } from '@vates/decorate-with'
|
||||
import { defer } from 'golike-defer'
|
||||
import { dirname, basename } from 'node:path'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
import { formatFilenameDate } from '../../_filenameDate.mjs'
|
||||
import { getOldEntries } from '../../_getOldEntries.mjs'
|
||||
import { TAG_BASE_DELTA } from '../../_incrementalVm.mjs'
|
||||
import { Task } from '../../Task.mjs'
|
||||
|
||||
import { MixinRemoteWriter } from './_MixinRemoteWriter.mjs'
|
||||
@@ -21,45 +23,42 @@ import { Disposable } from 'promise-toolbox'
|
||||
const { warn } = createLogger('xo:backups:DeltaBackupWriter')
|
||||
|
||||
export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrementalWriter) {
|
||||
#parentVdiPaths
|
||||
#vhds
|
||||
async checkBaseVdis(baseUuidToSrcVdi) {
|
||||
this.#parentVdiPaths = {}
|
||||
const { handler } = this._adapter
|
||||
const adapter = this._adapter
|
||||
|
||||
const vdisDir = `${this._vmBackupDir}/vdis/${this._job.id}`
|
||||
|
||||
await asyncMap(baseUuidToSrcVdi, async ([baseUuid, srcVdiUuid]) => {
|
||||
let parentDestPath
|
||||
const vhdDir = `${vdisDir}/${srcVdiUuid}`
|
||||
await asyncMap(baseUuidToSrcVdi, async ([baseUuid, srcVdi]) => {
|
||||
let found = false
|
||||
try {
|
||||
const vhds = await handler.list(vhdDir, {
|
||||
const vhds = await handler.list(`${vdisDir}/${srcVdi.uuid}`, {
|
||||
filter: _ => _[0] !== '.' && _.endsWith('.vhd'),
|
||||
ignoreMissing: true,
|
||||
prependDir: true,
|
||||
})
|
||||
const packedBaseUuid = packUuid(baseUuid)
|
||||
// the last one is probably the right one
|
||||
|
||||
for (let i = vhds.length - 1; i >= 0 && parentDestPath === undefined; i--) {
|
||||
const path = vhds[i]
|
||||
await asyncMap(vhds, async path => {
|
||||
try {
|
||||
if (await adapter.isMergeableParent(packedBaseUuid, path)) {
|
||||
parentDestPath = path
|
||||
}
|
||||
await checkVhdChain(handler, path)
|
||||
// Warning, this should not be written as found = found || await adapter.isMergeableParent(packedBaseUuid, path)
|
||||
//
|
||||
// since all the checks of a path are done in parallel, found would be containing
|
||||
// only the last answer of isMergeableParent which is probably not the right one
|
||||
// this led to the support tickets https://help.vates.fr/#ticket/zoom/4751 , 4729, 4665 and 4300
|
||||
|
||||
const isMergeable = await adapter.isMergeableParent(packedBaseUuid, path)
|
||||
found = found || isMergeable
|
||||
} catch (error) {
|
||||
warn('checkBaseVdis', { error })
|
||||
await ignoreErrors.call(VhdAbstract.unlink(handler, path))
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
warn('checkBaseVdis', { error })
|
||||
}
|
||||
// no usable parent => the runner will have to decide to fall back to a full or stop backup
|
||||
if (parentDestPath === undefined) {
|
||||
if (!found) {
|
||||
baseUuidToSrcVdi.delete(baseUuid)
|
||||
} else {
|
||||
this.#parentVdiPaths[vhdDir] = parentDestPath
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -124,44 +123,6 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
|
||||
}
|
||||
}
|
||||
|
||||
async updateUuidAndChain({ isVhdDifferencing, vdis }) {
|
||||
assert.notStrictEqual(
|
||||
this.#vhds,
|
||||
undefined,
|
||||
'_transfer must be called before updateUuidAndChain for incremental backups'
|
||||
)
|
||||
|
||||
const parentVdiPaths = this.#parentVdiPaths
|
||||
const { handler } = this._adapter
|
||||
const vhds = this.#vhds
|
||||
await asyncEach(Object.entries(vdis), async ([id, vdi]) => {
|
||||
const isDifferencing = isVhdDifferencing[`${id}.vhd`]
|
||||
const path = `${this._vmBackupDir}/${vhds[id]}`
|
||||
if (isDifferencing) {
|
||||
assert.notStrictEqual(
|
||||
parentVdiPaths,
|
||||
'checkbasevdi must be called before updateUuidAndChain for incremental backups'
|
||||
)
|
||||
const parentPath = parentVdiPaths[dirname(path)]
|
||||
// we are in a incremental backup
|
||||
// we already computed the chain in checkBaseVdis
|
||||
assert.notStrictEqual(parentPath, undefined, 'A differential VHD must have a parent')
|
||||
// forbid any kind of loop
|
||||
assert.ok(basename(parentPath) < basename(path), `vhd must be sorted to be chained`)
|
||||
await chainVhd(handler, parentPath, handler, path)
|
||||
}
|
||||
|
||||
// set the correct UUID in the VHD if needed
|
||||
await Disposable.use(openVhd(handler, path), async vhd => {
|
||||
if (!vhd.footer.uuid.equals(packUuid(vdi.uuid))) {
|
||||
vhd.footer.uuid = packUuid(vdi.uuid)
|
||||
await vhd.readBlockAllocationTable() // required by writeFooter()
|
||||
await vhd.writeFooter()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async _deleteOldEntries() {
|
||||
const adapter = this._adapter
|
||||
const oldEntries = this._oldEntries
|
||||
@@ -180,10 +141,14 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
|
||||
const jobId = job.id
|
||||
const handler = adapter.handler
|
||||
|
||||
let metadataContent = await this._isAlreadyTransferred(timestamp)
|
||||
if (metadataContent !== undefined) {
|
||||
// @todo : should skip backup while being vigilant to not stuck the forked stream
|
||||
Task.info('This backup has already been transfered')
|
||||
}
|
||||
|
||||
const basename = formatFilenameDate(timestamp)
|
||||
// update this.#vhds before eventually skipping transfer, so that
|
||||
// updateUuidAndChain has all the mandatory data
|
||||
const vhds = (this.#vhds = mapValues(
|
||||
const vhds = mapValues(
|
||||
deltaExport.vdis,
|
||||
vdi =>
|
||||
`vdis/${jobId}/${
|
||||
@@ -193,15 +158,7 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
|
||||
vdi.uuid
|
||||
: vdi.$snapshot_of$uuid
|
||||
}/${adapter.getVhdFileName(basename)}`
|
||||
))
|
||||
|
||||
let metadataContent = await this._isAlreadyTransferred(timestamp)
|
||||
if (metadataContent !== undefined) {
|
||||
// skip backup while being vigilant to not stuck the forked stream
|
||||
Task.info('This backup has already been transfered')
|
||||
Object.values(deltaExport.streams).forEach(stream => stream.destroy())
|
||||
return { size: 0 }
|
||||
}
|
||||
)
|
||||
|
||||
metadataContent = {
|
||||
isVhdDifferencing,
|
||||
@@ -217,13 +174,38 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
|
||||
vm,
|
||||
vmSnapshot,
|
||||
}
|
||||
|
||||
const { size } = await Task.run({ name: 'transfer' }, async () => {
|
||||
let transferSize = 0
|
||||
await asyncEach(
|
||||
Object.keys(deltaExport.vdis),
|
||||
async id => {
|
||||
Object.entries(deltaExport.vdis),
|
||||
async ([id, vdi]) => {
|
||||
const path = `${this._vmBackupDir}/${vhds[id]}`
|
||||
|
||||
const isDifferencing = isVhdDifferencing[`${id}.vhd`]
|
||||
let parentPath
|
||||
if (isDifferencing) {
|
||||
const vdiDir = dirname(path)
|
||||
parentPath = (
|
||||
await handler.list(vdiDir, {
|
||||
filter: filename => filename[0] !== '.' && filename.endsWith('.vhd'),
|
||||
prependDir: true,
|
||||
})
|
||||
)
|
||||
.sort()
|
||||
.pop()
|
||||
|
||||
assert.notStrictEqual(
|
||||
parentPath,
|
||||
undefined,
|
||||
`missing parent of ${id} in ${dirname(path)}, looking for ${vdi.other_config[TAG_BASE_DELTA]}`
|
||||
)
|
||||
|
||||
parentPath = parentPath.slice(1) // remove leading slash
|
||||
|
||||
// TODO remove when this has been done before the export
|
||||
await checkVhd(handler, parentPath)
|
||||
}
|
||||
|
||||
// don't write it as transferSize += await async function
|
||||
// since i += await asyncFun lead to race condition
|
||||
// as explained : https://eslint.org/docs/latest/rules/require-atomic-updates
|
||||
@@ -235,6 +217,17 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
|
||||
writeBlockConcurrency: this._config.writeBlockConcurrency,
|
||||
})
|
||||
transferSize += transferSizeOneDisk
|
||||
|
||||
if (isDifferencing) {
|
||||
await chainVhd(handler, parentPath, handler, path)
|
||||
}
|
||||
|
||||
// set the correct UUID in the VHD
|
||||
await Disposable.use(openVhd(handler, path), async vhd => {
|
||||
vhd.footer.uuid = packUuid(vdi.uuid)
|
||||
await vhd.readBlockAllocationTable() // required by writeFooter()
|
||||
await vhd.writeFooter()
|
||||
})
|
||||
},
|
||||
{
|
||||
concurrency: settings.diskPerVmConcurrency,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import assert from 'node:assert'
|
||||
import { asyncMap, asyncMapSettled } from '@xen-orchestra/async-map'
|
||||
import ignoreErrors from 'promise-toolbox/ignoreErrors'
|
||||
import { formatDateTime } from '@xen-orchestra/xapi'
|
||||
@@ -15,7 +14,6 @@ import find from 'lodash/find.js'
|
||||
|
||||
export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
|
||||
async checkBaseVdis(baseUuidToSrcVdi, baseVm) {
|
||||
assert.notStrictEqual(baseVm, undefined)
|
||||
const sr = this._sr
|
||||
const replicatedVm = listReplicatedVms(sr.$xapi, this._job.id, sr.uuid, this._vmUuid).find(
|
||||
vm => vm.other_config[TAG_COPY_SRC] === baseVm.uuid
|
||||
@@ -38,9 +36,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
||||
}
|
||||
}
|
||||
}
|
||||
updateUuidAndChain() {
|
||||
// nothing to do, the chaining is not modified in this case
|
||||
}
|
||||
|
||||
prepare({ isFull }) {
|
||||
// create the task related to this export and ensure all methods are called in this context
|
||||
const task = new Task({
|
||||
|
||||
@@ -5,10 +5,6 @@ export class AbstractIncrementalWriter extends AbstractWriter {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
updateUuidAndChain() {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
@@ -113,13 +113,13 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
|
||||
)
|
||||
}
|
||||
|
||||
async _isAlreadyTransferred(timestamp) {
|
||||
_isAlreadyTransferred(timestamp) {
|
||||
const vmUuid = this._vmUuid
|
||||
const adapter = this._adapter
|
||||
const backupDir = getVmBackupDir(vmUuid)
|
||||
try {
|
||||
const actualMetadata = JSON.parse(
|
||||
await adapter._handler.readFile(`${backupDir}/${formatFilenameDate(timestamp)}.json`)
|
||||
adapter._handler.readFile(`${backupDir}/${formatFilenameDate(timestamp)}.json`)
|
||||
)
|
||||
return actualMetadata
|
||||
} catch (error) {}
|
||||
|
||||
@@ -230,7 +230,6 @@ Settings are described in [`@xen-orchestra/backups/\_runners/VmsXapi.mjs``](http
|
||||
- `checkBaseVdis(baseUuidToSrcVdi, baseVm)`
|
||||
- `prepare({ isFull })`
|
||||
- `transfer({ timestamp, deltaExport, sizeContainers })`
|
||||
- `updateUuidAndChain({ isVhdDifferencing, vdis })`
|
||||
- `cleanup()`
|
||||
- `healthCheck()` // is not executed if no health check sr or tag doesn't match
|
||||
- **Full**
|
||||
|
||||
@@ -20,7 +20,5 @@ export function split(path) {
|
||||
return parts
|
||||
}
|
||||
|
||||
// paths are made absolute otherwise fs.relative() would resolve them against working directory
|
||||
export const relativeFromFile = (file, path) => relative(dirname(normalize(file)), normalize(path))
|
||||
|
||||
export const relativeFromFile = (file, path) => relative(dirname(file), path)
|
||||
export const resolveFromFile = (file, path) => resolve('/', dirname(file), path).slice(1)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, it } from 'test'
|
||||
import { strict as assert } from 'assert'
|
||||
|
||||
import { relativeFromFile } from './path.js'
|
||||
|
||||
describe('relativeFromFile()', function () {
|
||||
for (const [title, args] of Object.entries({
|
||||
'file absolute and path absolute': ['/foo/bar/file.vhd', '/foo/baz/path.vhd'],
|
||||
'file relative and path absolute': ['foo/bar/file.vhd', '/foo/baz/path.vhd'],
|
||||
'file absolute and path relative': ['/foo/bar/file.vhd', 'foo/baz/path.vhd'],
|
||||
'file relative and path relative': ['foo/bar/file.vhd', 'foo/baz/path.vhd'],
|
||||
})) {
|
||||
it('works with ' + title, function () {
|
||||
assert.equal(relativeFromFile(...args), '../baz/path.vhd')
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -54,10 +54,10 @@ async function handleExistingFile(root, indexPath, path) {
|
||||
await indexFile(fullPath, indexPath)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') {
|
||||
} catch (err) {
|
||||
if (err.code !== 'EEXIST') {
|
||||
// there can be a symbolic link in the tree
|
||||
warn('handleExistingFile', { error })
|
||||
warn('handleExistingFile', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export async function watchRemote(remoteId, { root, immutabilityDuration, rebuil
|
||||
await File.liftImmutability(settingPath)
|
||||
} catch (error) {
|
||||
// file may not exists, and it's not really a problem
|
||||
info('lifting immutability on current settings', { error })
|
||||
info('lifting immutability on current settings', error)
|
||||
}
|
||||
await fs.writeFile(
|
||||
settingPath,
|
||||
|
||||
@@ -50,17 +50,7 @@ const RRD_POINTS_PER_STEP: { [key in RRD_STEP]: number } = {
|
||||
// Utils
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function parseNumber(value: number | string) {
|
||||
// Starting from XAPI 23.31, numbers in the JSON payload are encoded as
|
||||
// strings to support NaN, Infinity and -Infinity
|
||||
if (typeof value === 'string') {
|
||||
const asNumber = +value
|
||||
if (isNaN(asNumber) && value !== 'NaN') {
|
||||
throw new Error('cannot parse number: ' + value)
|
||||
}
|
||||
value = asNumber
|
||||
}
|
||||
|
||||
function convertNanToNull(value: number) {
|
||||
return isNaN(value) ? null : value
|
||||
}
|
||||
|
||||
@@ -69,7 +59,7 @@ function parseNumber(value: number | string) {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const computeValues = (dataRow: any, legendIndex: number, transformValue = identity) =>
|
||||
map(dataRow, ({ values }) => transformValue(parseNumber(values[legendIndex])))
|
||||
map(dataRow, ({ values }) => transformValue(convertNanToNull(values[legendIndex])))
|
||||
|
||||
const createGetProperty = (obj: object, property: string, defaultValue: unknown) =>
|
||||
defaults(obj, { [property]: defaultValue })[property] as any
|
||||
@@ -329,14 +319,8 @@ export default class XapiStats {
|
||||
},
|
||||
abortSignal,
|
||||
})
|
||||
const text = await resp.text()
|
||||
try {
|
||||
// starting from XAPI 23.31, the response is valid JSON
|
||||
return JSON.parse(text)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line import/no-named-as-default-member -- https://github.com/json5/json5/issues/287
|
||||
return JSON5.parse(text)
|
||||
}
|
||||
// eslint-disable-next-line import/no-named-as-default-member -- https://github.com/json5/json5/issues/287
|
||||
return JSON5.parse(await resp.text())
|
||||
}
|
||||
|
||||
// To avoid multiple requests, we keep a cache for the stats and
|
||||
@@ -399,10 +383,7 @@ export default class XapiStats {
|
||||
abortSignal,
|
||||
})
|
||||
|
||||
const actualStep = parseNumber(json.meta.step)
|
||||
if (actualStep !== step) {
|
||||
throw new FaultyGranularity(`Unable to get the true granularity: ${actualStep}`)
|
||||
}
|
||||
const actualStep = json.meta.step as number
|
||||
|
||||
if (json.data.length > 0) {
|
||||
// fetched data is organized from the newest to the oldest
|
||||
@@ -426,15 +407,14 @@ export default class XapiStats {
|
||||
|
||||
let stepStats = xoObjectStats[actualStep]
|
||||
let cacheStepStats = cacheXoObjectStats[actualStep]
|
||||
const endTimestamp = parseNumber(json.meta.end)
|
||||
if (stepStats === undefined || stepStats.endTimestamp !== endTimestamp) {
|
||||
if (stepStats === undefined || stepStats.endTimestamp !== json.meta.end) {
|
||||
stepStats = xoObjectStats[actualStep] = {
|
||||
endTimestamp,
|
||||
endTimestamp: json.meta.end,
|
||||
interval: actualStep,
|
||||
canBeExpired: false,
|
||||
}
|
||||
cacheStepStats = cacheXoObjectStats[actualStep] = {
|
||||
endTimestamp,
|
||||
endTimestamp: json.meta.end,
|
||||
interval: actualStep,
|
||||
canBeExpired: true,
|
||||
}
|
||||
@@ -458,6 +438,10 @@ export default class XapiStats {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (actualStep !== step) {
|
||||
throw new FaultyGranularity(`Unable to get the true granularity: ${actualStep}`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "../web-core/lib/**/*", "../web-core/lib/**/*.vue"],
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"rootDir": "..",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@core/*": ["../web-core/lib/*"]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
'@core': fileURLToPath(new URL('../web-core/lib', import.meta.url)),
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -27,16 +27,6 @@ log.error('could not join server', {
|
||||
})
|
||||
```
|
||||
|
||||
A logging method has the following signature:
|
||||
|
||||
```ts
|
||||
interface LoggingMethod {
|
||||
(error): void
|
||||
|
||||
(message: string, data?: { error?: Error; [property: string]: any }): void
|
||||
}
|
||||
```
|
||||
|
||||
### Consumer
|
||||
|
||||
Then, at application level, configure the logs are handled:
|
||||
|
||||
@@ -45,16 +45,6 @@ log.error('could not join server', {
|
||||
})
|
||||
```
|
||||
|
||||
A logging method has the following signature:
|
||||
|
||||
```ts
|
||||
interface LoggingMethod {
|
||||
(error): void
|
||||
|
||||
(message: string, data?: { error?: Error; [property: string]: any }): void
|
||||
}
|
||||
```
|
||||
|
||||
### Consumer
|
||||
|
||||
Then, at application level, configure the logs are handled:
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<UiIcon :class="className" :icon="icon" class="power-state-icon" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from '@core/components/icon/UiIcon.vue'
|
||||
import type { POWER_STATE } from '@core/types/power-state.type'
|
||||
import { faMoon, faPause, faPlay, faStop } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
state: POWER_STATE
|
||||
}>()
|
||||
|
||||
const icons = {
|
||||
running: faPlay,
|
||||
paused: faPause,
|
||||
suspended: faMoon,
|
||||
halted: faStop,
|
||||
}
|
||||
|
||||
const icon = computed(() => icons[props.state])
|
||||
|
||||
const className = computed(() => `state-${props.state}`)
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.power-state-icon {
|
||||
&.state-suspended {
|
||||
color: var(--color-purple-d60);
|
||||
}
|
||||
|
||||
&.state-running {
|
||||
color: var(--color-green-base);
|
||||
}
|
||||
|
||||
&.state-paused {
|
||||
color: var(--color-purple-l40);
|
||||
}
|
||||
|
||||
&.state-halted {
|
||||
color: var(--color-red-base);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +0,0 @@
|
||||
<template>
|
||||
<li class="tree-item">
|
||||
<slot />
|
||||
<slot v-if="isExpanded" name="sublist" />
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { IK_TREE_ITEM_EXPANDED, IK_TREE_ITEM_HAS_CHILDREN, IK_TREE_ITEM_TOGGLE } from '@core/utils/injection-keys.util'
|
||||
import { useToggle } from '@vueuse/core'
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
const slots = defineSlots<{
|
||||
default: () => void
|
||||
sublist: () => void
|
||||
}>()
|
||||
|
||||
const [isExpanded, toggle] = useToggle(true)
|
||||
|
||||
const hasChildren = computed(() => slots.sublist !== undefined)
|
||||
provide(IK_TREE_ITEM_HAS_CHILDREN, hasChildren)
|
||||
provide(IK_TREE_ITEM_TOGGLE, toggle)
|
||||
provide(IK_TREE_ITEM_EXPANDED, isExpanded)
|
||||
</script>
|
||||
@@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<li class="tree-item-error">
|
||||
<slot />
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.tree-item-error {
|
||||
padding-left: 3rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.6rem;
|
||||
line-height: 150%;
|
||||
color: var(--color-red-base);
|
||||
}
|
||||
</style>
|
||||
@@ -1,133 +0,0 @@
|
||||
<template>
|
||||
<RouterLink v-slot="{ isExactActive, href, navigate }" :to="route" custom>
|
||||
<div
|
||||
:class="isExactActive ? 'exact-active' : $props.active ? 'active' : undefined"
|
||||
:style="{ paddingLeft: `${depth * 20}px` }"
|
||||
class="tree-item-label"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<UiIcon v-if="hasToggle" :icon="isExpanded ? faAngleDown : faAngleRight" @click="toggle()" />
|
||||
<a v-tooltip="hasTooltip" :href="href" class="link" @click="navigate">
|
||||
<slot name="icon">
|
||||
<UiIcon :icon="icon" class="icon" />
|
||||
</slot>
|
||||
<div ref="textElement" class="text">
|
||||
<slot />
|
||||
</div>
|
||||
</a>
|
||||
<div class="actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from '@core/components/icon/UiIcon.vue'
|
||||
import { vTooltip } from '@core/directives/tooltip.directive'
|
||||
import { hasEllipsis } from '@core/utils/has-ellipsis.util'
|
||||
import {
|
||||
IK_TREE_ITEM_EXPANDED,
|
||||
IK_TREE_ITEM_HAS_CHILDREN,
|
||||
IK_TREE_ITEM_TOGGLE,
|
||||
IK_TREE_LIST_DEPTH,
|
||||
} from '@core/utils/injection-keys.util'
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
|
||||
import { faAngleDown, faAngleRight } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
defineProps<{
|
||||
icon?: IconDefinition
|
||||
route: RouteLocationRaw
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const textElement = ref<HTMLElement>()
|
||||
const hasTooltip = computed(() => hasEllipsis(textElement.value))
|
||||
|
||||
const hasToggle = inject(
|
||||
IK_TREE_ITEM_HAS_CHILDREN,
|
||||
computed(() => false)
|
||||
)
|
||||
|
||||
const toggle = inject(IK_TREE_ITEM_TOGGLE, () => undefined)
|
||||
const isExpanded = inject(IK_TREE_ITEM_EXPANDED, ref(true))
|
||||
|
||||
const depth = inject(IK_TREE_LIST_DEPTH, 0)
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.tree-item-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-grey-100);
|
||||
border-radius: 0.8rem;
|
||||
background-color: var(--background-color-primary);
|
||||
column-gap: 0.4rem;
|
||||
padding: 0 0.8rem;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-grey-100);
|
||||
background-color: var(--background-color-purple-20);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--background-color-purple-30);
|
||||
}
|
||||
|
||||
&.exact-active {
|
||||
background-color: var(--background-color-purple-10);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-color-purple-20);
|
||||
}
|
||||
|
||||
> .ui-icon {
|
||||
color: var(--color-purple-base);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--background-color-purple-30);
|
||||
}
|
||||
}
|
||||
|
||||
> .ui-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.8rem 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
gap: 1.2rem;
|
||||
font-weight: 500;
|
||||
font-size: 2rem;
|
||||
|
||||
&:hover,
|
||||
.icon {
|
||||
color: var(--color-grey-100);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 1.4rem;
|
||||
padding-inline-end: 0.4rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<ul class="tree-list">
|
||||
<slot />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { IK_TREE_LIST_DEPTH } from '@core/utils/injection-keys.util'
|
||||
import { inject, provide } from 'vue'
|
||||
|
||||
const depth = inject(IK_TREE_LIST_DEPTH, 0)
|
||||
provide(IK_TREE_LIST_DEPTH, depth + 1)
|
||||
</script>
|
||||
@@ -1,47 +0,0 @@
|
||||
<!-- Adapted from https://www.benmvp.com/blog/how-to-create-circle-svg-gradient-loading-spinner/ -->
|
||||
|
||||
<template>
|
||||
<svg class="ui-spinner" fill="none" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient :id="secondHalfId">
|
||||
<stop offset="0%" stop-color="currentColor" stop-opacity="0" />
|
||||
<stop offset="100%" stop-color="currentColor" stop-opacity="0.5" />
|
||||
</linearGradient>
|
||||
<linearGradient :id="firstHalfId">
|
||||
<stop offset="0%" stop-color="currentColor" stop-opacity="1" />
|
||||
<stop offset="100%" stop-color="currentColor" stop-opacity="0.5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g stroke-width="40">
|
||||
<path :stroke="`url(#${secondHalfId})`" d="M 30 200 A 170 170 180 0 1 370 200" />
|
||||
<path :stroke="`url(#${firstHalfId})`" d="M 370 200 A 170 170 0 0 1 30 200" />
|
||||
<path d="M 30 200 A 170 170 180 0 1 30 200" stroke="currentColor" stroke-linecap="round" />
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { uniqueId } from '@core/utils/unique-id.util'
|
||||
|
||||
const firstHalfId = uniqueId('spinner-first-half-')
|
||||
const secondHalfId = uniqueId('spinner-second-half-')
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-spinner {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +0,0 @@
|
||||
<template>
|
||||
<UiSpinner v-if="busy" class="ui-icon" />
|
||||
<FontAwesomeIcon v-else-if="icon !== undefined" :fixed-width="fixedWidth" :icon="icon" class="ui-icon" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiSpinner from '@core/components/UiSpinner.vue'
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
defineProps<{
|
||||
busy?: boolean
|
||||
icon?: IconDefinition
|
||||
fixedWidth?: boolean
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,29 +0,0 @@
|
||||
<template>
|
||||
<FontAwesomeLayers class="vm-icon">
|
||||
<UiIcon :icon="faDisplay" />
|
||||
<PowerStateIcon :state="state" />
|
||||
</FontAwesomeLayers>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PowerStateIcon from '@core/components/PowerStateIcon.vue'
|
||||
import UiIcon from '@core/components/icon/UiIcon.vue'
|
||||
import type { POWER_STATE } from '@core/types/power-state.type'
|
||||
import { faDisplay } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
defineProps<{
|
||||
state: POWER_STATE
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.vm-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.power-state-icon {
|
||||
font-size: 0.7em;
|
||||
transform: translate(80%, 70%);
|
||||
}
|
||||
</style>
|
||||
@@ -1,65 +0,0 @@
|
||||
# Tooltip Directive
|
||||
|
||||
By default, the tooltip will appear centered above the target element.
|
||||
|
||||
## Directive argument
|
||||
|
||||
The directive argument can be either:
|
||||
|
||||
- The tooltip content
|
||||
- An object containing the tooltip content and/or placement: `{ content: "...", placement: "..." }` (both optional)
|
||||
|
||||
## Tooltip content
|
||||
|
||||
The tooltip content can be either:
|
||||
|
||||
- `false` or an empty-string to disable the tooltip
|
||||
- `true` or `undefined` to enable the tooltip and extract its content from the element's innerText.
|
||||
- Non-empty string to enable the tooltip and use the string as content.
|
||||
|
||||
## Tooltip placement
|
||||
|
||||
Tooltip can be placed on the following positions:
|
||||
|
||||
- `top`
|
||||
- `top-start`
|
||||
- `top-end`
|
||||
- `bottom`
|
||||
- `bottom-start`
|
||||
- `bottom-end`
|
||||
- `left`
|
||||
- `left-start`
|
||||
- `left-end`
|
||||
- `right`
|
||||
- `right-start`
|
||||
- `right-end`
|
||||
|
||||
## Usage
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Boolean / Undefined -->
|
||||
<span v-tooltip="true">This content will be ellipsized by CSS but displayed entirely in the tooltip</span>
|
||||
<span v-tooltip>This content will be ellipsized by CSS but displayed entirely in the tooltip</span>
|
||||
|
||||
<!-- String -->
|
||||
<span v-tooltip="'Tooltip content'">Item</span>
|
||||
|
||||
<!-- Object -->
|
||||
<span v-tooltip="{ content: 'Foobar', placement: 'left-end' }">Item</span>
|
||||
|
||||
<!-- Dynamic -->
|
||||
<span v-tooltip="myTooltip">Item</span>
|
||||
|
||||
<!-- Conditional -->
|
||||
<span v-tooltip="isTooltipEnabled && 'Foobar'">Item</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { vTooltip } from '@/directives/tooltip.directive'
|
||||
|
||||
const myTooltip = ref('Content') // or ref({ content: "Content", placement: "left-end" })
|
||||
const isTooltipEnabled = ref(true)
|
||||
</script>
|
||||
```
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { TooltipEvents, TooltipOptions } from '@core/stores/tooltip.store'
|
||||
import { useTooltipStore } from '@core/stores/tooltip.store'
|
||||
import { isObject } from 'lodash-es'
|
||||
import type { Options } from 'placement.js'
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
type TooltipDirectiveContent = undefined | boolean | string
|
||||
|
||||
type TooltipDirectiveOptions =
|
||||
| TooltipDirectiveContent
|
||||
| {
|
||||
content?: TooltipDirectiveContent
|
||||
placement?: Options['placement']
|
||||
}
|
||||
|
||||
const parseOptions = (options: TooltipDirectiveOptions, target: HTMLElement): TooltipOptions => {
|
||||
const { placement, content } = isObject(options) ? options : { placement: undefined, content: options }
|
||||
|
||||
return {
|
||||
placement,
|
||||
content: content === true || content === undefined ? target.innerText.trim() : content,
|
||||
}
|
||||
}
|
||||
|
||||
export const vTooltip: Directive<HTMLElement, TooltipDirectiveOptions> = {
|
||||
mounted(target, binding) {
|
||||
const store = useTooltipStore()
|
||||
|
||||
const events: TooltipEvents = binding.modifiers.focus
|
||||
? { on: 'focusin', off: 'focusout' }
|
||||
: { on: 'mouseenter', off: 'mouseleave' }
|
||||
|
||||
store.register(target, parseOptions(binding.value, target), events)
|
||||
},
|
||||
updated(target, binding) {
|
||||
const store = useTooltipStore()
|
||||
store.updateOptions(target, parseOptions(binding.value, target))
|
||||
},
|
||||
beforeUnmount(target) {
|
||||
const store = useTooltipStore()
|
||||
store.unregister(target)
|
||||
},
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useEventListener, type WindowEventName } from '@vueuse/core'
|
||||
import { uniqueId } from '@core/utils/unique-id.util'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { Options } from 'placement.js'
|
||||
import { computed, type EffectScope, effectScope, ref } from 'vue'
|
||||
|
||||
export type TooltipOptions = {
|
||||
content: string | false
|
||||
placement: Options['placement']
|
||||
}
|
||||
|
||||
export type TooltipEvents = { on: WindowEventName; off: WindowEventName }
|
||||
|
||||
export const useTooltipStore = defineStore('tooltip', () => {
|
||||
const targetsScopes = new WeakMap<HTMLElement, EffectScope>()
|
||||
const targets = ref(new Set<HTMLElement>())
|
||||
const targetsOptions = ref(new Map<HTMLElement, TooltipOptions>())
|
||||
const targetsIds = ref(new Map<HTMLElement, string>())
|
||||
|
||||
const register = (target: HTMLElement, options: TooltipOptions, events: TooltipEvents) => {
|
||||
const scope = effectScope()
|
||||
|
||||
targetsScopes.set(target, scope)
|
||||
targetsOptions.value.set(target, options)
|
||||
targetsIds.value.set(target, uniqueId('tooltip-'))
|
||||
|
||||
scope.run(() => {
|
||||
useEventListener(target, events.on, () => {
|
||||
targets.value.add(target)
|
||||
|
||||
scope.run(() => {
|
||||
useEventListener(
|
||||
target,
|
||||
events.off,
|
||||
() => {
|
||||
targets.value.delete(target)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const updateOptions = (target: HTMLElement, options: TooltipOptions) => {
|
||||
targetsOptions.value.set(target, options)
|
||||
}
|
||||
|
||||
const unregister = (target: HTMLElement) => {
|
||||
targets.value.delete(target)
|
||||
targetsOptions.value.delete(target)
|
||||
targetsScopes.get(target)?.stop()
|
||||
targetsScopes.delete(target)
|
||||
targetsIds.value.delete(target)
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
unregister,
|
||||
updateOptions,
|
||||
tooltips: computed(() => {
|
||||
return Array.from(targets.value.values()).map(target => {
|
||||
return {
|
||||
target,
|
||||
options: targetsOptions.value.get(target)!,
|
||||
key: targetsIds.value.get(target)!,
|
||||
}
|
||||
})
|
||||
}),
|
||||
}
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export type POWER_STATE = 'running' | 'paused' | 'halted' | 'suspended'
|
||||
@@ -1,11 +0,0 @@
|
||||
export const hasEllipsis = (target: Element | undefined | null, { vertical = false }: { vertical?: boolean } = {}) => {
|
||||
if (target == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (vertical) {
|
||||
return target.clientHeight < target.scrollHeight
|
||||
}
|
||||
|
||||
return target.clientWidth < target.scrollWidth
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { ComputedRef, InjectionKey, Ref } from 'vue'
|
||||
|
||||
export const IK_TREE_ITEM_HAS_CHILDREN = Symbol('IK_TREE_ITEM_HAS_CHILDREN') as InjectionKey<ComputedRef<boolean>>
|
||||
|
||||
export const IK_TREE_ITEM_TOGGLE = Symbol('IK_TREE_ITEM_TOGGLE') as InjectionKey<(force?: boolean) => void>
|
||||
|
||||
export const IK_TREE_ITEM_EXPANDED = Symbol('IK_TREE_ITEM_EXPANDED') as InjectionKey<Ref<boolean>>
|
||||
|
||||
export const IK_TREE_LIST_DEPTH = Symbol('IK_TREE_LIST_DEPTH') as InjectionKey<number>
|
||||
@@ -1,8 +0,0 @@
|
||||
const uniqueIds = new Map<string | undefined, number>()
|
||||
|
||||
export const uniqueId = (prefix?: string) => {
|
||||
const id = uniqueIds.get(prefix) || 0
|
||||
uniqueIds.set(prefix, id + 1)
|
||||
|
||||
return prefix !== undefined ? `${prefix}-${id}` : `${id}`
|
||||
}
|
||||
@@ -10,18 +10,7 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.5.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.5",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"@vueuse/core": "^10.7.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"pinia": "^2.1.7",
|
||||
"placement.js": "^1.0.0-beta.5",
|
||||
"vue": "^3.4.13",
|
||||
"vue-router": "^4.2.5"
|
||||
"vue": "^3.4.13"
|
||||
},
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/web-core",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
@@ -36,6 +25,6 @@
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=8.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "lib/**/*", "lib/**/*.vue"],
|
||||
"exclude": ["lib/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@core/*": ["./lib/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,13 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": [
|
||||
"env.d.ts",
|
||||
"typed-router.d.ts",
|
||||
"src/**/*",
|
||||
"src/**/*.vue",
|
||||
"../web-core/lib/**/*",
|
||||
"../web-core/lib/**/*.vue"
|
||||
],
|
||||
"include": ["env.d.ts", "typed-router.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"rootDir": "..",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@core/*": ["../web-core/lib/*"]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
'@core': fileURLToPath(new URL('../web-core/lib', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -21,23 +21,12 @@ export default class Vif {
|
||||
MAC = '',
|
||||
} = {}
|
||||
) {
|
||||
if (device === undefined) {
|
||||
const allowedDevices = await this.call('VM.get_allowed_VIF_devices', VM)
|
||||
if (allowedDevices.length === 0) {
|
||||
const error = new Error('could not find an allowed VIF device')
|
||||
error.poolUuid = this.pool.uuid
|
||||
error.vmRef = VM
|
||||
throw error
|
||||
}
|
||||
|
||||
device = allowedDevices[0]
|
||||
}
|
||||
|
||||
const [powerState, ...rest] = await Promise.all([
|
||||
this.getField('VM', VM, 'power_state'),
|
||||
MTU ?? this.getField('network', network, 'MTU'),
|
||||
device ?? (await this.call('VM.get_allowed_VIF_devices', VM))[0],
|
||||
MTU ?? (await this.getField('network', network, 'MTU')),
|
||||
])
|
||||
;[MTU] = rest
|
||||
;[device, MTU] = rest
|
||||
|
||||
const vifRef = await this.call('VIF.create', {
|
||||
currently_attached: powerState === 'Suspended' ? currently_attached : undefined,
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -1,28 +1,5 @@
|
||||
# ChangeLog
|
||||
|
||||
## **5.91.2** (2024-02-09)
|
||||
|
||||
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [REST API] Add `/groups` collection [Forum#70500](https://xcp-ng.org/forum/post/70500)
|
||||
- [REST API] Add `/groups/:id/users` and `/users/:id/groups` collection [Forum#70500](https://xcp-ng.org/forum/post/70500)
|
||||
- [REST API] Expose messages associated to XAPI objects at `/:collection/:object/messages`
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Import/VMWare] Fix `(Failure \"Expected string, got 'I(0)'\")` (PR [#7361](https://github.com/vatesfr/xen-orchestra/issues/7361))
|
||||
- [Plugin/load-balancer] Fixing `TypeError: Cannot read properties of undefined (reading 'high')` happening when trying to optimize a host with performance plan [#7359](https://github.com/vatesfr/xen-orchestra/issues/7359) (PR [#7362](https://github.com/vatesfr/xen-orchestra/pull/7362))
|
||||
- Changing the number of displayed items per page should send back to the first page [#7350](https://github.com/vatesfr/xen-orchestra/issues/7350)
|
||||
- [Plugin/load-balancer] Correctly create a _simple_ instead of a _density_ plan when it is selected (PR [#7358](https://github.com/vatesfr/xen-orchestra/pull/7358))
|
||||
|
||||
### Released packages
|
||||
|
||||
- xo-server 5.136.0
|
||||
- xo-server-load-balancer 0.8.1
|
||||
- xo-web 5.136.1
|
||||
|
||||
## **5.91.1** (2024-02-06)
|
||||
|
||||
### Bug fixes
|
||||
@@ -41,6 +18,8 @@
|
||||
|
||||
## **5.91.0** (2024-01-31)
|
||||
|
||||
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
|
||||
|
||||
### Highlights
|
||||
|
||||
- [Import/VMWare] Speed up import and make all imports thin [#7323](https://github.com/vatesfr/xen-orchestra/issues/7323)
|
||||
|
||||
@@ -7,22 +7,13 @@
|
||||
|
||||
> Users must be able to say: “Nice enhancement, I'm eager to test it”
|
||||
|
||||
- Disable search engine indexing via a `robots.txt`
|
||||
- [Stats] Support format used by XAPI 23.31
|
||||
- [REST API] Export host [SMT](https://en.wikipedia.org/wiki/Simultaneous_multithreading) status at `/hosts/:id/smt` [Forum#71374](https://xcp-ng.org/forum/post/71374)
|
||||
- [Home & REST API] `$container` field of an halted VM now points to a host if a VDI is on a local storage [Forum#71769](https://xcp-ng.org/forum/post/71769)
|
||||
- [Size Input] Ability to select two new units in the dropdown (`TiB`, `PiB`) (PR [#7382](https://github.com/vatesfr/xen-orchestra/pull/7382))
|
||||
|
||||
|
||||
### Bug fixes
|
||||
|
||||
> Users must be able to say: “I had this issue, happy to know it's fixed”
|
||||
|
||||
- [Settings/XO Config] Sort backups from newest to oldest
|
||||
- [Plugins/audit] Don't log `tag.getAllConfigured` calls
|
||||
- [Remotes] Correctly clear error when the remote is tested with success
|
||||
- [Import/VMWare] Fix importing last snapshot (PR [#7370](https://github.com/vatesfr/xen-orchestra/pull/7370))
|
||||
- [Host/Reboot] Fix false positive warning when restarting an host after updates (PR [#7366](https://github.com/vatesfr/xen-orchestra/pull/7366))
|
||||
- [Import/VMWare] Fix `(Failure \"Expected string, got 'I(0)'\")` (PR [#7361](https://github.com/vatesfr/xen-orchestra/issues/7361))
|
||||
- [Plugin/load-balancer] Fixing `TypeError: Cannot read properties of undefined (reading 'high')` happening when trying to optimize a host with performance plan [#7359](https://github.com/vatesfr/xen-orchestra/issues/7359) (PR [#7362](https://github.com/vatesfr/xen-orchestra/pull/7362))
|
||||
- Changing the number of displayed items per page should send back to the first page [#7350](https://github.com/vatesfr/xen-orchestra/issues/7350)
|
||||
|
||||
### Packages to release
|
||||
|
||||
@@ -40,12 +31,8 @@
|
||||
|
||||
<!--packages-start-->
|
||||
|
||||
- @xen-orchestra/backups patch
|
||||
- @xen-orchestra/fs patch
|
||||
- @xen-orchestra/xapi patch
|
||||
- vhd-lib patch
|
||||
- xo-server minor
|
||||
- xo-server-audit patch
|
||||
- xo-web minor
|
||||
- xo-server patch
|
||||
- xo-server-load-balancer patch
|
||||
- xo-web patch
|
||||
|
||||
<!--packages-end-->
|
||||
|
||||
@@ -34,8 +34,9 @@ But it's not the only way to see this: there is multiple possibilities to "optim
|
||||
|
||||
- maybe you want to spread the VM load on the maximum number of server, to get the most of your hardware? (previous example)
|
||||
- maybe you want to reduce power consumption and migrate your VMs to the minimum number of hosts possible? (and shutdown useless hosts)
|
||||
- or maybe both, depending of your own schedule?
|
||||
|
||||
Those ways can be also called modes: "performance" for 1 and "density" for number 2.
|
||||
Those ways can be also called modes: "performance" for 1, "density" for number 2 and "mixed" for the last.
|
||||
|
||||
## Configure a plan
|
||||
|
||||
@@ -46,6 +47,7 @@ A plan has:
|
||||
- a name
|
||||
- pool(s) where to apply the policy
|
||||
- a mode (see paragraph below)
|
||||
- a behavior (aggressive, normal, low)
|
||||
|
||||
### Plan modes
|
||||
|
||||
@@ -53,7 +55,7 @@ There are 3 modes possible:
|
||||
|
||||
- performance
|
||||
- density
|
||||
- simple
|
||||
- mixed
|
||||
|
||||
#### Performance
|
||||
|
||||
@@ -63,9 +65,14 @@ VMs are placed to use all possible resources. This means balance the load to giv
|
||||
|
||||
This time, the objective is to use the least hosts possible, and to concentrate your VMs. In this mode, you can choose to shutdown unused (and compatible) hosts.
|
||||
|
||||
#### Simple
|
||||
#### Mixed
|
||||
|
||||
This mode allows you to use VM anti-affinity without using any load balancing mechanism. (see paragraph below)
|
||||
This mode allows you to use both performance and density, but alternatively, depending of a schedule. E.g:
|
||||
|
||||
- **performance** from 6:00 AM to 7:00 PM
|
||||
- **density** from 7:01 PM to 5:59 AM
|
||||
|
||||
In this case, you'll have the best of both when needed (energy saving during the night and performance during the day).
|
||||
|
||||
### Threshold
|
||||
|
||||
@@ -80,10 +87,6 @@ If the CPU threshold is set to 90%, the load balancer will be only triggered if
|
||||
|
||||
For free memory, it will be triggered if there is **less** free RAM than the threshold.
|
||||
|
||||
### Exclusion
|
||||
|
||||
If you want to prevent load balancing from triggering migrations on a particular host or VM, it is possible to exclude it from load balancing. It can be configured via the "Excluded hosts" parameter in each plan, and in the "Ignored VM tags" parameter which is common to every plan.
|
||||
|
||||
### Timing
|
||||
|
||||
The global situation (resource usage) is examined **every minute**.
|
||||
|
||||
15
docs/xoa.md
15
docs/xoa.md
@@ -93,21 +93,6 @@ Follow the instructions:
|
||||
|
||||
You can also download XOA from xen-orchestra.com in an XVA file. Once you've got the XVA file, you can import it with `xe vm-import filename=xoa_unified.xva` or via XenCenter.
|
||||
|
||||
If you want to use static IP address for your appliance:
|
||||
|
||||
```sh
|
||||
xe vm-param-set uuid="$uuid" \
|
||||
xenstore-data:vm-data/ip="$ip" \
|
||||
xenstore-data:vm-data/netmask="$netmask" \
|
||||
xenstore-data:vm-data/gateway="$gateway"
|
||||
```
|
||||
|
||||
If you want to replace the default DNS server:
|
||||
|
||||
```sh
|
||||
xe vm-param-set uuid="$uuid" xenstore-data:vm-data/dns="$dns"
|
||||
```
|
||||
|
||||
After the VM is imported, you just need to start it with `xe vm-start vm="XOA"` or with XenCenter.
|
||||
|
||||
## First console connection
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"name": "xen-orchestra",
|
||||
"version": "0.0.0",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.0.0",
|
||||
"@babel/eslint-parser": "^7.13.8",
|
||||
@@ -96,7 +94,7 @@
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "TURBO_TELEMETRY_DISABLED=1 turbo run build --scope xo-server --scope xo-server-'*' --scope xo-web",
|
||||
"build": "turbo run build --scope xo-server --scope xo-server-'*' --scope xo-web",
|
||||
"build:xo-lite": "turbo run build --scope @xen-orchestra/lite",
|
||||
"clean": "scripts/run-script.js --parallel clean",
|
||||
"dev": "scripts/run-script.js --parallel --concurrency 0 --verbose dev",
|
||||
|
||||
@@ -46,9 +46,9 @@ const VhdSynthetic = class VhdSynthetic extends VhdAbstract {
|
||||
}
|
||||
|
||||
get compressionType() {
|
||||
const compressionType = this.#vhds[0].compressionType
|
||||
for (let i = 0; i < this.#vhds.length; i++) {
|
||||
if (compressionType !== this.#vhds[i].compressionType) {
|
||||
const compressionType = this.vhds[0].compressionType
|
||||
for (let i = 0; i < this.vhds.length; i++) {
|
||||
if (compressionType !== this.vhds[i].compressionType) {
|
||||
return 'MIXED'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const { relativeFromFile } = require('@xen-orchestra/fs/path')
|
||||
const { dirname, relative } = require('path')
|
||||
|
||||
const { openVhd } = require('./openVhd')
|
||||
const { DISK_TYPES } = require('./_constants')
|
||||
@@ -21,7 +21,7 @@ module.exports = async function chain(parentHandler, parentPath, childHandler, c
|
||||
}
|
||||
await childVhd.readBlockAllocationTable()
|
||||
|
||||
const parentName = relativeFromFile(childPath, parentPath)
|
||||
const parentName = relative(dirname(childPath), parentPath)
|
||||
header.parentUuid = parentVhd.footer.uuid
|
||||
header.parentUnicodeName = parentName
|
||||
await childVhd.setUniqueParentLocator(parentName)
|
||||
|
||||
@@ -72,7 +72,6 @@ const DEFAULT_BLOCKED_LIST = {
|
||||
'system.getServerTimezone': true,
|
||||
'system.getServerVersion': true,
|
||||
'system.getVersion': true,
|
||||
'tag.getAllConfigured': true,
|
||||
'test.getPermissionsForUser': true,
|
||||
'user.getAll': true,
|
||||
'user.getAuthenticationTokens': true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server-load-balancer",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Load balancer for XO-Server",
|
||||
"keywords": [
|
||||
|
||||
@@ -12,8 +12,6 @@ import { EXECUTION_DELAY, debug } from './utils'
|
||||
|
||||
const PERFORMANCE_MODE = 0
|
||||
const DENSITY_MODE = 1
|
||||
const SIMPLE_MODE = 2
|
||||
const MODES = { 'Performance mode': PERFORMANCE_MODE, 'Density mode': DENSITY_MODE, 'Simple mode': SIMPLE_MODE }
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -37,7 +35,7 @@ export const configurationSchema = {
|
||||
},
|
||||
|
||||
mode: {
|
||||
enum: Object.keys(MODES),
|
||||
enum: ['Performance mode', 'Density mode', 'Simple mode'],
|
||||
title: 'Mode',
|
||||
},
|
||||
|
||||
@@ -149,7 +147,7 @@ class LoadBalancerPlugin {
|
||||
|
||||
if (plans) {
|
||||
for (const plan of plans) {
|
||||
this._addPlan(MODES[plan.mode], plan)
|
||||
this._addPlan(plan.mode === 'Performance mode' ? PERFORMANCE_MODE : DENSITY_MODE, plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,6 @@ port = 80
|
||||
requestTimeout = 0
|
||||
|
||||
[http.mounts]
|
||||
'/robots.txt' = './robots.txt'
|
||||
'/' = '../xo-web/dist/'
|
||||
'/v6' = '../../@xen-orchestra/web/dist/'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "xo-server",
|
||||
"version": "5.136.0",
|
||||
"version": "5.135.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Server part of Xen-Orchestra",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -27,7 +27,7 @@ async function sendToNagios(app, jobName, vmBackupInfo) {
|
||||
jobName
|
||||
)
|
||||
} catch (error) {
|
||||
warn('sendToNagios:', { error })
|
||||
warn('sendToNagios:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import semver from 'semver'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import assert from 'assert'
|
||||
import { format } from 'json-rpc-peer'
|
||||
@@ -137,38 +136,13 @@ export async function restart({
|
||||
const pool = this.getObject(host.$poolId, 'pool')
|
||||
const master = this.getObject(pool.master, 'host')
|
||||
const hostRebootRequired = host.rebootRequired
|
||||
|
||||
// we are currently in an host upgrade process
|
||||
if (hostRebootRequired && host.id !== master.id) {
|
||||
// this error is not ideal but it means that the pool master must be fully upgraded/rebooted before the current host can be rebooted.
|
||||
//
|
||||
// there is a single error for the 3 cases because the client must handle them the same way
|
||||
const throwError = () =>
|
||||
incorrectState({
|
||||
actual: hostRebootRequired,
|
||||
expected: false,
|
||||
object: master.id,
|
||||
property: 'rebootRequired',
|
||||
})
|
||||
|
||||
if (semver.lt(master.version, host.version)) {
|
||||
log.error(`master version (${master.version}) is older than the host version (${host.version})`, {
|
||||
masterId: master.id,
|
||||
hostId: host.id,
|
||||
})
|
||||
throwError()
|
||||
}
|
||||
|
||||
if (semver.eq(master.version, host.version)) {
|
||||
if ((await this.getXapi(host).listMissingPatches(master._xapiId)).length > 0) {
|
||||
log.error('master has missing patches', { masterId: master.id })
|
||||
throwError()
|
||||
}
|
||||
if (master.rebootRequired) {
|
||||
log.error('master needs to reboot')
|
||||
throwError()
|
||||
}
|
||||
}
|
||||
if (hostRebootRequired && host.id !== master.id && host.version === master.version) {
|
||||
throw incorrectState({
|
||||
actual: hostRebootRequired,
|
||||
expected: false,
|
||||
object: master.id,
|
||||
property: 'rebootRequired',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
export async function scan({ host }) {
|
||||
await this.getXapi(host).call('PUSB.scan', host._xapiRef)
|
||||
}
|
||||
|
||||
scan.params = {
|
||||
host: { type: 'string' },
|
||||
}
|
||||
scan.resolve = {
|
||||
host: ['host', 'host', 'operate'],
|
||||
}
|
||||
|
||||
export async function set({ pusb, enabled }) {
|
||||
const xapi = this.getXapi(pusb)
|
||||
|
||||
if (enabled !== undefined && enabled !== pusb.passthroughEnabled) {
|
||||
await xapi.call('PUSB.set_passthrough_enabled', pusb._xapiRef, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
set.params = {
|
||||
id: { type: 'string' },
|
||||
enabled: { type: 'boolean', optional: true },
|
||||
}
|
||||
|
||||
set.resolve = {
|
||||
pusb: ['id', 'PUSB', 'administrate'],
|
||||
}
|
||||
@@ -328,34 +328,6 @@ const TRANSFORMS = {
|
||||
|
||||
const { creation } = xoData.extract(obj) ?? {}
|
||||
|
||||
let $container
|
||||
if (obj.resident_on !== 'OpaqueRef:NULL') {
|
||||
// resident_on is set when the VM is running (or paused or suspended on a host)
|
||||
$container = link(obj, 'resident_on')
|
||||
} else {
|
||||
// if the VM is halted, the $container is the pool
|
||||
$container = link(obj, 'pool')
|
||||
|
||||
// unless one of its VDI is on a non shared SR
|
||||
//
|
||||
// linked objects may not be there when this code run, and it will only be
|
||||
// refreshed when the VM XAPI record change, this value is not guaranteed
|
||||
// to be up-to-date, but it practice it appears to work fine thanks to
|
||||
// `VBDs` and `current_operations` changing when a VDI is
|
||||
// added/removed/migrated
|
||||
for (const vbd of obj.$VBDs) {
|
||||
const sr = vbd?.$VDI?.$SR
|
||||
if (sr !== undefined && !sr.shared) {
|
||||
const pbd = sr.$PBDs[0]
|
||||
const hostId = pbd && link(pbd, 'host')
|
||||
if (hostId !== undefined) {
|
||||
$container = hostId
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const vm = {
|
||||
// type is redefined after for controllers/, templates &
|
||||
// snapshots.
|
||||
@@ -450,7 +422,8 @@ const TRANSFORMS = {
|
||||
xenTools,
|
||||
...getVmGuestToolsProps(obj),
|
||||
|
||||
$container,
|
||||
// TODO: handle local VMs (`VM.get_possible_hosts()`).
|
||||
$container: isRunning ? link(obj, 'resident_on') : link(obj, 'pool'),
|
||||
$VBDs: link(obj, 'VBDs'),
|
||||
|
||||
// TODO: dedupe
|
||||
@@ -889,17 +862,6 @@ const TRANSFORMS = {
|
||||
vm: link(obj, 'VM'),
|
||||
}
|
||||
},
|
||||
|
||||
pusb(obj) {
|
||||
return {
|
||||
type: 'PUSB',
|
||||
|
||||
description: obj.description,
|
||||
host: link(obj, 'host'),
|
||||
passthroughEnabled: obj.passthrough_enabled,
|
||||
usbGroup: link(obj, 'USB_group'),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -45,17 +45,7 @@ const RRD_POINTS_PER_STEP = {
|
||||
// Utils
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function parseNumber(value) {
|
||||
// Starting from XAPI 23.31, numbers in the JSON payload are encoded as
|
||||
// strings to support NaN, Infinity and -Infinity
|
||||
if (typeof value === 'string') {
|
||||
const asNumber = +value
|
||||
if (isNaN(asNumber) && value !== 'NaN') {
|
||||
throw new Error('cannot parse number: ' + value)
|
||||
}
|
||||
value = asNumber
|
||||
}
|
||||
|
||||
function convertNanToNull(value) {
|
||||
return isNaN(value) ? null : value
|
||||
}
|
||||
|
||||
@@ -68,7 +58,7 @@ async function getServerTimestamp(xapi, hostRef) {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const computeValues = (dataRow, legendIndex, transformValue = identity) =>
|
||||
map(dataRow, ({ values }) => transformValue(parseNumber(values[legendIndex])))
|
||||
map(dataRow, ({ values }) => transformValue(convertNanToNull(values[legendIndex])))
|
||||
|
||||
const combineStats = (stats, path, combineValues) => zipWith(...map(stats, path), (...values) => combineValues(values))
|
||||
|
||||
@@ -255,15 +245,7 @@ export default class XapiStats {
|
||||
start: timestamp,
|
||||
},
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
try {
|
||||
// starting from XAPI 23.31, the response is valid JSON
|
||||
return JSON.parse(data)
|
||||
} catch (_) {
|
||||
return JSON5.parse(data)
|
||||
}
|
||||
})
|
||||
.then(response => response.text().then(JSON5.parse))
|
||||
.catch(err => {
|
||||
delete this.#hostCache[hostUuid][step]
|
||||
throw err
|
||||
@@ -317,7 +299,7 @@ export default class XapiStats {
|
||||
// To avoid crossing over the boundary, we ask for one less step
|
||||
const optimumTimestamp = currentTimeStamp - maxDuration + step
|
||||
const json = await this._getJson(xapi, host, optimumTimestamp, step)
|
||||
const actualStep = parseNumber(json.meta.step)
|
||||
const actualStep = json.meta.step
|
||||
|
||||
if (actualStep !== step) {
|
||||
throw new FaultyGranularity(`Unable to get the true granularity: ${actualStep}`)
|
||||
@@ -344,10 +326,9 @@ export default class XapiStats {
|
||||
return
|
||||
}
|
||||
|
||||
const endTimestamp = parseNumber(json.meta.end)
|
||||
if (stepStats === undefined || stepStats.endTimestamp !== endTimestamp) {
|
||||
if (stepStats === undefined || stepStats.endTimestamp !== json.meta.end) {
|
||||
stepStats = {
|
||||
endTimestamp,
|
||||
endTimestamp: json.meta.end,
|
||||
interval: actualStep,
|
||||
stats: {},
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ export default class MigrateVm {
|
||||
const stream = vhd.stream()
|
||||
await vdi.$importContent(stream, { format: VDI_FORMAT_VHD })
|
||||
}
|
||||
return { vdi, vhd }
|
||||
return vhd
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncMapSettled from '@xen-orchestra/async-map/legacy.js'
|
||||
import { basename } from 'path'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { format, parse } from 'xo-remote-parser'
|
||||
import {
|
||||
DEFAULT_ENCRYPTION_ALGORITHM,
|
||||
@@ -18,35 +17,17 @@ import { Remotes } from '../models/remote.mjs'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const { warn } = createLogger('xo:mixins:remotes')
|
||||
|
||||
const obfuscateRemote = ({ url, ...remote }) => {
|
||||
const parsedUrl = parse(url)
|
||||
remote.url = format(sensitiveValues.obfuscate(parsedUrl))
|
||||
return remote
|
||||
}
|
||||
|
||||
// these properties should be defined on the remote object itself and not as
|
||||
// part of the remote URL
|
||||
//
|
||||
// there is a bug somewhere that keep putting them into the URL, this list
|
||||
// is here to help track it
|
||||
const INVALID_URL_PARAMS = ['benchmarks', 'id', 'info', 'name', 'proxy', 'enabled', 'error', 'url']
|
||||
|
||||
function validateUrl(url) {
|
||||
const parsedUrl = parse(url)
|
||||
|
||||
const { path } = parsedUrl
|
||||
function validatePath(url) {
|
||||
const { path } = parse(url)
|
||||
if (path !== undefined && basename(path) === 'xo-vm-backups') {
|
||||
throw invalidParameters('remote url should not end with xo-vm-backups')
|
||||
}
|
||||
|
||||
for (const param of INVALID_URL_PARAMS) {
|
||||
if (Object.hasOwn(parsedUrl, param)) {
|
||||
// log with stack trace
|
||||
warn(new Error('invalid remote URL param ' + param))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class {
|
||||
@@ -201,22 +182,6 @@ export default class {
|
||||
if (remote === undefined) {
|
||||
throw noSuchObject(id, 'remote')
|
||||
}
|
||||
|
||||
const parsedUrl = parse(remote.url)
|
||||
let fixed = false
|
||||
for (const param of INVALID_URL_PARAMS) {
|
||||
if (Object.hasOwn(parsedUrl, param)) {
|
||||
// delete the value to trace its real origin when it's added back
|
||||
// with `updateRemote()`
|
||||
delete parsedUrl[param]
|
||||
fixed = true
|
||||
}
|
||||
}
|
||||
if (fixed) {
|
||||
remote.url = format(parsedUrl)
|
||||
this._remotes.update(remote).catch(warn)
|
||||
}
|
||||
|
||||
return remote
|
||||
}
|
||||
|
||||
@@ -237,7 +202,7 @@ export default class {
|
||||
}
|
||||
|
||||
async createRemote({ name, options, proxy, url }) {
|
||||
validateUrl(url)
|
||||
validatePath(url)
|
||||
|
||||
const params = {
|
||||
enabled: false,
|
||||
@@ -254,10 +219,6 @@ export default class {
|
||||
}
|
||||
|
||||
updateRemote(id, { enabled, name, options, proxy, url }) {
|
||||
if (url !== undefined) {
|
||||
validateUrl(url)
|
||||
}
|
||||
|
||||
const handlers = this._handlers
|
||||
const handler = handlers[id]
|
||||
if (handler !== undefined) {
|
||||
@@ -277,7 +238,7 @@ export default class {
|
||||
@synchronized()
|
||||
async _updateRemote(id, { url, ...props }) {
|
||||
if (url !== undefined) {
|
||||
validateUrl(url)
|
||||
validatePath(url)
|
||||
}
|
||||
|
||||
const remote = await this._getRemote(id)
|
||||
|
||||
@@ -100,17 +100,6 @@ async function sendObjects(iterable, req, res, path = req.path) {
|
||||
return pipeline(makeObjectsStream(iterable, makeResult, json, res), res)
|
||||
}
|
||||
|
||||
function handleArray(array, filter, limit) {
|
||||
if (filter !== undefined) {
|
||||
array = array.filter(filter)
|
||||
}
|
||||
if (limit < array.length) {
|
||||
array.length = limit
|
||||
}
|
||||
|
||||
return array
|
||||
}
|
||||
|
||||
const handleOptionalUserFilter = filter => filter && CM.parse(filter).createPredicate()
|
||||
|
||||
const subRouter = (app, path) => {
|
||||
@@ -171,7 +160,77 @@ export default class RestApi {
|
||||
)
|
||||
})
|
||||
|
||||
const collections = { __proto__: null }
|
||||
const types = [
|
||||
'host',
|
||||
'network',
|
||||
'pool',
|
||||
'SR',
|
||||
'VBD',
|
||||
'VDI-snapshot',
|
||||
'VDI',
|
||||
'VIF',
|
||||
'VM-snapshot',
|
||||
'VM-template',
|
||||
'VM',
|
||||
]
|
||||
const collections = Object.fromEntries(
|
||||
types.map(type => {
|
||||
const id = type.toLocaleLowerCase() + 's'
|
||||
return [id, { id, isCorrectType: _ => _.type === type, type }]
|
||||
})
|
||||
)
|
||||
|
||||
collections.backup = { id: 'backup' }
|
||||
collections.restore = { id: 'restore' }
|
||||
collections.tasks = { id: 'tasks' }
|
||||
collections.users = { id: 'users' }
|
||||
|
||||
collections.hosts.routes = {
|
||||
__proto__: null,
|
||||
|
||||
async 'audit.txt'(req, res) {
|
||||
const host = req.xapiObject
|
||||
|
||||
res.setHeader('content-type', 'text/plain')
|
||||
await pipeline(await host.$xapi.getResource('/audit_log', { host }), compressMaybe(req, res))
|
||||
},
|
||||
|
||||
async 'logs.tar'(req, res) {
|
||||
const host = req.xapiObject
|
||||
|
||||
res.setHeader('content-type', 'application/x-tar')
|
||||
await pipeline(await host.$xapi.getResource('/host_logs_download', { host }), compressMaybe(req, res))
|
||||
},
|
||||
|
||||
async missing_patches(req, res) {
|
||||
await app.checkFeatureAuthorization('LIST_MISSING_PATCHES')
|
||||
|
||||
const host = req.xapiObject
|
||||
res.json(await host.$xapi.listMissingPatches(host))
|
||||
},
|
||||
}
|
||||
|
||||
collections.pools.routes = {
|
||||
__proto__: null,
|
||||
|
||||
async missing_patches(req, res) {
|
||||
await app.checkFeatureAuthorization('LIST_MISSING_PATCHES')
|
||||
|
||||
const xapi = req.xapiObject.$xapi
|
||||
const missingPatches = new Map()
|
||||
await asyncEach(Object.values(xapi.objects.indexes.type.host ?? {}), async host => {
|
||||
try {
|
||||
for (const patch of await xapi.listMissingPatches(host)) {
|
||||
const { uuid: key = `${patch.name}-${patch.version}-${patch.release}` } = patch
|
||||
missingPatches.set(key, patch)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(host.uuid, error)
|
||||
}
|
||||
})
|
||||
res.json(Array.from(missingPatches.values()))
|
||||
},
|
||||
}
|
||||
|
||||
const withParams = (fn, paramsSchema) => {
|
||||
fn.params = paramsSchema
|
||||
@@ -179,235 +238,68 @@ export default class RestApi {
|
||||
return fn
|
||||
}
|
||||
|
||||
{
|
||||
const types = [
|
||||
'host',
|
||||
'message',
|
||||
'network',
|
||||
'pool',
|
||||
'SR',
|
||||
'VBD',
|
||||
'VDI-snapshot',
|
||||
'VDI',
|
||||
'VIF',
|
||||
'VM-snapshot',
|
||||
'VM-template',
|
||||
'VM',
|
||||
]
|
||||
function getObject(id, req) {
|
||||
const { type } = this
|
||||
const object = app.getObject(id, type)
|
||||
collections.pools.actions = {
|
||||
__proto__: null,
|
||||
|
||||
// add also the XAPI version of the object
|
||||
req.xapiObject = app.getXapiObject(object)
|
||||
create_vm: withParams(
|
||||
defer(async ($defer, { xapiObject: { $xapi } }, { affinity, boot, install, template, ...params }, req) => {
|
||||
params.affinityHost = affinity
|
||||
params.installRepository = install?.repository
|
||||
|
||||
return object
|
||||
}
|
||||
function getObjects(filter, limit) {
|
||||
return app.getObjects({
|
||||
filter: every(this.isCorrectType, filter),
|
||||
limit,
|
||||
})
|
||||
}
|
||||
async function messages(req, res) {
|
||||
const {
|
||||
object: { id },
|
||||
query,
|
||||
} = req
|
||||
await sendObjects(
|
||||
app.getObjects({
|
||||
filter: every(_ => _.type === 'message' && _.$object === id, handleOptionalUserFilter(query.filter)),
|
||||
limit: ifDef(query.limit, Number),
|
||||
}),
|
||||
req,
|
||||
res,
|
||||
'/messages'
|
||||
)
|
||||
}
|
||||
for (const type of types) {
|
||||
const id = type.toLocaleLowerCase() + 's'
|
||||
const vm = await $xapi.createVm(template, params, undefined, req.user.id)
|
||||
$defer.onFailure.call($xapi, 'VM_destroy', vm.$ref)
|
||||
|
||||
collections[id] = { getObject, getObjects, routes: { messages }, isCorrectType: _ => _.type === type, type }
|
||||
}
|
||||
|
||||
collections.hosts.routes = {
|
||||
...collections.hosts.routes,
|
||||
|
||||
async 'audit.txt'(req, res) {
|
||||
const host = req.xapiObject
|
||||
|
||||
res.setHeader('content-type', 'text/plain')
|
||||
await pipeline(await host.$xapi.getResource('/audit_log', { host }), compressMaybe(req, res))
|
||||
},
|
||||
|
||||
async 'logs.tar'(req, res) {
|
||||
const host = req.xapiObject
|
||||
|
||||
res.setHeader('content-type', 'application/x-tar')
|
||||
await pipeline(await host.$xapi.getResource('/host_logs_download', { host }), compressMaybe(req, res))
|
||||
},
|
||||
|
||||
async missing_patches(req, res) {
|
||||
await app.checkFeatureAuthorization('LIST_MISSING_PATCHES')
|
||||
|
||||
const host = req.xapiObject
|
||||
res.json(await host.$xapi.listMissingPatches(host))
|
||||
},
|
||||
|
||||
async smt({ xapiObject }, res) {
|
||||
res.json({ enabled: await xapiObject.$xapi.isHyperThreadingEnabled(xapiObject.$id) })
|
||||
},
|
||||
}
|
||||
|
||||
collections.pools.routes = {
|
||||
...collections.pools.routes,
|
||||
|
||||
async missing_patches(req, res) {
|
||||
await app.checkFeatureAuthorization('LIST_MISSING_PATCHES')
|
||||
|
||||
const xapi = req.xapiObject.$xapi
|
||||
const missingPatches = new Map()
|
||||
await asyncEach(Object.values(xapi.objects.indexes.type.host ?? {}), async host => {
|
||||
try {
|
||||
for (const patch of await xapi.listMissingPatches(host)) {
|
||||
const { uuid: key = `${patch.name}-${patch.version}-${patch.release}` } = patch
|
||||
missingPatches.set(key, patch)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(host.uuid, error)
|
||||
}
|
||||
})
|
||||
res.json(Array.from(missingPatches.values()))
|
||||
},
|
||||
}
|
||||
|
||||
collections.pools.actions = {
|
||||
create_vm: withParams(
|
||||
defer(async ($defer, { xapiObject: { $xapi } }, { affinity, boot, install, template, ...params }, req) => {
|
||||
params.affinityHost = affinity
|
||||
params.installRepository = install?.repository
|
||||
|
||||
const vm = await $xapi.createVm(template, params, undefined, req.user.id)
|
||||
$defer.onFailure.call($xapi, 'VM_destroy', vm.$ref)
|
||||
|
||||
if (boot) {
|
||||
await $xapi.callAsync('VM.start', vm.$ref, false, false)
|
||||
}
|
||||
|
||||
return vm.uuid
|
||||
}),
|
||||
{
|
||||
affinity: { type: 'string', optional: true },
|
||||
auto_poweron: { type: 'boolean', optional: true },
|
||||
boot: { type: 'boolean', default: false },
|
||||
clone: { type: 'boolean', default: true },
|
||||
install: {
|
||||
type: 'object',
|
||||
optional: true,
|
||||
properties: {
|
||||
method: { enum: ['cdrom', 'network'] },
|
||||
repository: { type: 'string' },
|
||||
},
|
||||
},
|
||||
memory: { type: 'integer', optional: true },
|
||||
name_description: { type: 'string', minLength: 0, optional: true },
|
||||
name_label: { type: 'string' },
|
||||
template: { type: 'string' },
|
||||
if (boot) {
|
||||
await $xapi.callAsync('VM.start', vm.$ref, false, false)
|
||||
}
|
||||
),
|
||||
emergency_shutdown: async ({ xapiObject }) => {
|
||||
await app.checkFeatureAuthorization('POOL_EMERGENCY_SHUTDOWN')
|
||||
|
||||
await xapiObject.$xapi.pool_emergencyShutdown()
|
||||
},
|
||||
rolling_update: async ({ object }) => {
|
||||
await app.checkFeatureAuthorization('ROLLING_POOL_UPDATE')
|
||||
|
||||
await app.rollingPoolUpdate(object)
|
||||
},
|
||||
}
|
||||
collections.vms.actions = {
|
||||
clean_reboot: ({ xapiObject: vm }) => vm.$callAsync('clean_reboot').then(noop),
|
||||
clean_shutdown: ({ xapiObject: vm }) => vm.$callAsync('clean_shutdown').then(noop),
|
||||
hard_reboot: ({ xapiObject: vm }) => vm.$callAsync('hard_reboot').then(noop),
|
||||
hard_shutdown: ({ xapiObject: vm }) => vm.$callAsync('hard_shutdown').then(noop),
|
||||
snapshot: withParams(
|
||||
async ({ xapiObject: vm }, { name_label }) => {
|
||||
const ref = await vm.$snapshot({ name_label })
|
||||
return vm.$xapi.getField('VM', ref, 'uuid')
|
||||
return vm.uuid
|
||||
}),
|
||||
{
|
||||
affinity: { type: 'string', optional: true },
|
||||
auto_poweron: { type: 'boolean', optional: true },
|
||||
boot: { type: 'boolean', default: false },
|
||||
clone: { type: 'boolean', default: true },
|
||||
install: {
|
||||
type: 'object',
|
||||
optional: true,
|
||||
properties: {
|
||||
method: { enum: ['cdrom', 'network'] },
|
||||
repository: { type: 'string' },
|
||||
},
|
||||
},
|
||||
{ name_label: { type: 'string', optional: true } }
|
||||
),
|
||||
start: ({ xapiObject: vm }) => vm.$callAsync('start', false, false).then(noop),
|
||||
}
|
||||
}
|
||||
memory: { type: 'integer', optional: true },
|
||||
name_description: { type: 'string', minLength: 0, optional: true },
|
||||
name_label: { type: 'string' },
|
||||
template: { type: 'string' },
|
||||
}
|
||||
),
|
||||
emergency_shutdown: async ({ xapiObject }) => {
|
||||
await app.checkFeatureAuthorization('POOL_EMERGENCY_SHUTDOWN')
|
||||
|
||||
collections.backup = {}
|
||||
collections.groups = {
|
||||
getObject(id) {
|
||||
return app.getGroup(id)
|
||||
await xapiObject.$xapi.pool_emergencyShutdown()
|
||||
},
|
||||
async getObjects(filter, limit) {
|
||||
return handleArray(await app.getAllGroups(), filter, limit)
|
||||
rolling_update: async ({ xoObject }) => {
|
||||
await app.checkFeatureAuthorization('ROLLING_POOL_UPDATE')
|
||||
|
||||
await app.rollingPoolUpdate(xoObject)
|
||||
},
|
||||
routes: {
|
||||
async users(req, res) {
|
||||
const { filter, limit } = req.query
|
||||
await sendObjects(
|
||||
handleArray(
|
||||
await Promise.all(req.object.users.map(id => app.getUser(id).then(getUserPublicProperties))),
|
||||
handleOptionalUserFilter(filter),
|
||||
ifDef(limit, Number)
|
||||
),
|
||||
req,
|
||||
res,
|
||||
'/users'
|
||||
)
|
||||
}
|
||||
collections.vms.actions = {
|
||||
__proto__: null,
|
||||
|
||||
clean_reboot: ({ xapiObject: vm }) => vm.$callAsync('clean_reboot').then(noop),
|
||||
clean_shutdown: ({ xapiObject: vm }) => vm.$callAsync('clean_shutdown').then(noop),
|
||||
hard_reboot: ({ xapiObject: vm }) => vm.$callAsync('hard_reboot').then(noop),
|
||||
hard_shutdown: ({ xapiObject: vm }) => vm.$callAsync('hard_shutdown').then(noop),
|
||||
snapshot: withParams(
|
||||
async ({ xapiObject: vm }, { name_label }) => {
|
||||
const ref = await vm.$snapshot({ name_label })
|
||||
return vm.$xapi.getField('VM', ref, 'uuid')
|
||||
},
|
||||
},
|
||||
}
|
||||
collections.restore = {}
|
||||
collections.tasks = {}
|
||||
collections.users = {
|
||||
getObject(id) {
|
||||
return app.getUser(id).then(getUserPublicProperties)
|
||||
},
|
||||
async getObjects(filter, limit) {
|
||||
return handleArray(await app.getAllUsers(), filter, limit)
|
||||
},
|
||||
routes: {
|
||||
async groups(req, res) {
|
||||
const { filter, limit } = req.query
|
||||
await sendObjects(
|
||||
handleArray(
|
||||
await Promise.all(req.object.groups.map(id => app.getGroup(id))),
|
||||
handleOptionalUserFilter(filter),
|
||||
ifDef(limit, Number)
|
||||
),
|
||||
req,
|
||||
res,
|
||||
'/groups'
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// normalize collections
|
||||
for (const id of Object.keys(collections)) {
|
||||
const collection = collections[id]
|
||||
|
||||
// inject id into the collection
|
||||
collection.id = id
|
||||
|
||||
// set null as prototypes to speed-up look-ups
|
||||
Object.setPrototypeOf(collection, null)
|
||||
const { actions, routes } = collection
|
||||
if (actions !== undefined) {
|
||||
Object.setPrototypeOf(actions, null)
|
||||
}
|
||||
if (routes !== undefined) {
|
||||
Object.setPrototypeOf(routes, null)
|
||||
}
|
||||
{ name_label: { type: 'string', optional: true } }
|
||||
),
|
||||
start: ({ xapiObject: vm }) => vm.$callAsync('start', false, false).then(noop),
|
||||
}
|
||||
|
||||
api.param('collection', (req, res, next) => {
|
||||
@@ -420,14 +312,14 @@ export default class RestApi {
|
||||
next()
|
||||
}
|
||||
})
|
||||
api.param('object', async (req, res, next) => {
|
||||
api.param('object', (req, res, next) => {
|
||||
const id = req.params.object
|
||||
const { type } = req.collection
|
||||
try {
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
req.object = await req.collection.getObject(id, req)
|
||||
return next()
|
||||
req.xapiObject = app.getXapiObject((req.xoObject = app.getObject(id, type)))
|
||||
next()
|
||||
} catch (error) {
|
||||
if (noSuchObject.is(error, { id })) {
|
||||
if (noSuchObject.is(error, { id, type })) {
|
||||
next('route')
|
||||
} else {
|
||||
next(error)
|
||||
@@ -586,12 +478,39 @@ export default class RestApi {
|
||||
}, true)
|
||||
)
|
||||
|
||||
api
|
||||
.get(
|
||||
'/users',
|
||||
wrap(async (req, res) => {
|
||||
let users = await app.getAllUsers()
|
||||
|
||||
const { filter, limit } = req.query
|
||||
if (filter !== undefined) {
|
||||
users = users.filter(CM.parse(filter).createPredicate())
|
||||
}
|
||||
if (limit < users.length) {
|
||||
users.length = limit
|
||||
}
|
||||
|
||||
sendObjects(users.map(getUserPublicProperties), req, res)
|
||||
})
|
||||
)
|
||||
.get(
|
||||
'/users/:id',
|
||||
wrap(async (req, res) => {
|
||||
res.json(getUserPublicProperties(await app.getUser(req.params.id)))
|
||||
})
|
||||
)
|
||||
|
||||
api.get(
|
||||
'/:collection',
|
||||
wrap(async (req, res) => {
|
||||
const { query } = req
|
||||
await sendObjects(
|
||||
await req.collection.getObjects(handleOptionalUserFilter(query.filter), ifDef(query.limit, Number)),
|
||||
await app.getObjects({
|
||||
filter: every(req.collection.isCorrectType, handleOptionalUserFilter(query.filter)),
|
||||
limit: ifDef(query.limit, Number),
|
||||
}),
|
||||
req,
|
||||
res
|
||||
)
|
||||
@@ -644,7 +563,7 @@ export default class RestApi {
|
||||
)
|
||||
|
||||
api.get('/:collection/:object', (req, res) => {
|
||||
let result = req.object
|
||||
let result = req.xoObject
|
||||
|
||||
// add locations of sub-routes for discoverability
|
||||
const { routes } = req.collection
|
||||
@@ -699,7 +618,7 @@ export default class RestApi {
|
||||
'/:collection/:object/tasks',
|
||||
wrap(async (req, res) => {
|
||||
const { query } = req
|
||||
const objectId = req.object.id
|
||||
const objectId = req.xoObject.id
|
||||
const tasks = app.tasks.list({
|
||||
filter: every(
|
||||
_ => _.status === 'pending' && _.properties.objectId === objectId,
|
||||
@@ -739,9 +658,9 @@ export default class RestApi {
|
||||
}
|
||||
}
|
||||
|
||||
const { object, xapiObject } = req
|
||||
const task = app.tasks.create({ name: `REST: ${action} ${req.collection.type}`, objectId: object.id })
|
||||
const pResult = task.run(() => fn({ object, xapiObject }, params, req))
|
||||
const { xapiObject, xoObject } = req
|
||||
const task = app.tasks.create({ name: `REST: ${action} ${req.collection.type}`, objectId: xoObject.id })
|
||||
const pResult = task.run(() => fn({ xapiObject, xoObject }, params, req))
|
||||
if (Object.hasOwn(req.query, 'sync')) {
|
||||
pResult.then(result => res.json(result), next)
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "xo-web",
|
||||
"version": "5.136.1",
|
||||
"version": "5.136.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Web interface client for Xen-Orchestra",
|
||||
"keywords": [
|
||||
|
||||
@@ -138,7 +138,7 @@ export class Range extends Component {
|
||||
|
||||
export Toggle from './toggle'
|
||||
|
||||
const UNITS = ['kiB', 'MiB', 'GiB', 'TiB', 'PiB']
|
||||
const UNITS = ['kiB', 'MiB', 'GiB']
|
||||
const DEFAULT_UNIT = 'GiB'
|
||||
|
||||
export class SizeInput extends BaseComponent {
|
||||
|
||||
@@ -54,9 +54,13 @@ export default class IsoDevice extends Component {
|
||||
() => this.props.vm.$pool,
|
||||
() => this.props.vm.$container,
|
||||
(vmPool, vmContainer) => sr => {
|
||||
const vmRunning = vmContainer !== vmPool
|
||||
const sameHost = vmContainer === sr.$container
|
||||
const samePool = vmPool === sr.$pool
|
||||
|
||||
return (
|
||||
vmPool === sr.$pool &&
|
||||
(sr.shared || vmContainer === sr.$container) &&
|
||||
samePool &&
|
||||
(vmRunning ? sr.shared || sameHost : true) &&
|
||||
(sr.SR_type === 'iso' || (sr.SR_type === 'udev' && sr.size))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export const reportOnSupportPanel = async ({ files = [], formatMessage = identit
|
||||
ADDITIONAL_FILES.map(({ fetch, name }) =>
|
||||
timeout.call(fetch(), ADDITIONAL_FILES_FETCH_TIMEOUT).then(
|
||||
file => formData.append('attachments', createBlobFromString(file), name),
|
||||
error => logger.warn(`cannot get ${name}`, { error })
|
||||
error => logger.warn(`cannot get ${name}`, error)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1099,9 +1099,7 @@ export const SelectXoCloudConfig = makeSubscriptionSelect(
|
||||
subscriber =>
|
||||
subscribeCloudXoConfigBackups(configs => {
|
||||
const xoObjects = groupBy(
|
||||
map(configs, config => ({ ...config, type: 'xoConfig' }))
|
||||
// from newest to oldest
|
||||
.sort((a, b) => b.createdAt - a.createdAt),
|
||||
map(configs, config => ({ ...config, type: 'xoConfig' })),
|
||||
'xoaId'
|
||||
)
|
||||
subscriber({
|
||||
|
||||
@@ -3,6 +3,7 @@ import _ from 'intl'
|
||||
import Copiable from 'copiable'
|
||||
import decorate from 'apply-decorators'
|
||||
import Icon from 'icon'
|
||||
import map from 'lodash/map'
|
||||
import React from 'react'
|
||||
import store from 'store'
|
||||
import HomeTags from 'home-tags'
|
||||
@@ -23,21 +24,10 @@ export default decorate([
|
||||
provideState({
|
||||
computed: {
|
||||
areHostsVersionsEqual: ({ areHostsVersionsEqualByPool }, { host }) => areHostsVersionsEqualByPool[host.$pool],
|
||||
inMemoryVms: (_, { vms }) => {
|
||||
const result = []
|
||||
for (const key of Object.keys(vms)) {
|
||||
const vm = vms[key]
|
||||
const { power_state } = vm
|
||||
if (power_state === 'Running' || power_state === 'Paused') {
|
||||
result.push(vm)
|
||||
}
|
||||
}
|
||||
return result
|
||||
},
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ statsOverview, host, nVms, vmController, state: { areHostsVersionsEqual, inMemoryVms } }) => {
|
||||
({ statsOverview, host, nVms, vmController, vms, state: { areHostsVersionsEqual } }) => {
|
||||
const pool = getObject(store.getState(), host.$pool)
|
||||
const vmsFilter = encodeURIComponent(new CM.Property('$container', new CM.String(host.id)).toString())
|
||||
return (
|
||||
@@ -130,7 +120,7 @@ export default decorate([
|
||||
tooltip={`${host.productBrand} (${formatSize(vmController.memory.size)})`}
|
||||
value={vmController.memory.size}
|
||||
/>
|
||||
{inMemoryVms.map(vm => (
|
||||
{map(vms, vm => (
|
||||
<UsageElement
|
||||
tooltip={`${vm.name_label} (${formatSize(vm.memory.size)})`}
|
||||
key={vm.id}
|
||||
|
||||
@@ -5,9 +5,10 @@ import decorate from 'apply-decorators'
|
||||
import Icon from 'icon'
|
||||
import React from 'react'
|
||||
import { confirm } from 'modal'
|
||||
import { getApiApplianceInfo, subscribeCloudXoConfig, subscribeCloudXoConfigBackups } from 'xo'
|
||||
import { groupBy, sortBy } from 'lodash'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { SelectXoCloudConfig } from 'select-objects'
|
||||
import { subscribeCloudXoConfig, subscribeCloudXoConfigBackups } from 'xo'
|
||||
|
||||
import BackupXoConfigModal from './backup-xo-config-modal'
|
||||
import RestoreXoConfigModal from './restore-xo-config-modal'
|
||||
@@ -87,7 +88,15 @@ const CloudConfig = decorate([
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
applianceId: async () => {
|
||||
const { id } = await getApiApplianceInfo()
|
||||
return id
|
||||
},
|
||||
groupedConfigs: ({ applianceId, sortedConfigs }) =>
|
||||
sortBy(groupBy(sortedConfigs, 'xoaId'), config => (config[0].xoaId === applianceId ? -1 : 1)),
|
||||
isConfigDefined: ({ config }) => config != null,
|
||||
sortedConfigs: (_, { cloudXoConfigBackups }) =>
|
||||
cloudXoConfigBackups?.sort((config, nextConfig) => config.createdAt - nextConfig.createdAt),
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
|
||||
@@ -33,7 +33,7 @@ const formatError = error => (typeof error === 'string' ? error : JSON.stringify
|
||||
|
||||
const _changeUrlElement = (value, { remote, element }) =>
|
||||
editRemote(remote, {
|
||||
url: format({ ...parse(remote.url), [element]: value === null ? undefined : value }),
|
||||
url: format({ ...remote, [element]: value === null ? undefined : value }),
|
||||
})
|
||||
const _showError = remote => alert(_('remoteConnectionFailed'), <pre>{formatError(remote.error)}</pre>)
|
||||
const _editRemoteName = (name, { remote }) => editRemote(remote, { name })
|
||||
|
||||
52
scripts/trustCertificate.mjs
Normal file
52
scripts/trustCertificate.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import https from 'node:https'
|
||||
import tls from 'node:tls'
|
||||
|
||||
const [, , host, port = 443] = process.argv
|
||||
|
||||
async function tryRequest(options) {
|
||||
https
|
||||
.request(options, res => {
|
||||
console.log('statusCode:', res.statusCode)
|
||||
})
|
||||
.on('error', function (error) {
|
||||
console.error('error:', error)
|
||||
})
|
||||
.end()
|
||||
}
|
||||
|
||||
function getCertificate(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
tls
|
||||
.connect(options, function () {
|
||||
resolve(this.getPeerX509Certificate())
|
||||
this.end()
|
||||
})
|
||||
.on('error', function (error) {
|
||||
this.destroy()
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Trying request : it fails if self-signed certificate
|
||||
const options = { host, port, rejectUnauthorized: true, servername: host }
|
||||
console.log('\n-> Request with no certificate')
|
||||
tryRequest(options)
|
||||
|
||||
// Asking for certificate
|
||||
const certificate = await getCertificate({ ...options, rejectUnauthorized: false })
|
||||
|
||||
console.log('=> Certificate acquired')
|
||||
// console.log(certificate.subject, certificate.issuer, certificate.validFrom, certificate.validTo)
|
||||
// console.log(certificate.verify(certificate.publicKey))
|
||||
|
||||
// Trying again with certificate
|
||||
console.log('\n-> Request using acquired certificate')
|
||||
tryRequest({
|
||||
...options,
|
||||
ca: [...tls.rootCertificates, certificate.toString()],
|
||||
// adding default ca with ...tls.rootCertificates avoids failing requests with other valid certificates, but it looks likes it also makes succeeding requests we want to fail (like https://pinning-test.badssl.com/)
|
||||
// checkServerIdentity: () => {return undefined}, // for localhost
|
||||
})
|
||||
Reference in New Issue
Block a user